From 0b93731ebf9f1872c291f90143a1e0d8bd442570 Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Thu, 30 Jul 2026 00:46:33 +1000 Subject: [PATCH] fix(site): reflect submitting state during batch update (#27630) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was written by Coder Agents on behalf of Jake Howell. ## Problem When bulk updating workspaces, the confirmation modal's **Update** button never entered a submitting/loading state, so there was no feedback that the update was actually in progress. ## Root cause The `BatchUpdateModalForm` shows a spinner when its `isProcessing` prop is `true`. That prop is fed by `batchActions.isProcessing` from `useBatchActions`. However, the `isProcessing` value was OR-ing together every mutation's `isPending` flag **except** `updateAllMutation` — the one that actually performs the batch update: ```ts isProcessing: favoriteAllMutation.isPending || unfavoriteAllMutation.isPending || startAllMutation.isPending || stopAllMutation.isPending || deleteAllMutation.isPending, // updateAllMutation.isPending was missing ``` As a result, the button stayed idle for the entire duration of a bulk update. ## Fix Include `updateAllMutation.isPending` in the `isProcessing` derivation so the button spinner and disabled state correctly reflect an in-flight batch update. ## Testing - [ ] Manually verify the Update button shows the spinner and is disabled while a bulk update runs. --- .../WorkspacesPage/WorkspacesPage.stories.tsx | 58 ++++++++++++++++++- site/src/pages/WorkspacesPage/batchActions.ts | 3 +- 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx index cf5ff0ab72..2cc55811ce 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -15,7 +15,11 @@ import { templateVersionsQueryKey, } from "#/api/queries/templates"; import { workspacesKey } from "#/api/queries/workspaces"; -import type { Workspace, WorkspaceAppHealth } from "#/api/typesGenerated"; +import type { + Workspace, + WorkspaceAppHealth, + WorkspaceBuild, +} from "#/api/typesGenerated"; import { workspaceChecks } from "#/modules/workspaces/permissions"; import { MockDefaultOrganization, @@ -373,6 +377,58 @@ export const BatchUpdateRunningWorkspace: Story = { }, }; +export const BatchUpdateShowsSubmittingState: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces").mockResolvedValue({ + workspaces: updateRunningWorkspaces, + count: updateRunningWorkspaces.length, + }); + spyOn(API, "getTemplateVersion").mockResolvedValue(MockTemplateVersion); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + // Hold the mutation pending so the button's submitting state stays + // observable until the story explicitly resolves it. + let resolveUpdate!: () => void; + const pendingUpdate = new Promise((resolve) => { + resolveUpdate = () => resolve(MockWorkspaceBuild); + }); + spyOn(API, "updateWorkspace").mockReturnValue(pendingUpdate); + + await selectWorkspaces(canvas, user, ["1", "2", "3"]); + await openBulkActions(canvas, user); + await user.click(await body.findByRole("menuitem", { name: /Update/ })); + + const modal = await body.findByRole("dialog", { name: /Review Updates/i }); + await user.click( + within(modal).getByRole("checkbox", { + name: /I acknowledge these risks\./, + }), + ); + const updateButton = within(modal).getByRole("button", { name: /Update/ }); + await user.click(updateButton); + + await step("Button reflects the submitting state", async () => { + await waitFor(() => expect(updateButton).toBeDisabled()); + await within(modal).findByText( + "Waiting for workspaces to finish processing", + ); + }); + + await step("Resolving the update clears the submitting state", async () => { + resolveUpdate(); + await waitFor(() => + expect( + body.queryByText("Waiting for workspaces to finish processing"), + ).not.toBeInTheDocument(), + ); + }); + }, +}; + const ignoreDormantWorkspaces: Workspace[] = [ { ...MockDormantOutdatedWorkspace, id: "1" }, { ...MockOutdatedWorkspace, id: "2" }, diff --git a/site/src/pages/WorkspacesPage/batchActions.ts b/site/src/pages/WorkspacesPage/batchActions.ts index 2ac7011e1e..56dba26f55 100644 --- a/site/src/pages/WorkspacesPage/batchActions.ts +++ b/site/src/pages/WorkspacesPage/batchActions.ts @@ -141,6 +141,7 @@ export function useBatchActions( unfavoriteAllMutation.isPending || startAllMutation.isPending || stopAllMutation.isPending || - deleteAllMutation.isPending, + deleteAllMutation.isPending || + updateAllMutation.isPending, }; }