Skip to content

Commit

Permalink
feat(images) allow bulk select/delete images in a project #618
Browse files Browse the repository at this point in the history
Signed-off-by: David Edler <david.edler@canonical.com>
  • Loading branch information
edlerd committed Feb 1, 2024
1 parent e38803b commit 6d54846
Show file tree
Hide file tree
Showing 25 changed files with 471 additions and 303 deletions.
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),
);
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

0 comments on commit 6d54846

Please sign in to comment.