From 81599b3e6061c1ec28620cba8ff42e3bd839ba9a Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 15 Jul 2026 18:10:31 +0000 Subject: [PATCH] feat(site/src/pages/TemplateBuilder): auto-fill customization fields from base template Seed the customizations step (ID, display name, description, icon) with defaults derived from the selected base template, keeping every field editable. Defaults are re-seeded when the base changes and preserved on back-navigation and same-base re-selection. Implements DEVEX-586. --- .../pages/TemplateBuilder/wizardState.test.ts | 201 ++++++++++++++++++ site/src/pages/TemplateBuilder/wizardState.ts | 37 +++- 2 files changed, 232 insertions(+), 6 deletions(-) diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts index dbcfcabaff0..c072f25a124 100644 --- a/site/src/pages/TemplateBuilder/wizardState.test.ts +++ b/site/src/pages/TemplateBuilder/wizardState.test.ts @@ -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 => ({ + 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"); + }); +}); diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 23b565465a6..0381f242234 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -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;