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 22, 2024
1 parent 7c11daf commit fc20256
Show file tree
Hide file tree
Showing 5 changed files with 202 additions and 19 deletions.
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(async (name) => {
const image = { fingerprint: name } as LxdImage;
return await 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
77 changes: 61 additions & 16 deletions src/pages/images/ImageList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import React, { FC, useState } from "react";
import React, { FC, useEffect, useState } from "react";
import {
EmptyState,
Icon,
List,
MainTable,
SearchBox,
TablePagination,
useNotify,
Expand All @@ -19,11 +18,16 @@ 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";

const ImageList: FC = () => {
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 +46,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 +109,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 @@ -173,34 +188,64 @@ const ImageList: FC = () => {
) : (
<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>
{selectedNames.length === 0 ? (
<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 className="p-panel__controls">
<BulkDeleteImageBtn
fingerprints={selectedNames}
project={project}
onStart={() => setProcessingNames(selectedNames)}
onFinish={() => setProcessingNames([])}
/>
</div>
)}
</div>
<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)}
/>
)
}
>
<MainTable
<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>
Expand Down
101 changes: 101 additions & 0 deletions src/pages/images/actions/BulkDeleteImageBtn.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import React, { FC, useState } from "react";
import { deleteImageBulk } from "api/images";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { ConfirmationButton, useNotify } from "@canonical/react-components";
import { useEventQueue } from "context/eventQueue";
import { getPromiseSettledCounts } from "util/helpers";
import { pluralize } from "util/instanceBulkActions";

interface Props {
fingerprints: string[];
project: string;
onStart: () => void;
onFinish: () => void;
}

const BulkDeleteImageBtn: FC<Props> = ({
fingerprints,
project,
onStart,
onFinish,
}) => {
const eventQueue = useEventQueue();
const notify = useNotify();
const [isLoading, setLoading] = useState(false);
const queryClient = useQueryClient();

const count = fingerprints.length;

const handleDelete = () => {
setLoading(true);
onStart();
void deleteImageBulk(fingerprints, project, eventQueue).then((results) => {
const { fulfilledCount, rejectedCount } =
getPromiseSettledCounts(results);
if (fulfilledCount === count) {
notify.success(
<>
<b>{fingerprints.length}</b>{" "}
{pluralize("image", fingerprints.length)} deleted.
</>,
);
} else if (rejectedCount === count) {
notify.failure(
"Image bulk deletion failed",
undefined,
<>
<b>{count}</b> {pluralize("image", count)} could not be deleted.
</>,
);
} else {
notify.failure(
"Image bulk deletion partially failed",
undefined,
<>
<b>{fulfilledCount}</b> {pluralize("image", fulfilledCount)}{" "}
deleted.
<br />
<b>{rejectedCount}</b> {pluralize("image", rejectedCount)} could not
be deleted.
</>,
);
}
void queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === queryKeys.images,
});
setLoading(false);
onFinish();
});
};

return (
<ConfirmationButton
loading={isLoading}
confirmationModalProps={{
title: "Confirm delete",
children: (
<p>
This will permanently delete{" "}
<b>
{fingerprints.length} {pluralize("image", fingerprints.length)}
</b>
.<br />
This action cannot be undone, and can result in data loss.
</p>
),
confirmButtonLabel: "Delete",
onConfirm: handleDelete,
}}
appearance=""
className="bulk-delete-images"
disabled={isLoading}
shiftClickEnabled
showShiftClickHint
>
Delete images
</ConfirmationButton>
);
};

export default BulkDeleteImageBtn;
4 changes: 4 additions & 0 deletions src/sass/_images.scss
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
.items-per-page {
margin-bottom: 0;
}

.bulk-delete-images {
margin-bottom: $spv--large;
}
}

.image-table {
Expand Down
8 changes: 6 additions & 2 deletions src/util/instanceBulkActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,15 @@ export const instanceActionLabel = (action: LxdInstanceAction): string => {
};

export const pluralizeInstance = (count: number) => {
return count === 1 ? "instance" : "instances";
return pluralize("instance", count);
};

export const pluralizeSnapshot = (count: number) => {
return count === 1 ? "snapshot" : "snapshots";
return pluralize("snapshot", count);
};

export const pluralize = (item: string, count: number) => {
return count === 1 ? item : item + "s";
};

export const statusLabel = (status: LxdInstanceStatus) => {
Expand Down

0 comments on commit fc20256

Please sign in to comment.