Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/mocks/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./parameters";
export * from "./tasks";
export * from "./workspaces";
51 changes: 51 additions & 0 deletions packages/mocks/src/parameters.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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: "" } };
}
5 changes: 5 additions & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
21 changes: 21 additions & 0 deletions packages/shared/src/workspaceUpdate/api.ts
Original file line number Diff line number Diff line change
@@ -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<void, WorkspaceUpdateInit>("workspaceUpdate/init"),
evaluate: defineRequest<ParameterValues, DynamicParametersResponse>(
"workspaceUpdate/evaluate",
),
// Commands
submit: defineCommand<ParameterValues>("workspaceUpdate/submit"),
cancel: defineCommand<void>("workspaceUpdate/cancel"),
} as const;
22 changes: 22 additions & 0 deletions packages/shared/src/workspaceUpdate/parameters.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
12 changes: 12 additions & 0 deletions packages/shared/src/workspaceUpdate/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { DynamicParametersResponse } from "coder/site/src/api/typesGenerated";

export type ParameterValues = Readonly<Record<string, string>>;

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;
}
7 changes: 5 additions & 2 deletions packages/ui/src/components/Field/Field.css
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
7 changes: 6 additions & 1 deletion packages/ui/src/components/Field/Field.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -30,7 +31,11 @@ const FieldStates = (): React.JSX.Element => {
<Field
label="CPU cores"
htmlFor={coresId}
error="Value must be between 1 and 16."
error={
<ValidationMessage severity="error">
Value must be between 1 and 16.
</ValidationMessage>
}
errorId={`${coresId}-error`}
>
<Input
Expand Down
5 changes: 3 additions & 2 deletions packages/ui/src/components/Field/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface FieldProps extends ComponentProps<"div"> {
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;
Expand All @@ -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,
Expand All @@ -37,12 +38,12 @@ export function Field({
return (
<div {...props} className={cx("ui-field", className)}>
{label !== undefined && <Label htmlFor={htmlFor}>{label}</Label>}
{children}
{description !== undefined && (
<div id={descriptionId} className="ui-field__description">
{description}
</div>
)}
{children}
{error !== undefined && (
<div id={errorId} className="ui-field__error">
{error}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/components/Select/Select.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
30 changes: 30 additions & 0 deletions packages/ui/src/components/ValidationMessage/ValidationMessage.css
Original file line number Diff line number Diff line change
@@ -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);
}
Original file line number Diff line number Diff line change
@@ -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 => (
<div style={{ display: "grid", gap: "16px", width: "260px" }}>
{MESSAGES.map(({ severity, value, message }) => (
<div key={severity}>
<Input
aria-label={severity}
aria-invalid={severity === "error" || undefined}
value={value}
onChange={() => undefined}
/>
<ValidationMessage severity={severity}>{message}</ValidationMessage>
</div>
))}
</div>
);

const meta: Meta<typeof Messages> = {
title: "UI/ValidationMessage",
component: Messages,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof Messages>;

export const Severities: Story = {};
29 changes: 29 additions & 0 deletions packages/ui/src/components/ValidationMessage/ValidationMessage.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div
{...props}
className={cx(
"ui-validation-message",
`ui-validation-message--${severity}`,
className,
)}
/>
);
}
4 changes: 4 additions & 0 deletions packages/ui/src/components/text-control.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
5 changes: 5 additions & 0 deletions packages/ui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading