Skip to content

Commit

Permalink
feat: generalise bulk delete button across UI pages [WD-19546] (#1123)
Browse files Browse the repository at this point in the history
  • Loading branch information
mas-who authored Feb 25, 2025
2 parents 5d981ff + 3825c39 commit 3fd8cb2
Show file tree
Hide file tree
Showing 13 changed files with 296 additions and 319 deletions.
87 changes: 87 additions & 0 deletions src/components/BulkDeleteButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { FC } from "react";
import { pluralize } from "util/instanceBulkActions";
import {
ConfirmationButton,
ConfirmationButtonProps,
Icon,
} from "@canonical/react-components";
import classnames from "classnames";

interface Props {
entities: unknown[];
deletableEntities: unknown[];
entityType: string;
onDelete: () => void;
disabledReason?: string;
bulkDeleteBreakdown?: string[];
confirmationButtonProps?: Partial<ConfirmationButtonProps>;
buttonLabel?: React.ReactNode;
className?: string;
}

const BulkDeleteButton: FC<Props> = ({
entities,
deletableEntities,
disabledReason,
entityType,
bulkDeleteBreakdown,
confirmationButtonProps,
onDelete,
buttonLabel = "Delete",
className,
}) => {
const totalCount = entities.length;
const deleteCount = deletableEntities.length;

const breakdown =
bulkDeleteBreakdown?.map((action) => {
return (
<li key={action} className="p-list__item">
- {action}
</li>
);
}) || [];

const modalContent = (
<>
{breakdown.length > 0 && (
<>
<p>
<b>{totalCount}</b> {pluralize(entityType, entities.length)}{" "}
selected:
</p>
<ul className="p-list">{breakdown}</ul>
</>
)}
<p className="u-no-padding--top">
This will permanently delete <b>{deleteCount}</b>{" "}
{pluralize(entityType, deleteCount)}.{"\n"}This action cannot be undone,
and can result in data loss.
</p>
</>
);

return (
<ConfirmationButton
className={classnames("has-icon", className)}
onHoverText={
disabledReason ?? `Delete ${pluralize(entityType, entities.length)}`
}
disabled={deleteCount === 0 || !!disabledReason}
shiftClickEnabled
showShiftClickHint
{...confirmationButtonProps}
confirmationModalProps={{
title: "Confirm delete",
children: modalContent,
confirmButtonLabel: "Delete",
onConfirm: onDelete,
}}
>
<Icon name="delete" />
<span>{buttonLabel}</span>
</ConfirmationButton>
);
};

export default BulkDeleteButton;
14 changes: 7 additions & 7 deletions src/pages/images/ImageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ const ImageList: FC = () => {
.filter(canDeleteImage)
.map((image) => image.fingerprint);

const selectedImages = images.filter((image) =>
selectedNames.includes(image.fingerprint),
);

const rows = filteredImages.map((image) => {
const actions = (
<List
Expand All @@ -121,10 +125,7 @@ const ImageList: FC = () => {

return {
key: image.fingerprint,
// disable image selection if user does not have entitlement to delete
name: deletableImages.includes(image.fingerprint)
? image.fingerprint
: "",
name: image.fingerprint,
columns: [
{
content: description,
Expand Down Expand Up @@ -221,9 +222,9 @@ const ImageList: FC = () => {
/>
</PageHeader.Search>
)}
{selectedNames.length > 0 && (
{selectedImages.length > 0 && (
<BulkDeleteImageBtn
fingerprints={selectedNames}
images={selectedImages}
project={project}
onStart={() => setProcessingNames(selectedNames)}
onFinish={() => setProcessingNames([])}
Expand Down Expand Up @@ -290,7 +291,6 @@ const ImageList: FC = () => {
filteredNames={filteredImages.map((item) => item.fingerprint)}
disabledNames={processingNames}
rows={[]}
disableSelect={!deletableImages.length}
/>
</TablePagination>
</ScrollableTable>
Expand Down
73 changes: 43 additions & 30 deletions src/pages/images/actions/BulkDeleteImageBtn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,23 @@ import { FC, useState } from "react";
import { deleteImageBulk } from "api/images";
import { useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "util/queryKeys";
import { ConfirmationButton } from "@canonical/react-components";
import { useEventQueue } from "context/eventQueue";
import { getPromiseSettledCounts } from "util/helpers";
import { pluralize } from "util/instanceBulkActions";
import { useToastNotification } from "context/toastNotificationProvider";
import BulkDeleteButton from "components/BulkDeleteButton";
import { useImageEntitlements } from "util/entitlements/images";
import { LxdImage } from "types/image";

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

const BulkDeleteImageBtn: FC<Props> = ({
fingerprints,
images,
project,
onStart,
onFinish,
Expand All @@ -25,28 +27,33 @@ const BulkDeleteImageBtn: FC<Props> = ({
const toastNotify = useToastNotification();
const [isLoading, setLoading] = useState(false);
const queryClient = useQueryClient();
const { canDeleteImage } = useImageEntitlements();

const count = fingerprints.length;
const totalCount = images.length;
const deletableImages = images.filter((image) => canDeleteImage(image));
const fingerprints = deletableImages.map((image) => image.fingerprint);
const deleteCount = deletableImages.length;

const handleDelete = () => {
setLoading(true);
onStart();
void deleteImageBulk(fingerprints, project, eventQueue).then((results) => {
const { fulfilledCount, rejectedCount } =
getPromiseSettledCounts(results);
if (fulfilledCount === count) {
if (fulfilledCount === deleteCount) {
toastNotify.success(
<>
<b>{fingerprints.length}</b>{" "}
{pluralize("image", fingerprints.length)} deleted.
</>,
);
} else if (rejectedCount === count) {
} else if (rejectedCount === deleteCount) {
toastNotify.failure(
"Image bulk deletion failed",
undefined,
<>
<b>{count}</b> {pluralize("image", count)} could not be deleted.
<b>{deleteCount}</b> {pluralize("image", deleteCount)} could not be
deleted.
</>,
);
} else {
Expand All @@ -70,32 +77,38 @@ const BulkDeleteImageBtn: FC<Props> = ({
});
};

const getBulkDeleteBreakdown = () => {
if (deleteCount === totalCount) {
return undefined;
}

const restrictedCount = totalCount - deleteCount;
return [
`${deleteCount} ${pluralize("image", deleteCount)} will be deleted.`,
`${restrictedCount} ${pluralize("image", restrictedCount)} that you do not have permission to delete will be ignored.`,
];
};

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,
<BulkDeleteButton
entities={images}
deletableEntities={deletableImages}
entityType="image"
onDelete={handleDelete}
disabledReason={
deleteCount === 0
? `You do not have permission to delete the selected ${pluralize("image", deleteCount)}`
: undefined
}
buttonLabel={`Delete ${pluralize("image", totalCount)}`}
confirmationButtonProps={{
appearance: "",
disabled: isLoading || deleteCount === 0,
loading: isLoading,
}}
appearance=""
bulkDeleteBreakdown={getBulkDeleteBreakdown()}
className="u-no-margin--bottom"
disabled={isLoading}
shiftClickEnabled
showShiftClickHint
>
Delete {pluralize("image", fingerprints.length)}
</ConfirmationButton>
/>
);
};

Expand Down
101 changes: 32 additions & 69 deletions src/pages/instances/actions/InstanceBulkDelete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import { queryKeys } from "util/queryKeys";
import { useQueryClient } from "@tanstack/react-query";
import { deletableStatuses } from "util/instanceDelete";
import { getPromiseSettledCounts } from "util/helpers";
import { ConfirmationButton, Icon } from "@canonical/react-components";
import { useEventQueue } from "context/eventQueue";
import { useToastNotification } from "context/toastNotificationProvider";
import { useInstanceEntitlements } from "util/entitlements/instances";
import BulkDeleteButton from "components/BulkDeleteButton";

interface Props {
instances: LxdInstance[];
Expand Down Expand Up @@ -79,90 +79,53 @@ const InstanceBulkDelete: FC<Props> = ({ instances, onStart, onFinish }) => {
});
};

const getStoppedInstances = () => {
if (!deleteCount) {
return null;
const getBulkDeleteBreakdown = () => {
if (ignoredCount + restrictedCount === 0) {
return undefined;
}

return (
<Fragment key="stopped-instances">
- {deleteCount} stopped {pluralize("instance", deleteCount)} will be
deleted
<br />
</Fragment>
);
};

const getRestrictedInstances = () => {
if (!restrictedCount) {
return null;
const breakdown: string[] = [];
if (deleteCount) {
breakdown.push(
`${deleteCount} stopped ${pluralize("instance", deleteCount)} will be deleted`,
);
}

return (
<Fragment key="restricted-instances">
- {restrictedCount} {pluralize("instance", deleteCount)} that you do not
have permission to delete will be ignored
<br />
</Fragment>
);
};
if (restrictedCount) {
breakdown.push(
`${restrictedCount} ${pluralize("instance", deleteCount)} that you do not have permission to delete will be ignored`,
);
}

const getIgnoredInstances = () => {
if (!ignoredCount) {
return null;
if (ignoredCount) {
breakdown.push(
`${ignoredCount} other ${pluralize("instance", ignoredCount)} will be ignored`,
);
}

return (
<Fragment key="ignored-instances">
- {ignoredCount} other {pluralize("instance", ignoredCount)} will be
ignored
<br />
</Fragment>
);
return breakdown;
};

return (
<div className="p-segmented-control bulk-actions">
<div className="p-segmented-control__list bulk-action-frame">
<ConfirmationButton
onHoverText={
<BulkDeleteButton
entities={instances}
deletableEntities={deletableInstances}
entityType="instance"
onDelete={handleDelete}
disabledReason={
restrictedCount === totalCount
? `You do not have permission to delete the selected ${pluralize("instance", instances.length)}`
: "Delete instances"
: undefined
}
appearance="base"
className="u-no-margin--bottom has-icon"
loading={isLoading}
confirmationModalProps={{
title: "Confirm delete",
children: (
<p>
{ignoredCount + restrictedCount > 0 && (
<>
<b>{totalCount}</b> instances selected:
<br />
<br />
{getStoppedInstances()}
{getRestrictedInstances()}
{getIgnoredInstances()}
<br />
</>
)}
This will permanently delete <b>{deleteCount}</b>{" "}
{pluralize("instance", deleteCount)}.{"\n"}This action cannot be
undone, and can result in data loss.
</p>
),
confirmButtonLabel: "Delete",
onConfirm: handleDelete,
confirmationButtonProps={{
loading: isLoading,
appearance: "base",
}}
disabled={deleteCount === 0}
shiftClickEnabled
showShiftClickHint
>
<Icon name="delete" />
<span>Delete</span>
</ConfirmationButton>
bulkDeleteBreakdown={getBulkDeleteBreakdown()}
className="u-no-margin--bottom"
/>
</div>
</div>
);
Expand Down
Loading

0 comments on commit 3fd8cb2

Please sign in to comment.