diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 163c727715..9910fb128f 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1183,20 +1183,20 @@ func TestAIBridgeListClients(t *testing.T) { Client: sql.NullString{String: string(aiblib.ClientClaudeCode), Valid: true}, }, &endedAt) - // Completed interception with no client — should appear as "Unknown". + // Completed interception with no client. Should appear as "Unknown". dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ InitiatorID: firstUser.UserID, StartedAt: now, }, &endedAt) - // Duplicate client — should be deduplicated in results. + // Duplicate client. Should be deduplicated in results. dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ InitiatorID: firstUser.UserID, StartedAt: now, Client: sql.NullString{String: string(aiblib.ClientCursor), Valid: true}, }, &endedAt) - // In-flight interception (no ended_at) — must NOT appear in results. + // In-flight interception (no ended_at). Must NOT appear in results. dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ InitiatorID: firstUser.UserID, StartedAt: now, diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx deleted file mode 100644 index f6c19d6f55..0000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import type { FC } from "react"; -import { useWorkspaceSettings } from "../useWorkspaceSettings"; -import WorkspaceParametersPage from "./WorkspaceParametersPage"; -import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental"; - -const WorkspaceParametersExperimentRouter: FC = () => { - const { workspace } = useWorkspaceSettings(); - - return ( - <> - {workspace.template_use_classic_parameter_flow ? ( - - ) : ( - - )} - - ); -}; - -export default WorkspaceParametersExperimentRouter; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx deleted file mode 100644 index a82731d1fc..0000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersForm.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { useFormik } from "formik"; -import type { FC } from "react"; -import * as Yup from "yup"; -import type { - TemplateVersionParameter, - Workspace, - WorkspaceBuildParameter, -} from "#/api/typesGenerated"; -import { Alert } from "#/components/Alert/Alert"; -import { Button } from "#/components/Button/Button"; -import { - FormFields, - FormFooter, - FormSection, - HorizontalForm, -} from "#/components/Form/Form"; -import { RichParameterInput } from "#/components/RichParameterInput/RichParameterInput"; -import { Spinner } from "#/components/Spinner/Spinner"; -import { ClassicParameterFlowDeprecationWarning } from "#/modules/workspaces/ClassicParameterFlowDeprecationWarning/ClassicParameterFlowDeprecationWarning"; -import { getFormHelpers } from "#/utils/formUtils"; -import { - type AutofillBuildParameter, - getInitialRichParameterValues, - useValidationSchemaForRichParameters, -} from "#/utils/richParameters"; - -export type WorkspaceParametersFormValues = { - rich_parameter_values: WorkspaceBuildParameter[]; -}; - -interface WorkspaceParameterFormProps { - workspace: Workspace; - templateVersionRichParameters: TemplateVersionParameter[]; - autofillParams: AutofillBuildParameter[]; - isSubmitting: boolean; - canChangeVersions: boolean; - templatePermissions: { canUpdateTemplate: boolean } | undefined; - error: unknown; - onCancel: () => void; - onSubmit: (values: WorkspaceParametersFormValues) => void; -} - -export const WorkspaceParametersForm: FC = ({ - workspace, - onCancel, - onSubmit, - templateVersionRichParameters, - autofillParams, - error, - canChangeVersions, - templatePermissions, - isSubmitting, -}) => { - const form = useFormik({ - onSubmit, - initialValues: { - rich_parameter_values: getInitialRichParameterValues( - templateVersionRichParameters, - autofillParams, - ), - }, - validationSchema: Yup.object({ - rich_parameter_values: useValidationSchemaForRichParameters( - templateVersionRichParameters, - ), - }), - }); - const getFieldHelpers = getFormHelpers( - form, - error, - ); - const hasEphemeralParameters = templateVersionRichParameters.some( - (parameter) => parameter.ephemeral, - ); - const hasNonEphemeralParameters = templateVersionRichParameters.some( - (parameter) => !parameter.ephemeral, - ); - - const disabled = - workspace.outdated && - workspace.template_require_active_version && - !canChangeVersions; - - return ( - <> - {disabled && ( - - The template for this workspace requires automatic updates. Update the - workspace to edit parameters. - - )} - - - {hasNonEphemeralParameters && ( - - - {templateVersionRichParameters.map((parameter, index) => - // Since we are adding the values to the form based on the index - // we can't filter them to not loose the right index position - !parameter.ephemeral ? ( - { - await form.setFieldValue( - `rich_parameter_values.${index}`, - { - name: parameter.name, - value: value, - }, - ); - }} - parameter={parameter} - parameterAutofill={autofillParams?.find( - ({ name }) => name === parameter.name, - )} - /> - ) : null, - )} - - - )} - {hasEphemeralParameters && ( - - - {templateVersionRichParameters.map((parameter, index) => - // Since we are adding the values to the form based on the index - // we can't filter them to not loose the right index position - parameter.mutable && parameter.ephemeral ? ( - { - await form.setFieldValue( - `rich_parameter_values.${index}`, - { - name: parameter.name, - value: value, - }, - ); - }} - parameter={parameter} - /> - ) : null, - )} - - - )} - - - - - - - - - ); -}; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx index 5afb507cfa..91d0001729 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.stories.tsx @@ -1,92 +1,307 @@ -import type { Meta, StoryObj } from "@storybook/react-vite"; -import { action } from "storybook/actions"; +import type { Meta, StoryObj, WebSocketEvent } from "@storybook/react-vite"; import { + expect, + screen, + spyOn, + userEvent, + waitFor, + within, +} from "storybook/test"; +import { + reactRouterOutlet, + reactRouterParameters, +} from "storybook-addon-remix-react-router"; +import { API } from "#/api/api"; +import { workspaceBuildParametersKey } from "#/api/queries/workspaceBuilds"; +import { workspaceByOwnerAndNameKey } from "#/api/queries/workspaces"; +import type { Workspace } from "#/api/typesGenerated"; +import type { WorkspacePermissions } from "#/modules/workspaces/permissions"; +import { + MockDropdownParameter, + MockOutdatedRunningWorkspaceRequireActiveVersion, MockOutdatedStoppedWorkspaceRequireActiveVersion, - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - MockTemplateVersionParameter3, + MockPermissions, + MockPreviewParameter, + MockStoppedWorkspace, + MockUserOwner, MockWorkspace, MockWorkspaceBuildParameter1, MockWorkspaceBuildParameter2, MockWorkspaceBuildParameter3, } from "#/testHelpers/entities"; -import { WorkspaceParametersPageView } from "./WorkspaceParametersPage"; +import { + withAuthProvider, + withDashboardProvider, + withWebSocket, +} from "#/testHelpers/storybook"; +import { WorkspaceSettingsLayout } from "../WorkspaceSettingsLayout"; +import WorkspaceParametersPage from "./WorkspaceParametersPage"; -const meta: Meta = { - title: "pages/WorkspaceSettingsPage/WorkspaceParametersPageView", - component: WorkspaceParametersPageView, +const meta = { + title: "pages/WorkspaceParametersPage", + component: WorkspaceSettingsLayout, + decorators: [withAuthProvider, withDashboardProvider, withWebSocket], args: { - submitError: undefined, - isSubmitting: false, - workspace: MockWorkspace, - canChangeVersions: true, - onCancel: action("onCancel"), - buildParameters: [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - MockWorkspaceBuildParameter3, - ], - templateVersionParameters: [ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, + permissions: MockPermissions, + }, + parameters: { + layout: "fullscreen", + user: MockUserOwner, + reactRouter: workspaceRouterParameters(MockWorkspace), + queries: workspaceQueries(MockWorkspace), + webSocket: [ { - ...MockTemplateVersionParameter3, - mutable: false, + event: "open", + }, + { + event: "message", + data: JSON.stringify({ + id: 0, + diagnostics: [], + parameters: [MockPreviewParameter, MockDropdownParameter], + }), }, ], }, -}; +} satisfies Meta; export default meta; -type Story = StoryObj; +type Story = StoryObj; -const Example: Story = {}; - -export const Empty: Story = { - args: { - buildParameters: [], - templateVersionParameters: [], - }, -}; - -export const RequireActiveVersionNoChangeVersion: Story = { - args: { - workspace: MockOutdatedStoppedWorkspaceRequireActiveVersion, - canChangeVersions: false, - buildParameters: [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - MockWorkspaceBuildParameter3, - ], - templateVersionParameters: [ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, +export const NoParameters: Story = { + parameters: { + webSocket: [ { - ...MockTemplateVersionParameter3, - mutable: false, + event: "open", + }, + { + event: "message", + data: JSON.stringify({ + id: 0, + diagnostics: [], + parameters: [], + }), }, ], }, }; -export const RequireActiveVersionCanChangeVersion: Story = { - args: { - workspace: MockOutdatedStoppedWorkspaceRequireActiveVersion, - canChangeVersions: true, - buildParameters: [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - MockWorkspaceBuildParameter3, - ], - templateVersionParameters: [ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - { - ...MockTemplateVersionParameter3, - mutable: false, - }, - ], +export const Parameters: Story = {}; + +export const Required: Story = { + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Update and restart" }), + ); }, }; -export { Example as WorkspaceParametersPage }; +export const ShowConfirmation: Story = { + beforeEach: () => { + spyOn(API, "stopWorkspace").mockRejectedValue( + new Error("would have stopped"), + ); + }, + parameters: { + webSocket: filledWebSocketParams(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Update and restart" }), + ); + }, +}; + +export const RestartWorkspace: Story = { + beforeEach: () => { + spyOn(API, "stopWorkspace").mockRejectedValue( + new Error("would have stopped"), + ); + }, + parameters: { + webSocket: filledWebSocketParams(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Update and restart" }), + ); + await userEvent.click( + await screen.findByRole("button", { name: "Restart" }), + ); + await waitFor(() => + expect(screen.getByText("would have stopped")).toBeInTheDocument(), + ); + }, +}; + +export const StartWorkspace: Story = { + beforeEach: () => { + spyOn(API, "stopWorkspace").mockRejectedValue( + new Error("should not hit this"), + ); + spyOn(API, "postWorkspaceBuild").mockRejectedValue( + new Error("would have started"), + ); + }, + parameters: { + reactRouter: workspaceRouterParameters(MockStoppedWorkspace), + queries: workspaceQueries(MockStoppedWorkspace), + webSocket: filledWebSocketParams(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + await canvas.findByRole("button", { name: "Update and start" }), + ); + await waitFor(() => + expect(screen.getByText("would have started")).toBeInTheDocument(), + ); + }, +}; + +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( + MockOutdatedStoppedWorkspaceRequireActiveVersion, + ), + queries: workspaceQueries( + MockOutdatedStoppedWorkspaceRequireActiveVersion, + { updateWorkspaceVersion: true }, + ), + webSocket: filledWebSocketParams(), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + // Wait for the form to render before asserting absence of warning. + const submitButton = await canvas.findByRole("button", { + name: "Update and start", + }); + expect( + canvas.queryByText(/requires automatic updates/), + ).not.toBeInTheDocument(); + expect(submitButton).not.toBeDisabled(); + }, +}; + +function workspaceRouterParameters(workspace: Workspace) { + return reactRouterParameters({ + location: { + pathParams: { + username: `@${workspace.owner_name}`, + workspace: workspace.name, + }, + }, + routing: reactRouterOutlet( + { + path: "/:username/:workspace/settings/parameters", + }, + , + ), + }); +} + +function workspaceQueries( + workspace: Workspace, + permissionOverrides?: Partial, +) { + return [ + { + key: workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name), + data: workspace, + }, + { + key: workspaceBuildParametersKey(workspace.latest_build.id), + data: [ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter2, + MockWorkspaceBuildParameter3, + ], + }, + { + key: ["workspaces", workspace.id, "permissions"], + data: { + readWorkspace: true, + shareWorkspace: true, + updateWorkspace: true, + updateWorkspaceVersion: true, + deleteFailedWorkspace: true, + ...permissionOverrides, + } satisfies WorkspacePermissions, + }, + ]; +} + +function filledWebSocketParams(): WebSocketEvent[] { + return [ + { + event: "open", + }, + { + event: "message", + data: JSON.stringify({ + id: 0, + diagnostics: [], + parameters: [ + { + ...MockPreviewParameter, + value: { valid: true, value: "test" }, + }, + MockDropdownParameter, + ], + }), + }, + ]; +} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx index 7beba625f3..923b998a10 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.test.tsx @@ -1,133 +1,394 @@ import { screen, waitFor, within } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; +import { act } from "react"; import { API } from "#/api/api"; +import type * as TypesGen from "#/api/typesGenerated"; +import { createDeferred } from "#/testHelpers/deferred"; import { + MockPreviewParameter1, + MockPreviewParameter2, + MockPreviewParameter4, + MockPreviewParameter7, MockTemplateVersionParameter1, - MockTemplateVersionParameter2, MockTemplateVersionParameter4, + MockTemplateVersionParameter7, MockWorkspace, - MockWorkspaceBuild, MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, MockWorkspaceBuildParameter4, + MockWorkspaceBuildParameter7, } from "#/testHelpers/entities"; +import { + checkParameters, + editParameters, + isBuildParameter, +} from "#/testHelpers/parameters"; import { renderWithWorkspaceSettingsLayout, waitForLoaderToBeRemoved, } from "#/testHelpers/renderHelpers"; +import { mockDynamicParameterWebSocket } from "#/testHelpers/websockets"; import WorkspaceParametersPage from "./WorkspaceParametersPage"; -test("Submit the workspace settings page successfully", async () => { - // Mock the API calls that loads data - vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce( - MockWorkspace, - ); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - // Immutable parameters - MockTemplateVersionParameter4, - ]); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - // Immutable value - MockWorkspaceBuildParameter4, - ]); - // Mock the API calls that submit data - const postWorkspaceBuildSpy = vi - .spyOn(API, "postWorkspaceBuild") - .mockResolvedValue(MockWorkspaceBuild); - // Setup event and rendering - const user = userEvent.setup(); - renderWithWorkspaceSettingsLayout(, { - route: "/@test-user/test-workspace/settings", - path: "/:username/:workspace/settings", - // Need this because after submit the user is redirected - extraRoutes: [{ path: "/:username/:workspace", element:
}], - }); - await waitForLoaderToBeRemoved(); - // Fill the form and submit - const form = screen.getByTestId("form"); - const parameter1 = within(form).getByLabelText( - MockWorkspaceBuildParameter1.name, - { exact: false }, - ); - await user.clear(parameter1); - await user.type(parameter1, "new-value"); - const parameter2 = within(form).getByLabelText( - MockWorkspaceBuildParameter2.name, - { exact: false }, - ); - await user.clear(parameter2); - await user.type(parameter2, "3"); - await user.click( - within(form).getByRole("button", { name: "Submit and restart" }), - ); - // Assert that the API calls were made with the correct data - await waitFor(() => { - expect(postWorkspaceBuildSpy).toHaveBeenCalledWith(MockWorkspace.id, { - reason: "dashboard", - transition: "start", - rich_parameter_values: [ - { name: MockTemplateVersionParameter1.name, value: "new-value" }, - { name: MockTemplateVersionParameter2.name, value: "3" }, +describe("WorkspaceParametersPage", () => { + const renderWorkspaceParametersPage = ( + route = `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/settings`, + ) => { + return renderWithWorkspaceSettingsLayout(, { + route, + path: "/:username/:workspace/settings", + extraRoutes: [ + { + // Need this because after submit the user is redirected. + path: "/:username/:workspace", + element:
Workspace Page
, + }, ], }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce( + MockWorkspace, + ); + vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ + MockTemplateVersionParameter1, // a mutable string + MockTemplateVersionParameter4, // an immutable string + MockTemplateVersionParameter7, // optional string + ]); + vi.spyOn(API, "postWorkspaceBuild").mockRejectedValueOnce( + new Error("not implemented"), + ); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("waits for and sends initial build parameters", async () => { + const { promise, resolve } = + createDeferred(); + vi.spyOn(API, "getWorkspaceBuildParameters").mockReturnValueOnce(promise); + + const [_, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { + publisher.publishOpen(new Event("open")); + // The initial message always has the default values. + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: -1, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + }); + + renderWorkspaceParametersPage(); + + // Wait for both requests to have been made. Client should not have sent + // any message yet since build parameters have not resolved. + await waitFor(() => { + expect(API.getWorkspaceBuildParameters).toHaveBeenCalled(); + expect(API.templateVersionDynamicParameters).toHaveBeenCalled(); + expect(mockPublisher.clientSentData).toHaveLength(0); + }); + + // Build parameters now resolve. + const buildParameters = [ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter4, + MockWorkspaceBuildParameter7, + ]; + await act(async () => { + resolve(buildParameters); + }); + + // The client's init message should include all the build values. + await waitFor(() => { + expect(mockPublisher.clientSentData).toHaveLength(1); + expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( + expect.objectContaining({ + id: 0, + inputs: Object.fromEntries( + buildParameters.map((p) => [p.name, p.value]), + ), + }), + ); + }); + + // Should still be waiting for the response. + expect(screen.queryByTestId("loader")).toBeInTheDocument(); + + // Respond to the init message with up-to-date values. + mockPublisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: 0, + parameters: [ + { + ...MockPreviewParameter1, + value: { valid: true, value: MockWorkspaceBuildParameter1.value }, + }, + { + ...MockPreviewParameter4, + value: { valid: true, value: MockWorkspaceBuildParameter4.value }, + }, + { + ...MockPreviewParameter7, + value: { valid: true, value: MockWorkspaceBuildParameter7.value }, + }, + ], + diagnostics: [], + }), + }), + ); + + // Finally the page is rendered with the build values. + await waitForLoaderToBeRemoved(); + await checkParameters( + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter4, + MockWorkspaceBuildParameter7, + ); + + // The submit button should be enabled. + const form = screen.getByTestId("form"); + const submitButton = within(form).getByRole("button", { + name: /update and restart/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + }); + + it("skips zero-length initial parameters", async () => { + const { promise, resolve } = + createDeferred(); + vi.spyOn(API, "getWorkspaceBuildParameters").mockReturnValueOnce(promise); + + const [_, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { + publisher.publishOpen(new Event("open")); + // The initial message always has the default values. + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: -1, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + }); + + renderWorkspaceParametersPage(); + + // Wait for both requests to have been made. Client should not have sent + // any message yet since build parameters have not resolved. + await waitFor(() => { + expect(API.getWorkspaceBuildParameters).toHaveBeenCalled(); + expect(API.templateVersionDynamicParameters).toHaveBeenCalled(); + expect(mockPublisher.clientSentData).toHaveLength(0); + }); + + // Build parameters now resolve. + await act(async () => { + resolve([]); + }); + + // Since there are no build values, the page is rendered with defaults and + // the client does not need to send anything. + await waitForLoaderToBeRemoved(); + await checkParameters( + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ); + expect(mockPublisher.clientSentData).toHaveLength(0); + + // The submit button should be enabled. + const form = screen.getByTestId("form"); + const submitButton = within(form).getByRole("button", { + name: /update and restart/i, + }); + await waitFor(() => expect(submitButton).toBeEnabled()); + }); + + it("does not clobber build parameters", async () => { + const buildParameters = [ + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter4, + MockWorkspaceBuildParameter7, + ]; + + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce( + buildParameters, + ); + + const [, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { + publisher.publishOpen(new Event("open")); + // The initial message always has the default values. + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: -1, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + }); + + renderWorkspaceParametersPage(); + + // Wait for the client's init message then respond with different values. + await waitFor(() => { + expect(mockPublisher.clientSentData).toHaveLength(1); + expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( + expect.objectContaining({ + id: 0, + inputs: Object.fromEntries( + buildParameters.map((p) => [p.name, p.value]), + ), + }), + ); + }); + + mockPublisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: 0, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter2, // new field + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + + // Page should render with the build values, but the new field that was not + // part of the previous build should also show up. + await waitForLoaderToBeRemoved(); + await checkParameters( + MockWorkspaceBuildParameter1, + MockWorkspaceBuildParameter4, + MockWorkspaceBuildParameter7, + MockPreviewParameter2, + ); + + // However the submit button should be disabled because the state + // mismatches. + const form = screen.getByTestId("form"); + const submitButton = within(form).getByRole("button", { + name: /update and restart/i, + }); + await waitFor(() => expect(submitButton).toBeDisabled()); + }); + + it("does not clobber edited parameters", async () => { + vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); + + const [, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { + publisher.publishOpen(new Event("open")); + // The initial message always has the default values. + publisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: -1, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + }); + + renderWorkspaceParametersPage(); + + // Page should render with the default values. + await waitForLoaderToBeRemoved(); + await checkParameters( + MockPreviewParameter1, + MockPreviewParameter4, + MockPreviewParameter7, + ); + + // Blank out one field and fill out another. + const editedParameters = [ + // Put the blank one first to ensure we are preserving blank values and + // not just including it the first time due to the change handler. + { + name: MockPreviewParameter1.name, + value: "", + }, + { + name: MockPreviewParameter7.name, + value: "not-blank", + }, + ]; + editParameters(...editedParameters); + + // The client should now send all parameters. + await waitFor(() => { + expect(mockPublisher.clientSentData).toHaveLength(1); + expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( + expect.objectContaining({ + id: 0, + inputs: Object.fromEntries( + [...editedParameters, MockPreviewParameter4].map((p) => [ + p.name, + isBuildParameter(p) ? p.value : p.value.value, + ]), + ), + }), + ); + }); + + // Respond with different values. + mockPublisher.publishMessage( + new MessageEvent("message", { + data: JSON.stringify({ + id: 0, + parameters: [ + MockPreviewParameter1, + MockPreviewParameter2, // new field + MockPreviewParameter4, + MockPreviewParameter7, + ], + diagnostics: [], + }), + }), + ); + + // The form should keep the user's values but include the new field. + await checkParameters( + ...editedParameters, + MockPreviewParameter4, + MockPreviewParameter2, + ); + + // However the submit button should be disabled because the state + // mismatches. + const form = screen.getByTestId("form"); + const submitButton = within(form).getByRole("button", { + name: /update and restart/i, + }); + await waitFor(() => expect(submitButton).toBeDisabled()); }); }); - -test("Submit button is only enabled when changes are made", async () => { - // Mock the API calls that loads data - vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce( - MockWorkspace, - ); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ - MockTemplateVersionParameter1, - MockTemplateVersionParameter2, - // Immutable parameters - MockTemplateVersionParameter4, - ]); - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - // Immutable value - MockWorkspaceBuildParameter4, - ]); - // Setup event and rendering - const user = userEvent.setup(); - renderWithWorkspaceSettingsLayout(, { - route: "/@test-user/test-workspace/settings", - path: "/:username/:workspace/settings", - // Need this because after submit the user is redirected - extraRoutes: [{ path: "/:username/:workspace", element:
}], - }); - await waitForLoaderToBeRemoved(); - - const submitButton: HTMLButtonElement = screen.getByRole("button", { - name: "Submit and restart", - }); - - const form = screen.getByTestId("form"); - const parameter1 = within(form).getByLabelText( - MockWorkspaceBuildParameter1.name, - { exact: false }, - ); - - // There are no changes, the button should be disabled. - expect(submitButton.disabled).toBeTruthy(); - - // Make changes to the form - await user.clear(parameter1); - await user.type(parameter1, "new-value"); - - // There are now changes, the button should be enabled. - expect(submitButton.disabled).toBeFalsy(); - - // Change form value back to default - await user.clear(parameter1); - await user.type(parameter1, MockWorkspaceBuildParameter1.value); - - // There are now no changes, the button should be disabled. - expect(submitButton.disabled).toBeTruthy(); -}); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx index 4e05f8a119..1dfa7a8f70 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage.tsx @@ -1,196 +1,355 @@ -import { ExternalLinkIcon } from "lucide-react"; +import { CircleHelpIcon } from "lucide-react"; import type { FC } from "react"; +import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; import { useMutation, useQuery } from "react-query"; -import { useNavigate } from "react-router"; +import { useNavigate, useSearchParams } from "react-router"; import { API } from "#/api/api"; -import { isApiValidationError } from "#/api/errors"; -import { checkAuthorization } from "#/api/queries/authCheck"; -import { richParameters } from "#/api/queries/templates"; -import { workspaceBuildParameters } from "#/api/queries/workspaceBuilds"; +import { DetailedError } from "#/api/errors"; import type { - TemplateVersionParameter, - Workspace, + DynamicParametersRequest, + DynamicParametersResponse, WorkspaceBuildParameter, } from "#/api/typesGenerated"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { Button } from "#/components/Button/Button"; +import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; import { EmptyState } from "#/components/EmptyState/EmptyState"; +import { Link } from "#/components/Link/Link"; import { Loader } from "#/components/Loader/Loader"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "#/components/Tooltip/Tooltip"; import { docs } from "#/utils/docs"; import { pageTitle } from "#/utils/page"; -import { - type WorkspacePermissions, - workspaceChecks, -} from "../../../modules/workspaces/permissions"; +import type { AutofillBuildParameter } from "#/utils/richParameters"; import { useWorkspaceSettings } from "../useWorkspaceSettings"; -import { - WorkspaceParametersForm, - type WorkspaceParametersFormValues, -} from "./WorkspaceParametersForm"; +import { WorkspaceParametersPageView } from "./WorkspaceParametersPageView"; const WorkspaceParametersPage: FC = () => { - const { workspace } = useWorkspaceSettings(); - const build = workspace.latest_build; - const { data: templateVersionParameters } = useQuery( - richParameters(build.template_version_id), - ); - const { data: buildParameters } = useQuery( - workspaceBuildParameters(build.id), - ); + const { permissions, workspace } = useWorkspaceSettings(); const navigate = useNavigate(); - const updateParameters = useMutation({ + const [searchParams] = useSearchParams(); + const templateVersionId = searchParams.get("templateVersionId") ?? undefined; + + const [confirmingRestart, setConfirmingRestart] = useState<{ + open: boolean; + buildParameters?: WorkspaceBuildParameter[]; + }>({ open: false }); + + // autofill the form with the workspace build parameters from the latest build + // CLEANUP(bad-vibes): raw query options + const { + data: latestBuildParameters, + isLoading: latestBuildParametersLoading, + } = useQuery({ + queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"], + queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id), + }); + + const [latestResponse, setLatestResponse] = + useState(null); + // The current expected response ID. Starts at -1 because the backend sends + // an initial message when the web socket is connected with -1. + const wsResponseId = useRef(-1); + const ws = useRef(null); + const [wsError, setWsError] = useState(null); + // The expected ID of the init message, so we can wait until the initial + // parameters have gone through before rendering the form. + const [initId, setInitId] = useState(Number.NaN); + + // Parameters from the latest build, formatted as auto-fill parameters. + const autofillParameters: AutofillBuildParameter[] = + latestBuildParameters?.map((p) => ({ + ...p, + source: "active_build", + })) ?? []; + + // sendMessage increments the ID and sends the form values on the web socket + // and return true. If the socket is not open, it does not increment the ID + // and returns false. + const sendMessage = (formValues: Record): boolean => { + const request: DynamicParametersRequest = { + id: wsResponseId.current + 1, + owner_id: workspace.owner_id, + inputs: formValues, + }; + if (ws.current && ws.current.readyState === WebSocket.OPEN) { + wsResponseId.current = wsResponseId.current + 1; + ws.current.send(JSON.stringify(request)); + return true; + } + if (ws.current) { + console.error( + "Tried to send message but the web socket state is %s", + ws.current.readyState, + request, + ); + } + return false; + }; + + // Send the initial parameters if necessary and mark the ID of the response we + // need to wait for until we can finally render the form with the right state. + const sendInitialParameters = useEffectEvent(() => { + if (latestBuildParametersLoading || !Number.isNaN(initId)) { + return; + } + if (autofillParameters.length > 0) { + const values = Object.fromEntries( + autofillParameters.map((afp) => [afp.name, afp.value]), + ); + if (!sendMessage(values)) { + return; + } + } + // If there were no parameters to send, this will end up just using the + // response we already have. Otherwise it will wait for the next response. + setInitId(wsResponseId.current); + }); + + // Send the build parameters once we get them. + useEffect(() => { + // sendInitialParameters already makes this check but the linter complains + // if the dependency is not used. + if (!latestBuildParametersLoading) { + sendInitialParameters(); + } + }, [latestBuildParametersLoading]); + + useEffect(() => { + if (!templateVersionId && !workspace.latest_build.template_version_id) + return; + + // CLEANUP(bad-vibes): raw api call + const socket = API.templateVersionDynamicParameters( + templateVersionId ?? workspace.latest_build.template_version_id, + workspace.owner_id, + { + onOpen: () => { + // If we already have the build parameters, send them now. + sendInitialParameters(); + }, + // Record the latest message every time we get one from the web + // socket. Stale responses are discarded. + onMessage: (response: DynamicParametersResponse) => { + if (response.id >= wsResponseId.current) { + setLatestResponse(response); + } + }, + onError: (error) => { + if (ws.current === socket) { + setWsError(error); + } + }, + onClose: () => { + if (ws.current === socket) { + setWsError( + new DetailedError( + "Websocket connection for dynamic parameters unexpectedly closed.", + "Refresh the page to reset the form.", + ), + ); + } + }, + }, + ); + + ws.current = socket; + + return () => { + socket.close(); + }; + }, [ + templateVersionId, + workspace.latest_build.template_version_id, + workspace.owner_id, + ]); + + const startWithParameters = useMutation({ mutationFn: (buildParameters: WorkspaceBuildParameter[]) => API.postWorkspaceBuild(workspace.id, { transition: "start", + template_version_id: templateVersionId, rich_parameter_values: buildParameters, reason: "dashboard", }), onSuccess: () => { - navigate(`/${workspace.owner_name}/${workspace.name}`); + navigate(`/@${workspace.owner_name}/${workspace.name}`); }, }); - // Permissions - const checks = workspace ? workspaceChecks(workspace) : {}; - const permissionsQuery = useQuery({ - ...checkAuthorization({ checks }), - enabled: workspace !== undefined, + const restartWithParameters = useMutation({ + mutationFn: async (buildParameters: WorkspaceBuildParameter[]) => { + const stopBuild = await API.stopWorkspace(workspace.id); + const awaitedStopBuild = await API.waitForBuild(stopBuild); + + // If the restart is canceled halfway through, make sure we bail + if (awaitedStopBuild?.status === "canceled") { + return; + } + + return API.postWorkspaceBuild(workspace.id, { + transition: "start", + template_version_id: templateVersionId, + rich_parameter_values: buildParameters, + reason: "dashboard", + }); + }, + onSuccess: () => { + navigate(`/@${workspace.owner_name}/${workspace.name}`); + }, }); - const permissions = permissionsQuery.data as WorkspacePermissions | undefined; + const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion); - const templatePermissionsQuery = useQuery({ - ...checkAuthorization({ - checks: { - canUpdateTemplate: { - object: { - resource_type: "template", - resource_id: workspace.template_id, - }, - action: "update", - }, - }, - }), - enabled: workspace !== undefined, - }); + const handleSubmit = (values: { + rich_parameter_values: WorkspaceBuildParameter[]; + }) => { + if (!latestResponse?.parameters) { + return; + } - const templatePermissions = templatePermissionsQuery.data as - | { canUpdateTemplate: boolean } - | undefined; + // Only submit mutable parameters + const onlyMutableValues = latestResponse.parameters + .filter((p) => p.mutable) + .map((p) => { + const value = values.rich_parameter_values.find( + (v) => v.name === p.name, + ); + if (!value) { + throw new Error(`Missing value for parameter ${p.name}`); + } + return value; + }); + + // We only enable the button to navigate to this page if the workspace can + // accept new jobs, but if the workspace is in any pending state (user + // manually loaded the page or workspace state changed after load) then we + // could still submit a build that will fail. + if (workspace.latest_build.status === "running") { + setConfirmingRestart({ open: true, buildParameters: onlyMutableValues }); + } else { + startWithParameters.mutate(onlyMutableValues); + } + }; + + const sortedParams = useMemo(() => { + if (!latestResponse?.parameters) { + return []; + } + return [...latestResponse.parameters].sort((a, b) => a.order - b.order); + }, [latestResponse?.parameters]); + + const error = + wsError || startWithParameters.error || restartWithParameters.error; + + // Some of these checks conceptually overlap, but opting to be explicit. + const isLoading = + latestBuildParametersLoading || + !latestResponse || + Number.isNaN(initId) || + latestResponse.id < initId || + (ws.current && ws.current.readyState === WebSocket.CONNECTING); + + let submitLabel = "Update and start"; + if (restartWithParameters.isPending) { + submitLabel = "Stopping workspace"; + } else if (startWithParameters.isPending) { + submitLabel = "Starting workspace"; + } else if (workspace.latest_build.status === "running") { + submitLabel = "Update and restart"; + } return ( - <> +
{pageTitle(workspace.name, "Parameters")} - { - if (!templateVersionParameters) { - return; - } - // When updating the parameters, the API does not accept immutable - // values so we need to filter them - const onlyMutableValues = templateVersionParameters - .filter((p) => p.mutable) - .map((p) => { - const value = values.rich_parameter_values.find( - (v) => v.name === p.name, - ); - if (!value) { - throw new Error(`Missing value for parameter ${p.name}`); - } - return value; - }); - updateParameters.mutate(onlyMutableValues); - }} - onCancel={() => { - navigate("../.."); - }} - /> - - ); -}; - -type WorkspaceParametersPageViewProps = { - workspace: Workspace; - canChangeVersions: boolean; - templatePermissions: { canUpdateTemplate: boolean } | undefined; - templateVersionParameters?: TemplateVersionParameter[]; - buildParameters?: WorkspaceBuildParameter[]; - submitError: unknown; - isSubmitting: boolean; - onSubmit: (formValues: WorkspaceParametersFormValues) => void; - onCancel: () => void; -}; - -export const WorkspaceParametersPageView: FC< - WorkspaceParametersPageViewProps -> = ({ - workspace, - canChangeVersions, - templatePermissions, - templateVersionParameters, - buildParameters, - submitError, - onSubmit, - isSubmitting, - onCancel, -}) => { - return ( -
- -

Workspace parameters

+ + +

Workspace parameters

+ + + + + + + Dynamic Parameters enhances Coder's existing parameter system + with real-time validation, conditional parameter behavior, and + richer input types. +
+ + View docs + +
+
+
+
- {submitError && !isApiValidationError(submitError) ? ( - - ) : null} + {Boolean(error) && } - {templateVersionParameters && buildParameters ? ( - templateVersionParameters.length > 0 ? ( - ({ - ...p, - source: "active_build", - }))} - templateVersionRichParameters={templateVersionParameters} - error={submitError} - isSubmitting={isSubmitting} - onSubmit={onSubmit} - onCancel={onCancel} - /> - ) : ( - - - - Learn more about parameters - - - } - className="border border-solid rounded-lg" - /> - ) - ) : ( + {isLoading ? ( + ) : sortedParams.length > 0 ? ( + + navigate(`/@${workspace.owner_name}/${workspace.name}`) + } + sendMessage={sendMessage} + /> + ) : ( + + Learn more about parameters + + } + /> )} + + { + restartWithParameters.mutate(confirmingRestart.buildParameters ?? []); + setConfirmingRestart({ open: false }); + }} + onClose={() => setConfirmingRestart({ open: false })} + title="Restart your workspace?" + confirmText="Restart" + description={ + <> + Restarting your workspace will stop all running processes and{" "} + delete non-persistent data. + + } + />
); }; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.stories.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.stories.tsx deleted file mode 100644 index 9ff0a6b39c..0000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.stories.tsx +++ /dev/null @@ -1,227 +0,0 @@ -import type { Meta, StoryObj, WebSocketEvent } from "@storybook/react-vite"; -import { - expect, - screen, - spyOn, - userEvent, - waitFor, - within, -} from "storybook/test"; -import { - reactRouterOutlet, - reactRouterParameters, -} from "storybook-addon-remix-react-router"; -import { API } from "#/api/api"; -import { workspaceBuildParametersKey } from "#/api/queries/workspaceBuilds"; -import { workspaceByOwnerAndNameKey } from "#/api/queries/workspaces"; -import type { Workspace } from "#/api/typesGenerated"; -import type { WorkspacePermissions } from "#/modules/workspaces/permissions"; -import { - MockDropdownParameter, - MockPermissions, - MockPreviewParameter, - MockStoppedWorkspace, - MockUserOwner, - MockWorkspace, - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - MockWorkspaceBuildParameter3, -} from "#/testHelpers/entities"; -import { - withAuthProvider, - withDashboardProvider, - withWebSocket, -} from "#/testHelpers/storybook"; -import { WorkspaceSettingsLayout } from "../WorkspaceSettingsLayout"; -import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental"; - -const meta = { - title: "pages/WorkspaceParametersPageExperimental", - component: WorkspaceSettingsLayout, - decorators: [withAuthProvider, withDashboardProvider, withWebSocket], - args: { - permissions: MockPermissions, - }, - parameters: { - layout: "fullscreen", - user: MockUserOwner, - reactRouter: workspaceRouterParameters(MockWorkspace), - queries: workspaceQueries(MockWorkspace), - webSocket: [ - { - event: "open", - }, - { - event: "message", - data: JSON.stringify({ - id: 0, - diagnostics: [], - parameters: [MockPreviewParameter, MockDropdownParameter], - }), - }, - ], - }, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const NoParameters: Story = { - parameters: { - webSocket: [ - { - event: "open", - }, - { - event: "message", - data: JSON.stringify({ - id: 0, - diagnostics: [], - parameters: [], - }), - }, - ], - }, -}; - -export const Parameters: Story = {}; - -export const Required: Story = { - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click( - await canvas.findByRole("button", { name: "Update and restart" }), - ); - }, -}; - -export const ShowConfirmation: Story = { - beforeEach: () => { - spyOn(API, "stopWorkspace").mockRejectedValue( - new Error("would have stopped"), - ); - }, - parameters: { - webSocket: filledWebSocketParams(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click( - await canvas.findByRole("button", { name: "Update and restart" }), - ); - }, -}; - -export const RestartWorkspace: Story = { - beforeEach: () => { - spyOn(API, "stopWorkspace").mockRejectedValue( - new Error("would have stopped"), - ); - }, - parameters: { - webSocket: filledWebSocketParams(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click( - await canvas.findByRole("button", { name: "Update and restart" }), - ); - await userEvent.click( - await screen.findByRole("button", { name: "Restart" }), - ); - await waitFor(() => - expect(screen.getByText("would have stopped")).toBeInTheDocument(), - ); - }, -}; - -export const StartWorkspace: Story = { - beforeEach: () => { - spyOn(API, "stopWorkspace").mockRejectedValue( - new Error("should not hit this"), - ); - spyOn(API, "postWorkspaceBuild").mockRejectedValue( - new Error("would have started"), - ); - }, - parameters: { - reactRouter: workspaceRouterParameters(MockStoppedWorkspace), - queries: workspaceQueries(MockStoppedWorkspace), - webSocket: filledWebSocketParams(), - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await userEvent.click( - await canvas.findByRole("button", { name: "Update and start" }), - ); - await waitFor(() => - expect(screen.getByText("would have started")).toBeInTheDocument(), - ); - }, -}; - -function workspaceRouterParameters(workspace: Workspace) { - return reactRouterParameters({ - location: { - pathParams: { - username: `@${workspace.owner_name}`, - workspace: workspace.name, - }, - }, - routing: reactRouterOutlet( - { - path: "/:username/:workspace/settings/parameters", - }, - , - ), - }); -} - -function workspaceQueries(workspace: Workspace) { - return [ - { - key: workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name), - data: workspace, - }, - { - key: workspaceBuildParametersKey(workspace.latest_build.id), - data: [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter2, - MockWorkspaceBuildParameter3, - ], - }, - { - key: ["workspaces", workspace.id, "permissions"], - data: { - readWorkspace: true, - shareWorkspace: true, - updateWorkspace: true, - updateWorkspaceVersion: true, - deleteFailedWorkspace: true, - } satisfies WorkspacePermissions, - }, - ]; -} - -function filledWebSocketParams(): WebSocketEvent[] { - return [ - { - event: "open", - }, - { - event: "message", - data: JSON.stringify({ - id: 0, - diagnostics: [], - parameters: [ - { - ...MockPreviewParameter, - value: { valid: true, value: "test" }, - }, - MockDropdownParameter, - ], - }), - }, - ]; -} diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.test.tsx deleted file mode 100644 index 9dc8315f61..0000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.test.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { screen, waitFor, within } from "@testing-library/react"; -import { act } from "react"; -import { API } from "#/api/api"; -import type * as TypesGen from "#/api/typesGenerated"; -import { createDeferred } from "#/testHelpers/deferred"; -import { - MockPreviewParameter1, - MockPreviewParameter2, - MockPreviewParameter4, - MockPreviewParameter7, - MockTemplateVersionParameter1, - MockTemplateVersionParameter4, - MockTemplateVersionParameter7, - MockWorkspace, - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter4, - MockWorkspaceBuildParameter7, -} from "#/testHelpers/entities"; -import { - checkParameters, - editParameters, - isBuildParameter, -} from "#/testHelpers/parameters"; -import { - renderWithWorkspaceSettingsLayout, - waitForLoaderToBeRemoved, -} from "#/testHelpers/renderHelpers"; -import { mockDynamicParameterWebSocket } from "#/testHelpers/websockets"; -import WorkspaceParametersPageExperimental from "./WorkspaceParametersPageExperimental"; - -describe("WorkspaceParametersPageExperimental", () => { - const renderWorkspaceParametersPageExperimental = ( - route = `/@${MockWorkspace.owner_name}/${MockWorkspace.name}/settings`, - ) => { - return renderWithWorkspaceSettingsLayout( - , - { - route, - path: "/:username/:workspace/settings", - extraRoutes: [ - { - // Need this because after submit the user is redirected. - path: "/:username/:workspace", - element:
Workspace Page
, - }, - ], - }, - ); - }; - - beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(API, "getWorkspaceByOwnerAndName").mockResolvedValueOnce( - MockWorkspace, - ); - vi.spyOn(API, "getTemplateVersionRichParameters").mockResolvedValueOnce([ - MockTemplateVersionParameter1, // a mutable string - MockTemplateVersionParameter4, // an immutable string - MockTemplateVersionParameter7, // optional string - ]); - vi.spyOn(API, "postWorkspaceBuild").mockRejectedValueOnce( - new Error("not implemented"), - ); - }); - - afterEach(() => { - vi.useRealTimers(); - vi.restoreAllMocks(); - }); - - it("waits for and sends initial build parameters", async () => { - const { promise, resolve } = - createDeferred(); - vi.spyOn(API, "getWorkspaceBuildParameters").mockReturnValueOnce(promise); - - const [_, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { - publisher.publishOpen(new Event("open")); - // The initial message always has the default values. - publisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: -1, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - }); - - renderWorkspaceParametersPageExperimental(); - - // Wait for both requests to have been made. Client should not have sent - // any message yet since build parameters have not resolved. - await waitFor(() => { - expect(API.getWorkspaceBuildParameters).toHaveBeenCalled(); - expect(API.templateVersionDynamicParameters).toHaveBeenCalled(); - expect(mockPublisher.clientSentData).toHaveLength(0); - }); - - // Build parameters now resolve. - const buildParameters = [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter4, - MockWorkspaceBuildParameter7, - ]; - await act(async () => { - resolve(buildParameters); - }); - - // The client's init message should include all the build values. - await waitFor(() => { - expect(mockPublisher.clientSentData).toHaveLength(1); - expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( - expect.objectContaining({ - id: 0, - inputs: Object.fromEntries( - buildParameters.map((p) => [p.name, p.value]), - ), - }), - ); - }); - - // Should still be waiting for the response. - expect(screen.queryByTestId("loader")).toBeInTheDocument(); - - // Respond to the init message with up-to-date values. - mockPublisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: 0, - parameters: [ - { - ...MockPreviewParameter1, - value: { valid: true, value: MockWorkspaceBuildParameter1.value }, - }, - { - ...MockPreviewParameter4, - value: { valid: true, value: MockWorkspaceBuildParameter4.value }, - }, - { - ...MockPreviewParameter7, - value: { valid: true, value: MockWorkspaceBuildParameter7.value }, - }, - ], - diagnostics: [], - }), - }), - ); - - // Finally the page is rendered with the build values. - await waitForLoaderToBeRemoved(); - await checkParameters( - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter4, - MockWorkspaceBuildParameter7, - ); - - // The submit button should be enabled. - const form = screen.getByTestId("form"); - const submitButton = within(form).getByRole("button", { - name: /update and restart/i, - }); - await waitFor(() => expect(submitButton).toBeEnabled()); - }); - - it("skips zero-length initial parameters", async () => { - const { promise, resolve } = - createDeferred(); - vi.spyOn(API, "getWorkspaceBuildParameters").mockReturnValueOnce(promise); - - const [_, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { - publisher.publishOpen(new Event("open")); - // The initial message always has the default values. - publisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: -1, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - }); - - renderWorkspaceParametersPageExperimental(); - - // Wait for both requests to have been made. Client should not have sent - // any message yet since build parameters have not resolved. - await waitFor(() => { - expect(API.getWorkspaceBuildParameters).toHaveBeenCalled(); - expect(API.templateVersionDynamicParameters).toHaveBeenCalled(); - expect(mockPublisher.clientSentData).toHaveLength(0); - }); - - // Build parameters now resolve. - await act(async () => { - resolve([]); - }); - - // Since there are no build values, the page is rendered with defaults and - // the client does not need to send anything. - await waitForLoaderToBeRemoved(); - await checkParameters( - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ); - expect(mockPublisher.clientSentData).toHaveLength(0); - - // The submit button should be enabled. - const form = screen.getByTestId("form"); - const submitButton = within(form).getByRole("button", { - name: /update and restart/i, - }); - await waitFor(() => expect(submitButton).toBeEnabled()); - }); - - it("does not clobber build parameters", async () => { - const buildParameters = [ - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter4, - MockWorkspaceBuildParameter7, - ]; - - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce( - buildParameters, - ); - - const [, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { - publisher.publishOpen(new Event("open")); - // The initial message always has the default values. - publisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: -1, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - }); - - renderWorkspaceParametersPageExperimental(); - - // Wait for the client's init message then respond with different values. - await waitFor(() => { - expect(mockPublisher.clientSentData).toHaveLength(1); - expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( - expect.objectContaining({ - id: 0, - inputs: Object.fromEntries( - buildParameters.map((p) => [p.name, p.value]), - ), - }), - ); - }); - - mockPublisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: 0, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter2, // new field - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - - // Page should render with the build values, but the new field that was not - // part of the previous build should also show up. - await waitForLoaderToBeRemoved(); - await checkParameters( - MockWorkspaceBuildParameter1, - MockWorkspaceBuildParameter4, - MockWorkspaceBuildParameter7, - MockPreviewParameter2, - ); - - // However the submit button should be disabled because the state - // mismatches. - const form = screen.getByTestId("form"); - const submitButton = within(form).getByRole("button", { - name: /update and restart/i, - }); - await waitFor(() => expect(submitButton).toBeDisabled()); - }); - - it("does not clobber edited parameters", async () => { - vi.spyOn(API, "getWorkspaceBuildParameters").mockResolvedValueOnce([]); - - const [, mockPublisher] = mockDynamicParameterWebSocket((publisher) => { - publisher.publishOpen(new Event("open")); - // The initial message always has the default values. - publisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: -1, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - }); - - renderWorkspaceParametersPageExperimental(); - - // Page should render with the default values. - await waitForLoaderToBeRemoved(); - await checkParameters( - MockPreviewParameter1, - MockPreviewParameter4, - MockPreviewParameter7, - ); - - // Blank out one field and fill out another. - const editedParameters = [ - // Put the blank one first to ensure we are preserving blank values and - // not just including it the first time due to the change handler. - { - name: MockPreviewParameter1.name, - value: "", - }, - { - name: MockPreviewParameter7.name, - value: "not-blank", - }, - ]; - editParameters(...editedParameters); - - // The client should now send all parameters. - await waitFor(() => { - expect(mockPublisher.clientSentData).toHaveLength(1); - expect(JSON.parse(mockPublisher.clientSentData[0] as string)).toEqual( - expect.objectContaining({ - id: 0, - inputs: Object.fromEntries( - [...editedParameters, MockPreviewParameter4].map((p) => [ - p.name, - isBuildParameter(p) ? p.value : p.value.value, - ]), - ), - }), - ); - }); - - // Respond with different values. - mockPublisher.publishMessage( - new MessageEvent("message", { - data: JSON.stringify({ - id: 0, - parameters: [ - MockPreviewParameter1, - MockPreviewParameter2, // new field - MockPreviewParameter4, - MockPreviewParameter7, - ], - diagnostics: [], - }), - }), - ); - - // The form should keep the user's values but include the new field. - await checkParameters( - ...editedParameters, - MockPreviewParameter4, - MockPreviewParameter2, - ); - - // However the submit button should be disabled because the state - // mismatches. - const form = screen.getByTestId("form"); - const submitButton = within(form).getByRole("button", { - name: /update and restart/i, - }); - await waitFor(() => expect(submitButton).toBeDisabled()); - }); -}); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx deleted file mode 100644 index 7dd35166f6..0000000000 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageExperimental.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { CircleHelpIcon } from "lucide-react"; -import type { FC } from "react"; -import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { useMutation, useQuery } from "react-query"; -import { useNavigate, useSearchParams } from "react-router"; -import { API } from "#/api/api"; -import { DetailedError } from "#/api/errors"; -import type { - DynamicParametersRequest, - DynamicParametersResponse, - WorkspaceBuildParameter, -} from "#/api/typesGenerated"; -import { ErrorAlert } from "#/components/Alert/ErrorAlert"; -import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; -import { EmptyState } from "#/components/EmptyState/EmptyState"; -import { Link } from "#/components/Link/Link"; -import { Loader } from "#/components/Loader/Loader"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "#/components/Tooltip/Tooltip"; -import { docs } from "#/utils/docs"; -import { pageTitle } from "#/utils/page"; -import type { AutofillBuildParameter } from "#/utils/richParameters"; -import { useWorkspaceSettings } from "../useWorkspaceSettings"; -import { WorkspaceParametersPageViewExperimental } from "./WorkspaceParametersPageViewExperimental"; - -const WorkspaceParametersPageExperimental: FC = () => { - const { permissions, workspace } = useWorkspaceSettings(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const templateVersionId = searchParams.get("templateVersionId") ?? undefined; - - const [confirmingRestart, setConfirmingRestart] = useState<{ - open: boolean; - buildParameters?: WorkspaceBuildParameter[]; - }>({ open: false }); - - // autofill the form with the workspace build parameters from the latest build - const { - data: latestBuildParameters, - isLoading: latestBuildParametersLoading, - } = useQuery({ - queryKey: ["workspaceBuilds", workspace.latest_build.id, "parameters"], - queryFn: () => API.getWorkspaceBuildParameters(workspace.latest_build.id), - }); - - const [latestResponse, setLatestResponse] = - useState(null); - // The current expected response ID. Starts at -1 because the backend sends - // an initial message when the web socket is connected with -1. - const wsResponseId = useRef(-1); - const ws = useRef(null); - const [wsError, setWsError] = useState(null); - // The expected ID of the init message, so we can wait until the initial - // parameters have gone through before rendering the form. - const [initId, setInitId] = useState(Number.NaN); - - // Parameters from the latest build, formatted as auto-fill parameters. - const autofillParameters: AutofillBuildParameter[] = - latestBuildParameters?.map((p) => ({ - ...p, - source: "active_build", - })) ?? []; - - // sendMessage increments the ID and sends the form values on the web socket - // and return true. If the socket is not open, it does not increment the ID - // and returns false. - const sendMessage = (formValues: Record): boolean => { - const request: DynamicParametersRequest = { - id: wsResponseId.current + 1, - owner_id: workspace.owner_id, - inputs: formValues, - }; - if (ws.current && ws.current.readyState === WebSocket.OPEN) { - wsResponseId.current = wsResponseId.current + 1; - ws.current.send(JSON.stringify(request)); - return true; - } - if (ws.current) { - console.error( - "Tried to send message but the web socket state is %s", - ws.current.readyState, - request, - ); - } - return false; - }; - - // Send the initial parameters if necessary and mark the ID of the response we - // need to wait for until we can finally render the form with the right state. - const sendInitialParameters = useEffectEvent(() => { - if (latestBuildParametersLoading || !Number.isNaN(initId)) { - return; - } - if (autofillParameters.length > 0) { - const values = Object.fromEntries( - autofillParameters.map((afp) => [afp.name, afp.value]), - ); - if (!sendMessage(values)) { - return; - } - } - // If there were no parameters to send, this will end up just using the - // response we already have. Otherwise it will wait for the next response. - setInitId(wsResponseId.current); - }); - - // Send the build parameters once we get them. - useEffect(() => { - // sendInitialParameters already makes this check but the linter complains - // if the dependency is not used. - if (!latestBuildParametersLoading) { - sendInitialParameters(); - } - }, [latestBuildParametersLoading]); - - useEffect(() => { - if (!templateVersionId && !workspace.latest_build.template_version_id) - return; - - const socket = API.templateVersionDynamicParameters( - templateVersionId ?? workspace.latest_build.template_version_id, - workspace.owner_id, - { - onOpen: () => { - // If we already have the build parameters, send them now. - sendInitialParameters(); - }, - // Record the latest message every time we get one from the web - // socket. Stale responses are discarded. - onMessage: (response: DynamicParametersResponse) => { - if (response.id >= wsResponseId.current) { - setLatestResponse(response); - } - }, - onError: (error) => { - if (ws.current === socket) { - setWsError(error); - } - }, - onClose: () => { - if (ws.current === socket) { - setWsError( - new DetailedError( - "Websocket connection for dynamic parameters unexpectedly closed.", - "Refresh the page to reset the form.", - ), - ); - } - }, - }, - ); - - ws.current = socket; - - return () => { - socket.close(); - }; - }, [ - templateVersionId, - workspace.latest_build.template_version_id, - workspace.owner_id, - ]); - - const startWithParameters = useMutation({ - mutationFn: (buildParameters: WorkspaceBuildParameter[]) => - API.postWorkspaceBuild(workspace.id, { - transition: "start", - template_version_id: templateVersionId, - rich_parameter_values: buildParameters, - reason: "dashboard", - }), - onSuccess: () => { - navigate(`/@${workspace.owner_name}/${workspace.name}`); - }, - }); - - const restartWithParameters = useMutation({ - mutationFn: async (buildParameters: WorkspaceBuildParameter[]) => { - const stopBuild = await API.stopWorkspace(workspace.id); - const awaitedStopBuild = await API.waitForBuild(stopBuild); - - // If the restart is canceled halfway through, make sure we bail - if (awaitedStopBuild?.status === "canceled") { - return; - } - - return API.postWorkspaceBuild(workspace.id, { - transition: "start", - template_version_id: templateVersionId, - rich_parameter_values: buildParameters, - reason: "dashboard", - }); - }, - onSuccess: () => { - navigate(`/@${workspace.owner_name}/${workspace.name}`); - }, - }); - - const canChangeVersions = Boolean(permissions?.updateWorkspaceVersion); - - const handleSubmit = (values: { - rich_parameter_values: WorkspaceBuildParameter[]; - }) => { - if (!latestResponse?.parameters) { - return; - } - - // Only submit mutable parameters - const onlyMutableValues = latestResponse.parameters - .filter((p) => p.mutable) - .map((p) => { - const value = values.rich_parameter_values.find( - (v) => v.name === p.name, - ); - if (!value) { - throw new Error(`Missing value for parameter ${p.name}`); - } - return value; - }); - - // We only enable the button to navigate to this page if the workspace can - // accept new jobs, but if the workspace is in any pending state (user - // manually loaded the page or workspace state changed after load) then we - // could still submit a build that will fail. - if (workspace.latest_build.status === "running") { - setConfirmingRestart({ open: true, buildParameters: onlyMutableValues }); - } else { - startWithParameters.mutate(onlyMutableValues); - } - }; - - const sortedParams = useMemo(() => { - if (!latestResponse?.parameters) { - return []; - } - return [...latestResponse.parameters].sort((a, b) => a.order - b.order); - }, [latestResponse?.parameters]); - - const error = - wsError || startWithParameters.error || restartWithParameters.error; - - // Some of these checks conceptually overlap, but opting to be explicit. - const isLoading = - latestBuildParametersLoading || - !latestResponse || - Number.isNaN(initId) || - latestResponse.id < initId || - (ws.current && ws.current.readyState === WebSocket.CONNECTING); - - let submitLabel = "Update and start"; - if (restartWithParameters.isPending) { - submitLabel = "Stopping workspace"; - } else if (startWithParameters.isPending) { - submitLabel = "Starting workspace"; - } else if (workspace.latest_build.status === "running") { - submitLabel = "Update and restart"; - } - - return ( -
- {pageTitle(workspace.name, "Parameters")} - -
- - -

Workspace parameters

- - - - - - - Dynamic Parameters enhances Coder's existing parameter system - with real-time validation, conditional parameter behavior, and - richer input types. -
- - View docs - -
-
-
-
-
-
- - {Boolean(error) && } - - {isLoading ? ( - - ) : sortedParams.length > 0 ? ( - - navigate(`/@${workspace.owner_name}/${workspace.name}`) - } - sendMessage={sendMessage} - /> - ) : ( - - Learn more about parameters - - } - /> - )} - - { - restartWithParameters.mutate(confirmingRestart.buildParameters ?? []); - setConfirmingRestart({ open: false }); - }} - onClose={() => setConfirmingRestart({ open: false })} - title="Restart your workspace?" - confirmText="Restart" - description={ - <> - Restarting your workspace will stop all running processes and{" "} - delete non-persistent data. - - } - /> -
- ); -}; - -export default WorkspaceParametersPageExperimental; diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageView.tsx similarity index 96% rename from site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx rename to site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageView.tsx index 86234c2e70..5ba346d558 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageViewExperimental.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPageView.tsx @@ -21,7 +21,7 @@ import { cn } from "#/utils/cn"; import { docs } from "#/utils/docs"; import type { AutofillBuildParameter } from "#/utils/richParameters"; -type WorkspaceParametersPageViewExperimentalProps = { +type WorkspaceParametersPageViewProps = { workspace: Workspace; autofillParameters: AutofillBuildParameter[]; parameters: PreviewParameter[]; @@ -37,8 +37,8 @@ type WorkspaceParametersPageViewExperimentalProps = { templateVersionId: string | undefined; }; -export const WorkspaceParametersPageViewExperimental: FC< - WorkspaceParametersPageViewExperimentalProps +export const WorkspaceParametersPageView: FC< + WorkspaceParametersPageViewProps > = ({ workspace, autofillParameters, @@ -189,8 +189,8 @@ export const WorkspaceParametersPageViewExperimental: FC< className={cn( "text-xs flex flex-col rounded-md border px-4 pb-3 border-solid", diagnostic.severity === "error" - ? " text-content-destructive border-border-destructive" - : " text-content-warning border-border-warning", + ? "text-content-destructive border-border-destructive" + : "text-content-warning border-border-warning", )} >
diff --git a/site/src/router.tsx b/site/src/router.tsx index 1b814c4d50..4b2cdd7b39 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -88,10 +88,10 @@ const WorkspaceSchedulePage = lazy( "./pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceSchedulePage" ), ); -const WorkspaceParametersExperimentRouter = lazy( +const WorkspaceParametersPage = lazy( () => import( - "./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersExperimentRouter" + "./pages/WorkspaceSettingsPage/WorkspaceParametersPage/WorkspaceParametersPage" ), ); const WorkspaceSharingPage = lazy( @@ -724,10 +724,7 @@ export const router = createBrowserRouter( /> }> } /> - } - /> + } /> } /> } /> diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index af0eb797c2..108889e6fa 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -1778,12 +1778,22 @@ export const MockDormantOutdatedWorkspace: TypesGen.Workspace = { dormant_at: new Date().toISOString(), }; -const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = { - ...MockWorkspace, - id: "test-outdated-workspace-require-active-version", - outdated: true, - template_require_active_version: true, -}; +export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockWorkspace, + id: "test-outdated-workspace-require-active-version", + outdated: true, + template_require_active_version: true, + }; + +export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, + }; const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockWorkspace, @@ -1796,15 +1806,6 @@ const MockOutdatedRunningWorkspaceAlwaysUpdate: TypesGen.Workspace = { }, }; -export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = - { - ...MockOutdatedRunningWorkspaceRequireActiveVersion, - latest_build: { - ...MockWorkspaceBuild, - status: "stopped", - }, - }; - export const MockOutdatedStoppedWorkspaceAlwaysUpdate: TypesGen.Workspace = { ...MockOutdatedRunningWorkspaceAlwaysUpdate, latest_build: {