diff --git a/site/src/components/MoreMenu/MoreMenu.tsx b/site/src/components/MoreMenu/MoreMenu.tsx index b1af6c2caadda..1d7cd6d1d17b6 100644 --- a/site/src/components/MoreMenu/MoreMenu.tsx +++ b/site/src/components/MoreMenu/MoreMenu.tsx @@ -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) => { const menu = useMoreMenuContext(); - return ( - - - - ); + return cloneElement(children as ReactElement, { + "aria-haspopup": "true", + ...props, + ref: menu.triggerRef, + onClick: menu.open, + }); }; +export const ThreeDotsButton = forwardRef( + (props, ref) => { + return ( + + + + ); + }, +); + export const MoreMenuContent = (props: Omit) => { const menu = useMoreMenuContext(); diff --git a/site/src/pages/GroupsPage/GroupPage.tsx b/site/src/pages/GroupsPage/GroupPage.tsx index 41d6238dfb487..d5fde824b072d 100644 --- a/site/src/pages/GroupsPage/GroupPage.tsx +++ b/site/src/pages/GroupsPage/GroupPage.tsx @@ -50,6 +50,7 @@ import { MoreMenuContent, MoreMenuItem, MoreMenuTrigger, + ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; export const GroupPage: FC = () => { @@ -287,7 +288,9 @@ const GroupMemberRow = (props: { {canUpdate && ( - + + + = ({ return ( <> - + + + { diff --git a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx index c0f2f723bf0c4..49911eedf395f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplatePermissionsPage/TemplatePermissionsPageView.tsx @@ -34,6 +34,7 @@ import { MoreMenuContent, MoreMenuItem, MoreMenuTrigger, + ThreeDotsButton, } from "components/MoreMenu/MoreMenu"; type AddTemplateUserOrGroupProps = { @@ -287,7 +288,9 @@ export const TemplatePermissionsPageView: FC< {canUpdatePermissions && ( - + + + {canUpdatePermissions && ( - + + + - + + + {user.status === "active" || user.status === "dormant" ? ( = ({ {canCancel && } - + + + diff --git a/site/src/pages/WorkspacesPage/BatchActions.tsx b/site/src/pages/WorkspacesPage/BatchActions.tsx new file mode 100644 index 0000000000000..6209b088f8bfd --- /dev/null +++ b/site/src/pages/WorkspacesPage/BatchActions.tsx @@ -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; +}) => { + 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 ( + { + onClose(); + setConfirmation({ value: "", error: false }); + }} + title={`Delete ${checkedWorkspaces?.length} ${ + checkedWorkspaces.length === 1 ? "workspace" : "workspaces" + }`} + description={ +
{ + e.preventDefault(); + confirmDeletion(); + }} + > + + Deleting these workspaces is irreversible! Are you sure you want to + proceed? Type{" "} + theme.palette.text.primary, + fontWeight: 600, + }} + > + `DELETE` + {" "} + to confirm. + + { + const value = e.currentTarget?.value; + setConfirmation((c) => ({ ...c, value })); + }} + error={confirmation.error} + helperText={confirmation.error && "Please type DELETE to confirm"} + /> + + } + /> + ); +}; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index aaa352bbfd61c..50451e18b50a3 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -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(); + 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(); + 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) => { diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index 140af4fcf3e69..e237fc7867215 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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([]); - 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)} /> { - 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 ( - { - onClose(); - setConfirmValue(""); - setConfirmError(false); - }} - title={`Delete ${checkedWorkspaces?.length} ${ - checkedWorkspaces.length === 1 ? "workspace" : "workspaces" - }`} - description={ -
{ - e.preventDefault(); - await confirmDeletion(); - }} - > - - Deleting these workspaces is irreversible! Are you sure you want to - proceed? Type{" "} - theme.palette.text.primary, - fontWeight: 600, - }} - > - `DELETE` - {" "} - to confirm. - - { - setConfirmValue(e.currentTarget.value); - }} - error={confirmError} - helperText={confirmError && "Please type DELETE to confirm"} - /> - - } - /> - ); -}; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index 0a1a416363060..d476df1c585d2 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -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"} - - - + + + } + > + Actions + + + + w.latest_build.status === "stopped", + ) + } + > + Start + + w.latest_build.status === "running", + ) + } + > + Stop + + + + Delete + + + ) : (