Skip to content

feat(site): improve bulk delete flow #11093

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions site/src/components/Resources/ResourceAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand Down
313 changes: 249 additions & 64 deletions site/src/pages/WorkspacesPage/BatchActions.tsx
Original file line number Diff line number Diff line change
@@ -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>;
Expand Down Expand Up @@ -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 });

const confirmDeletion = () => {
setConfirmation((c) => ({ ...c, error: false }));
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
checkedWorkspaces,
open,
onClose,
onConfirm,
isLoading,
}) => {
const [stage, setStage] = useState<
"consequences" | "workspaces" | "resources"
>("consequences");

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} />
));
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use the loading attr as eadger would not work?

<img loading="eadger" src="..." />

More: https://web.dev/articles/browser-level-image-lazy-loading#relationship_between_the_loading_attribute_and_fetch_priority

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we could add that to these to make sure it snags them, but the problem is that even if we put this on the images where they normally appear, they're not inserted in the DOM until the user is already supposed to be seeing them. the only way to make them load faster is to tell the browser about these images in advance.


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>>;
Loading