mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): add stop and start batch actions (#10565)
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
+7
-2
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user