feat(site): improve bulk delete flow (#11093)

This commit is contained in:
Kayla Washburn
2023-12-12 10:14:28 -07:00
committed by GitHub
parent 007b2b8db0
commit 689da5b7c1
10 changed files with 331 additions and 77 deletions
@@ -10,19 +10,19 @@ const FALLBACK_ICON = "/icon/widgets.svg";
const BUILT_IN_ICON_PATHS: {
[resourceType: WorkspaceResource["type"]]: string;
} = {
docker_volume: "/icon/folder.svg",
docker_volume: "/icon/database.svg",
docker_container: "/icon/memory.svg",
docker_image: "/icon/image.svg",
kubernetes_persistent_volume_claim: "/icon/folder.svg",
docker_image: "/icon/container.svg",
kubernetes_persistent_volume_claim: "/icon/database.svg",
kubernetes_pod: "/icon/memory.svg",
google_compute_disk: "/icon/folder.svg",
google_compute_disk: "/icon/database.svg",
google_compute_instance: "/icon/memory.svg",
aws_instance: "/icon/memory.svg",
kubernetes_deployment: "/icon/memory.svg",
null_resource: FALLBACK_ICON,
};
const getIconPathResource = (resourceType: string): string => {
export const getIconPathResource = (resourceType: string): string => {
if (resourceType in BUILT_IN_ICON_PATHS) {
return BUILT_IN_ICON_PATHS[resourceType];
}
@@ -66,10 +66,15 @@ export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
<>
{showDormancyWarning && (
<>
<h4>{"Dormancy Threshold"}</h4>
<h4>Dormancy Threshold</h4>
<Stack direction="row" spacing={5}>
<div css={styles.dialogDescription}>{`
This change will result in ${inactiveWorkspacesToGoDormant} workspaces being immediately transitioned to the dormant state and ${inactiveWorkspacesToGoDormantInWeek} over the next seven days. To prevent this, do you want to reset the inactivity period for all template workspaces?`}</div>
<div css={styles.dialogDescription}>
This change will result in {inactiveWorkspacesToGoDormant}{" "}
workspaces being immediately transitioned to the dormant state
and {inactiveWorkspacesToGoDormantInWeek} over the next seven
days. To prevent this, do you want to reset the inactivity
period for all template workspaces?
</div>
<FormControlLabel
css={{ marginTop: 16 }}
control={
+249 -64
View File
@@ -1,12 +1,17 @@
import { useTheme } from "@emotion/react";
import TextField from "@mui/material/TextField";
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
import ScheduleIcon from "@mui/icons-material/Schedule";
import { visuallyHidden } from "@mui/utils";
import dayjs from "dayjs";
import "dayjs/plugin/relativeTime";
import { type Interpolation, type Theme } from "@emotion/react";
import { type FC, type ReactNode, useState } from "react";
import { useMutation } from "react-query";
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
import type { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { type FC, useState } from "react";
import { useMutation } from "react-query";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import { getIconPathResource } from "components/Resources/ResourceAvatar";
import { Stack } from "components/Stack/Stack";
interface UseBatchActionsProps {
onSuccess: () => Promise<void>;
@@ -68,77 +73,257 @@ type BatchDeleteConfirmationProps = {
onConfirm: () => void;
};
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = (
props,
) => {
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
const theme = useTheme();
const [confirmation, setConfirmation] = useState({ value: "", error: false });
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
checkedWorkspaces,
open,
onClose,
onConfirm,
isLoading,
}) => {
const [stage, setStage] = useState<
"consequences" | "workspaces" | "resources"
>("consequences");
const confirmDeletion = () => {
setConfirmation((c) => ({ ...c, error: false }));
if (confirmation.value !== "DELETE") {
setConfirmation((c) => ({ ...c, error: true }));
return;
const onProceed = () => {
switch (stage) {
case "resources":
onConfirm();
break;
case "workspaces":
setStage("resources");
break;
case "consequences":
setStage("workspaces");
break;
}
onConfirm();
};
const workspaceCount = `${checkedWorkspaces.length} ${
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
}`;
let confirmText: ReactNode = <>Review selected workspaces&hellip;</>;
if (stage === "workspaces") {
confirmText = <>Confirm {workspaceCount}&hellip;</>;
}
if (stage === "resources") {
const resources = checkedWorkspaces
.map((workspace) => workspace.latest_build.resources.length)
.reduce((a, b) => a + b, 0);
const resourceCount = `${resources} ${
resources === 1 ? "resource" : "resources"
}`;
confirmText = (
<>
Delete {workspaceCount} and {resourceCount}
</>
);
}
// The flicker of these icons is quit noticeable if they aren't loaded in advance,
// so we insert them into the document without actually displaying them yet.
const resourceIconPreloads = [
...new Set(
checkedWorkspaces.flatMap((workspace) =>
workspace.latest_build.resources.map(
(resource) => resource.icon || getIconPathResource(resource.type),
),
),
),
].map((url) => (
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
));
return (
<ConfirmDialog
type="delete"
open={open}
confirmLoading={isLoading}
onConfirm={confirmDeletion}
onClose={() => {
setStage("consequences");
onClose();
setConfirmation({ value: "", error: false });
}}
title={`Delete ${checkedWorkspaces?.length} ${
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
}`}
title={`Delete ${workspaceCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
type="delete"
description={
<form
onSubmit={async (e) => {
e.preventDefault();
confirmDeletion();
}}
>
<div>
Deleting these workspaces is irreversible! Are you sure you want to
proceed? Type{" "}
<code
css={{
fontFamily: MONOSPACE_FONT_FAMILY,
color: theme.palette.text.primary,
fontWeight: 600,
}}
>
`DELETE`
</code>{" "}
to confirm.
</div>
<TextField
value={confirmation.value}
required
autoFocus
fullWidth
inputProps={{
"aria-label": "Type DELETE to confirm",
}}
placeholder="Type DELETE to confirm"
css={{ marginTop: 16 }}
onChange={(e) => {
const value = e.currentTarget?.value;
setConfirmation((c) => ({ ...c, value }));
}}
error={confirmation.error}
helperText={confirmation.error && "Please type DELETE to confirm"}
/>
</form>
<>
{stage === "consequences" && <Consequences />}
{stage === "workspaces" && (
<Workspaces workspaces={checkedWorkspaces} />
)}
{stage === "resources" && (
<Resources workspaces={checkedWorkspaces} />
)}
{resourceIconPreloads}
</>
}
/>
);
};
interface StageProps {
workspaces: Workspace[];
}
const Consequences: FC = () => {
return (
<>
<p>Deleting workspaces is irreversible!</p>
<ul css={styles.consequences}>
<li>
Terraform resources belonging to deleted workspaces will be destroyed.
</li>
<li>Any data stored in the workspace will be permanently deleted.</li>
</ul>
</>
);
};
const Workspaces: FC<StageProps> = ({ workspaces }) => {
const mostRecent = workspaces.reduce(
(latestSoFar, against) => {
if (!latestSoFar) {
return against;
}
return new Date(against.last_used_at).getTime() >
new Date(latestSoFar.last_used_at).getTime()
? against
: latestSoFar;
},
undefined as Workspace | undefined,
);
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
return (
<>
<ul css={styles.workspacesList}>
{workspaces.map((workspace) => (
<li key={workspace.id} css={styles.workspace}>
<Stack
direction="row"
alignItems="center"
justifyContent="space-between"
>
<span css={{ fontWeight: 500, color: "#fff" }}>
{workspace.name}
</span>
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{workspace.owner_name}
</span>
</Stack>
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{dayjs(workspace.last_used_at).fromNow()}
</span>
</Stack>
</Stack>
</Stack>
</li>
))}
</ul>
<Stack justifyContent="center" direction="row" css={{ fontSize: 14 }}>
<Stack direction="row" alignItems="center" spacing={1}>
<PersonIcon />
<span>{ownersCount}</span>
</Stack>
{mostRecent && (
<Stack direction="row" alignItems="center" spacing={1}>
<ScheduleIcon css={styles.summaryIcon} />
<span>Last used {dayjs(mostRecent.last_used_at).fromNow()}</span>
</Stack>
)}
</Stack>
</>
);
};
const Resources: FC<StageProps> = ({ workspaces }) => {
const resources: Record<string, { count: number; icon: string }> = {};
workspaces.forEach((workspace) =>
workspace.latest_build.resources.forEach((resource) => {
if (!resources[resource.type]) {
resources[resource.type] = {
count: 0,
icon: resource.icon || getIconPathResource(resource.type),
};
}
resources[resource.type].count++;
}),
);
return (
<Stack>
<p>
Deleting{" "}
{workspaces.length === 1 ? "this workspace" : "these workspaces"} will
also permanently destroy&hellip;
</p>
<Stack
direction="row"
justifyContent="center"
wrap="wrap"
css={{ gap: "6px 20px", fontSize: 14 }}
>
{Object.entries(resources).map(([type, summary]) => (
<Stack key={type} direction="row" alignItems="center" spacing={1}>
<img alt="" src={summary.icon} css={styles.summaryIcon} />
<span>
{summary.count} <code>{type}</code>
</span>
</Stack>
))}
</Stack>
</Stack>
);
};
const PersonIcon: FC = () => {
// This size doesn't match the rest of the icons because MUI is just really
// inconsistent. We have to make it bigger than the rest, and pull things in
// on the sides to compensate.
return <PersonOutlinedIcon css={{ width: 18, height: 18, margin: -1 }} />;
};
const styles = {
summaryIcon: { width: 16, height: 16 },
consequences: {
display: "flex",
flexDirection: "column",
gap: 8,
paddingLeft: 16,
marginBottom: 0,
},
workspacesList: (theme) => ({
listStyleType: "none",
padding: 0,
border: `1px solid ${theme.palette.divider}`,
borderRadius: 8,
overflow: "hidden auto",
maxHeight: 184,
}),
workspace: (theme) => ({
padding: "8px 16px",
borderBottom: `1px solid ${theme.palette.divider}`,
"&:last-child": {
border: "none",
},
}),
} satisfies Record<string, Interpolation<Theme>>;
@@ -0,0 +1,38 @@
import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react";
import { MockWorkspace, MockUser2 } from "testHelpers/entities";
import { BatchDeleteConfirmation } from "./BatchActions";
const meta: Meta<typeof BatchDeleteConfirmation> = {
title: "pages/WorkspacesPage/BatchDelete",
component: BatchDeleteConfirmation,
args: {
onClose: action("onClose"),
onConfirm: action("onConfirm"),
open: true,
checkedWorkspaces: [
MockWorkspace,
{
...MockWorkspace,
name: "Test-Workspace-2",
last_used_at: "2023-08-16T15:29:10.302441433Z",
owner_id: MockUser2.id,
owner_name: MockUser2.username,
},
{
...MockWorkspace,
name: "Test-Workspace-3",
last_used_at: "2023-11-16T15:29:10.302441433Z",
owner_id: MockUser2.id,
owner_name: MockUser2.username,
},
],
},
};
export default meta;
type Story = StoryObj<typeof BatchDeleteConfirmation>;
const Example: Story = {};
export { Example as BatchDelete };
@@ -63,11 +63,17 @@ describe("WorkspacesPage", () => {
await user.click(getWorkspaceCheckbox(workspaces[0]));
await user.click(getWorkspaceCheckbox(workspaces[1]));
await user.click(screen.getByRole("button", { name: /actions/i }));
const deleteButton = await screen.findByText(/delete/i);
await user.click(deleteButton);
await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE");
await user.click(screen.getByTestId("confirm-button"));
// The button changes its text, and advances the content of the modal,
// but it is technically the same button being clicked 3 times.
const confirmButton = await screen.findByTestId("confirm-button");
await user.click(confirmButton);
await user.click(confirmButton);
await user.click(confirmButton);
await waitFor(() => {
expect(deleteWorkspace).toHaveBeenCalledTimes(2);
@@ -127,7 +127,7 @@ const mockTemplates = [
];
const meta: Meta<typeof WorkspacesPageView> = {
title: "pages/WorkspacesPageView",
title: "pages/WorkspacesPage",
component: WorkspacesPageView,
args: {
limit: DEFAULT_RECORDS_PER_PAGE,
+1
View File
@@ -12,6 +12,7 @@
"code.svg",
"coder.svg",
"conda.svg",
"container.svg",
"database.svg",
"datagrip.svg",
"dataspell.svg",
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-container"><path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z"/><path d="M10 21.9V14L2.1 9.1"/><path d="m10 14 11.9-6.9"/><path d="M14 19.8v-8.1"/><path d="M18 17.5V9.4"/></svg>

After

Width:  |  Height:  |  Size: 490 B

+1 -1
View File
@@ -1 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M24 22q-8.05 0-13.025-2.45T6 14q0-3.15 4.975-5.575Q15.95 6 24 6t13.025 2.425Q42 10.85 42 14q0 3.1-4.975 5.55Q32.05 22 24 22Zm0 10q-7.3 0-12.65-2.2Q6 27.6 6 24.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 27 24 27q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 21.5 42 19.5v5q0 3.1-5.35 5.3Q31.3 32 24 32Zm0 10q-7.3 0-12.65-2.2Q6 37.6 6 34.5v-5q0 1.95 1.875 3.375t4.65 2.35q2.775.925 5.9 1.35Q21.55 37 24 37q2.5 0 5.6-.425 3.1-.425 5.875-1.325 2.775-.9 4.65-2.325Q42 31.5 42 29.5v5q0 3.1-5.35 5.3Q31.3 42 24 42Z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-database"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M3 5V19A9 3 0 0 0 21 19V5"/><path d="M3 12A9 3 0 0 0 21 12"/></svg>

Before

Width:  |  Height:  |  Size: 630 B

After

Width:  |  Height:  |  Size: 320 B

+19 -1
View File
@@ -1 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" height="48" width="48" fill="#FFF"><path d="M18.85 29.15V18.9H29.1v10.25Zm3-3h4.25V21.9h-4.25ZM18 42v-4h-5q-1.2 0-2.1-.9-.9-.9-.9-2.1v-5H6v-3h4v-6.2H6v-3h4v-5q0-1.2.9-2.1.9-.9 2.1-.9h5V6h3v3.8h6.2V6h3v3.8h5q1.2 0 2.1.9.9.9.9 2.1v5H42v3h-3.8V27H42v3h-3.8v5q0 1.2-.9 2.1-.9.9-2.1.9h-5v4h-3v-4H21v4Zm17.2-7V12.8H13V35ZM24 24Z"/></svg>
<svg width="24" height="24" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_197_2)">
<path d="M9 2H3C2.44772 2 2 2.44772 2 3V9C2 9.55228 2.44772 10 3 10H9C9.55228 10 10 9.55228 10 9V3C10 2.44772 9.55228 2 9 2Z" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 4.5H4.5V7.5H7.5V4.5Z" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 0.5V2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 0.5V2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.5 10V11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M7.5 10V11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 4.5H11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10 7H11.5" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 4.5H2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M0.5 7H2" stroke="#fff" stroke-linecap="round" stroke-linejoin="round"/>
</g>
<defs>
<clipPath id="clip0_197_2">
<rect width="12" height="12" fill="#fff"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 1.2 KiB