From c4fc390abc5468265645403919923eb4c90ba586 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Fri, 16 Oct 2026 16:52:01 +0000 Subject: [PATCH] fix(site): use the active version on the parameters page when it is required Users who cannot change template versions on a template that requires the active version could only edit parameters when the page was opened with ?templateVersionId=. Opening it from the settings sidebar showed a read-only form, leaving them stuck when the new version rejected a saved value. The page now always targets the active version for these users. Fixes #19676 --- .../WorkspaceParametersPage.stories.tsx | 51 ------------------ .../WorkspaceParametersPage.test.tsx | 54 +++++++++++++++++++ .../WorkspaceParametersPage.tsx | 12 ++++- site/src/testHelpers/entities.ts | 13 +++-- 4 files changed, 71 insertions(+), 59 deletions(-) diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx index 91d00017296..eddbf226a12 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx @@ -18,7 +18,6 @@ import type { Workspace } from "#/api/typesGenerated"; import type { WorkspacePermissions } from "#/modules/workspaces/permissions"; import { MockDropdownParameter, - MockOutdatedRunningWorkspaceRequireActiveVersion, MockOutdatedStoppedWorkspaceRequireActiveVersion, MockPermissions, MockPreviewParameter, @@ -162,56 +161,6 @@ export const StartWorkspace: Story = { }, }; -export const RequireActiveVersionBlocked: Story = { - parameters: { - reactRouter: workspaceRouterParameters( - MockOutdatedStoppedWorkspaceRequireActiveVersion, - ), - queries: workspaceQueries( - MockOutdatedStoppedWorkspaceRequireActiveVersion, - { updateWorkspaceVersion: false }, - ), - webSocket: filledWebSocketParams(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitFor(() => - expect( - canvas.getByText(/requires automatic updates/), - ).toBeInTheDocument(), - ); - const submitButton = canvas.getByRole("button", { - name: "Update and start", - }); - expect(submitButton).toBeDisabled(); - }, -}; - -export const RequireActiveVersionBlockedRunning: Story = { - parameters: { - reactRouter: workspaceRouterParameters( - MockOutdatedRunningWorkspaceRequireActiveVersion, - ), - queries: workspaceQueries( - MockOutdatedRunningWorkspaceRequireActiveVersion, - { updateWorkspaceVersion: false }, - ), - webSocket: filledWebSocketParams(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await waitFor(() => - expect( - canvas.getByText(/requires automatic updates/), - ).toBeInTheDocument(), - ); - const submitButton = canvas.getByRole("button", { - name: "Update and restart", - }); - expect(submitButton).toBeDisabled(); - }, -}; - export const RequireActiveVersionEditable: Story = { parameters: { reactRouter: workspaceRouterParameters( diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx index 14ad3fe2083..fb26ed7a8ff 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx @@ -10,6 +10,7 @@ import { MockPreviewParameter2, MockPreviewParameter4, MockPreviewParameter7, + MockTemplateVersion2, MockWorkspace, MockWorkspaceBuildParameter1, MockWorkspaceBuildParameter4, @@ -315,6 +316,59 @@ describe("WorkspaceParametersPage", () => { ); }); + it("uses the required active version when opened without one", async () => { + const workspace: TypesGen.Workspace = { + ...MockOutdatedStoppedWorkspaceRequireActiveVersion, + template_active_version_id: MockTemplateVersion2.id, + }; + vi.mocked(API.getWorkspaceByOwnerAndName) + .mockReset() + .mockResolvedValue(workspace); + vi.spyOn(API, "checkAuthorization").mockResolvedValue({ + readWorkspace: true, + shareWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: false, + deleteFailedWorkspace: false, + }); + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + + const [, mockPublisher] = mockDynamicParameterWebSocket(); + + renderWorkspaceParametersPage( + `/@${workspace.owner_name}/${workspace.name}/settings`, + ); + + await connectWithInitialParameters(mockPublisher, [ + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ]); + expect(API.templateVersionDynamicParameters).toHaveBeenCalledTimes(1); + expect(API.templateVersionDynamicParameters).toHaveBeenCalledWith( + workspace.template_active_version_id, + workspace.owner_id, + expect.anything(), + ); + await waitForLoaderToBeRemoved(); + + const form = screen.getByTestId("form"); + const submitButton = within(form).getByRole("button", { + name: /update and start/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + + await userEvent.click(submitButton); + await waitFor(() => + expect(API.postWorkspaceBuild).toHaveBeenCalledWith( + workspace.id, + expect.objectContaining({ + template_version_id: workspace.template_active_version_id, + }), + ), + ); + }); + it("does not clobber edited parameters", async () => { vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index cb4790ead92..d0d305e2cf3 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -30,7 +30,14 @@ const WorkspaceParametersPage: React.FC = () => { const { permissions, workspace } = useWorkspaceSettings(); const navigate = useNavigate(); const [searchParams] = useSearchParams(); - const templateVersionId = searchParams.get("templateVersionId") ?? undefined; + const mustUseActiveVersion = + workspace.template_require_active_version && + !permissions?.updateWorkspaceVersion; + const requestedVersionId = searchParams.get("templateVersionId") ?? undefined; + const templateVersionId = mustUseActiveVersion + ? workspace.template_active_version_id + : requestedVersionId; + const permissionsLoaded = permissions !== undefined; const [confirmingRestart, setConfirmingRestart] = useState<{ open: boolean; @@ -119,6 +126,8 @@ const WorkspaceParametersPage: React.FC = () => { }, [latestBuildParametersLoading]); useEffect(() => { + // The target version depends on permissions. + if (!permissionsLoaded) return; if (!templateVersionId && !workspace.latest_build.template_version_id) return; @@ -168,6 +177,7 @@ const WorkspaceParametersPage: React.FC = () => { socket.close(); }; }, [ + permissionsLoaded, templateVersionId, workspace.latest_build.template_version_id, workspace.owner_id, diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 987a0698b9b..cef60938aee 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1898,13 +1898,12 @@ export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { dormant_at: new Date().toISOString(), }; -export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockWorkspace, - id: "test-outdated-workspace-require-active-version", - outdated: true, - template_require_active_version: true, - }; +const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = { + ...MockWorkspace, + id: "test-outdated-workspace-require-active-version", + outdated: true, + template_require_active_version: true, +}; export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = {