From 3e85cfb2c5dc411eaacbc1d39b0c80d1c349d610 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Thu, 9 Jul 2026 08:13:38 -0700 Subject: [PATCH] fix: during workspace bulk start/stop, skip workspaces already in target state (#27108) Previously, bulk start required every selected workspace to be stopped, and bulk stop required every selected workspace to be running. Mixed selections disabled both buttons entirely. - Change the disabled checks on bulk start/stop from `every()` to `some()` so the buttons are enabled when at least one workspace is eligible. - Filter workspaces by status in the mutation functions so only eligible workspaces are sent to the API, matching the pattern used by other batch mutations (update, favorite, unfavorite). - Update docs to reflect the new behavior. > [!NOTE] > Generated by Coder Agents. [View session](https://coder.com/).
Implementation plan ## Problem When an admin selects multiple workspaces and opens the "Bulk actions" dropdown, the **Start** menu item is disabled unless *every* selected workspace has `latest_build.status === "stopped"`. If even one workspace is already running (or in any other non-stopped state), the Start button is grayed out and unusable. Same issue applies to **Stop**. ## Changes ### 1. Relax disabled condition (`WorkspacesPageView.tsx`) Changed `every()` to `some()` for both Start and Stop dropdown items. The buttons are now enabled when at least one selected workspace is in the target state. ### 2. Filter in mutations (`batchActions.ts`) Added `.filter()` before `.map()` in both `startAllMutation` and `stopAllMutation` so only eligible workspaces hit the API. This matches the existing pattern in `updateAllMutation`, `favoriteAllMutation`, and `unfavoriteAllMutation`. ### 3. Update documentation (`docs/user-guides/workspace-management.md`) Replaced "can only be applied to a set of workspaces which are all in the same state" with "apply to eligible workspaces in the selection, skipping workspaces that are already in the target state." ## Testing Four new Storybook stories: | Story | What it tests | |-------|---------------| | `StartIgnoresAlreadyRunningWorkspaces` | Mixed selection; only stopped workspaces get `startWorkspace` calls | | `StopIgnoresAlreadyStoppedWorkspaces` | Mixed selection; only running workspaces get `stopWorkspace` calls | | `StartDisabledWhenNoWorkspacesAreStartable` | All running; Start button is disabled | | `StopDisabledWhenNoWorkspacesAreStoppable` | All stopped; Stop button is disabled |
--- docs/user-guides/workspace-management.md | 6 +- .../WorkspacesPage/WorkspacesPage.stories.tsx | 103 ++++++++++++++++++ .../WorkspacesPage/WorkspacesPageView.tsx | 4 +- site/src/pages/WorkspacesPage/batchActions.ts | 14 ++- 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index 840c5e793d..877263902f 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 a7d247c3d7..210d8bf6d1 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 f208ed6235..6f0fb34ca6 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 c4401a17cb..b372415a24 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) => {