feat(site/src/pages/TemplateBuilder): auto-fill customization fields from base template (#27272)

Closes
[DEVEX-586](https://linear.app/codercom/issue/DEVEX-586/auto-fill-customization-fields-from-selected-base-template).

The Template Builder wizard's final **Customizations** step rendered
empty inputs for ID, Display name, Description, and Icon. This seeds
those fields with sensible defaults derived from the selected base
template, while keeping every field editable.

## Changes

All frontend, in `site/src/pages/TemplateBuilder/wizardState.ts`:

- `SelectedBaseMeta` now carries `description`, mapped in
`toSelectedBaseMeta`.
- New pure helper `baseCustomizationDefaults(base)` maps the base to `{
name: base.id, displayName: base.name, description, icon }`.
- `SET_BASE` seeds the four customization fields when the base changes
(still clearing base variable values). Re-selecting the same base
preserves user edits.
- `initWizardState` seeds the same defaults for the `?base=` deeplink
entry path.
- `RESET_CUSTOMIZATIONS` (fired on back-navigation) now resets only
organization/provisioner state, so auto-filled values survive stepping
back and forth.

No change was needed in `TemplateCustomizationsStep.tsx`; it already
binds to these state fields, so seeded values render and stay editable.
Existing placeholders remain as the fallback when a base value is empty
(e.g. `scratch`).

### Default mapping

| Form field   | State key     | Source             |
| ------------ | ------------- | ------------------ |
| ID           | `name`        | `base.id`          |
| Display name | `displayName` | `base.name`        |
| Description  | `description` | `base.description` |
| Icon         | `icon`        | `base.icon`        |

`base.id` is used for the ID field because base ids are already valid
template slugs (`docker`, `aws-linux`, ...). Icon values are already
served asset paths (`/icon/*`, `/emojis/*`), so no lookup map is needed.

## Testing

- `wizardState.test.ts`: 27 passing, including new coverage for seeding,
base-change re-seed, same-base edit preservation,
`RESET_CUSTOMIZATIONS`, `initWizardState`, `toSelectedBaseMeta`, and
`baseCustomizationDefaults`.
- `biome check` clean; `tsc --noEmit` clean.

<img width="1043" height="593" alt="Screenshot 2026-07-15 at 2 17 19 PM"
src="https://github.com/user-attachments/assets/25620df4-b020-4430-b5f3-9ce8b7b9f379"
/>

<details>
<summary>Implementation plan</summary>

# DEVEX-586: Auto-fill customization fields from selected base template

## Goal

In the Template Builder wizard, the final **Customizations** step
currently renders empty inputs for Display name, Description, ID, and
Icon. Pre-populate these with sensible defaults derived from the
selected base template, while keeping every field editable. Organization
is already auto-selected when a single org is available, so it is out of
scope beyond leaving it untouched.

Source: Linear DEVEX-586 (Ben Potter): "Would love if all these options
... were auto-filled and generated and can be edited versus the user
manually filling it out." Fields called out: display name, description,
ID, and icon.

## Current behavior (findings)

Frontend lives in `site/src/pages/TemplateBuilder/`.

- `TemplateCustomizationsStep.tsx` renders the four fields bound to
`state.displayName`, `state.description`, `state.name` (the "ID" field),
and `state.icon`. All start empty (`initialWizardState`).
- `wizardState.ts` holds the state, the `wizardReducer`, and the
`SelectedBaseMeta` UI type.
  - `SelectedBaseMeta` did not carry `description`.
- `toSelectedBaseMeta(base)` maps the API `TemplateBuilderBase` into
`SelectedBaseMeta`.
- `SET_BASE` set the base and cleared `baseVariableValues` only when the
base id changed; customization fields were left empty.
- `RESET_CUSTOMIZATIONS` (dispatched by `handleBack`) blanked
org/provisioner plus `name`, `displayName`, `description`, `icon`.
- `initWizardState(preselectedBase)` seeded `baseTemplateId` and
`selectedBase` for deeplinks but left customization fields empty.
- API type `TemplateBuilderBase` exposes `id`, `name`, `description`,
`icon`, `os`, `variables`, `prerequisites`.
- Backend (`coderd/templatebuilder_handler.go`) builds each base from
the built-in `TemplateExample`: `id = ex.ID`, `name = ex.Name`,
`description = ex.Description`, `icon = ex.Icon`.
- Base IDs are valid template names (lowercase, hyphen-separated):
`docker`, `kubernetes`, `aws-linux`, `aws-windows`, `gcp-linux`,
`gcp-windows`, `azure-linux`, `digitalocean-linux`, `scratch`.

## Default mapping

| Form field | State key | Default source |
| --- | --- | --- |
| ID (required) | `name` | `base.id` |
| Display name | `displayName` | `base.name` |
| Description | `description` | `base.description` |
| Icon | `icon` | `base.iconUrl` (`base.icon`) |
| Organization | `organizationId` | unchanged (already auto-selected) |

Rationale for `name = base.id`: base ids are guaranteed valid template
slugs, whereas slugifying the human display name is lossy and can
collide. The field stays editable.

## Icon default: confirmed, no map needed

The API already returns normalized, served asset paths in `base.icon`
(e.g. `/icon/docker.png`, `/icon/aws.svg`, `/emojis/1f4e6.png` for
`scratch`), the same values `CreateTemplatePage` assigns when creating
from a built-in example. `IconField` accepts any URL/path, so every base
renders correctly. A base-id-to-icon map is unnecessary.

## Implementation

Frontend-only, in `wizardState.ts`:

1. Add `description?: string` to `SelectedBaseMeta`; map it in
`toSelectedBaseMeta`.
2. Add pure helper `baseCustomizationDefaults(base)` returning `{ name,
displayName, description, icon }`.
3. `SET_BASE`: on base change, spread the defaults alongside the
`baseVariableValues: {}` reset; on same-base re-selection, preserve
existing values.
4. `initWizardState(preselectedBase)`: spread defaults into the returned
state.
5. Narrow `RESET_CUSTOMIZATIONS` to reset only `organizationId` and
`hasProvisioners`.
6. `toCreateTemplateRequest` already sends the fields, so no payload
change.

## Tests

Reducer unit tests in `wizardState.test.ts` for seeding, base-change
re-seed, same-base edit preservation, `RESET_CUSTOMIZATIONS`,
`initWizardState`, `toSelectedBaseMeta` (description carried), and
`baseCustomizationDefaults`.

## Scope / non-goals

- No backend changes; the API already returns all needed fields.
- Organization auto-selection is unchanged.
- No slug transformation of display names (base id is used directly).
- No base-id-to-icon map.

</details>

---
*Opened by Coder Agents on behalf of @jeremyruppel.*
This commit is contained in:
Jeremy Ruppel
2026-07-22 15:09:37 -05:00
committed by GitHub
parent 923667013e
commit c309f4d5ff
2 changed files with 232 additions and 6 deletions
@@ -1,10 +1,15 @@
import { describe, expect, it } from "vitest";
import type { TemplateBuilderBase } from "#/api/typesGenerated";
import {
baseCustomizationDefaults,
initialWizardState,
initWizardState,
moduleHasConfigurableVars,
type SelectedBaseMeta,
type TemplateBuilderWizardState,
toComposeRequest,
toCreateTemplateRequest,
toSelectedBaseMeta,
type WizardAction,
wizardReducer,
} from "./wizardState";
@@ -34,6 +39,90 @@ describe("wizardReducer", () => {
expect(state.selectedBase?.name).toBe("Docker");
});
it("seeds customization defaults from the selected base", () => {
const state = reduce([
{
type: "SET_BASE",
base: {
id: "docker",
name: "Docker Containers",
description: "Run workspaces as Docker containers",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
},
},
]);
expect(state.name).toBe("docker");
expect(state.displayName).toBe("Docker Containers");
expect(state.description).toBe("Run workspaces as Docker containers");
expect(state.icon).toBe("/icon/docker.png");
});
it("re-seeds customization defaults when the base changes", () => {
const state = reduce([
{
type: "SET_BASE",
base: {
id: "docker",
name: "Docker Containers",
description: "Docker",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
},
},
{
type: "SET_BASE",
base: {
id: "aws-linux",
name: "AWS Linux",
description: "AWS EC2 Linux",
iconUrl: "/icon/aws.svg",
hasParameters: false,
hasPrerequisites: false,
},
},
]);
expect(state.name).toBe("aws-linux");
expect(state.displayName).toBe("AWS Linux");
expect(state.description).toBe("AWS EC2 Linux");
expect(state.icon).toBe("/icon/aws.svg");
});
it("preserves customization edits when same base is re-selected", () => {
const state = reduce([
{
type: "SET_BASE",
base: {
id: "docker",
name: "Docker Containers",
description: "Docker",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
},
},
{
type: "SET_CUSTOMIZATION",
field: "displayName",
value: "My Custom Name",
},
{
type: "SET_BASE",
base: {
id: "docker",
name: "Docker Containers",
description: "Docker",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
},
},
]);
expect(state.displayName).toBe("My Custom Name");
});
it("clears base variable values when base changes", () => {
const state = reduce([
{
@@ -270,6 +359,38 @@ describe("wizardReducer", () => {
});
});
describe("RESET_CUSTOMIZATIONS", () => {
it("clears org and provisioner state but keeps base-derived fields", () => {
const state = reduce([
{
type: "SET_BASE",
base: {
id: "docker",
name: "Docker Containers",
description: "Docker",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
},
},
{
type: "SET_CUSTOMIZATION",
field: "organizationId",
value: "org-123",
},
{ type: "SET_HAS_PROVISIONERS", value: true },
{ type: "RESET_CUSTOMIZATIONS" },
]);
expect(state.organizationId).toBeUndefined();
expect(state.hasProvisioners).toBeUndefined();
// Base-derived fields survive so re-entering the step stays filled.
expect(state.name).toBe("docker");
expect(state.displayName).toBe("Docker Containers");
expect(state.description).toBe("Docker");
expect(state.icon).toBe("/icon/docker.png");
});
});
describe("RESET", () => {
it("returns to initial state", () => {
const state = reduce([
@@ -432,3 +553,83 @@ describe("toCreateTemplateRequest", () => {
expect(request.icon).toBeUndefined();
});
});
describe("toSelectedBaseMeta", () => {
const makeBase = (
overrides: Partial<TemplateBuilderBase> = {},
): TemplateBuilderBase => ({
id: "docker",
name: "Docker Containers",
description: "Run workspaces as Docker containers",
icon: "/icon/docker.png",
os: "linux",
variables: [],
prerequisites: "",
...overrides,
});
it("carries description and icon through to the UI metadata", () => {
const meta = toSelectedBaseMeta(makeBase());
expect(meta.description).toBe("Run workspaces as Docker containers");
expect(meta.iconUrl).toBe("/icon/docker.png");
expect(meta.name).toBe("Docker Containers");
expect(meta.id).toBe("docker");
});
});
describe("baseCustomizationDefaults", () => {
it("maps base metadata to editable customization defaults", () => {
const base: SelectedBaseMeta = {
id: "aws-linux",
name: "AWS Linux",
description: "AWS EC2 Linux",
iconUrl: "/icon/aws.svg",
hasParameters: false,
hasPrerequisites: false,
};
expect(baseCustomizationDefaults(base)).toEqual({
name: "aws-linux",
displayName: "AWS Linux",
description: "AWS EC2 Linux",
icon: "/icon/aws.svg",
});
});
it("falls back to empty strings for missing description and icon", () => {
const base: SelectedBaseMeta = {
id: "scratch",
name: "Scratch",
hasParameters: false,
hasPrerequisites: false,
};
expect(baseCustomizationDefaults(base)).toEqual({
name: "scratch",
displayName: "Scratch",
description: "",
icon: "",
});
});
});
describe("initWizardState", () => {
it("returns the initial state without a preselected base", () => {
expect(initWizardState()).toEqual(initialWizardState);
});
it("seeds base and customization defaults from a preselected base", () => {
const state = initWizardState({
id: "docker",
name: "Docker Containers",
description: "Docker",
iconUrl: "/icon/docker.png",
hasParameters: false,
hasPrerequisites: false,
});
expect(state.baseTemplateId).toBe("docker");
expect(state.selectedBase?.id).toBe("docker");
expect(state.name).toBe("docker");
expect(state.displayName).toBe("Docker Containers");
expect(state.description).toBe("Docker");
expect(state.icon).toBe("/icon/docker.png");
});
});
+31 -6
View File
@@ -13,6 +13,7 @@ import type {
export type SelectedBaseMeta = {
id: string;
name: string;
description?: string;
iconUrl?: string;
os?: string;
hasParameters: boolean;
@@ -28,6 +29,7 @@ export function toSelectedBaseMeta(
return {
id: base.id,
name: base.name,
description: base.description,
iconUrl: base.icon,
os: base.os,
hasParameters:
@@ -36,6 +38,24 @@ export function toSelectedBaseMeta(
};
}
/**
* Derives editable customization defaults from the selected base template.
* Empty base values fall through to the fields' existing placeholders.
*/
export function baseCustomizationDefaults(base: SelectedBaseMeta): {
name: string;
displayName: string;
description: string;
icon: string;
} {
return {
name: base.id,
displayName: base.name,
description: base.description ?? "",
icon: base.iconUrl ?? "",
};
}
/**
* UI-only metadata for a selected module.
* Kept separate from the API request payload.
@@ -88,6 +108,7 @@ export function initWizardState(
...initialWizardState,
baseTemplateId: preselectedBase.id,
selectedBase: preselectedBase,
...baseCustomizationDefaults(preselectedBase),
};
}
@@ -120,12 +141,17 @@ export function wizardReducer(
switch (action.type) {
case "SET_BASE": {
const baseChanged = state.baseTemplateId !== action.base.id;
if (!baseChanged) {
return { ...state, selectedBase: action.base };
}
// Changing the base clears base variable values and re-seeds the
// customization fields with defaults derived from the new base.
return {
...state,
baseTemplateId: action.base.id,
selectedBase: action.base,
// Clear base variable values when base changes.
baseVariableValues: baseChanged ? {} : state.baseVariableValues,
baseVariableValues: {},
...baseCustomizationDefaults(action.base),
};
}
case "SET_BASE_VARIABLES":
@@ -168,14 +194,13 @@ export function wizardReducer(
hasProvisioners: action.value,
};
case "RESET_CUSTOMIZATIONS":
// Reset only organization and provisioner detection so re-entering the
// step re-runs org auto-select cleanly. The base-derived fields are
// left intact (they are re-seeded by SET_BASE when the base changes).
return {
...state,
organizationId: undefined,
hasProvisioners: undefined,
name: "",
displayName: "",
description: "",
icon: "",
};
case "RESET":
return initialWizardState;