Skip to content

Commit ef70165

Browse files
authored
feat: add orphan option to workspace delete in UI (#10654)
* added workspace delete dialog * added stories and tests * PR review * fix flake * fixed stories
1 parent 4f08330 commit ef70165

File tree

10 files changed

+319
-28
lines changed

10 files changed

+319
-28
lines changed

docs/workspaces.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,12 @@ though the exact behavior depends on the template. For more information, see
115115
> You can use `coder show <workspace-name>` to see which resources are
116116
> persistent and which are ephemeral.
117117
118-
When a workspace is deleted, all of the workspace's resources are deleted.
118+
Typically, when a workspace is deleted, all of the workspace's resources are
119+
deleted along with it. Rarely, one may wish to delete a workspace without
120+
deleting its resources, e.g. a workspace in a broken state. Users with the
121+
Template Admin role have the option to do so both in the UI, and also in the CLI
122+
by running the `delete` command with the `--orphan` flag. This option should be
123+
considered cautiously as orphaning may lead to unaccounted cloud resources.
119124

120125
## Repairing workspaces
121126

site/src/api/api.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -545,11 +545,11 @@ export const stopWorkspace = (
545545

546546
export const deleteWorkspace = (
547547
workspaceId: string,
548-
logLevel?: TypesGen.CreateWorkspaceBuildRequest["log_level"],
548+
options?: Pick<TypesGen.CreateWorkspaceBuildRequest, "log_level" & "orphan">,
549549
) =>
550550
postWorkspaceBuild(workspaceId, {
551551
transition: "delete",
552-
log_level: logLevel,
552+
...options,
553553
});
554554

555555
export const cancelWorkspaceBuild = async (

site/src/components/Dialogs/Dialog.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
5959
disabled={disabled}
6060
type="submit"
6161
css={[
62-
type === "delete" && styles.errorButton,
62+
type === "delete" && styles.warningButton,
6363
type === "success" && styles.successButton,
6464
]}
6565
>
@@ -71,26 +71,26 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
7171
};
7272

7373
const styles = {
74-
errorButton: (theme) => ({
74+
warningButton: (theme) => ({
7575
"&.MuiButton-contained": {
76-
backgroundColor: colors.red[10],
77-
borderColor: colors.red[9],
76+
backgroundColor: colors.orange[12],
77+
borderColor: colors.orange[9],
7878

7979
"&:not(.MuiLoadingButton-loading)": {
8080
color: theme.palette.text.primary,
8181
},
8282

8383
"&:hover:not(:disabled)": {
84-
backgroundColor: colors.red[9],
85-
borderColor: colors.red[9],
84+
backgroundColor: colors.orange[9],
85+
borderColor: colors.orange[9],
8686
},
8787

8888
"&.Mui-disabled": {
89-
backgroundColor: colors.red[15],
90-
borderColor: colors.red[15],
89+
backgroundColor: colors.orange[14],
90+
borderColor: colors.orange[15],
9191

9292
"&:not(.MuiLoadingButton-loading)": {
93-
color: colors.red[9],
93+
color: colors.orange[12],
9494
},
9595
},
9696
},

site/src/components/MoreMenu/MoreMenu.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ export const MoreMenuItem = (
113113
{...menuItemProps}
114114
css={(theme) => ({
115115
fontSize: 14,
116-
color: danger ? theme.palette.error.light : undefined,
116+
color: danger ? theme.palette.warning.light : undefined,
117117
"& .MuiSvgIcon-root": {
118118
width: 16,
119119
height: 16,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { type ComponentProps } from "react";
2+
import { Meta, StoryObj } from "@storybook/react";
3+
import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog";
4+
import { MockWorkspace } from "testHelpers/entities";
5+
6+
const meta: Meta<typeof WorkspaceDeleteDialog> = {
7+
title: "pages/WorkspacePage/WorkspaceDeleteDialog",
8+
component: WorkspaceDeleteDialog,
9+
};
10+
11+
export default meta;
12+
type Story = StoryObj<typeof WorkspaceDeleteDialog>;
13+
14+
const args: ComponentProps<typeof WorkspaceDeleteDialog> = {
15+
workspace: MockWorkspace,
16+
canUpdateTemplate: false,
17+
isOpen: true,
18+
onCancel: () => {},
19+
onConfirm: () => {},
20+
workspaceBuildDateStr: "2 days ago",
21+
};
22+
23+
export const NotTemplateAdmin: Story = {
24+
args,
25+
};
26+
27+
export const TemplateAdmin: Story = {
28+
args: {
29+
...args,
30+
canUpdateTemplate: true,
31+
},
32+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { Workspace, CreateWorkspaceBuildRequest } from "api/typesGenerated";
2+
import { useId, useState, FormEvent } from "react";
3+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
4+
import { type Interpolation, type Theme } from "@emotion/react";
5+
import { colors } from "theme/colors";
6+
import TextField from "@mui/material/TextField";
7+
import { docs } from "utils/docs";
8+
import Link from "@mui/material/Link";
9+
import Checkbox from "@mui/material/Checkbox";
10+
11+
const styles = {
12+
workspaceInfo: (theme) => ({
13+
display: "flex",
14+
justifyContent: "space-between",
15+
backgroundColor: colors.gray[14],
16+
border: `1px solid ${theme.palette.divider}`,
17+
borderRadius: 8,
18+
padding: 12,
19+
marginBottom: 20,
20+
lineHeight: "1.3em",
21+
22+
"& .name": {
23+
fontSize: 18,
24+
fontWeight: 800,
25+
color: theme.palette.text.primary,
26+
},
27+
28+
"& .label": {
29+
fontSize: 11,
30+
color: theme.palette.text.secondary,
31+
},
32+
33+
"& .info": {
34+
fontSize: 14,
35+
fontWeight: 500,
36+
color: theme.palette.text.primary,
37+
},
38+
}),
39+
orphanContainer: () => ({
40+
marginTop: 24,
41+
display: "flex",
42+
backgroundColor: colors.orange[15],
43+
justifyContent: "space-between",
44+
border: `1px solid ${colors.orange[11]}`,
45+
borderRadius: 8,
46+
padding: 12,
47+
lineHeight: "18px",
48+
49+
"& .option": {
50+
color: colors.orange[11],
51+
"&.Mui-checked": {
52+
color: colors.orange[11],
53+
},
54+
},
55+
56+
"& .info": {
57+
fontSize: "14px",
58+
color: colors.orange[10],
59+
fontWeight: 500,
60+
},
61+
}),
62+
} satisfies Record<string, Interpolation<Theme>>;
63+
64+
interface WorkspaceDeleteDialogProps {
65+
workspace: Workspace;
66+
canUpdateTemplate: boolean;
67+
isOpen: boolean;
68+
onCancel: () => void;
69+
onConfirm: (arg: CreateWorkspaceBuildRequest["orphan"]) => void;
70+
workspaceBuildDateStr: string;
71+
}
72+
export const WorkspaceDeleteDialog = (props: WorkspaceDeleteDialogProps) => {
73+
const {
74+
workspace,
75+
canUpdateTemplate,
76+
isOpen,
77+
onCancel,
78+
onConfirm,
79+
workspaceBuildDateStr,
80+
} = props;
81+
const hookId = useId();
82+
const [userConfirmationText, setUserConfirmationText] = useState("");
83+
const [orphanWorkspace, setOrphanWorkspace] =
84+
useState<CreateWorkspaceBuildRequest["orphan"]>(false);
85+
const [isFocused, setIsFocused] = useState(false);
86+
87+
const deletionConfirmed = workspace.name === userConfirmationText;
88+
const onSubmit = (event: FormEvent) => {
89+
event.preventDefault();
90+
if (deletionConfirmed) {
91+
onConfirm(orphanWorkspace);
92+
}
93+
};
94+
95+
const hasError = !deletionConfirmed && userConfirmationText.length > 0;
96+
const displayErrorMessage = hasError && !isFocused;
97+
const inputColor = hasError ? "error" : "primary";
98+
99+
return (
100+
<ConfirmDialog
101+
type="delete"
102+
hideCancel={false}
103+
open={isOpen}
104+
title="Delete Workspace"
105+
onConfirm={() => onConfirm(orphanWorkspace)}
106+
onClose={onCancel}
107+
disabled={!deletionConfirmed}
108+
description={
109+
<>
110+
<div css={styles.workspaceInfo}>
111+
<div>
112+
<p className="name">{workspace.name}</p>
113+
<p className="label">workspace</p>
114+
</div>
115+
<div>
116+
<p className="info">{workspaceBuildDateStr}</p>
117+
<p className="label">created</p>
118+
</div>
119+
</div>
120+
121+
<p>Deleting this workspace is irreversible!</p>
122+
<p>
123+
Type &ldquo;<strong>{workspace.name}</strong>&ldquo; below to
124+
confirm:
125+
</p>
126+
127+
<form onSubmit={onSubmit}>
128+
<TextField
129+
fullWidth
130+
autoFocus
131+
css={{ marginTop: 32 }}
132+
name="confirmation"
133+
autoComplete="off"
134+
id={`${hookId}-confirm`}
135+
placeholder={workspace.name}
136+
value={userConfirmationText}
137+
onChange={(event) => setUserConfirmationText(event.target.value)}
138+
onFocus={() => setIsFocused(true)}
139+
onBlur={() => setIsFocused(false)}
140+
label="Workspace name"
141+
color={inputColor}
142+
error={displayErrorMessage}
143+
helperText={
144+
displayErrorMessage &&
145+
`${userConfirmationText} does not match the name of this workspace`
146+
}
147+
InputProps={{ color: inputColor }}
148+
inputProps={{
149+
"data-testid": "delete-dialog-name-confirmation",
150+
}}
151+
/>
152+
{canUpdateTemplate && (
153+
<div css={styles.orphanContainer}>
154+
<div css={{ flexDirection: "column" }}>
155+
<Checkbox
156+
id="orphan_resources"
157+
size="small"
158+
color="warning"
159+
onChange={() => {
160+
setOrphanWorkspace(!orphanWorkspace);
161+
}}
162+
className="option"
163+
name="orphan_resources"
164+
checked={orphanWorkspace}
165+
data-testid="orphan-checkbox"
166+
/>
167+
</div>
168+
<div css={{ flexDirection: "column" }}>
169+
<p className="info">Orphan resources</p>
170+
<span css={{ fontSize: "11px" }}>
171+
Skip resource cleanup. Resources such as volumes and virtual
172+
machines will not be destroyed.&nbsp;
173+
<Link
174+
href={docs("/workspaces#workspace-resources")}
175+
target="_blank"
176+
rel="noreferrer"
177+
>
178+
Learn more...
179+
</Link>
180+
</span>
181+
</div>
182+
</div>
183+
)}
184+
</form>
185+
</>
186+
}
187+
/>
188+
);
189+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./WorkspaceDeleteDialog";

site/src/pages/WorkspacePage/WorkspacePage.test.tsx

+58-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
MockTemplateVersion3,
1616
MockUser,
1717
MockDeploymentConfig,
18+
MockWorkspaceBuildDelete,
1819
} from "testHelpers/entities";
1920
import * as api from "api/api";
2021
import { renderWithAuth } from "testHelpers/renderHelpers";
@@ -90,7 +91,7 @@ describe("WorkspacePage", () => {
9091

9192
// Get dialog and confirm
9293
const dialog = await screen.findByTestId("dialog");
93-
const labelText = "Name of the workspace to delete";
94+
const labelText = "Workspace name";
9495
const textField = within(dialog).getByLabelText(labelText);
9596
await user.type(textField, MockWorkspace.name);
9697
const confirmButton = within(dialog).getByRole("button", {
@@ -101,6 +102,62 @@ describe("WorkspacePage", () => {
101102
expect(deleteWorkspaceMock).toBeCalled();
102103
});
103104

105+
it("orphans the workspace on delete if option is selected", async () => {
106+
const user = userEvent.setup({ delay: 0 });
107+
108+
// set permissions
109+
server.use(
110+
rest.post("/api/v2/authcheck", async (req, res, ctx) => {
111+
return res(
112+
ctx.status(200),
113+
ctx.json({
114+
updateTemplates: true,
115+
updateWorkspace: true,
116+
updateTemplate: true,
117+
}),
118+
);
119+
}),
120+
);
121+
122+
const deleteWorkspaceMock = jest
123+
.spyOn(api, "deleteWorkspace")
124+
.mockResolvedValueOnce(MockWorkspaceBuildDelete);
125+
await renderWorkspacePage();
126+
127+
// open the workspace action popover so we have access to all available ctas
128+
const trigger = screen.getByTestId("workspace-options-button");
129+
await user.click(trigger);
130+
131+
// Click on delete
132+
const button = await screen.findByTestId("delete-button");
133+
await user.click(button);
134+
135+
// Get dialog and enter confirmation text
136+
const dialog = await screen.findByTestId("dialog");
137+
const labelText = "Workspace name";
138+
const textField = within(dialog).getByLabelText(labelText);
139+
await user.type(textField, MockWorkspace.name);
140+
141+
// check orphan option
142+
const orphanCheckbox = within(
143+
screen.getByTestId("orphan-checkbox"),
144+
).getByRole("checkbox");
145+
146+
await user.click(orphanCheckbox);
147+
148+
// confirm
149+
const confirmButton = within(dialog).getByRole("button", {
150+
name: "Delete",
151+
hidden: false,
152+
});
153+
await user.click(confirmButton);
154+
// arguments are workspace.name, log level (undefined), and orphan
155+
expect(deleteWorkspaceMock).toBeCalledWith(MockWorkspace.id, {
156+
log_level: undefined,
157+
orphan: true,
158+
});
159+
});
160+
104161
it("requests a start job when the user presses Start", async () => {
105162
server.use(
106163
rest.get(

0 commit comments

Comments
 (0)