coder/site/src/pages/WorkspacesPage/BatchDeleteConfirmation.tsx

297 lines
8.2 KiB
TypeScript

import { useTheme, type Interpolation, type Theme } from "@emotion/react";
import PersonOutlinedIcon from "@mui/icons-material/PersonOutlined";
import ScheduleIcon from "@mui/icons-material/Schedule";
import { visuallyHidden } from "@mui/utils";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { type FC, type ReactNode, useState } from "react";
import type { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Stack } from "components/Stack/Stack";
import { getResourceIconPath } from "utils/workspace";
dayjs.extend(relativeTime);
type BatchDeleteConfirmationProps = {
checkedWorkspaces: readonly Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export const BatchDeleteConfirmation: FC<BatchDeleteConfirmationProps> = ({
checkedWorkspaces,
open,
onClose,
onConfirm,
isLoading,
}) => {
const [stage, setStage] = useState<
"consequences" | "workspaces" | "resources"
>("consequences");
const onProceed = () => {
switch (stage) {
case "resources":
onConfirm();
break;
case "workspaces":
setStage("resources");
break;
case "consequences":
setStage("workspaces");
break;
}
};
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 || getResourceIconPath(resource.type),
),
),
),
].map((url) => (
<img key={url} alt="" aria-hidden css={{ ...visuallyHidden }} src={url} />
));
return (
<ConfirmDialog
type="delete"
open={open}
onClose={() => {
setStage("consequences");
onClose();
}}
title={`Delete ${workspaceCount}`}
hideCancel
confirmLoading={isLoading}
confirmText={confirmText}
onConfirm={onProceed}
description={
<>
{stage === "consequences" && <Consequences />}
{stage === "workspaces" && (
<Workspaces workspaces={checkedWorkspaces} />
)}
{stage === "resources" && (
<Resources workspaces={checkedWorkspaces} />
)}
{resourceIconPreloads}
</>
}
/>
);
};
interface StageProps {
workspaces: readonly 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 theme = useTheme();
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"
spacing={3}
>
<span
css={{ fontWeight: 500, color: theme.experimental.l1.text }}
>
{workspace.name}
</span>
<Stack css={{ gap: 0, fontSize: 14 }} justifyContent="flex-end">
<Stack
direction="row"
alignItems="center"
justifyContent="flex-end"
spacing={1}
>
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{workspace.owner_name}
</span>
<PersonIcon />
</Stack>
<Stack
direction="row"
alignItems="center"
spacing={1}
justifyContent="flex-end"
>
<span
css={{ whiteSpace: "nowrap", textOverflow: "ellipsis" }}
>
{dayjs(workspace.last_used_at).fromNow()}
</span>
<ScheduleIcon css={styles.summaryIcon} />
</Stack>
</Stack>
</Stack>
</li>
))}
</ul>
<Stack
justifyContent="center"
direction="row"
wrap="wrap"
css={{ gap: "6px 20px", 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 || getResourceIconPath(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>>;