Skip to content

Commit 5ae6cda

Browse files
authored
feat: add warning message when trying to delete active template (#10142)
* refactor: clean up TemplatePageHeader * chore: add react query configs for workspace lists * feat: add delete-intercept functionality * refactor: improve readability * refactor: rename entities for readability/accuracy * refactor: clean up variable names again * refactor: remove redudant function calls * fix: update logic check for safe deletions * fix: update workspaces query logic * fix: update call site for workspaces key
1 parent 78b9201 commit 5ae6cda

File tree

4 files changed

+183
-95
lines changed

4 files changed

+183
-95
lines changed

site/src/api/queries/workspaces.ts

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import * as API from "api/api";
2+
import { type QueryOptions } from "react-query";
3+
import {
4+
type WorkspacesResponse,
5+
type WorkspacesRequest,
6+
} from "api/typesGenerated";
7+
8+
export function workspacesKey(config: WorkspacesRequest = {}) {
9+
const { q, limit } = config;
10+
return ["workspaces", { q, limit }] as const;
11+
}
12+
13+
export function workspaces(config: WorkspacesRequest = {}) {
14+
// Duplicates some of the work from workspacesKey, but that felt better than
15+
// letting invisible properties sneak into the query logic
16+
const { q, limit } = config;
17+
18+
return {
19+
queryKey: workspacesKey(config),
20+
queryFn: () => API.getWorkspaces({ q, limit }),
21+
} as const satisfies QueryOptions<WorkspacesResponse>;
22+
}
Original file line numberDiff line numberDiff line change
@@ -1,114 +1,178 @@
1-
import Button from "@mui/material/Button";
2-
import AddIcon from "@mui/icons-material/AddOutlined";
1+
import { type FC, useRef, useState } from "react";
2+
import { Link as RouterLink, useNavigate } from "react-router-dom";
3+
import { useDeletionDialogState } from "./useDeletionDialogState";
4+
5+
import { useQuery } from "react-query";
6+
import { workspaces } from "api/queries/workspaces";
7+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
38
import {
49
AuthorizationResponse,
510
Template,
611
TemplateVersion,
712
} from "api/typesGenerated";
13+
814
import { Avatar } from "components/Avatar/Avatar";
915
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
16+
import { Stack } from "components/Stack/Stack";
17+
import { Margins } from "components/Margins/Margins";
1018
import {
1119
PageHeader,
1220
PageHeaderTitle,
1321
PageHeaderSubtitle,
1422
} from "components/PageHeader/PageHeader";
15-
import { Stack } from "components/Stack/Stack";
16-
import { FC, useRef, useState } from "react";
17-
import { Link as RouterLink, useNavigate } from "react-router-dom";
18-
import { useDeleteTemplate } from "./deleteTemplate";
19-
import { Margins } from "components/Margins/Margins";
23+
24+
import Button from "@mui/material/Button";
2025
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
2126
import Menu from "@mui/material/Menu";
2227
import MenuItem from "@mui/material/MenuItem";
23-
import SettingsOutlined from "@mui/icons-material/SettingsOutlined";
24-
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
25-
import EditOutlined from "@mui/icons-material/EditOutlined";
26-
import FileCopyOutlined from "@mui/icons-material/FileCopyOutlined";
2728
import IconButton from "@mui/material/IconButton";
29+
import AddIcon from "@mui/icons-material/AddOutlined";
30+
import SettingsIcon from "@mui/icons-material/SettingsOutlined";
31+
import DeleteIcon from "@mui/icons-material/DeleteOutlined";
32+
import EditIcon from "@mui/icons-material/EditOutlined";
33+
import CopyIcon from "@mui/icons-material/FileCopyOutlined";
2834

29-
const TemplateMenu: FC<{
35+
type TemplateMenuProps = {
3036
templateName: string;
3137
templateVersion: string;
38+
templateId: string;
3239
onDelete: () => void;
33-
}> = ({ templateName, templateVersion, onDelete }) => {
40+
};
41+
42+
const TemplateMenu: FC<TemplateMenuProps> = ({
43+
templateName,
44+
templateVersion,
45+
templateId,
46+
onDelete,
47+
}) => {
48+
const dialogState = useDeletionDialogState(templateId, onDelete);
3449
const menuTriggerRef = useRef<HTMLButtonElement>(null);
3550
const [isMenuOpen, setIsMenuOpen] = useState(false);
3651
const navigate = useNavigate();
3752

53+
const queryText = `template:${templateName}`;
54+
const workspaceCountQuery = useQuery({
55+
...workspaces({ q: queryText }),
56+
select: (res) => res.count,
57+
});
58+
3859
// Returns a function that will execute the action and close the menu
3960
const onMenuItemClick = (actionFn: () => void) => () => {
4061
setIsMenuOpen(false);
41-
4262
actionFn();
4363
};
4464

65+
const safeToDeleteTemplate = workspaceCountQuery.data === 0;
66+
4567
return (
46-
<div>
47-
<IconButton
48-
aria-controls="template-options"
49-
aria-haspopup="true"
50-
onClick={() => setIsMenuOpen(true)}
51-
ref={menuTriggerRef}
52-
arial-label="More options"
53-
>
54-
<MoreVertOutlined />
55-
</IconButton>
56-
57-
<Menu
58-
id="template-options"
59-
anchorEl={menuTriggerRef.current}
60-
open={isMenuOpen}
61-
onClose={() => setIsMenuOpen(false)}
62-
>
63-
<MenuItem
64-
onClick={onMenuItemClick(() =>
65-
navigate(`/templates/${templateName}/settings`),
66-
)}
68+
<>
69+
<div>
70+
<IconButton
71+
aria-controls="template-options"
72+
aria-haspopup="true"
73+
onClick={() => setIsMenuOpen(true)}
74+
ref={menuTriggerRef}
75+
arial-label="More options"
6776
>
68-
<SettingsOutlined />
69-
Settings
70-
</MenuItem>
71-
<MenuItem
72-
onClick={onMenuItemClick(() =>
73-
navigate(
74-
`/templates/${templateName}/versions/${templateVersion}/edit`,
75-
),
76-
)}
77-
>
78-
<EditOutlined />
79-
Edit files
80-
</MenuItem>
81-
<MenuItem
82-
onClick={onMenuItemClick(() =>
83-
navigate(`/templates/new?fromTemplate=${templateName}`),
84-
)}
77+
<MoreVertOutlined />
78+
</IconButton>
79+
80+
<Menu
81+
id="template-options"
82+
anchorEl={menuTriggerRef.current}
83+
open={isMenuOpen}
84+
onClose={() => setIsMenuOpen(false)}
8585
>
86-
<FileCopyOutlined />
87-
Duplicate&hellip;
88-
</MenuItem>
89-
<MenuItem onClick={onMenuItemClick(onDelete)}>
90-
<DeleteOutlined />
91-
Delete&hellip;
92-
</MenuItem>
93-
</Menu>
94-
</div>
86+
<MenuItem
87+
onClick={onMenuItemClick(() =>
88+
navigate(`/templates/${templateName}/settings`),
89+
)}
90+
>
91+
<SettingsIcon />
92+
Settings
93+
</MenuItem>
94+
95+
<MenuItem
96+
onClick={onMenuItemClick(() =>
97+
navigate(
98+
`/templates/${templateName}/versions/${templateVersion}/edit`,
99+
),
100+
)}
101+
>
102+
<EditIcon />
103+
Edit files
104+
</MenuItem>
105+
106+
<MenuItem
107+
onClick={onMenuItemClick(() =>
108+
navigate(`/templates/new?fromTemplate=${templateName}`),
109+
)}
110+
>
111+
<CopyIcon />
112+
Duplicate&hellip;
113+
</MenuItem>
114+
115+
<MenuItem
116+
onClick={onMenuItemClick(dialogState.openDeleteConfirmation)}
117+
>
118+
<DeleteIcon />
119+
Delete&hellip;
120+
</MenuItem>
121+
</Menu>
122+
</div>
123+
124+
{safeToDeleteTemplate ? (
125+
<DeleteDialog
126+
isOpen={dialogState.isDeleteDialogOpen}
127+
onConfirm={dialogState.confirmDelete}
128+
onCancel={dialogState.cancelDeleteConfirmation}
129+
entity="template"
130+
name={templateName}
131+
/>
132+
) : (
133+
<ConfirmDialog
134+
type="info"
135+
title="Unable to delete"
136+
hideCancel={false}
137+
open={dialogState.isDeleteDialogOpen}
138+
onClose={dialogState.cancelDeleteConfirmation}
139+
confirmText="See workspaces"
140+
confirmLoading={workspaceCountQuery.status !== "success"}
141+
onConfirm={() => {
142+
navigate({
143+
pathname: "/workspaces",
144+
search: new URLSearchParams({ filter: queryText }).toString(),
145+
});
146+
}}
147+
description={
148+
<>
149+
{workspaceCountQuery.isSuccess && (
150+
<>
151+
This template is used by{" "}
152+
<strong>
153+
{workspaceCountQuery.data} workspace
154+
{workspaceCountQuery.data === 1 ? "" : "s"}
155+
</strong>
156+
. Please delete all related workspaces before deleting this
157+
template.
158+
</>
159+
)}
160+
161+
{workspaceCountQuery.isLoading && (
162+
<>Loading information about workspaces used by this template.</>
163+
)}
164+
165+
{workspaceCountQuery.isError && (
166+
<>Unable to determine workspaces used by this template.</>
167+
)}
168+
</>
169+
}
170+
/>
171+
)}
172+
</>
95173
);
96174
};
97175

98-
const CreateWorkspaceButton: FC<{
99-
templateName: string;
100-
className?: string;
101-
}> = ({ templateName }) => (
102-
<Button
103-
variant="contained"
104-
startIcon={<AddIcon />}
105-
component={RouterLink}
106-
to={`/templates/${templateName}/workspace`}
107-
>
108-
Create Workspace
109-
</Button>
110-
);
111-
112176
export type TemplatePageHeaderProps = {
113177
template: Template;
114178
activeVersion: TemplateVersion;
@@ -123,19 +187,27 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
123187
onDeleteTemplate,
124188
}) => {
125189
const hasIcon = template.icon && template.icon !== "";
126-
const deleteTemplate = useDeleteTemplate(template, onDeleteTemplate);
127190

128191
return (
129192
<Margins>
130193
<PageHeader
131194
actions={
132195
<>
133-
<CreateWorkspaceButton templateName={template.name} />
196+
<Button
197+
variant="contained"
198+
startIcon={<AddIcon />}
199+
component={RouterLink}
200+
to={`/templates/${template.name}/workspace`}
201+
>
202+
Create Workspace
203+
</Button>
204+
134205
{permissions.canUpdateTemplate && (
135206
<TemplateMenu
136207
templateVersion={activeVersion.name}
137208
templateName={template.name}
138-
onDelete={deleteTemplate.openDeleteConfirmation}
209+
templateId={template.id}
210+
onDelete={onDeleteTemplate}
139211
/>
140212
)}
141213
</>
@@ -154,6 +226,7 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
154226
? template.display_name
155227
: template.name}
156228
</PageHeaderTitle>
229+
157230
{template.description !== "" && (
158231
<PageHeaderSubtitle condensed>
159232
{template.description}
@@ -162,15 +235,6 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
162235
</div>
163236
</Stack>
164237
</PageHeader>
165-
166-
<DeleteDialog
167-
isOpen={deleteTemplate.isDeleteDialogOpen}
168-
confirmLoading={deleteTemplate.state.status === "deleting"}
169-
onConfirm={deleteTemplate.confirmDelete}
170-
onCancel={deleteTemplate.cancelDeleteConfirmation}
171-
entity="template"
172-
name={template.name}
173-
/>
174238
</Margins>
175239
);
176240
};

site/src/pages/TemplatePage/deleteTemplate.test.ts renamed to site/src/pages/TemplatePage/useDeletionDialogState.test.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
import { act, renderHook, waitFor } from "@testing-library/react";
22
import { MockTemplate } from "testHelpers/entities";
3-
import { useDeleteTemplate } from "./deleteTemplate";
3+
import { useDeletionDialogState } from "./useDeletionDialogState";
44
import * as API from "api/api";
55

66
test("delete dialog starts closed", () => {
77
const { result } = renderHook(() =>
8-
useDeleteTemplate(MockTemplate, jest.fn()),
8+
useDeletionDialogState(MockTemplate.id, jest.fn()),
99
);
1010
expect(result.current.isDeleteDialogOpen).toBeFalsy();
1111
});
1212

1313
test("confirm template deletion", async () => {
1414
const onDeleteTemplate = jest.fn();
1515
const { result } = renderHook(() =>
16-
useDeleteTemplate(MockTemplate, onDeleteTemplate),
16+
useDeletionDialogState(MockTemplate.id, onDeleteTemplate),
1717
);
1818

1919
//Open delete confirmation
@@ -31,7 +31,7 @@ test("confirm template deletion", async () => {
3131

3232
test("cancel template deletion", () => {
3333
const { result } = renderHook(() =>
34-
useDeleteTemplate(MockTemplate, jest.fn()),
34+
useDeletionDialogState(MockTemplate.id, jest.fn()),
3535
);
3636

3737
//Open delete confirmation

site/src/pages/TemplatePage/deleteTemplate.ts renamed to site/src/pages/TemplatePage/useDeletionDialogState.ts

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1+
import { useState } from "react";
12
import { deleteTemplate } from "api/api";
23
import { getErrorMessage } from "api/errors";
3-
import { Template } from "api/typesGenerated";
44
import { displayError } from "components/GlobalSnackbar/utils";
5-
import { useState } from "react";
65

76
type DeleteTemplateState =
87
| { status: "idle" }
98
| { status: "confirming" }
109
| { status: "deleting" };
1110

12-
export const useDeleteTemplate = (template: Template, onDelete: () => void) => {
11+
export const useDeletionDialogState = (
12+
templateId: string,
13+
onDelete: () => void,
14+
) => {
1315
const [state, setState] = useState<DeleteTemplateState>({ status: "idle" });
1416
const isDeleteDialogOpen =
1517
state.status === "confirming" || state.status === "deleting";
@@ -25,7 +27,7 @@ export const useDeleteTemplate = (template: Template, onDelete: () => void) => {
2527
const confirmDelete = async () => {
2628
try {
2729
setState({ status: "deleting" });
28-
await deleteTemplate(template.id);
30+
await deleteTemplate(templateId);
2931
onDelete();
3032
} catch (e) {
3133
setState({ status: "confirming" });

0 commit comments

Comments
 (0)