Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow bulk select/delete images in a project and unify pagination placement #620

Merged
merged 1 commit into from
Feb 3, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ const EditClusterGroup = lazy(() => import("pages/cluster/EditClusterGroup"));
const EditNetworkForward = lazy(
() => import("pages/networks/EditNetworkForward"),
);
const Images = lazy(() => import("pages/images/Images"));
const ImageList = lazy(() => import("pages/images/ImageList"));
const InstanceDetail = lazy(() => import("pages/instances/InstanceDetail"));
const InstanceList = lazy(() => import("pages/instances/InstanceList"));
const Login = lazy(() => import("pages/login/Login"));
Expand Down Expand Up @@ -301,7 +301,7 @@ const App: FC = () => {
/>
<Route
path="/ui/project/:project/images"
element={<ProtectedRoute outlet={<Images />} />}
element={<ProtectedRoute outlet={<ImageList />} />}
/>
<Route
path="/ui/cluster"
Expand Down
31 changes: 30 additions & 1 deletion src/api/images.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import { handleResponse } from "util/helpers";
import {
continueOrFinish,
handleResponse,
pushFailure,
pushSuccess,
} from "util/helpers";
import { ImportImage, LxdImage } from "types/image";
import { LxdApiResponse } from "types/apiResponse";
import { LxdOperationResponse } from "types/operation";
import { EventQueue } from "context/eventQueue";

export const fetchImage = (
image: string,
Expand Down Expand Up @@ -38,6 +44,29 @@ export const deleteImage = (
});
};

export const deleteImageBulk = (
fingerprints: string[],
project: string,
eventQueue: EventQueue,
): Promise<PromiseSettledResult<void>[]> => {
const results: PromiseSettledResult<void>[] = [];
return new Promise((resolve) => {
void Promise.allSettled(
fingerprints.map((name) => {
const image = { fingerprint: name } as LxdImage;
return deleteImage(image, project).then((operation) => {
eventQueue.set(
operation.metadata.id,
() => pushSuccess(results),
(msg) => pushFailure(results, msg),
() => continueOrFinish(results, fingerprints.length, resolve),
);
});
}),
);
});
};

