From a35211425bfd570c35566f9e32addc11a5c18fcd Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 30 Sep 2026 15:36:13 +0300 Subject: [PATCH] feat: dynamic parameter form for workspace updates Updating a workspace whose template uses dynamic parameters now works like the dashboard. The extension evaluates the new template version with the current values, and opens a form only when they fail. If the build still rejects the values, the form reopens with the server's reasons. Servers before 2.24 lack the evaluate endpoint and keep the classic prompts. The update prompts now show the new template version's message, and a failed update shows the server's error detail instead of the HTTP status. The form follows the VS Code settings editor layout and uses a new ValidationMessage component in @repo/ui that matches VS Code's input validation styles. --- CHANGELOG.md | 15 ++ packages/mocks/src/index.ts | 1 + packages/mocks/src/parameters.ts | 51 ++++ packages/shared/src/index.ts | 5 + packages/shared/src/workspaceUpdate/api.ts | 21 ++ .../shared/src/workspaceUpdate/parameters.ts | 22 ++ packages/shared/src/workspaceUpdate/types.ts | 12 + packages/ui/src/components/Field/Field.css | 7 +- .../ui/src/components/Field/Field.stories.tsx | 7 +- packages/ui/src/components/Field/Field.tsx | 5 +- packages/ui/src/components/Select/Select.css | 4 + .../ValidationMessage/ValidationMessage.css | 30 +++ .../ValidationMessage.stories.tsx | 54 ++++ .../ValidationMessage/ValidationMessage.tsx | 29 ++ packages/ui/src/components/text-control.css | 4 + packages/ui/src/index.ts | 5 + packages/ui/src/tokens.css | 43 +++ packages/workspace-update/package.json | 31 +++ packages/workspace-update/src/App.tsx | 91 +++++++ .../src/components/Diagnostics.tsx | 40 +++ .../src/components/ParameterField.tsx | 250 ++++++++++++++++++ .../WorkspaceUpdateForm.stories.tsx | 140 ++++++++++ .../src/components/WorkspaceUpdateForm.tsx | 107 ++++++++ packages/workspace-update/src/css.d.ts | 1 + .../src/hooks/useDebouncedValue.ts | 11 + .../src/hooks/useWorkspaceUpdateApi.ts | 6 + packages/workspace-update/src/index.css | 73 +++++ packages/workspace-update/src/index.tsx | 28 ++ .../workspace-update/storybook.preview.ts | 1 + packages/workspace-update/tsconfig.json | 11 + packages/workspace-update/vite.config.ts | 6 + pnpm-lock.yaml | 49 ++++ src/api/dynamicParameters.ts | 187 +++++++++++++ src/api/updateParameters.ts | 14 +- src/api/workspace.ts | 15 ++ src/commands.ts | 13 +- src/container.ts | 10 + src/error/normalize.ts | 11 + src/featureSet.ts | 3 + src/remote/workspaceStateMachine.ts | 67 ++++- src/webviews/coderPanel.ts | 63 +++++ src/webviews/resultPanel.ts | 70 +---- .../workspaceUpdatePanelFactory.ts | 60 +++++ test/mocks/testHelpers.ts | 28 ++ test/tsconfig.json | 3 +- test/unit/api/dynamicParameters.test.ts | 155 +++++++++++ test/unit/api/workspace.test.ts | 1 + test/unit/commands.updateWorkspace.test.ts | 24 ++ test/unit/featureSet.test.ts | 11 + .../unit/remote/workspaceStateMachine.test.ts | 113 +++++++- .../webviews/tasks/tasksPanelProvider.test.ts | 26 +- .../workspaceUpdatePanelFactory.test.ts | 85 ++++++ .../webview/ui/components/components.test.tsx | 15 ++ test/webview/workspace-update/App.test.tsx | 168 ++++++++++++ vitest.config.mts | 4 + 55 files changed, 2194 insertions(+), 112 deletions(-) create mode 100644 packages/mocks/src/parameters.ts create mode 100644 packages/shared/src/workspaceUpdate/api.ts create mode 100644 packages/shared/src/workspaceUpdate/parameters.ts create mode 100644 packages/shared/src/workspaceUpdate/types.ts create mode 100644 packages/ui/src/components/ValidationMessage/ValidationMessage.css create mode 100644 packages/ui/src/components/ValidationMessage/ValidationMessage.stories.tsx create mode 100644 packages/ui/src/components/ValidationMessage/ValidationMessage.tsx 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/Diagnostics.tsx create mode 100644 packages/workspace-update/src/components/ParameterField.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/useDebouncedValue.ts create mode 100644 packages/workspace-update/src/hooks/useWorkspaceUpdateApi.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/dynamicParameters.ts create mode 100644 src/webviews/coderPanel.ts create mode 100644 src/webviews/workspaceUpdate/workspaceUpdatePanelFactory.ts create mode 100644 test/unit/api/dynamicParameters.test.ts create mode 100644 test/unit/webviews/workspaceUpdate/workspaceUpdatePanelFactory.test.ts create mode 100644 test/webview/workspace-update/App.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c5963531f..396638e763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ from published versions since it shows up in the VS Code extension changelog tab and is confusing to users. Add it back between releases if needed. --> +## Unreleased + +### Added + +- Updating a workspace whose template uses dynamic parameters works like the + dashboard: the update goes ahead when the current values are still valid, and + a form asks only for missing or invalid ones. Servers before v2.24 keep the + classic prompts. +- The update prompts show the new template version's message. + +### Fixed + +- A failed workspace update shows the server's error detail instead of the HTTP + status. + ## [v1.17.2](https://github.com/coder/vscode-coder/releases/tag/v1.17.2) 2026-10-28 ### Changed diff --git a/packages/mocks/src/index.ts b/packages/mocks/src/index.ts index bf3d4e3af4..f1b990c9ee 100644 --- a/packages/mocks/src/index.ts +++ b/packages/mocks/src/index.ts @@ -1,2 +1,3 @@ +export * from "./parameters"; export * from "./tasks"; export * from "./workspaces"; diff --git a/packages/mocks/src/parameters.ts b/packages/mocks/src/parameters.ts new file mode 100644 index 0000000000..7c3e95c8d6 --- /dev/null +++ b/packages/mocks/src/parameters.ts @@ -0,0 +1,51 @@ +/** + * Test factories for dynamic template parameter types. + */ + +import type { + FriendlyDiagnostic, + PreviewParameter, + PreviewParameterOption, +} from "coder/site/src/api/typesGenerated"; + +/** Create a mutable, optional string PreviewParameter with no diagnostics. */ +export function previewParameter( + overrides: Partial = {}, +): PreviewParameter { + return { + name: "region", + display_name: "", + description: "", + type: "string", + form_type: "input", + styling: {}, + mutable: true, + default_value: { valid: true, value: "" }, + value: { valid: true, value: "" }, + icon: "", + options: [], + validations: [], + required: false, + order: 0, + ephemeral: false, + diagnostics: [], + ...overrides, + }; +} + +/** Create a PreviewParameterOption whose name defaults to its value. */ +export function parameterOption( + value: string, + name = value, + description = "", +): PreviewParameterOption { + return { name, description, value: { valid: true, value }, icon: "" }; +} + +/** Create a FriendlyDiagnostic, an error unless `severity` says otherwise. */ +export function diagnostic( + summary: string, + severity: FriendlyDiagnostic["severity"] = "error", +): FriendlyDiagnostic { + return { severity, summary, detail: "", extra: { code: "" } }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 711137cb6b..21c41e2ff8 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -33,3 +33,8 @@ export type { // Workspaces types and API export * from "./workspaces/types"; export { WorkspacesApi } from "./workspaces/api"; + +// Workspace Update types and API +export * from "./workspaceUpdate/types"; +export * from "./workspaceUpdate/parameters"; +export { WorkspaceUpdateApi } 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..b425add727 --- /dev/null +++ b/packages/shared/src/workspaceUpdate/api.ts @@ -0,0 +1,21 @@ +/** + * Workspace Update API - Type-safe message definitions for the parameter + * form shown when updating a workspace to the latest template version. + */ + +import { defineCommand, defineRequest } from "../ipc/protocol"; + +import type { DynamicParametersResponse } from "coder/site/src/api/typesGenerated"; + +import type { ParameterValues, WorkspaceUpdateInit } from "./types"; + +export const WorkspaceUpdateApi = { + // Requests + init: defineRequest("workspaceUpdate/init"), + evaluate: defineRequest( + "workspaceUpdate/evaluate", + ), + // Commands + submit: defineCommand("workspaceUpdate/submit"), + cancel: defineCommand("workspaceUpdate/cancel"), +} as const; diff --git a/packages/shared/src/workspaceUpdate/parameters.ts b/packages/shared/src/workspaceUpdate/parameters.ts new file mode 100644 index 0000000000..1fd6d9f6aa --- /dev/null +++ b/packages/shared/src/workspaceUpdate/parameters.ts @@ -0,0 +1,22 @@ +import type { DynamicParametersResponse } from "coder/site/src/api/typesGenerated"; + +export function hasErrorDiagnostics({ + parameters, + diagnostics, +}: DynamicParametersResponse): boolean { + return [...diagnostics, ...parameters.flatMap((p) => p.diagnostics)].some( + (d) => d.severity === "error", + ); +} + +/** Multi-select values are stored as a JSON-encoded string array. */ +export function parseMultiSelectValue(raw: string): string[] | null { + try { + const parsed: unknown = JSON.parse(raw); + return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") + ? parsed + : null; + } catch { + return null; + } +} diff --git a/packages/shared/src/workspaceUpdate/types.ts b/packages/shared/src/workspaceUpdate/types.ts new file mode 100644 index 0000000000..3cb2c0341a --- /dev/null +++ b/packages/shared/src/workspaceUpdate/types.ts @@ -0,0 +1,12 @@ +import type { DynamicParametersResponse } from "coder/site/src/api/typesGenerated"; + +export type ParameterValues = Readonly>; + +export interface WorkspaceUpdateInit { + readonly workspaceName: string; + /** Omitted parameters use the server's value. */ + readonly values: ParameterValues; + /** The server's evaluation of `values`. */ + readonly evaluation: DynamicParametersResponse; + readonly restart: boolean; +} diff --git a/packages/ui/src/components/Field/Field.css b/packages/ui/src/components/Field/Field.css index e2af3c4558..e1c15de747 100644 --- a/packages/ui/src/components/Field/Field.css +++ b/packages/ui/src/components/Field/Field.css @@ -4,15 +4,18 @@ } .ui-field { + --ui-field-gap: 4px; + display: flex; flex-direction: column; - gap: 4px; + gap: var(--ui-field-gap); } .ui-field__description { color: var(--ui-description-foreground); } +/* Attaches to the control above, like VS Code's input validation. */ .ui-field__error { - color: var(--ui-error-foreground); + margin-top: calc(-1 * var(--ui-field-gap)); } diff --git a/packages/ui/src/components/Field/Field.stories.tsx b/packages/ui/src/components/Field/Field.stories.tsx index 8037c606c6..4d4664bf2e 100644 --- a/packages/ui/src/components/Field/Field.stories.tsx +++ b/packages/ui/src/components/Field/Field.stories.tsx @@ -3,6 +3,7 @@ import { useId, useState } from "react"; import { PIXEL_ALL_THEMES } from "#storybook"; import { Input } from "../Input/Input"; +import { ValidationMessage } from "../ValidationMessage/ValidationMessage"; import { Field } from "./Field"; @@ -30,7 +31,11 @@ const FieldStates = (): React.JSX.Element => { + Value must be between 1 and 16. + + } errorId={`${coresId}-error`} > { description?: ReactNode; /** ID for the description; associate it with the control via aria-describedby. */ descriptionId?: string; + /** Usually one or more `ValidationMessage`s. */ error?: ReactNode; /** ID for the error; the consumer owns aria-describedby and aria-invalid. */ errorId?: string; @@ -22,7 +23,7 @@ export interface FieldProps extends ComponentProps<"div"> { } /* Lays out a labelled control like a settings-editor entry: semibold label, - control, then muted description or error text. */ + muted description, control, then validation messages flush below it. */ export function Field({ label, htmlFor, @@ -37,12 +38,12 @@ export function Field({ return (
{label !== undefined && } - {children} {description !== undefined && (
{description}
)} + {children} {error !== undefined && (
{error} diff --git a/packages/ui/src/components/Select/Select.css b/packages/ui/src/components/Select/Select.css index 678b022bcf..98d80e7d9c 100644 --- a/packages/ui/src/components/Select/Select.css +++ b/packages/ui/src/components/Select/Select.css @@ -14,6 +14,10 @@ outline: 0; } +.ui-select__trigger[aria-invalid="true"] { + border-color: var(--ui-message-error-border); +} + .ui-select__trigger:focus { border-color: var(--ui-focus-border); } diff --git a/packages/ui/src/components/ValidationMessage/ValidationMessage.css b/packages/ui/src/components/ValidationMessage/ValidationMessage.css new file mode 100644 index 0000000000..a9fa5d30fe --- /dev/null +++ b/packages/ui/src/components/ValidationMessage/ValidationMessage.css @@ -0,0 +1,30 @@ +/* Matches VS Code's .monaco-inputbox-message. */ +.ui-validation-message { + box-sizing: border-box; + margin-top: -1px; + padding: 0.4em; + color: var(--ui-validation-foreground); + background: var(--ui-validation-background); + border: 1px solid var(--ui-validation-border); + font-size: 12px; + line-height: 17px; + overflow-wrap: break-word; +} + +.ui-validation-message--info { + --ui-validation-foreground: var(--ui-message-info-foreground); + --ui-validation-background: var(--ui-message-info-background); + --ui-validation-border: var(--ui-message-info-border); +} + +.ui-validation-message--warning { + --ui-validation-foreground: var(--ui-message-warning-foreground); + --ui-validation-background: var(--ui-message-warning-background); + --ui-validation-border: var(--ui-message-warning-border); +} + +.ui-validation-message--error { + --ui-validation-foreground: var(--ui-message-error-foreground); + --ui-validation-background: var(--ui-message-error-background); + --ui-validation-border: var(--ui-message-error-border); +} diff --git a/packages/ui/src/components/ValidationMessage/ValidationMessage.stories.tsx b/packages/ui/src/components/ValidationMessage/ValidationMessage.stories.tsx new file mode 100644 index 0000000000..8cd514e581 --- /dev/null +++ b/packages/ui/src/components/ValidationMessage/ValidationMessage.stories.tsx @@ -0,0 +1,54 @@ +import { PIXEL_ALL_THEMES } from "#storybook"; + +import { Input } from "../Input/Input"; + +import { + ValidationMessage, + type ValidationSeverity, +} from "./ValidationMessage"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const MESSAGES: ReadonlyArray<{ + severity: ValidationSeverity; + value: string; + message: string; +}> = [ + { severity: "info", value: "dev", message: "Names are case-sensitive." }, + { + severity: "warning", + value: "4", + message: "Fewer than 8 cores may slow down builds.", + }, + { + severity: "error", + value: "32", + message: "Value must be between 1 and 16.", + }, +]; + +const Messages = (): React.JSX.Element => ( +
+ {MESSAGES.map(({ severity, value, message }) => ( +
+ undefined} + /> + {message} +
+ ))} +
+); + +const meta: Meta = { + title: "UI/ValidationMessage", + component: Messages, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const Severities: Story = {}; diff --git a/packages/ui/src/components/ValidationMessage/ValidationMessage.tsx b/packages/ui/src/components/ValidationMessage/ValidationMessage.tsx new file mode 100644 index 0000000000..ea5097bb80 --- /dev/null +++ b/packages/ui/src/components/ValidationMessage/ValidationMessage.tsx @@ -0,0 +1,29 @@ +import { type ComponentProps } from "react"; + +import { cx } from "#cx"; + +import "./ValidationMessage.css"; + +export type ValidationSeverity = "info" | "warning" | "error"; + +export interface ValidationMessageProps extends ComponentProps<"div"> { + severity: ValidationSeverity; +} + +/** VS Code's input box validation message. */ +export function ValidationMessage({ + severity, + className, + ...props +}: ValidationMessageProps): React.JSX.Element { + return ( +
+ ); +} diff --git a/packages/ui/src/components/text-control.css b/packages/ui/src/components/text-control.css index e2ec7e3a30..d3bd84eaea 100644 --- a/packages/ui/src/components/text-control.css +++ b/packages/ui/src/components/text-control.css @@ -14,6 +14,10 @@ height: 26px; } +.ui-text-control:is([aria-invalid="true"], :has(> [aria-invalid="true"])) { + border-color: var(--ui-message-error-border); +} + .ui-text-control:focus-within { border-color: var(--ui-focus-border); } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 507bc8a49d..4693d89da8 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -52,6 +52,11 @@ export { type StatusPillTone, } from "./components/StatusPill/StatusPill"; export { Textarea, type TextareaProps } from "./components/Textarea/Textarea"; +export { + ValidationMessage, + type ValidationMessageProps, + type ValidationSeverity, +} from "./components/ValidationMessage/ValidationMessage"; export type { CodiconName } from "./codicons"; export { ContextMenu, diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 7a15db40ca..1caa381dca 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -29,6 +29,11 @@ ); --ui-description-foreground: var(--vscode-descriptionForeground); --ui-border: var(--vscode-sideBar-border, transparent); + /* Divides sections in an editor tab, like the settings editor's headers */ + --ui-separator: var( + --vscode-settings-headerBorder, + var(--vscode-panel-border, var(--ui-border)) + ); --ui-link-foreground: var(--vscode-textLink-foreground); --ui-focus-border: var( --vscode-focusBorder, @@ -276,6 +281,44 @@ /* Only defined by high contrast themes; transparent elsewhere */ --ui-contrast-border: var(--vscode-contrastBorder, transparent); + /* Validation and banner messages */ + --ui-message-info-background: var( + --vscode-inputValidation-infoBackground, + transparent + ); + --ui-message-info-border: var( + --vscode-inputValidation-infoBorder, + var(--ui-contrast-border) + ); + --ui-message-info-foreground: var( + --vscode-inputValidation-infoForeground, + var(--ui-foreground) + ); + --ui-message-warning-background: var( + --vscode-inputValidation-warningBackground, + transparent + ); + --ui-message-warning-border: var( + --vscode-inputValidation-warningBorder, + var(--ui-contrast-border) + ); + --ui-message-warning-foreground: var( + --vscode-inputValidation-warningForeground, + var(--ui-foreground) + ); + --ui-message-error-background: var( + --vscode-inputValidation-errorBackground, + transparent + ); + --ui-message-error-border: var( + --vscode-inputValidation-errorBorder, + var(--ui-contrast-border) + ); + --ui-message-error-foreground: var( + --vscode-inputValidation-errorForeground, + var(--ui-foreground) + ); + /* Portalled overlays; matches the native hover widget's stacking */ --ui-z-index-overlay: 40; } diff --git a/packages/workspace-update/package.json b/packages/workspace-update/package.json new file mode 100644 index 0000000000..40269ea104 --- /dev/null +++ b/packages/workspace-update/package.json @@ -0,0 +1,31 @@ +{ + "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:*", + "@tanstack/react-query": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@repo/mocks": "workspace:*", + "@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..3d26ba4e29 --- /dev/null +++ b/packages/workspace-update/src/App.tsx @@ -0,0 +1,91 @@ +import { + hasErrorDiagnostics, + type ParameterValues, + type WorkspaceUpdateInit, +} from "@repo/shared"; +import { ErrorState, LoadingState } from "@repo/ui"; +import { useVsCodeState } from "@repo/webview-shared/react"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useState } from "react"; + +import { WorkspaceUpdateForm } from "./components/WorkspaceUpdateForm"; +import { useDebouncedValue } from "./hooks/useDebouncedValue"; +import { useWorkspaceUpdateApi } from "./hooks/useWorkspaceUpdateApi"; + +import type { PreviewParameter } from "coder/site/src/api/typesGenerated"; + +/** Like the dashboard, only typing is debounced. */ +const TYPING_DEBOUNCE_MS = 500; +const TYPED_FORM_TYPES: ReadonlySet = new Set(["input", "textarea"]); + +const queryKeys = { + init: ["init"], + evaluate: (values: ParameterValues) => ["evaluate", values], +}; + +export default function App() { + const api = useWorkspaceUpdateApi(); + const init = useQuery({ + queryKey: queryKeys.init, + queryFn: () => api.init(), + staleTime: Infinity, + }); + + if (init.isPending) { + return ; + } + if (init.isError) { + return ( + void init.refetch()} + /> + ); + } + return ; +} + +function WorkspaceUpdate({ init }: { init: WorkspaceUpdateInit }) { + const api = useWorkspaceUpdateApi(); + // Survives the panel being hidden. + const [values, setValues] = useVsCodeState(init.values); + const [debounceMs, setDebounceMs] = useState(0); + const inputs = useDebouncedValue(values, debounceMs); + const evaluation = useQuery({ + queryKey: queryKeys.evaluate(inputs), + queryFn: () => api.evaluate(inputs), + initialData: inputs === init.values ? init.evaluation : undefined, + placeholderData: keepPreviousData, + // Same inputs on the same template version always evaluate the same. + staleTime: Infinity, + }); + // Restored edits start from the initial evaluation until theirs arrives. + const data = evaluation.data ?? init.evaluation; + + const onChange = (parameter: PreviewParameter, value: string) => { + setDebounceMs( + TYPED_FORM_TYPES.has(parameter.form_type) ? TYPING_DEBOUNCE_MS : 0, + ); + setValues({ ...values, [parameter.name]: value }); + }; + + return ( + + ); +} diff --git a/packages/workspace-update/src/components/Diagnostics.tsx b/packages/workspace-update/src/components/Diagnostics.tsx new file mode 100644 index 0000000000..17f6055468 --- /dev/null +++ b/packages/workspace-update/src/components/Diagnostics.tsx @@ -0,0 +1,40 @@ +import { ValidationMessage } from "@repo/ui"; + +import type { FriendlyDiagnostic } from "coder/site/src/api/typesGenerated"; + +function diagnosticText({ summary, detail }: FriendlyDiagnostic) { + return detail ? `${summary}: ${detail}` : summary; +} + +/** Messages under a field, read through the control's `aria-describedby`. */ +export function FieldDiagnostics({ + diagnostics, +}: { + diagnostics: readonly FriendlyDiagnostic[]; +}) { + return diagnostics.map((diagnostic) => ( + + {diagnosticText(diagnostic)} + + )); +} + +/** Messages about the whole form, announced as they appear. */ +export function FormDiagnostics({ + diagnostics, +}: { + diagnostics: readonly FriendlyDiagnostic[]; +}) { + return diagnostics.map((diagnostic) => ( + + {diagnosticText(diagnostic)} + + )); +} diff --git a/packages/workspace-update/src/components/ParameterField.tsx b/packages/workspace-update/src/components/ParameterField.tsx new file mode 100644 index 0000000000..10867f70fb --- /dev/null +++ b/packages/workspace-update/src/components/ParameterField.tsx @@ -0,0 +1,250 @@ +import { parseMultiSelectValue } from "@repo/shared"; +import { + Checkbox, + Field, + Input, + PasswordInput, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + StatusPill, + Textarea, +} from "@repo/ui"; +import { useId } from "react"; + +import { FieldDiagnostics } from "./Diagnostics"; + +import type { + FriendlyDiagnostic, + PreviewParameter, +} from "coder/site/src/api/typesGenerated"; + +interface ParameterFieldProps { + parameter: PreviewParameter; + value: string; + disabled: boolean; + onChange: (value: string) => void; +} + +function ParameterLabel({ + parameter, + label, + required, +}: { + parameter: PreviewParameter; + label: string; + required: FriendlyDiagnostic | undefined; +}) { + return ( + + + {label} + {parameter.required && ( + * + )} + + {!parameter.mutable && ( + + Immutable + + )} + {parameter.ephemeral && ( + + Ephemeral + + )} + {required && ( + + Required + + )} + + ); +} + +/** Like the dashboard's `DynamicParameter`. */ +export function ParameterField(props: ParameterFieldProps) { + const { parameter } = props; + const id = useId(); + const label = parameter.display_name || parameter.name; + const required = parameter.diagnostics.find( + (d) => d.extra?.code === "required", + ); + const diagnostics = parameter.diagnostics.filter((d) => d !== required); + const descriptionId = `${id}-description`; + const diagnosticsId = `${id}-diagnostics`; + + return ( + + } + htmlFor={id} + description={parameter.description || undefined} + descriptionId={descriptionId} + error={ + diagnostics.length > 0 ? ( + + ) : undefined + } + errorId={diagnosticsId} + > + 0 && diagnosticsId, + ] + .filter(Boolean) + .join(" ") || undefined + } + invalid={parameter.diagnostics.some((d) => d.severity === "error")} + /> + + ); +} + +function ParameterControl({ + parameter, + value, + disabled, + onChange, + id, + label, + describedBy, + invalid, +}: ParameterFieldProps & { + id: string; + label: string; + describedBy: string | undefined; + invalid: boolean; +}) { + const control = { + id, + disabled, + "aria-describedby": describedBy, + "aria-invalid": invalid || undefined, + }; + const text = { + ...control, + value, + onChange, + placeholder: parameter.styling.placeholder, + }; + const options = parameter.options.map((option) => ({ + value: option.value.value, + label: option.name || option.value.value, + description: option.description || undefined, + })); + + switch (parameter.form_type) { + case "error": + return null; + case "checkbox": + case "switch": + return ( + onChange(String(checked))} + > + {parameter.styling.label} + + ); + case "dropdown": + case "radio": + return ( + + ); + case "multi-select": { + const selected = parseMultiSelectValue(value) ?? []; + return ( +
+ {options.map((option) => ( + + onChange( + JSON.stringify( + checked + ? [...selected, option.value] + : selected.filter((v) => v !== option.value), + ), + ) + } + > + {option.label} + + ))} +
+ ); + } + case "textarea": + return