From 8c9fb0015daf20e57aa375a0510e5c767a130f13 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 15 Sep 2026 15:24:13 +0000 Subject: [PATCH] feat: add dynamic workspace update parameter editor --- packages/shared/src/index.ts | 2 + packages/shared/src/workspaceUpdate/api.ts | 34 + packages/workspace-update/package.json | 29 + packages/workspace-update/src/App.tsx | 24 + .../WorkspaceUpdateForm.stories.tsx | 125 ++++ .../src/components/WorkspaceUpdateForm.tsx | 458 ++++++++++++++ packages/workspace-update/src/css.d.ts | 1 + .../src/hooks/useWorkspaceUpdate.ts | 21 + packages/workspace-update/src/index.css | 76 +++ packages/workspace-update/src/index.tsx | 21 + .../workspace-update/storybook.preview.ts | 1 + packages/workspace-update/tsconfig.json | 11 + packages/workspace-update/vite.config.ts | 6 + pnpm-lock.yaml | 43 ++ src/api/axios.d.ts | 5 + src/api/dynamicUpdateSession.ts | 483 ++++++++++++++ src/commands.ts | 15 + src/core/container.ts | 11 + src/logging/httpLogger.ts | 30 +- src/remote/workspaceStateMachine.ts | 10 +- .../workspaceUpdatePanelFactory.ts | 179 ++++++ test/unit/api/dynamicUpdateSession.test.ts | 587 ++++++++++++++++++ .../command/updateWorkspace.telemetry.test.ts | 39 +- test/unit/commands.netcheck.test.ts | 1 + test/unit/commands.supportBundle.test.ts | 1 + test/unit/commands.telemetry.test.ts | 4 + test/unit/logging/httpLogger.test.ts | 53 +- .../unit/remote/workspaceStateMachine.test.ts | 123 +++- .../workspaceUpdatePanelFactory.test.ts | 163 +++++ .../WorkspaceUpdateForm.test.ts | 266 ++++++++ 30 files changed, 2797 insertions(+), 25 deletions(-) create mode 100644 packages/shared/src/workspaceUpdate/api.ts create mode 100644 packages/workspace-update/package.json create mode 100644 packages/workspace-update/src/App.tsx create mode 100644 packages/workspace-update/src/components/WorkspaceUpdateForm.stories.tsx create mode 100644 packages/workspace-update/src/components/WorkspaceUpdateForm.tsx create mode 100644 packages/workspace-update/src/css.d.ts create mode 100644 packages/workspace-update/src/hooks/useWorkspaceUpdate.ts create mode 100644 packages/workspace-update/src/index.css create mode 100644 packages/workspace-update/src/index.tsx create mode 100644 packages/workspace-update/storybook.preview.ts create mode 100644 packages/workspace-update/tsconfig.json create mode 100644 packages/workspace-update/vite.config.ts create mode 100644 src/api/dynamicUpdateSession.ts create mode 100644 src/webviews/workspaceUpdate/workspaceUpdatePanelFactory.ts create mode 100644 test/unit/api/dynamicUpdateSession.test.ts create mode 100644 test/unit/webviews/workspaceUpdate/workspaceUpdatePanelFactory.test.ts create mode 100644 test/webview/workspace-update/WorkspaceUpdateForm.test.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 684e49e72e..b004b66007 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -33,3 +33,5 @@ export type { // Workspaces types and API export * from "./workspaces/types"; export { WorkspacesApi } from "./workspaces/api"; + +export * from "./workspaceUpdate/api"; diff --git a/packages/shared/src/workspaceUpdate/api.ts b/packages/shared/src/workspaceUpdate/api.ts new file mode 100644 index 0000000000..bb15381ed3 --- /dev/null +++ b/packages/shared/src/workspaceUpdate/api.ts @@ -0,0 +1,34 @@ +import { defineCommand, defineNotification } from "../ipc/protocol"; + +import type { + PreviewParameter, + FriendlyDiagnostic, +} from "coder/site/src/api/typesGenerated"; + +export interface WorkspaceUpdateState { + readonly workspaceName: string; + readonly templateVersionId: string; + readonly revision: number; + readonly status: + "loading" | "evaluating" | "ready" | "submitting" | "success" | "error"; + readonly parameters: readonly PreviewParameter[]; + readonly inputs: Readonly>; + readonly locked: readonly string[]; + readonly diagnostics: readonly FriendlyDiagnostic[]; + readonly error?: string; + readonly canSubmit: boolean; + readonly canRetry?: boolean; +} + +export const WorkspaceUpdateApi = { + stateChanged: defineNotification( + "workspaceUpdate/stateChanged", + ), + ready: defineCommand("workspaceUpdate/ready"), + change: defineCommand<{ name: string; value: string }>( + "workspaceUpdate/change", + ), + retry: defineCommand("workspaceUpdate/retry"), + submit: defineCommand<{ revision: number }>("workspaceUpdate/submit"), + cancel: defineCommand("workspaceUpdate/cancel"), +} as const; diff --git a/packages/workspace-update/package.json b/packages/workspace-update/package.json new file mode 100644 index 0000000000..f68bf44aeb --- /dev/null +++ b/packages/workspace-update/package.json @@ -0,0 +1,29 @@ +{ + "name": "@repo/workspace-update", + "version": "1.0.0", + "description": "Coder workspace update parameter form webview", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite build --watch", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@repo/shared": "workspace:*", + "@repo/ui": "workspace:*", + "@repo/webview-shared": "workspace:*", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@repo/storybook-utils": "workspace:*", + "@rolldown/plugin-babel": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "babel-plugin-react-compiler": "catalog:", + "typescript": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/workspace-update/src/App.tsx b/packages/workspace-update/src/App.tsx new file mode 100644 index 0000000000..1c7558fbfd --- /dev/null +++ b/packages/workspace-update/src/App.tsx @@ -0,0 +1,24 @@ +import { WorkspaceUpdateForm } from "./components/WorkspaceUpdateForm"; +import { useWorkspaceUpdate } from "./hooks/useWorkspaceUpdate"; + +export default function App(): React.JSX.Element { + const { api, state } = useWorkspaceUpdate(); + + if (!state) { + return ( +
+ Loading workspace update… +
+ ); + } + + return ( + api.submit({ revision })} + /> + ); +} diff --git a/packages/workspace-update/src/components/WorkspaceUpdateForm.stories.tsx b/packages/workspace-update/src/components/WorkspaceUpdateForm.stories.tsx new file mode 100644 index 0000000000..b799057f16 --- /dev/null +++ b/packages/workspace-update/src/components/WorkspaceUpdateForm.stories.tsx @@ -0,0 +1,125 @@ +import { useState } from "react"; + +import { WorkspaceUpdateForm } from "./WorkspaceUpdateForm"; + +import type { WorkspaceUpdateState } from "@repo/shared"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const parameter = ( + name: string, + formType: WorkspaceUpdateState["parameters"][number]["form_type"], + options: ReadonlyArray<{ + name: string; + value: string; + description?: string; + }> = [], +): WorkspaceUpdateState["parameters"][number] => ({ + name, + display_name: name, + description: `Configure ${name}.`, + type: formType === "slider" ? "number" : "string", + form_type: formType, + styling: {}, + mutable: true, + default_value: { valid: false, value: "" }, + value: { valid: false, value: "" }, + icon: "", + options: options.map((option) => ({ + name: option.name, + description: option.description ?? "", + value: { valid: true, value: option.value }, + icon: "", + })), + validations: [], + required: false, + order: 0, + ephemeral: false, + diagnostics: [], +}); + +const state: WorkspaceUpdateState = { + workspaceName: "dev", + templateVersionId: "template-version-1", + revision: 3, + status: "ready", + inputs: { + region: "us-east", + autoscale: "true", + cores: "4", + tags: '["team,blue", "staging"]', + features: '["metrics"]', + }, + locked: ["locked"], + diagnostics: [], + canSubmit: true, + parameters: [ + parameter("region", "radio", [ + { name: "US East", value: "us-east" }, + { name: "Europe", value: "eu-west", description: "Frankfurt" }, + ]), + parameter("autoscale", "switch"), + parameter("cores", "slider"), + parameter("notes", "textarea"), + parameter("features", "multi-select", [ + { name: "Metrics", value: "metrics" }, + { name: "Tracing", value: "tracing" }, + ]), + parameter("tags", "tag-select"), + { ...parameter("token", "input"), styling: { mask_input: true } }, + { ...parameter("locked", "input"), mutable: false }, + ], +}; + +function InteractiveForm(): React.JSX.Element { + const [inputs, setInputs] = useState(state.inputs); + return ( + + setInputs((current) => ({ ...current, [name]: value })) + } + onCancel={() => undefined} + onRetry={() => undefined} + onSubmit={() => undefined} + /> + ); +} + +const meta: Meta = { + title: "Workspace Update/Form", + component: InteractiveForm, +}; + +export default meta; +type Story = StoryObj; + +export const Ready: Story = {}; + +export const Evaluating: Story = { + render: () => ( + undefined} + onCancel={() => undefined} + onRetry={() => undefined} + onSubmit={() => undefined} + /> + ), +}; + +export const Error: Story = { + render: () => ( + undefined} + onCancel={() => undefined} + onRetry={() => undefined} + onSubmit={() => undefined} + /> + ), +}; diff --git a/packages/workspace-update/src/components/WorkspaceUpdateForm.tsx b/packages/workspace-update/src/components/WorkspaceUpdateForm.tsx new file mode 100644 index 0000000000..0bce74c333 --- /dev/null +++ b/packages/workspace-update/src/components/WorkspaceUpdateForm.tsx @@ -0,0 +1,458 @@ +import { + Button, + Checkbox, + ErrorState, + Field, + Input, + LoadingState, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Textarea, +} from "@repo/ui"; +import { useId, useState } from "react"; + +import type { WorkspaceUpdateState } from "@repo/shared"; +import type { PreviewParameter } from "coder/site/src/api/typesGenerated"; + +export interface WorkspaceUpdateFormProps { + state: WorkspaceUpdateState; + onCancel: () => void; + onChange: (change: { name: string; value: string }) => void; + onRetry: () => void; + onSubmit: (revision: number) => void; +} + +const TAG_SELECT_HELP = + 'Enter a JSON string array, for example ["us-east", "team,blue"]. This preserves items containing commas.'; + +/** Parse a tag-select value without treating commas as separators. */ +export function parseTagSelectValue(value: string): string[] | undefined { + try { + const parsed: unknown = JSON.parse(value); + return Array.isArray(parsed) && + parsed.every((item) => typeof item === "string") + ? parsed + : undefined; + } catch { + return undefined; + } +} + +function parameterValue( + parameter: PreviewParameter, + inputs: Readonly>, +): string { + return ( + inputs[parameter.name] ?? + (parameter.value.valid ? parameter.value.value : "") + ); +} + +function parameterLabel(parameter: PreviewParameter): string { + return `${parameter.styling.label || parameter.display_name || parameter.name}${parameter.required ? " (required)" : ""}`; +} + +function formatDiagnostics(parameter: PreviewParameter): string | undefined { + const errors = parameter.diagnostics + .filter((diagnostic) => diagnostic.severity === "error") + .map((diagnostic) => + [diagnostic.summary, diagnostic.detail].filter(Boolean).join(" — "), + ) + .filter(Boolean); + return errors.length > 0 ? errors.join(" ") : undefined; +} + +function parameterDescription( + parameter: PreviewParameter, + locked: boolean, +): string | undefined { + const description = [ + parameter.description, + parameter.form_type === "tag-select" ? TAG_SELECT_HELP : undefined, + locked + ? "This parameter is locked." + : !parameter.mutable + ? "This value cannot be changed after this update." + : undefined, + parameter.ephemeral + ? "This value resets to its default on the next build." + : undefined, + ] + .filter(Boolean) + .join(" "); + return description || undefined; +} + +function range(parameter: PreviewParameter): { min?: number; max?: number } { + const min = parameter.validations.find( + (validation) => validation.validation_min !== null, + )?.validation_min; + const max = parameter.validations.find( + (validation) => validation.validation_max !== null, + )?.validation_max; + return { + ...(min !== null && min !== undefined ? { min } : {}), + ...(max !== null && max !== undefined ? { max } : {}), + }; +} + +interface ParameterFieldProps { + disabled: boolean; + parameter: PreviewParameter; + value: string; + onChange: (value: string) => void; +} + +function ParameterField({ + disabled, + parameter, + value, + onChange, +}: ParameterFieldProps): React.JSX.Element { + const id = useId(); + const descriptionId = `${id}-description`; + const errorId = `${id}-error`; + const error = formatDiagnostics(parameter); + const description = parameterDescription(parameter, disabled); + const options = parameter.options.filter((option) => option.value.valid); + const formType = + parameter.form_type || + (parameter.type === "bool" + ? "checkbox" + : parameter.type === "list(string)" + ? "tag-select" + : options.length + ? "dropdown" + : "input"); + const describedBy = [description && descriptionId, error && errorId] + .filter(Boolean) + .join(" "); + const common = { + disabled, + "aria-describedby": describedBy || undefined, + "aria-invalid": error ? true : undefined, + }; + + if (formType === "error") { + return ( + +
+ This parameter cannot be configured. +
+
+ ); + } + + if (formType === "checkbox" || formType === "switch") { + return ( + + onChange(String(checked))} + > + {value === "true" ? "Enabled" : "Disabled"} + + + ); + } + + if (formType === "multi-select") { + const selected = parseTagSelectValue(value) ?? []; + return ( + +
+ {options.map((option) => { + const optionValue = option.value.valid ? option.value.value : ""; + const checked = selected.includes(optionValue); + return ( + { + const next = nextChecked + ? [...selected, optionValue] + : selected.filter((item) => item !== optionValue); + onChange(JSON.stringify(next)); + }} + > + {option.name || optionValue} + {option.description ? ( + + {option.description} + + ) : null} + + ); + })} +
+
+ ); + } + + if (formType === "dropdown" || formType === "radio") { + const selectedIndex = options.findIndex( + (option) => (option.value.valid ? option.value.value : "") === value, + ); + return ( + + + + ); + } + + const tagSelectInvalid = + formType === "tag-select" && !parseTagSelectValue(value) + ? "Enter a JSON array of strings." + : undefined; + const numeric = formType === "slider" || parameter.type === "number"; + return ( + + {formType === "textarea" ? ( +