Skip to content

Commit 7f26111

Browse files
feat(site): add stop and start batch actions (#10565)
1 parent 861ae1a commit 7f26111

File tree

11 files changed

+354
-161
lines changed

11 files changed

+354
-161
lines changed

site/src/components/MoreMenu/MoreMenu.tsx

+36-14
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { useRef, useState, createContext, useContext, ReactNode } from "react";
1+
import {
2+
useRef,
3+
useState,
4+
createContext,
5+
useContext,
6+
ReactNode,
7+
cloneElement,
8+
HTMLProps,
9+
forwardRef,
10+
ReactElement,
11+
} from "react";
212
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
313
import Menu, { MenuProps } from "@mui/material/Menu";
414
import MenuItem, { MenuItemProps } from "@mui/material/MenuItem";
@@ -44,23 +54,35 @@ const useMoreMenuContext = () => {
4454
return ctx;
4555
};
4656

47-
export const MoreMenuTrigger = (props: IconButtonProps) => {
57+
export const MoreMenuTrigger = ({
58+
children,
59+
...props
60+
}: HTMLProps<HTMLButtonElement>) => {
4861
const menu = useMoreMenuContext();
4962

50-
return (
51-
<IconButton
52-
aria-controls="more-options"
53-
aria-label="More options"
54-
aria-haspopup="true"
55-
onClick={menu.open}
56-
ref={menu.triggerRef}
57-
{...props}
58-
>
59-
<MoreVertOutlined />
60-
</IconButton>
61-
);
63+
return cloneElement(children as ReactElement, {
64+
"aria-haspopup": "true",
65+
...props,
66+
ref: menu.triggerRef,
67+
onClick: menu.open,
68+
});
6269
};
6370

71+
export const ThreeDotsButton = forwardRef<HTMLButtonElement, IconButtonProps>(
72+
(props, ref) => {
73+
return (
74+
<IconButton
75+
aria-controls="more-options"
76+
aria-label="More options"
77+
ref={ref}
78+
{...props}
79+
>
80+
<MoreVertOutlined />
81+
</IconButton>
82+
);
83+
},
84+
);
85+
6486
export const MoreMenuContent = (props: Omit<MenuProps, "open" | "onClose">) => {
6587
const menu = useMoreMenuContext();
6688

site/src/pages/GroupsPage/GroupPage.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
MoreMenuContent,
5151
MoreMenuItem,
5252
MoreMenuTrigger,
53+
ThreeDotsButton,
5354
} from "components/MoreMenu/MoreMenu";
5455

5556
export const GroupPage: FC = () => {
@@ -287,7 +288,9 @@ const GroupMemberRow = (props: {
287288
<TableCell width="1%">
288289
{canUpdate && (
289290
<MoreMenu>
290-
<MoreMenuTrigger />
291+
<MoreMenuTrigger>
292+
<ThreeDotsButton />
293+
</MoreMenuTrigger>
291294
<MoreMenuContent>
292295
<MoreMenuItem
293296
danger

site/src/pages/TemplatePage/TemplatePageHeader.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
MoreMenuContent,
3232
MoreMenuItem,
3333
MoreMenuTrigger,
34+
ThreeDotsButton,
3435
} from "components/MoreMenu/MoreMenu";
3536
import Divider from "@mui/material/Divider";
3637

@@ -59,7 +60,9 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
5960
return (
6061
<>
6162
<MoreMenu>
62-
<MoreMenuTrigger />
63+
<MoreMenuTrigger>
64+
<ThreeDotsButton />
65+
</MoreMenuTrigger>
6366
<MoreMenuContent>
6467
<MoreMenuItem
6568
onClick={() => {

site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx

+7-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
MoreMenuContent,
3535
MoreMenuItem,
3636
MoreMenuTrigger,
37+
ThreeDotsButton,
3738
} from "components/MoreMenu/MoreMenu";
3839

3940
type AddTemplateUserOrGroupProps = {
@@ -287,7 +288,9 @@ export const TemplatePermissionsPageView: FC<
287288
<TableCell>
288289
{canUpdatePermissions && (
289290
<MoreMenu>
290-
<MoreMenuTrigger />
291+
<MoreMenuTrigger>
292+
<ThreeDotsButton />
293+
</MoreMenuTrigger>
291294
<MoreMenuContent>
292295
<MoreMenuItem
293296
danger
@@ -334,7 +337,9 @@ export const TemplatePermissionsPageView: FC<
334337
<TableCell>
335338
{canUpdatePermissions && (
336339
<MoreMenu>
337-
<MoreMenuTrigger />
340+
<MoreMenuTrigger>
341+
<ThreeDotsButton />
342+
</MoreMenuTrigger>
338343
<MoreMenuContent>
339344
<MoreMenuItem
340345
danger

site/src/pages/UsersPage/UsersTable/UsersTableBody.tsx

+4-1
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
MoreMenuTrigger,
3131
MoreMenuContent,
3232
MoreMenuItem,
33+
ThreeDotsButton,
3334
} from "components/MoreMenu/MoreMenu";
3435
import Divider from "@mui/material/Divider";
3536

@@ -183,7 +184,9 @@ export const UsersTableBody: FC<
183184
{canEditUsers && (
184185
<TableCell>
185186
<MoreMenu>
186-
<MoreMenuTrigger />
187+
<MoreMenuTrigger>
188+
<ThreeDotsButton />
189+
</MoreMenuTrigger>
187190
<MoreMenuContent>
188191
{user.status === "active" || user.status === "dormant" ? (
189192
<MoreMenuItem

site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx

+10-7
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
MoreMenuContent,
2929
MoreMenuItem,
3030
MoreMenuTrigger,
31+
ThreeDotsButton,
3132
} from "components/MoreMenu/MoreMenu";
3233

3334
export interface WorkspaceActionsProps {
@@ -132,13 +133,15 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
132133

133134
{canCancel && <CancelButton handleAction={handleCancel} />}
134135
<MoreMenu>
135-
<MoreMenuTrigger
136-
title="More options"
137-
size="small"
138-
data-testid="workspace-options-button"
139-
aria-controls="workspace-options"
140-
disabled={!canAcceptJobs}
141-
/>
136+
<MoreMenuTrigger>
137+
<ThreeDotsButton
138+
title="More options"
139+
size="small"
140+
data-testid="workspace-options-button"
141+
aria-controls="workspace-options"
142+
disabled={!canAcceptJobs}
143+
/>
144+
</MoreMenuTrigger>
142145

143146
<MoreMenuContent id="workspace-options">
144147
<MoreMenuItem onClick={handleSettings}>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import TextField from "@mui/material/TextField";
2+
import { Box } from "@mui/system";
3+
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
4+
import { Workspace } from "api/typesGenerated";
5+
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
6+
import { displayError } from "components/GlobalSnackbar/utils";
7+
import { useState } from "react";
8+
import { useMutation } from "react-query";
9+
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
10+
11+
export const useBatchActions = (options: {
12+
onSuccess: () => Promise<void>;
13+
}) => {
14+
const { onSuccess } = options;
15+
16+
const startAllMutation = useMutation({
17+
mutationFn: async (workspaces: Workspace[]) => {
18+
return Promise.all(
19+
workspaces.map((w) =>
20+
startWorkspace(w.id, w.latest_build.template_version_id),
21+
),
22+
);
23+
},
24+
onSuccess,
25+
onError: () => {
26+
displayError("Failed to start workspaces");
27+
},
28+
});
29+
30+
const stopAllMutation = useMutation({
31+
mutationFn: async (workspaces: Workspace[]) => {
32+
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
33+
},
34+
onSuccess,
35+
onError: () => {
36+
displayError("Failed to stop workspaces");
37+
},
38+
});
39+
40+
const deleteAllMutation = useMutation({
41+
mutationFn: async (workspaces: Workspace[]) => {
42+
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
43+
},
44+
onSuccess,
45+
onError: () => {
46+
displayError("Failed to delete workspaces");
47+
},
48+
});
49+
50+
return {
51+
startAll: startAllMutation.mutateAsync,
52+
stopAll: stopAllMutation.mutateAsync,
53+
deleteAll: deleteAllMutation.mutateAsync,
54+
isLoading:
55+
startAllMutation.isLoading ||
56+
stopAllMutation.isLoading ||
57+
deleteAllMutation.isLoading,
58+
};
59+
};
60+
61+
type BatchDeleteConfirmationProps = {
62+
checkedWorkspaces: Workspace[];
63+
open: boolean;
64+
isLoading: boolean;
65+
onClose: () => void;
66+
onConfirm: () => void;
67+
};
68+
69+
export const BatchDeleteConfirmation = (
70+
props: BatchDeleteConfirmationProps,
71+
) => {
72+
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
73+
const [confirmation, setConfirmation] = useState({ value: "", error: false });
74+
75+
const confirmDeletion = () => {
76+
setConfirmation((c) => ({ ...c, error: false }));
77+
78+
if (confirmation.value !== "DELETE") {
79+
setConfirmation((c) => ({ ...c, error: true }));
80+
return;
81+
}
82+
83+
onConfirm();
84+
};
85+
86+
return (
87+
<ConfirmDialog
88+
type="delete"
89+
open={open}
90+
confirmLoading={isLoading}
91+
onConfirm={confirmDeletion}
92+
onClose={() => {
93+
onClose();
94+
setConfirmation({ value: "", error: false });
95+
}}
96+
title={`Delete ${checkedWorkspaces?.length} ${
97+
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
98+
}`}
99+
description={
100+
<form
101+
onSubmit={async (e) => {
102+
e.preventDefault();
103+
confirmDeletion();
104+
}}
105+
>
106+
<Box>
107+
Deleting these workspaces is irreversible! Are you sure you want to
108+
proceed? Type{" "}
109+
<Box
110+
component="code"
111+
sx={{
112+
fontFamily: MONOSPACE_FONT_FAMILY,
113+
color: (theme) => theme.palette.text.primary,
114+
fontWeight: 600,
115+
}}
116+
>
117+
`DELETE`
118+
</Box>{" "}
119+
to confirm.
120+
</Box>
121+
<TextField
122+
value={confirmation.value}
123+
required
124+
autoFocus
125+
fullWidth
126+
inputProps={{
127+
"aria-label": "Type DELETE to confirm",
128+
}}
129+
placeholder="Type DELETE to confirm"
130+
sx={{ mt: 2 }}
131+
onChange={(e) => {
132+
const value = e.currentTarget?.value;
133+
setConfirmation((c) => ({ ...c, value }));
134+
}}
135+
error={confirmation.error}
136+
helperText={confirmation.error && "Please type DELETE to confirm"}
137+
/>
138+
</form>
139+
}
140+
/>
141+
);
142+
};

0 commit comments

Comments
 (0)