Skip to content

Commit 689da5b

Browse files
authoredDec 12, 2023
feat(site): improve bulk delete flow (#11093)
1 parent 007b2b8 commit 689da5b

File tree

10 files changed

+331
-77
lines changed

10 files changed

+331
-77
lines changed
 

‎site/src/components/Resources/ResourceAvatar.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,19 +10,19 @@ const FALLBACK_ICON = "/icon/widgets.svg";
1010
const BUILT_IN_ICON_PATHS: {
1111
[resourceType: WorkspaceResource["type"]]: string;
1212
} = {
13-
docker_volume: "/icon/folder.svg",
13+
docker_volume: "/icon/database.svg",
1414
docker_container: "/icon/memory.svg",
15-
docker_image: "/icon/image.svg",
16-
kubernetes_persistent_volume_claim: "/icon/folder.svg",
15+
docker_image: "/icon/container.svg",
16+
kubernetes_persistent_volume_claim: "/icon/database.svg",
1717
kubernetes_pod: "/icon/memory.svg",
18-
google_compute_disk: "/icon/folder.svg",
18+
google_compute_disk: "/icon/database.svg",
1919
google_compute_instance: "/icon/memory.svg",
2020
aws_instance: "/icon/memory.svg",
2121
kubernetes_deployment: "/icon/memory.svg",
2222
null_resource: FALLBACK_ICON,
2323
};
2424

25-
const getIconPathResource = (resourceType: string): string => {
25+
export const getIconPathResource = (resourceType: string): string => {
2626
if (resourceType in BUILT_IN_ICON_PATHS) {
2727
return BUILT_IN_ICON_PATHS[resourceType];
2828
}

‎site/src/pages/TemplateSettingsPage/TemplateSchedulePage/ScheduleDialog.tsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -66,10 +66,15 @@ export const ScheduleDialog: FC<PropsWithChildren<ScheduleDialogProps>> = ({
6666
<>
6767
{showDormancyWarning && (
6868
<>
69-
<h4>{"Dormancy Threshold"}</h4>
69+
<h4>Dormancy Threshold</h4>
7070
<Stack direction="row" spacing={5}>
71-
<div css={styles.dialogDescription}>{`
72-
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>
71+
<div css={styles.dialogDescription}>
72+
This change will result in {inactiveWorkspacesToGoDormant}{" "}
73+
workspaces being immediately transitioned to the dormant state
74+
and {inactiveWorkspacesToGoDormantInWeek} over the next seven
75+
days. To prevent this, do you want to reset the inactivity
76+
period for all template workspaces?
77+
</div>
7378
<FormControlLabel
7479
css={{ marginTop: 16 }}
7580
control={
Lines changed: 249 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1-
import { useTheme } from "@emotion/react";
2-
import TextField from "@mui/material/TextField";
1+
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
2+
import ScheduleIcon from "@mui/icons-material/Schedule";
3+
import { visuallyHidden } from "@mui/utils";
4+
import dayjs from "dayjs";
5+
import "dayjs/plugin/relativeTime";
6+
import { type Interpolation, type Theme } from "@emotion/react";
7+
import { type FC, type ReactNode, useState } from "react";
8+
import { useMutation } from "react-query";
39
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
410
import type { Workspace } from "api/typesGenerated";
511
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
612
import { displayError } from "components/GlobalSnackbar/utils";
7-
import { type FC, useState } from "react";
8-
import { useMutation } from "react-query";
9-
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
13+
import { getIconPathResource } from "components/Resources/ResourceAvatar";
14+
import { Stack } from "components/Stack/Stack";
1015

1116
interface UseBatchActionsProps {
1217
onSuccess: () => Promise<void>;
@@ -68,77 +73,257 @@ type BatchDeleteConfirmationProps = {
6873
onConfirm: () => void;
6974
};
7075

71-
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = (
72-
props,
73-
) => {
74-
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
75-
const theme = useTheme();
76-
const [confirmation, setConfirmation] = useState({ value: "", error: false });
77-
78-
const confirmDeletion = () => {
79-
setConfirmation((c) => ({ ...c, error: false }));
76+
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
77+
checkedWorkspaces,
78+
open,
79+
onClose,
80+
onConfirm,
81+
isLoading,
82+
}) => {
83+
const [stage, setStage] = useState<
84+
"consequences" | "workspaces" | "resources"
85+
>("consequences");
8086

81-
if (confirmation.value !== "DELETE") {
82-
setConfirmation((c) => ({ ...c, error: true }));
83-
return;
87+
const onProceed = () => {
88+
switch (stage) {
89+
case "resources":
90+
onConfirm();
91+
break;
92+
case "workspaces":
93+
setStage("resources");
94+
break;
95+
case "consequences":
96+
setStage("workspaces");
97+
break;
8498
}
85-
86-
onConfirm();
8799
};
88100

101+
const workspaceCount = `${checkedWorkspaces.length} ${
102+
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
103+
}`;
104+
105+
let confirmText: ReactNode = <>Review selected workspaces&hellip;</>;
106+
if (stage === "workspaces") {
107+
confirmText = <>Confirm {workspaceCount}&hellip;</>;
108+
}
109+
if (stage === "resources") {
110+
const resources = checkedWorkspaces
111+
.map((workspace) => workspace.latest_build.resources.length)
112+
.reduce((a, b) => a + b, 0);
113+
const resourceCount = `${resources} ${
114+
resources === 1 ? "resource" : "resources"
115+
}`;
116+
confirmText = (
117+
<>
118+
Delete {workspaceCount} and {resourceCount}
119+
</>
120+
);
121+
}
122+
123+
// The flicker of these icons is quit noticeable if they aren't loaded in advance,
124+
// so we insert them into the document without actually displaying them yet.
125+
const resourceIconPreloads = [
126+
...new Set(
127+
checkedWorkspaces.flatMap((workspace) =>
128+
workspace.latest_build.resources.map(
129+
(resource) => resource.icon || getIconPathResource(resource.type),
130+
),
131+
),
132+
),
133+
].map((url) => (
134+
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
135+
));
136+
89137
return (
90138
<ConfirmDialog
91-
type="delete"
92139
open={open}
93-
confirmLoading={isLoading}
94-
onConfirm={confirmDeletion}
95140
onClose={() => {
141+
setStage("consequences");
96142
onClose();
97-
setConfirmation({ value: "", error: false });
98143
}}
99-
title={`Delete ${checkedWorkspaces?.length} ${
100-
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
101-
}`}
144+
title={`Delete ${workspaceCount}`}
145+
hideCancel
146+
confirmLoading={isLoading}
147+
confirmText={confirmText}
148+
onConfirm={onProceed}
149+
type="delete"
102150
description={
103-
<form
104-
onSubmit={async (e) => {
105-
e.preventDefault();
106-
confirmDeletion();
107-
}}
108-
>
109-
<div>
110-
Deleting these workspaces is irreversible! Are you sure you want to
111-
proceed? Type{" "}
112-
<code
113-
css={{
114-
fontFamily: MONOSPACE_FONT_FAMILY,
115-
color: theme.palette.text.primary,
116-
fontWeight: 600,
117-
}}
118-
>
119-
`DELETE`
120-
</code>{" "}
121-
to confirm.
122-
</div>
123-
<TextField
124-
value={confirmation.value}
125-
required
126-
autoFocus
127-
fullWidth
128-
inputProps={{
129-
"aria-label": "Type DELETE to confirm",
130-
}}
131-
placeholder="Type DELETE to confirm"
132-
css={{ marginTop: 16 }}
133-
onChange={(e) => {
134-
const value = e.currentTarget?.value;
135-
setConfirmation((c) => ({ ...c, value }));
136-
}}
137-
error={confirmation.error}
138-
helperText={confirmation.error && "Please type DELETE to confirm"}
139-
/>
140-
</form>
151+
<>
152+
{stage === "consequences" && <Consequences />}
153+
{stage === "workspaces" && (
154+
<Workspaces workspaces={checkedWorkspaces} />
155+
)}
156+
{stage === "resources" && (
157+
<Resources workspaces={checkedWorkspaces} />
158+
)}
159+
{resourceIconPreloads}
160+
</>
141161
}
142162
/>
143163
);
144164
};
165+
166+
interface StageProps {
167+
workspaces: Workspace[];
168+
}
169+
170+
const Consequences: FC = () => {
171+
return (
172+
<>
173+
<p>Deleting workspaces is irreversible!</p>
174+
<ul css={styles.consequences}>
175+
<li>
176+
Terraform resources belonging to deleted workspaces will be destroyed.
177+
</li>
178+
<li>Any data stored in the workspace will be permanently deleted.</li>
179+
</ul>
180+
</>
181+
);
182+
};
183+
184+
const Workspaces: FC<StageProps> = ({ workspaces }) => {
185+
const mostRecent = workspaces.reduce(
186+
(latestSoFar, against) => {
187+
if (!latestSoFar) {
188+
return against;
189+
}
190+
191+
return new Date(against.last_used_at).getTime() >
192+
new Date(latestSoFar.last_used_at).getTime()
193+
? against
194+
: latestSoFar;
195+
},
196+
undefined as Workspace | undefined,
197+
);
198+
199+
const owners = new Set(workspaces.map((it) => it.owner_id)).size;
200+
const ownersCount = `${owners} ${owners === 1 ? "owner" : "owners"}`;
201+
202+
return (
203+
<>
204+
<ul css={styles.workspacesList}>
205+
{workspaces.map((workspace) => (
206+
<li key={workspace.id} css={styles.workspace}>
207+
<Stack
208+
direction="row"
209+
alignItems="center"
210+
justifyContent="space-between"
211+
>
212+
<span css={{ fontWeight: 500, color: "#fff" }}>
213+
{workspace.name}
214+
</span>
215+
<Stack css={{ gap: 0, fontSize: 14, width: 128 }}>
216+
<Stack direction="row" alignItems="center" spacing={1}>
217+
<PersonIcon />
218+
<span
219+
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
220+
>
221+
{workspace.owner_name}
222+
</span>
223+
</Stack>
224+
<Stack direction="row" alignItems="center" spacing={1}>
225+
<ScheduleIcon css={styles.summaryIcon} />
226+
<span
227+
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
228+
>
229+
{dayjs(workspace.last_used_at).fromNow()}
230+
</span>
231+
</Stack>
232+
</Stack>
233+
</Stack>
234+
</li>
235+
))}
236+
</ul>
237+
<Stack justifyContent="center" direction="row" css={{ fontSize: 14 }}>
238+
<Stack direction="row" alignItems="center" spacing={1}>
239+
<PersonIcon />
240+
<span>{ownersCount}</span>
241+
</Stack>
242+
{mostRecent && (
243+
<Stack direction="row" alignItems="center" spacing={1}>
244+
<ScheduleIcon css={styles.summaryIcon} />
245+
<span>Last used {dayjs(mostRecent.last_used_at).fromNow()}</span>
246+
</Stack>
247+
)}
248+
</Stack>
249+
</>
250+
);
251+
};
252+
253+
const Resources: FC<StageProps> = ({ workspaces }) => {
254+
const resources: Record<string, { count: number; icon: string }> = {};
255+
workspaces.forEach((workspace) =>
256+
workspace.latest_build.resources.forEach((resource) => {
257+
if (!resources[resource.type]) {
258+
resources[resource.type] = {
259+
count: 0,
260+
icon: resource.icon || getIconPathResource(resource.type),
261+
};
262+
}
263+
264+
resources[resource.type].count++;
265+
}),
266+
);
267+
268+
return (
269+
<Stack>
270+
<p>
271+
Deleting{" "}
272+
{workspaces.length === 1 ? "this workspace" : "these workspaces"} will
273+
also permanently destroy&hellip;
274+
</p>
275+
<Stack
276+
direction="row"
277+
justifyContent="center"
278+
wrap="wrap"
279+
css={{ gap: "6px 20px", fontSize: 14 }}
280+
>
281+
{Object.entries(resources).map(([type, summary]) => (
282+
<Stack key={type} direction="row" alignItems="center" spacing={1}>
283+
<img alt="" src={summary.icon} css={styles.summaryIcon} />
284+
<span>
285+
{summary.count} <code>{type}</code>
286+
</span>
287+
</Stack>
288+
))}
289+
</Stack>
290+
</Stack>
291+
);
292+
};
293+
294+
const PersonIcon: FC = () => {
295+
// This size doesn't match the rest of the icons because MUI is just really
296+
// inconsistent. We have to make it bigger than the rest, and pull things in
297+
// on the sides to compensate.
298+
return <PersonOutlinedIcon css={{ width: 18, height: 18, margin: -1 }} />;
299+
};
300+
301+
const styles = {
302+
summaryIcon: { width: 16, height: 16 },
303+
304+
consequences: {
305+
display: "flex",
306+
flexDirection: "column",
307+
gap: 8,
308+
paddingLeft: 16,
309+
marginBottom: 0,
310+
},
311+
312+
workspacesList: (theme) => ({
313+
listStyleType: "none",
314+
padding: 0,
315+
border: `1px solid ${theme.palette.divider}`,
316+
borderRadius: 8,
317+
overflow: "hidden auto",
318+
maxHeight: 184,
319+
}),
320+
321+
workspace: (theme) => ({
322+
padding: "8px 16px",
323+
borderBottom: `1px solid ${theme.palette.divider}`,
324+
325+
"&:last-child": {
326+
border: "none",
327+
},
328+
}),
329+
} satisfies Record<string, Interpolation<Theme>>;

0 commit comments

Comments
 (0)