mirror of
https://github.com/coder/coder.git
synced 2026-09-21 20:51:01 +08:00
chore: make dynamic parameters WorkspaceParametersPage the default (#25095)
This commit is contained in:
@@ -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,
|
||||
|
||||
-20
@@ -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;
|
||||
-181
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+282
-67
@@ -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,
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+375
-114
@@ -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();
|
||||
});
|
||||
|
||||
+312
-153
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
-227
@@ -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,
|
||||
],
|
||||
}),
|
||||
},
|
||||
];
|
||||
}
|
||||
-397
@@ -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());
|
||||
});
|
||||
});
|
||||
-355
@@ -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;
|
||||
+5
-5
@@ -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
@@ -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>
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user