export const importImage = (
remoteImage: ImportImage,
): Promise<LxdOperationResponse> => {
Expand Down
40 changes: 40 additions & 0 deletions src/components/PageHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React, { FC, PropsWithChildren } from "react";

const Left: FC<PropsWithChildren> = ({ children }) => {
return <div className="page-header__left">{children}</div>;
};

const Title: FC<PropsWithChildren> = ({ children }) => {
return <h1 className="p-heading--4 u-no-margin--bottom">{children}</h1>;
};

const Search: FC<PropsWithChildren> = ({ children }) => {
return (
<div className="page-header__search margin-right u-no-margin--bottom">
{children}
</div>
);
};

const BaseActions: FC<PropsWithChildren> = ({ children }) => {
return <div className="page-header__base-actions">{children}</div>;
};

const Header: FC<PropsWithChildren> = ({ children }) => {
return <div className="p-panel__header page-header">{children}</div>;
};

type PageHeaderComponents = FC<PropsWithChildren> & {
Left: FC<PropsWithChildren>;
Title: FC<PropsWithChildren>;
Search: FC<PropsWithChildren>;
BaseActions: FC<PropsWithChildren>;
};

const PageHeader = Header as PageHeaderComponents;
PageHeader.Left = Left;
PageHeader.Title = Title;
PageHeader.Search = Search;
PageHeader.BaseActions = BaseActions;

export default PageHeader;
3 changes: 1 addition & 2 deletions src/pages/cluster/ClusterList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,14 +87,13 @@ const ClusterList: FC = () => {
<>
<ScrollableTable
dependencies={[filteredMembers, notify.notification]}
belowId="pagination"
tableId="cluster-table"
>
<TablePagination
data={sortedRows}
id="pagination"
itemName="cluster member"
position="below"
className="u-no-margin--top"
>
<MainTable
id="cluster-table"
Expand Down
163 changes: 119 additions & 44 deletions src/pages/images/ImageList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import {
EmptyState,
Icon,
List,
MainTable,
Row,
SearchBox,
TablePagination,
useNotify,
Expand All @@ -19,11 +19,22 @@ import CreateInstanceFromImageBtn from "pages/images/actions/CreateInstanceFromI
import { localLxdToRemoteImage } from "util/images";
import ScrollableTable from "components/ScrollableTable";
import useSortTableData from "util/useSortTableData";
import SelectableMainTable from "components/SelectableMainTable";
import BulkDeleteImageBtn from "pages/images/actions/BulkDeleteImageBtn";
import SelectedTableNotification from "components/SelectedTableNotification";
import HelpLink from "components/HelpLink";
import NotificationRow from "components/NotificationRow";
import { useDocs } from "context/useDocs";
import CustomLayout from "components/CustomLayout";
import PageHeader from "components/PageHeader";

const ImageList: FC = () => {
const docBaseLink = useDocs();
const notify = useNotify();
const { project } = useParams<{ project: string }>();
const [query, setQuery] = useState<string>("");
const [processingNames, setProcessingNames] = useState<string[]>([]);
const [selectedNames, setSelectedNames] = useState<string[]>([]);

if (!project) {
return <>Missing project</>;
Expand All @@ -42,6 +53,16 @@ const ImageList: FC = () => {
notify.failure("Loading images failed", error);
}

useEffect(() => {
const validNames = new Set(images?.map((image) => image.fingerprint));
const validSelections = selectedNames.filter((name) =>
validNames.has(name),
mas-who marked this conversation as resolved.
Show resolved Hide resolved
);
if (validSelections.length !== selectedNames.length) {
setSelectedNames(validSelections);
}
}, [images]);

const headers = [
{ content: "Name", sortKey: "name" },
{ content: "Alias", sortKey: "alias" },
Expand Down Expand Up @@ -95,6 +116,7 @@ const ImageList: FC = () => {
const imageAlias = image.aliases.map((alias) => alias.name).join(", ");

return {
name: image.fingerprint,
columns: [
{
content: image.properties.description,
Expand Down Expand Up @@ -162,49 +184,102 @@ const ImageList: FC = () => {
return <Loader text="Loading images..." />;
}

return images.length === 0 ? (
<EmptyState
className="empty-state"
image={<Icon name="mount" className="empty-state-icon" />}
title="No images found in this project"
return (
<CustomLayout
contentClassName="u-no-padding--bottom"
header={
<PageHeader>
<PageHeader.Left>
<PageHeader.Title>
<HelpLink
href={`${docBaseLink}/image-handling/`}
title="Learn more about images"
>
Images
</HelpLink>
</PageHeader.Title>
{selectedNames.length === 0 && images.length > 0 && (
<PageHeader.Search>
<SearchBox
name="search-images"
className="search-box u-no-margin--bottom"
type="text"
onChange={(value) => {
setQuery(value);
}}
placeholder="Search"
value={query}
aria-label="Search for images"
/>
</PageHeader.Search>
)}
{selectedNames.length > 0 && (
<BulkDeleteImageBtn
fingerprints={selectedNames}
project={project}
onStart={() => setProcessingNames(selectedNames)}
onFinish={() => setProcessingNames([])}
/>
)}
</PageHeader.Left>
</PageHeader>
}
>
<p>Images will appear here, when launching an instance from a remote.</p>
</EmptyState>
) : (
<div className="image-list">
<div className="upper-controls-bar">
<div className="search-box-wrapper">
<SearchBox
name="search-images"
className="search-box margin-right"
type="text"
onChange={(value) => {
setQuery(value);
}}
placeholder="Search for images"
value={query}
aria-label="Search for images"
/>
</div>
</div>
<ScrollableTable dependencies={[images]} tableId="image-table">
<TablePagination
data={sortedRows}
id="pagination"
className="u-no-margin--top"
itemName="image"
>
<MainTable
id="image-table"
headers={headers}
sortable
className="image-table"
emptyStateMsg="No images found matching this search"
onUpdateSort={updateSort}
/>
</TablePagination>
</ScrollableTable>
</div>
<NotificationRow />
<Row>
{images.length === 0 && (
<EmptyState
className="empty-state"
image={<Icon name="mount" className="empty-state-icon" />}
title="No images found in this project"
>
<p>
Images will appear here, when launching an instance from a remote.
</p>
</EmptyState>
)}
{images.length > 0 && (
<ScrollableTable dependencies={[images]} tableId="image-table">
<TablePagination
data={sortedRows}
id="pagination"
itemName="image"
className="u-no-margin--top"
description={
selectedNames.length > 0 && (
<SelectedTableNotification
totalCount={images.length ?? 0}
itemName="image"
parentName="project"
selectedNames={selectedNames}
setSelectedNames={setSelectedNames}
filteredNames={filteredImages.map(
(item) => item.fingerprint,
)}
/>
)
}
>
<SelectableMainTable
id="image-table"
headers={headers}
sortable
className="image-table"
emptyStateMsg="No images found matching this search"
onUpdateSort={updateSort}
selectedNames={selectedNames}
setSelectedNames={setSelectedNames}
itemName="image"
parentName="project"
filteredNames={filteredImages.map((item) => item.fingerprint)}
processingNames={processingNames}
rows={[]}
/>
</TablePagination>
</ScrollableTable>
)}
</Row>
</CustomLayout>
);
};

Expand Down
31 changes: 0 additions & 31 deletions src/pages/images/Images.tsx

This file was deleted.

Loading
Loading