From 3190406de3dfee1fdb29e620d8d2105b322b940c Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 31 Mar 2026 11:36:47 +0100 Subject: [PATCH] fix(site): stop workspace deletes playing hide-and-seek (#23641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix workspaces list invalidation after kebab-menu delete and add Storybook coverage for the immediate `Deleting` state. > 🤖 This PR was made by Coder Agents and read by me. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../WorkspaceMoreActions.tsx | 9 +- .../WorkspacesPage/WorkspacesPage.stories.tsx | 172 ++++++++++++++++++ .../pages/WorkspacesPage/WorkspacesTable.tsx | 2 +- 3 files changed, 181 insertions(+), 2 deletions(-) create mode 100644 site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index f917facd87..7bde8c8094 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -45,6 +45,7 @@ type WorkspaceMoreActionsProps = { disabled: boolean; onStop?: () => void; isStopping?: boolean; + onActionSuccess?: () => Promise | void; }; export const WorkspaceMoreActions: FC = ({ @@ -52,6 +53,7 @@ export const WorkspaceMoreActions: FC = ({ disabled, onStop, isStopping, + onActionSuccess, }) => { const queryClient = useQueryClient(); @@ -97,8 +99,13 @@ export const WorkspaceMoreActions: FC = ({ // Delete const [isConfirmingDelete, setIsConfirmingDelete] = useState(false); + const deleteWorkspaceOptions = deleteWorkspace(workspace, queryClient); const deleteWorkspaceMutation = useMutation({ - ...deleteWorkspace(workspace, queryClient), + ...deleteWorkspaceOptions, + onSuccess: async (build) => { + await deleteWorkspaceOptions.onSuccess?.(build); + await onActionSuccess?.(); + }, onError: (error: unknown) => { handleError(error); }, diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx new file mode 100644 index 0000000000..53aff3701f --- /dev/null +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.stories.tsx @@ -0,0 +1,172 @@ +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { + expect, + fireEvent, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { API } from "#/api/api"; +import { getAuthorizationKey } from "#/api/queries/authCheck"; +import { workspacePermissionsByOrganization } from "#/api/queries/organizations"; +import { + getTemplatesQueryKey, + templateVersionsQueryKey, +} from "#/api/queries/templates"; +import type { Workspace } from "#/api/typesGenerated"; +import { workspaceChecks } from "#/modules/workspaces/permissions"; +import { + MockDefaultOrganization, + MockStoppedWorkspace, + MockTemplate, + MockTemplateVersion, + MockUserOwner, +} from "#/testHelpers/entities"; +import { + withAuthProvider, + withDashboardProvider, + withProxyProvider, +} from "#/testHelpers/storybook"; +import WorkspacesPage from "./WorkspacesPage"; + +const workspace: Workspace = { + ...MockStoppedWorkspace, + id: "workspace-1", + name: "workspace-1", + latest_build: { + ...MockStoppedWorkspace.latest_build, + workspace_id: "workspace-1", + workspace_name: "workspace-1", + workspace_owner_name: MockStoppedWorkspace.owner_name, + status: "stopped", + updated_at: "2024-01-01T00:00:00.000Z", + }, +}; + +const deletingWorkspace: Workspace = { + ...workspace, + latest_build: { + ...workspace.latest_build, + id: "workspace-1-delete-build", + transition: "delete", + status: "deleting", + updated_at: "2024-01-01T00:01:00.000Z", + }, +}; + +const meta: Meta = { + title: "pages/WorkspacesPage/WorkspacesPage", + component: WorkspacesPage, + decorators: [withAuthProvider, withDashboardProvider, withProxyProvider()], + parameters: { + user: MockUserOwner, + permissions: { + viewDeploymentConfig: false, + }, + queries: [ + { + key: getTemplatesQueryKey(), + data: [MockTemplate], + }, + { + key: workspacePermissionsByOrganization( + [MockTemplate.organization_id], + MockUserOwner.id, + ).queryKey, + data: { + [MockTemplate.organization_id]: { + createWorkspaceForUserID: true, + }, + }, + }, + { + key: getAuthorizationKey({ checks: workspaceChecks(workspace) }), + data: { + readWorkspace: true, + shareWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: true, + deleteFailedWorkspace: true, + }, + }, + { + key: templateVersionsQueryKey(workspace.template_id), + data: [MockTemplateVersion], + }, + ], + }, + beforeEach: () => { + spyOn(API, "getTemplates").mockResolvedValue([MockTemplate]); + spyOn(API, "checkAuthorization").mockImplementation(async ({ checks }) => { + return Object.fromEntries(Object.keys(checks).map((key) => [key, true])); + }); + spyOn(API, "getUsers").mockResolvedValue({ + users: [MockUserOwner], + count: 1, + }); + spyOn(API, "getOrganizations").mockResolvedValue([MockDefaultOrganization]); + spyOn(API, "getWorkspaceBuildParameters").mockResolvedValue([]); + }, +}; + +export default meta; +type Story = StoryObj; + +export const DeleteWorkspaceShowsDeletingStateImmediately: Story = { + beforeEach: () => { + spyOn(API, "getWorkspaces") + .mockResolvedValueOnce({ + workspaces: [workspace], + count: 1, + }) + .mockResolvedValue({ + workspaces: [deletingWorkspace], + count: 1, + }); + spyOn(API, "deleteWorkspace").mockResolvedValue( + deletingWorkspace.latest_build, + ); + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + const body = within(document.body); + const user = userEvent.setup(); + + await step("Open the delete dialog from the workspace row", async () => { + const row = await canvas.findByTestId(`workspace-${workspace.id}`); + await within(row).findByText("Stopped"); + await user.click(within(row).getByTestId("workspace-options-button")); + await user.click(await body.findByRole("menuitem", { name: /delete/i })); + }); + + await step("Confirm deletion", async () => { + const dialog = await body.findByRole("dialog"); + const confirmationInput = within(dialog).getByTestId( + "delete-dialog-name-confirmation", + ); + fireEvent.change(confirmationInput, { + target: { value: workspace.name }, + }); + const confirmButton = within(dialog).getByTestId("confirm-button"); + await waitFor(() => { + expect(confirmationInput).toHaveValue(workspace.name); + expect(confirmButton).toBeEnabled(); + }); + await user.click(confirmButton); + await waitFor(() => { + expect(API.deleteWorkspace).toHaveBeenCalledWith(workspace.id, { + orphan: false, + }); + }); + }); + + await step( + "Show the workspace as deleting immediately after the mutation", + async () => { + const row = await canvas.findByTestId(`workspace-${workspace.id}`); + await within(row).findByText("Deleting"); + }, + ); + }, +}; diff --git a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx index 0ecc517f12..9bfcd8600a 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesTable.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesTable.tsx @@ -581,9 +581,9 @@ const WorkspaceActionsCell: FC = ({ : undefined } isStopping={stopWorkspaceMutation.isPending} + onActionSuccess={onActionSuccess} /> - {/* Stop workspace confirmation dialog */}