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 <[email protected]>
  • Loading branch information
edlerd committed Jan 23, 2024
1 parent f8bbe8e commit 562ca76
Show file tree
Hide file tree
Showing 13 changed files with 403 additions and 198 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
36 changes: 36 additions & 0 deletions src/components/SelectableHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import React, { FC, ReactNode } from "react";

type Props = {
title: ReactNode;
baseActions?: ReactNode;
bulkActions: ReactNode;
searchAndFilter: ReactNode;
selectedCount: number;
};

const SelectableHeader: FC<Props> = ({
title,
selectedCount,
baseActions,
bulkActions,
searchAndFilter,
}) => {
return (
<div className="p-panel__header selectable-header">
<div className="selectable-header__left">
<h1 className="p-heading--4 u-no-margin--bottom">{title}</h1>
{selectedCount > 0 && bulkActions}
{selectedCount === 0 && (
<div className="selectable-header__search margin-right u-no-margin--bottom">
{searchAndFilter}
</div>
)}
</div>
{selectedCount === 0 && baseActions && (
<div className="selectable-header__base-actions">{baseActions}</div>
)}
</div>
);
};

export default SelectableHeader;
160 changes: 116 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 SelectableHeader from "components/SelectableHeader";

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,99 @@ 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="image-list"
header={
<SelectableHeader
title={
<HelpLink
href={`${docBaseLink}/image-handling/`}
title="Learn more about images"
>
Images
</HelpLink>
}
searchAndFilter={
<SearchBox
name="search-images"
className="search-box u-no-margin--bottom"
type="text"
onChange={(value) => {
setQuery(value);
}}
placeholder="Search for images"
value={query}
aria-label="Search for images"
/>
}
selectedCount={selectedNames.length}
bulkActions={
<BulkDeleteImageBtn
fingerprints={selectedNames}
project={project}
onStart={() => setProcessingNames(selectedNames)}
onFinish={() => setProcessingNames([])}
/>
}
/>
}
>
<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"
className="u-no-margin--top"
itemName="image"
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 562ca76

Please sign in to comment.