feat(site): add stop and start batch actions (#10565)

This commit is contained in:
Bruno Quaresma
2023-11-08 09:29:22 -03:00
committed by GitHub
parent 861ae1a23a
commit 7f26111c01
11 changed files with 354 additions and 161 deletions
+36 -14
View File
@@ -1,4 +1,14 @@
import { useRef, useState, createContext, useContext, ReactNode } from "react";
import {
useRef,
useState,
createContext,
useContext,
ReactNode,
cloneElement,
HTMLProps,
forwardRef,
ReactElement,
} from "react";
import MoreVertOutlined from "@mui/icons-material/MoreVertOutlined";
import Menu, { MenuProps } from "@mui/material/Menu";
import MenuItem, { MenuItemProps } from "@mui/material/MenuItem";
@@ -44,23 +54,35 @@ const useMoreMenuContext = () => {
return ctx;
};
export const MoreMenuTrigger = (props: IconButtonProps) => {
export const MoreMenuTrigger = ({
children,
...props
}: HTMLProps<HTMLButtonElement>) => {
const menu = useMoreMenuContext();
return (
<IconButton
aria-controls="more-options"
aria-label="More options"
aria-haspopup="true"
onClick={menu.open}
ref={menu.triggerRef}
{...props}
>
<MoreVertOutlined />
</IconButton>
);
return cloneElement(children as ReactElement, {
"aria-haspopup": "true",
...props,
ref: menu.triggerRef,
onClick: menu.open,
});
};
export const ThreeDotsButton = forwardRef<HTMLButtonElement, IconButtonProps>(
(props, ref) => {
return (
<IconButton
aria-controls="more-options"
aria-label="More options"
ref={ref}
{...props}
>
<MoreVertOutlined />
</IconButton>
);
},
);
export const MoreMenuContent = (props: Omit<MenuProps, "open" | "onClose">) => {
const menu = useMoreMenuContext();
+4 -1
View File
@@ -50,6 +50,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
export const GroupPage: FC = () => {
@@ -287,7 +288,9 @@ const GroupMemberRow = (props: {
<TableCell width="1%">
{canUpdate && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
@@ -31,6 +31,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";
@@ -59,7 +60,9 @@ const TemplateMenu: FC<TemplateMenuProps> = ({
return (
<>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
@@ -34,6 +34,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
type AddTemplateUserOrGroupProps = {
@@ -287,7 +288,9 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
@@ -334,7 +337,9 @@ export const TemplatePermissionsPageView: FC<
<TableCell>
{canUpdatePermissions && (
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
danger
@@ -30,6 +30,7 @@ import {
MoreMenuTrigger,
MoreMenuContent,
MoreMenuItem,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
import Divider from "@mui/material/Divider";
@@ -183,7 +184,9 @@ export const UsersTableBody: FC<
{canEditUsers && (
<TableCell>
<MoreMenu>
<MoreMenuTrigger />
<MoreMenuTrigger>
<ThreeDotsButton />
</MoreMenuTrigger>
<MoreMenuContent>
{user.status === "active" || user.status === "dormant" ? (
<MoreMenuItem
@@ -28,6 +28,7 @@ import {
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
ThreeDotsButton,
} from "components/MoreMenu/MoreMenu";
export interface WorkspaceActionsProps {
@@ -132,13 +133,15 @@ export const WorkspaceActions: FC<WorkspaceActionsProps> = ({
{canCancel && <CancelButton handleAction={handleCancel} />}
<MoreMenu>
<MoreMenuTrigger
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
/>
<MoreMenuTrigger>
<ThreeDotsButton
title="More options"
size="small"
data-testid="workspace-options-button"
aria-controls="workspace-options"
disabled={!canAcceptJobs}
/>
</MoreMenuTrigger>
<MoreMenuContent id="workspace-options">
<MoreMenuItem onClick={handleSettings}>
@@ -0,0 +1,142 @@
import TextField from "@mui/material/TextField";
import { Box } from "@mui/system";
import { deleteWorkspace, startWorkspace, stopWorkspace } from "api/api";
import { Workspace } from "api/typesGenerated";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { displayError } from "components/GlobalSnackbar/utils";
import { useState } from "react";
import { useMutation } from "react-query";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
export const useBatchActions = (options: {
onSuccess: () => Promise<void>;
}) => {
const { onSuccess } = options;
const startAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(
workspaces.map((w) =>
startWorkspace(w.id, w.latest_build.template_version_id),
),
);
},
onSuccess,
onError: () => {
displayError("Failed to start workspaces");
},
});
const stopAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => stopWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to stop workspaces");
},
});
const deleteAllMutation = useMutation({
mutationFn: async (workspaces: Workspace[]) => {
return Promise.all(workspaces.map((w) => deleteWorkspace(w.id)));
},
onSuccess,
onError: () => {
displayError("Failed to delete workspaces");
},
});
return {
startAll: startAllMutation.mutateAsync,
stopAll: stopAllMutation.mutateAsync,
deleteAll: deleteAllMutation.mutateAsync,
isLoading:
startAllMutation.isLoading ||
stopAllMutation.isLoading ||
deleteAllMutation.isLoading,
};
};
type BatchDeleteConfirmationProps = {
checkedWorkspaces: Workspace[];
open: boolean;
isLoading: boolean;
onClose: () => void;
onConfirm: () => void;
};
export const BatchDeleteConfirmation = (
props: BatchDeleteConfirmationProps,
) => {
const { checkedWorkspaces, open, onClose, onConfirm, isLoading } = props;
const [confirmation, setConfirmation] = useState({ value: "", error: false });
const confirmDeletion = () => {
setConfirmation((c) => ({ ...c, error: false }));
if (confirmation.value !== "DELETE") {
setConfirmation((c) => ({ ...c, error: true }));
return;
}
onConfirm();
};
return (
<ConfirmDialog
type="delete"
open={open}
confirmLoading={isLoading}
onConfirm={confirmDeletion}
onClose={() => {
onClose();
setConfirmation({ value: "", error: false });
}}
title={`Delete ${checkedWorkspaces?.length} ${
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
}`}
description={
<form
onSubmit={async (e) => {
e.preventDefault();
confirmDeletion();
}}
>
<Box>
Deleting these workspaces is irreversible! Are you sure you want to
proceed? Type{" "}
<Box
component="code"
sx={{
fontFamily: MONOSPACE_FONT_FAMILY,
color: (theme) => theme.palette.text.primary,
fontWeight: 600,
}}
>
`DELETE`
</Box>{" "}
to confirm.
</Box>
<TextField
value={confirmation.value}
required
autoFocus
fullWidth
inputProps={{
"aria-label": "Type DELETE to confirm",
}}
placeholder="Type DELETE to confirm"
sx={{ mt: 2 }}
onChange={(e) => {
const value = e.currentTarget?.value;
setConfirmation((c) => ({ ...c, value }));
}}
error={confirmation.error}
helperText={confirmation.error && "Please type DELETE to confirm"}
/>
</form>
}
/>
);
};
@@ -1,7 +1,11 @@
import { screen, waitFor, within } from "@testing-library/react";
import { rest } from "msw";
import * as CreateDayString from "utils/createDayString";
import { MockWorkspace, MockWorkspacesResponse } from "testHelpers/entities";
import {
MockStoppedWorkspace,
MockWorkspace,
MockWorkspacesResponse,
} from "testHelpers/entities";
import {
renderWithAuth,
waitForLoaderToBeRemoved,
@@ -59,7 +63,9 @@ describe("WorkspacesPage", () => {
await user.click(getWorkspaceCheckbox(workspaces[0]));
await user.click(getWorkspaceCheckbox(workspaces[1]));
await user.click(screen.getByRole("button", { name: /delete selected/i }));
await user.click(screen.getByRole("button", { name: /actions/i }));
const deleteButton = await screen.findByText(/delete/i);
await user.click(deleteButton);
await user.type(screen.getByLabelText(/type delete to confirm/i), "DELETE");
await user.click(screen.getByTestId("confirm-button"));
@@ -69,6 +75,66 @@ describe("WorkspacesPage", () => {
expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[0].id);
expect(deleteWorkspace).toHaveBeenCalledWith(workspaces[1].id);
});
it("stops only the running and selected workspaces", async () => {
const workspaces = [
{ ...MockWorkspace, id: "1" },
{ ...MockWorkspace, id: "2" },
{ ...MockWorkspace, id: "3" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const stopWorkspace = jest.spyOn(API, "stopWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
await user.click(getWorkspaceCheckbox(workspaces[0]));
await user.click(getWorkspaceCheckbox(workspaces[1]));
await user.click(screen.getByRole("button", { name: /actions/i }));
const stopButton = await screen.findByText(/stop/i);
await user.click(stopButton);
await waitFor(() => {
expect(stopWorkspace).toHaveBeenCalledTimes(2);
});
expect(stopWorkspace).toHaveBeenCalledWith(workspaces[0].id);
expect(stopWorkspace).toHaveBeenCalledWith(workspaces[1].id);
});
it("starts only the stopped and selected workspaces", async () => {
const workspaces = [
{ ...MockStoppedWorkspace, id: "1" },
{ ...MockStoppedWorkspace, id: "2" },
{ ...MockStoppedWorkspace, id: "3" },
];
jest
.spyOn(API, "getWorkspaces")
.mockResolvedValue({ workspaces, count: workspaces.length });
const startWorkspace = jest.spyOn(API, "startWorkspace");
const user = userEvent.setup();
renderWithAuth(<WorkspacesPage />);
await waitForLoaderToBeRemoved();
await user.click(getWorkspaceCheckbox(workspaces[0]));
await user.click(getWorkspaceCheckbox(workspaces[1]));
await user.click(screen.getByRole("button", { name: /actions/i }));
const startButton = await screen.findByText(/start/i);
await user.click(startButton);
await waitFor(() => {
expect(startWorkspace).toHaveBeenCalledTimes(2);
});
expect(startWorkspace).toHaveBeenCalledWith(
workspaces[0].id,
MockStoppedWorkspace.latest_build.template_version_id,
);
expect(startWorkspace).toHaveBeenCalledWith(
workspaces[1].id,
MockStoppedWorkspace.latest_build.template_version_id,
);
});
});
const getWorkspaceCheckbox = (workspace: Workspace) => {
+20 -121
View File
@@ -14,16 +14,11 @@ import { useTemplateFilterMenu, useStatusFilterMenu } from "./filter/menus";
import { useSearchParams } from "react-router-dom";
import { useFilter } from "components/Filter/filter";
import { useUserFilterMenu } from "components/Filter/UserFilter";
import { deleteWorkspace, getWorkspaces } from "api/api";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import Box from "@mui/material/Box";
import { MONOSPACE_FONT_FAMILY } from "theme/constants";
import TextField from "@mui/material/TextField";
import { displayError } from "components/GlobalSnackbar/utils";
import { getErrorMessage } from "api/errors";
import { getWorkspaces } from "api/api";
import { useEffectEvent } from "hooks/hookPolyfills";
import { useQuery } from "react-query";
import { templates } from "api/queries/templates";
import { BatchDeleteConfirmation, useBatchActions } from "./BatchActions";
function useSafeSearchParams() {
// Have to wrap setSearchParams because React Router doesn't make sure that
@@ -92,12 +87,18 @@ const WorkspacesPage: FC = () => {
}, [experimentEnabled, data, filterProps.filter.query]);
const updateWorkspace = useWorkspaceUpdate(queryKey);
const [checkedWorkspaces, setCheckedWorkspaces] = useState<Workspace[]>([]);
const [isDeletingAll, setIsDeletingAll] = useState(false);
const [isConfirmingDeleteAll, setIsConfirmingDeleteAll] = useState(false);
const [urlSearchParams] = searchParamsResult;
const { entitlements } = useDashboard();
const canCheckWorkspaces =
entitlements.features["workspace_batch_actions"].enabled;
const permissions = usePermissions();
const batchActions = useBatchActions({
onSuccess: async () => {
await refetch();
setCheckedWorkspaces([]);
},
});
// We want to uncheck the selected workspaces always when the url changes
// because of filtering or pagination
@@ -129,20 +130,24 @@ const WorkspacesPage: FC = () => {
onUpdateWorkspace={(workspace) => {
updateWorkspace.mutate(workspace);
}}
isRunningBatchAction={batchActions.isLoading}
onDeleteAll={() => {
setIsDeletingAll(true);
setIsConfirmingDeleteAll(true);
}}
onStartAll={() => batchActions.startAll(checkedWorkspaces)}
onStopAll={() => batchActions.stopAll(checkedWorkspaces)}
/>
<BatchDeleteConfirmation
isLoading={batchActions.isLoading}
checkedWorkspaces={checkedWorkspaces}
open={isDeletingAll}
onClose={() => {
setIsDeletingAll(false);
open={isConfirmingDeleteAll}
onConfirm={async () => {
await batchActions.deleteAll(checkedWorkspaces);
setIsConfirmingDeleteAll(false);
}}
onDelete={async () => {
await refetch();
setCheckedWorkspaces([]);
onClose={() => {
setIsConfirmingDeleteAll(false);
}}
/>
</>
@@ -199,109 +204,3 @@ const useWorkspacesFilter = ({
},
};
};
const BatchDeleteConfirmation = ({
checkedWorkspaces,
open,
onClose,
onDelete,
}: {
checkedWorkspaces: Workspace[];
open: boolean;
onClose: () => void;
onDelete: () => void;
}) => {
const [confirmValue, setConfirmValue] = useState("");
const [confirmError, setConfirmError] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const close = () => {
if (isDeleting) {
return;
}
onClose();
setConfirmValue("");
setConfirmError(false);
setIsDeleting(false);
};
const confirmDeletion = async () => {
setConfirmError(false);
if (confirmValue !== "DELETE") {
setConfirmError(true);
return;
}
try {
setIsDeleting(true);
await Promise.all(checkedWorkspaces.map((w) => deleteWorkspace(w.id)));
} catch (e) {
displayError(
"Error on deleting workspaces",
getErrorMessage(e, "An error occurred while deleting the workspaces"),
);
} finally {
close();
onDelete();
}
};
return (
<ConfirmDialog
type="delete"
open={open}
confirmLoading={isDeleting}
onConfirm={confirmDeletion}
onClose={() => {
onClose();
setConfirmValue("");
setConfirmError(false);
}}
title={`Delete ${checkedWorkspaces?.length} ${
checkedWorkspaces.length === 1 ? "workspace" : "workspaces"
}`}
description={
<form
onSubmit={async (e) => {
e.preventDefault();
await confirmDeletion();
}}
>
<Box>
Deleting these workspaces is irreversible! Are you sure you want to
proceed? Type{" "}
<Box
component="code"
sx={{
fontFamily: MONOSPACE_FONT_FAMILY,
color: (theme) => theme.palette.text.primary,
fontWeight: 600,
}}
>
`DELETE`
</Box>{" "}
to confirm.
</Box>
<TextField
value={confirmValue}
required
autoFocus
fullWidth
inputProps={{
"aria-label": "Type DELETE to confirm",
}}
placeholder="Type DELETE to confirm"
sx={{ mt: 2 }}
onChange={(e) => {
setConfirmValue(e.currentTarget.value);
}}
error={confirmError}
helperText={confirmError && "Please type DELETE to confirm"}
/>
</form>
}
/>
);
};
@@ -16,10 +16,20 @@ import {
TableToolbar,
} from "components/TableToolbar/TableToolbar";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import DeleteOutlined from "@mui/icons-material/DeleteOutlined";
import { WorkspacesButton } from "./WorkspacesButton";
import { UseQueryResult } from "react-query";
import StopOutlined from "@mui/icons-material/StopOutlined";
import PlayArrowOutlined from "@mui/icons-material/PlayArrowOutlined";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
import KeyboardArrowDownOutlined from "@mui/icons-material/KeyboardArrowDownOutlined";
import Divider from "@mui/material/Divider";
import LoadingButton from "@mui/lab/LoadingButton";
export const Language = {
pageTitle: "Workspaces",
@@ -45,7 +55,10 @@ export interface WorkspacesPageViewProps {
onPageChange: (page: number) => void;
onUpdateWorkspace: (workspace: Workspace) => void;
onCheckChange: (checkedWorkspaces: Workspace[]) => void;
isRunningBatchAction: boolean;
onDeleteAll: () => void;
onStartAll: () => void;
onStopAll: () => void;
canCheckWorkspaces: boolean;
templatesFetchStatus: TemplateQuery["status"];
templates: TemplateQuery["data"];
@@ -65,6 +78,9 @@ export const WorkspacesPageView = ({
checkedWorkspaces,
onCheckChange,
onDeleteAll,
onStopAll,
onStartAll,
isRunningBatchAction,
canCheckWorkspaces,
templates,
templatesFetchStatus,
@@ -128,15 +144,46 @@ export const WorkspacesPageView = ({
{workspaces?.length === 1 ? "workspace" : "workspaces"}
</Box>
<Box sx={{ marginLeft: "auto" }}>
<Button
size="small"
startIcon={<DeleteOutlined />}
onClick={onDeleteAll}
>
Delete selected
</Button>
</Box>
<MoreMenu>
<MoreMenuTrigger>
<LoadingButton
loading={isRunningBatchAction}
loadingPosition="end"
variant="text"
size="small"
css={{ borderRadius: 9999, marginLeft: "auto" }}
endIcon={<KeyboardArrowDownOutlined />}
>
Actions
</LoadingButton>
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={onStartAll}
disabled={
!checkedWorkspaces?.every(
(w) => w.latest_build.status === "stopped",
)
}
>
<PlayArrowOutlined /> Start
</MoreMenuItem>
<MoreMenuItem
onClick={onStopAll}
disabled={
!checkedWorkspaces?.every(
(w) => w.latest_build.status === "running",
)
}
>
<StopOutlined /> Stop
</MoreMenuItem>
<Divider />
<MoreMenuItem danger onClick={onDeleteAll}>
<DeleteOutlined /> Delete
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
</>
) : (
<PaginationStatus
+2 -2
View File
@@ -345,8 +345,8 @@ dark = createTheme(dark, {
root: {
// It should be the same as the menu padding
"& .MuiDivider-root": {
marginTop: 4,
marginBottom: 4,
marginTop: `4px !important`,
marginBottom: `4px !important`,
},
},
},