diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index 840c5e793df..877263902fc 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -107,8 +107,10 @@ Admins may apply bulk operations (update, delete, start, stop) in the checkboxes on the left, then use the top-right **Actions** dropdown to apply the operation. -The start and stop operations can only be applied to a set of workspaces which -are all in the same state. For update and delete, the user will be prompted for +The start and stop operations can be applied even when the selected workspaces +are not all in the same state. Bulk start will only apply to selected workspaces +that are currently stopped, and bulk stop will only apply to selected workspaces +that are currently running. For update and delete, the user will be prompted for confirmation before any action is taken. ![Bulk workspace actions](../images/user-guides/workspace-bulk-actions.png) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx index a7d247c3d77..210d8bf6d16 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -462,6 +462,12 @@ const stoppedWorkspaces: Workspace[] = [ { ...MockStoppedWorkspace, id: "3" }, ]; +const mixedStateWorkspaces: Workspace[] = [ + { ...MockStoppedWorkspace, id: "1" }, + { ...MockWorkspace, id: "2" }, + { ...MockStoppedWorkspace, id: "3" }, +]; + export const StartsOnlySelectedWorkspaces: Story = { beforeEach: () => { spyOn(API, "getWorkspaces").mockResolvedValue({ @@ -491,6 +497,103 @@ export const StartsOnlySelectedWorkspaces: Story = { }, }; +export const StartIgnoresAlreadyRunningWorkspaces: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: mixedStateWorkspaces, + count: mixedStateWorkspaces.length, + }); + spyOn(API, "startWorkspace").mockResolvedValue(MockWorkspaceBuild); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + await selectWorkspaces(canvas, user, ["1", "2", "3"]); + await openBulkActions(canvas, user); + + const startItem = await body.findByRole("menuitem", { name: /start/i }); + expect(startItem).not.toHaveAttribute("data-disabled"); + await user.click(startItem); + + await waitFor(() => expect(API.startWorkspace).toHaveBeenCalledTimes(2)); + expect(API.startWorkspace).toHaveBeenCalledWith( + "1", + MockStoppedWorkspace.latest_build.template_version_id, + ); + expect(API.startWorkspace).toHaveBeenCalledWith( + "3", + MockStoppedWorkspace.latest_build.template_version_id, + ); + }, +}; + +export const StopIgnoresAlreadyStoppedWorkspaces: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: mixedStateWorkspaces, + count: mixedStateWorkspaces.length, + }); + spyOn(API, "stopWorkspace").mockResolvedValue(MockWorkspaceBuild); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + await selectWorkspaces(canvas, user, ["1", "2", "3"]); + await openBulkActions(canvas, user); + + const stopItem = await body.findByRole("menuitem", { name: /stop/i }); + expect(stopItem).not.toHaveAttribute("data-disabled"); + await user.click(stopItem); + + await waitFor(() => expect(API.stopWorkspace).toHaveBeenCalledTimes(1)); + expect(API.stopWorkspace).toHaveBeenCalledWith("2"); + }, +}; + +export const StartDisabledWhenNoWorkspacesAreStartable: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: runningWorkspaces, + count: runningWorkspaces.length, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + await selectWorkspaces(canvas, user, ["1", "2", "3"]); + await openBulkActions(canvas, user); + + const startItem = await body.findByRole("menuitem", { name: /start/i }); + expect(startItem).toHaveAttribute("data-disabled"); + }, +}; + +export const StopDisabledWhenNoWorkspacesAreStoppable: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: stoppedWorkspaces, + count: stoppedWorkspaces.length, + }); + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + await selectWorkspaces(canvas, user, ["1", "2", "3"]); + await openBulkActions(canvas, user); + + const stopItem = await body.findByRole("menuitem", { name: /stop/i }); + expect(stopItem).toHaveAttribute("data-disabled"); + }, +}; + const appHealthStatuses: [WorkspaceAppHealth, boolean][] = [ ["healthy", true], ["disabled", true], diff --git a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx index f208ed62356..6f0fb34ca67 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPageView.tsx @@ -145,7 +145,7 @@ export const WorkspacesPageView: FC = ({ w.latest_build.status === "stopped" && !mustUpdateWorkspace(w, canChangeVersions), @@ -157,7 +157,7 @@ export const WorkspacesPageView: FC = ({ w.latest_build.status === "running", ) } diff --git a/site/src/pages/WorkspacesPage/batchActions.ts b/site/src/pages/WorkspacesPage/batchActions.ts index c4401a17cbe..b372415a24f 100644 --- a/site/src/pages/WorkspacesPage/batchActions.ts +++ b/site/src/pages/WorkspacesPage/batchActions.ts @@ -33,9 +33,11 @@ export function useBatchActions( const startAllMutation = useMutation({ mutationFn: (workspaces: readonly Workspace[]) => { return Promise.all( - workspaces.map((w) => - API.startWorkspace(w.id, w.latest_build.template_version_id), - ), + workspaces + .filter((w) => w.latest_build.status === "stopped") + .map((w) => + API.startWorkspace(w.id, w.latest_build.template_version_id), + ), ); }, onSuccess, @@ -48,7 +50,11 @@ export function useBatchActions( const stopAllMutation = useMutation({ mutationFn: (workspaces: readonly Workspace[]) => { - return Promise.all(workspaces.map((w) => API.stopWorkspace(w.id))); + return Promise.all( + workspaces + .filter((w) => w.latest_build.status === "running") + .map((w) => API.stopWorkspace(w.id)), + ); }, onSuccess, onError: (error) => {