|
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"; |
3 | 9 | import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
|
4 | 10 | import type { Workspace } from "api/typesGenerated";
|
5 | 11 | import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
|
6 | 12 | 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"; |
10 | 15 |
|
11 | 16 | interface UseBatchActionsProps {
|
12 | 17 | onSuccess: () => Promise<void>;
|
@@ -68,77 +73,257 @@ type BatchDeleteConfirmationProps = {
|
68 | 73 | onConfirm: () => void;
|
69 | 74 | };
|
70 | 75 |
|
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"); |
80 | 86 |
|
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; |
84 | 98 | }
|
85 |
| - |
86 |
| - onConfirm(); |
87 | 99 | };
|
88 | 100 |
|
| 101 | + const workspaceCount = `${checkedWorkspaces.length} ${ |
| 102 | + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" |
| 103 | + }`; |
| 104 | + |
| 105 | + let confirmText: ReactNode = <>Review selected workspaces…</>; |
| 106 | + if (stage === "workspaces") { |
| 107 | + confirmText = <>Confirm {workspaceCount}…</>; |
| 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 | + |
89 | 137 | return (
|
90 | 138 | <ConfirmDialog
|
91 |
| - type="delete" |
92 | 139 | open={open}
|
93 |
| - confirmLoading={isLoading} |
94 |
| - onConfirm={confirmDeletion} |
95 | 140 | onClose={() => {
|
| 141 | + setStage("consequences"); |
96 | 142 | onClose();
|
97 |
| - setConfirmation({ value: "", error: false }); |
98 | 143 | }}
|
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" |
102 | 150 | 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 | + </> |
141 | 161 | }
|
142 | 162 | />
|
143 | 163 | );
|
144 | 164 | };
|
| 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… |
| 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