chore: make dynamic parameters WorkspaceParametersPage the default (#25095)

This commit is contained in:
McKayla はな
2026-06-26 17:55:34 -06:00
committed by GitHub
parent efd93027ce
commit 9efbcc7601
12 changed files with 996 additions and 1543 deletions
+3 -3
View File
@@ -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,
@@ -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 ? (
<WorkspaceParametersPage />
) : (
<WorkspaceParametersPageExperimental />
)}
</>
);
};
export default WorkspaceParametersExperimentRouter;
@@ -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<WorkspaceParameterFormProps> = ({
workspace,
onCancel,
onSubmit,
templateVersionRichParameters,
autofillParams,
error,
canChangeVersions,
templatePermissions,
isSubmitting,
}) => {
const form = useFormik<WorkspaceParametersFormValues>({
onSubmit,
initialValues: {
rich_parameter_values: getInitialRichParameterValues(
templateVersionRichParameters,
autofillParams,
),
},
validationSchema: Yup.object({
rich_parameter_values: useValidationSchemaForRichParameters(
templateVersionRichParameters,
),
}),
});
const getFieldHelpers = getFormHelpers<WorkspaceParametersFormValues>(
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 && (
<Alert severity="warning" prominent>
The template for this workspace requires automatic updates. Update the
workspace to edit parameters.
</Alert>
)}
<ClassicParameterFlowDeprecationWarning
templateSettingsLink={`/templates/${workspace.organization_name}/${workspace.template_name}/settings`}
isEnabled={templatePermissions?.canUpdateTemplate ?? false}
/>
<HorizontalForm onSubmit={form.handleSubmit} data-testid="form">
{hasNonEphemeralParameters && (
<FormSection
title="Parameters"
description="Settings used by your template"
>
<FormFields>
{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 ? (
<RichParameterInput
{...getFieldHelpers(
`rich_parameter_values[${index}].value`,
)}
disabled={isSubmitting || disabled || !parameter.mutable}
key={parameter.name}
onChange={async (value) => {
await form.setFieldValue(
`rich_parameter_values.${index}`,
{
name: parameter.name,
value: value,
},
);
}}
parameter={parameter}
parameterAutofill={autofillParams?.find(
({ name }) => name === parameter.name,
)}
/>
) : null,
)}
</FormFields>
</FormSection>
)}
{hasEphemeralParameters && (
<FormSection
title="Ephemeral Parameters"
description="These parameters only apply for a single workspace start."
>
<FormFields>
{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 ? (
<RichParameterInput
{...getFieldHelpers(
`rich_parameter_values[${index}].value`,
)}
disabled={isSubmitting || disabled}
key={parameter.name}
onChange={async (value) => {
await form.setFieldValue(
`rich_parameter_values.${index}`,
{
name: parameter.name,
value: value,
},
);
}}
parameter={parameter}
/>
) : null,
)}
</FormFields>
</FormSection>
)}
<FormFooter>
<Button onClick={onCancel} variant="outline">
Cancel
</Button>
<Button
type="submit"
disabled={isSubmitting || disabled || !form.dirty}
>
<Spinner loading={isSubmitting} />
Submit and restart
</Button>
</FormFooter>
</HorizontalForm>
</>
);
};
@@ -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<typeof WorkspaceParametersPageView> = {
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<typeof WorkspaceParametersPage>;
export default meta;
type Story = StoryObj<typeof WorkspaceParametersPageView>;
type Story = StoryObj<typeof WorkspaceParametersPage>;
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",
},
<WorkspaceParametersPage />,
),
});
}
function workspaceQueries(
workspace: Workspace,
permissionOverrides?: Partial<WorkspacePermissions>,
) {
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,
],
}),
},
];
}
@@ -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(<WorkspaceParametersPage />, {
route: "/@test-user/test-workspace/settings",
path: "/:username/:workspace/settings",
// Need this because after submit the user is redirected
extraRoutes: [{ path: "/:username/:workspace", element: <div /> }],
});
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(<WorkspaceParametersPage />, {
route,
path: "/:username/:workspace/settings",
extraRoutes: [
{
// Need this because after submit the user is redirected.
path: "/:username/:workspace",
element: <div>Workspace Page</div>,
},
],
});
};
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<TypesGen.WorkspaceBuildParameter[]>();
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<TypesGen.WorkspaceBuildParameter[]>();
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(<WorkspaceParametersPage />, {
route: "/@test-user/test-workspace/settings",
path: "/:username/:workspace/settings",
// Need this because after submit the user is redirected
extraRoutes: [{ path: "/:username/:workspace", element: <div /> }],
});
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();
});
@@ -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<DynamicParametersResponse | null>(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<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(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<string, string>): 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 (
<>
<div className="flex flex-col gap-6 max-w-screen-md">
<title>{pageTitle(workspace.name, "Parameters")}</title>
<WorkspaceParametersPageView
workspace={workspace}
templateVersionParameters={templateVersionParameters}
buildParameters={buildParameters}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
submitError={updateParameters.error}
isSubmitting={updateParameters.isPending}
onSubmit={(values) => {
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 (
<div className="flex flex-col gap-10">
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row justify-between w-full items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<span className="flex flex-row items-center gap-2 justify-between w-full">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelpIcon className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</span>
</header>
{submitError && !isApiValidationError(submitError) ? (
<ErrorAlert error={submitError} className="mb-12" />
) : null}
{Boolean(error) && <ErrorAlert error={error} />}
{templateVersionParameters && buildParameters ? (
templateVersionParameters.length > 0 ? (
<WorkspaceParametersForm
workspace={workspace}
canChangeVersions={canChangeVersions}
templatePermissions={templatePermissions}
autofillParams={buildParameters.map((p) => ({
...p,
source: "active_build",
}))}
templateVersionRichParameters={templateVersionParameters}
error={submitError}
isSubmitting={isSubmitting}
onSubmit={onSubmit}
onCancel={onCancel}
/>
) : (
<EmptyState
message="This workspace has no parameters"
cta={
<Button asChild>
<a
href={docs("/admin/templates/extending-templates/parameters")}
target="_blank"
rel="noreferrer"
>
<ExternalLinkIcon className="size-icon-xs" />
Learn more about parameters
</a>
</Button>
}
className="border border-solid rounded-lg"
/>
)
) : (
{isLoading ? (
<Loader />
) : sortedParams.length > 0 ? (
<WorkspaceParametersPageView
templateVersionId={templateVersionId}
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse?.diagnostics ?? []}
isSubmitting={
startWithParameters.isPending || restartWithParameters.isPending
}
submitLabel={submitLabel}
onSubmit={handleSubmit}
onCancel={() =>
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
sendMessage={sendMessage}
/>
) : (
<EmptyState
className="border border-border border-solid rounded-md"
message="This workspace has no parameters"
cta={
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
Learn more about parameters
</Link>
}
/>
)}
<ConfirmDialog
type="info"
hideCancel={false}
open={confirmingRestart.open}
onConfirm={() => {
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{" "}
<strong>delete non-persistent data</strong>.
</>
}
/>
</div>
);
};
@@ -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<typeof WorkspaceParametersPageExperimental>;
export default meta;
type Story = StoryObj<typeof WorkspaceParametersPageExperimental>;
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",
},
<WorkspaceParametersPageExperimental />,
),
});
}
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,
],
}),
},
];
}
@@ -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(
<WorkspaceParametersPageExperimental />,
{
route,
path: "/:username/:workspace/settings",
extraRoutes: [
{
// Need this because after submit the user is redirected.
path: "/:username/:workspace",
element: <div>Workspace Page</div>,
},
],
},
);
};
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<TypesGen.WorkspaceBuildParameter[]>();
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<TypesGen.WorkspaceBuildParameter[]>();
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());
});
});
@@ -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<DynamicParametersResponse | null>(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<number>(-1);
const ws = useRef<WebSocket | null>(null);
const [wsError, setWsError] = useState<Error | null>(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<string, string>): 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 (
<div className="flex flex-col gap-6 max-w-screen-md">
<title>{pageTitle(workspace.name, "Parameters")}</title>
<header className="flex flex-col items-start gap-2">
<span className="flex flex-row items-center gap-2 justify-between w-full">
<span className="flex flex-row items-center gap-2">
<h1 className="text-3xl m-0">Workspace parameters</h1>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>
<CircleHelpIcon className="size-icon-xs text-content-secondary" />
</TooltipTrigger>
<TooltipContent className="max-w-xs text-sm">
Dynamic Parameters enhances Coder's existing parameter system
with real-time validation, conditional parameter behavior, and
richer input types.
<br />
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
View docs
</Link>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</span>
</span>
</header>
{Boolean(error) && <ErrorAlert error={error} />}
{isLoading ? (
<Loader />
) : sortedParams.length > 0 ? (
<WorkspaceParametersPageViewExperimental
templateVersionId={templateVersionId}
workspace={workspace}
autofillParameters={autofillParameters}
canChangeVersions={canChangeVersions}
parameters={sortedParams}
diagnostics={latestResponse?.diagnostics ?? []}
isSubmitting={
startWithParameters.isPending || restartWithParameters.isPending
}
submitLabel={submitLabel}
onSubmit={handleSubmit}
onCancel={() =>
navigate(`/@${workspace.owner_name}/${workspace.name}`)
}
sendMessage={sendMessage}
/>
) : (
<EmptyState
className="border border-border border-solid rounded-md"
message="This workspace has no parameters"
cta={
<Link
href={docs(
"/admin/templates/extending-templates/dynamic-parameters",
)}
>
Learn more about parameters
</Link>
}
/>
)}
<ConfirmDialog
type="info"
hideCancel={false}
open={confirmingRestart.open}
onConfirm={() => {
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{" "}
<strong>delete non-persistent data</strong>.
</>
}
/>
</div>
);
};
export default WorkspaceParametersPageExperimental;
@@ -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",
)}
>
<div className="flex items-center m-0">
+3 -6
View File
@@ -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(
/>
<Route path="settings" element={<WorkspaceSettingsLayout />}>
<Route index element={<WorkspaceSettingsPage />} />
<Route
path="parameters"
element={<WorkspaceParametersExperimentRouter />}
/>
<Route path="parameters" element={<WorkspaceParametersPage />} />
<Route path="schedule" element={<WorkspaceSchedulePage />} />
<Route path="sharing" element={<WorkspaceSharingPage />} />
</Route>
+16 -15
View File
@@ -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: {