From a631697bbdf541ac3ab25b75ae87bce3dbe44a1a Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 8 Jul 2026 20:32:02 +0000 Subject: [PATCH 1/3] fix(site): bulk start and stop should skip workspaces already in target state 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 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). Add four new Storybook stories to cover mixed-state selections and disabled-when-none-eligible cases. Update docs to reflect the new behavior. --- docs/user-guides/workspace-management.md | 4 +- .../WorkspacesPage/WorkspacesPage.stories.tsx | 103 ++++++++++++++++++ .../WorkspacesPage/WorkspacesPageView.tsx | 4 +- site/src/pages/WorkspacesPage/batchActions.ts | 14 ++- 4 files changed, 117 insertions(+), 8 deletions(-) diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index 840c5e793df..f238a16668a 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -107,8 +107,8 @@ 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 apply to eligible workspaces in the selection, +skipping workspaces that are already in the target state. 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) => { From a94609d877afd3056c6d05815b540c94e4e31503 Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 8 Jul 2026 21:11:57 +0000 Subject: [PATCH 2/3] docs: describe bulk start/stop more specifically --- docs/user-guides/workspace-management.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index f238a16668a..5f40de63d35 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -107,8 +107,7 @@ 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 apply to eligible workspaces in the selection, -skipping workspaces that are already in the target 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) From 21ccdf77a74dbe1945f8dd36deb61389c890150d Mon Sep 17 00:00:00 2001 From: Andrew Aquino Date: Wed, 8 Jul 2026 21:13:25 +0000 Subject: [PATCH 3/3] style: break bulk start/stop paragraph into multiple lines --- docs/user-guides/workspace-management.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/user-guides/workspace-management.md b/docs/user-guides/workspace-management.md index 5f40de63d35..877263902fc 100644 --- a/docs/user-guides/workspace-management.md +++ b/docs/user-guides/workspace-management.md @@ -107,7 +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 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 +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)