From 1546a0a58f718da9f0f7b393160458ac327dc0d7 Mon Sep 17 00:00:00 2001 From: Jeremy Ruppel Date: Wed, 17 Jun 2026 14:56:52 -0400 Subject: [PATCH] feat: add BaseTemplateParametersStep (#26426) Implement the base template parameters wizard step, which renders a configuration form for base template variables using the existing `TemplateConfiguration` and `ConfigurationField` components. - Map `TemplateBuilderModuleVariable` to `ConfigurationFieldDefinition` (switch for bool, text input for string/number) - Read variable definitions from the cached bases query - Disable Continue button until all required non-sensitive variables have values (`baseParametersComplete` helper) - Step is automatically skipped when the selected base has no parameters (e.g. Docker) - Update `toComposeRequest` to include `base_variable_values` in the API payload Relates to [DEVEX-284](https://linear.app/codercom/issue/DEVEX-284). > [!NOTE] > This PR was authored with Coder Agents. --- .../TemplateBuilder/BaseInfraSelectStep.tsx | 3 +- .../BaseTemplateParametersStep.tsx | 110 ++++++++++++++++++ .../TemplateBuilder/ConfigurationField.tsx | 16 +-- .../TemplateBuilderPageView.tsx | 25 +++- .../TemplateBuilder/TemplateConfiguration.tsx | 8 +- .../pages/TemplateBuilder/wizardState.test.ts | 14 +++ site/src/pages/TemplateBuilder/wizardState.ts | 4 + 7 files changed, 166 insertions(+), 14 deletions(-) create mode 100644 site/src/pages/TemplateBuilder/BaseTemplateParametersStep.tsx diff --git a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx index cf3e3d416b..9b4b0ee077 100644 --- a/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx +++ b/site/src/pages/TemplateBuilder/BaseInfraSelectStep.tsx @@ -18,7 +18,8 @@ function toSelectedBaseMeta(base: TemplateBuilderBase): SelectedBaseMeta { name: base.name, iconUrl: base.icon, os: base.os, - hasParameters: false, + hasParameters: + base.variables.length > 0 && base.variables.some((v) => !v.sensitive), }; } diff --git a/site/src/pages/TemplateBuilder/BaseTemplateParametersStep.tsx b/site/src/pages/TemplateBuilder/BaseTemplateParametersStep.tsx new file mode 100644 index 0000000000..e3d6f509b5 --- /dev/null +++ b/site/src/pages/TemplateBuilder/BaseTemplateParametersStep.tsx @@ -0,0 +1,110 @@ +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { templateBuilderBases } from "#/api/queries/templateBuilder"; +import type { + TemplateBuilderBasesResponse, + TemplateBuilderModuleVariable, +} from "#/api/typesGenerated"; +import type { ConfigurationFieldDefinition } from "./ConfigurationField"; +import { TemplateConfiguration } from "./TemplateConfiguration"; + +interface BaseTemplateParametersStepProps { + baseId: string; + values: Record; + onChangeValues: (values: Record) => void; +} + +function detailsUrl(baseId: string): string { + return `https://registry.coder.com/templates/${baseId}`; +} + +/** + * Maps a TemplateBuilderModuleVariable to a ConfigurationFieldDefinition, + * using the controlled values from wizard state. + */ +function variableToField( + variable: TemplateBuilderModuleVariable, + value: string, + onChange: (name: string, value: string) => void, +): ConfigurationFieldDefinition { + const id = `base-var-${variable.name}`; + + if (variable.type === "bool") { + return { + type: "switch", + id, + label: variable.name, + description: variable.description || undefined, + required: variable.required, + checked: value === "true", + onCheckedChange: (checked) => + onChange(variable.name, checked ? "true" : "false"), + }; + } + + return { + type: "text", + id, + label: variable.name, + description: variable.description || undefined, + required: variable.required, + placeholder: variable.required ? "Required" : "Optional", + field: { + name: variable.name, + id, + value, + onChange: (e) => onChange(variable.name, e.target.value), + onBlur: () => {}, + error: false, + }, + }; +} + +/** + * Returns true when all required, non-sensitive base variables have + * a non-empty value. Returns true when no variables need filling. + */ +export function baseParametersComplete( + bases: TemplateBuilderBasesResponse | undefined, + baseId: string | null, + values: Record, +): boolean { + if (!bases || !baseId) { + return true; + } + const base = bases.bases.find((b) => b.id === baseId); + if (!base) { + return true; + } + const required = base.variables.filter((v) => v.required && !v.sensitive); + return required.every((v) => { + const val = values[v.name]; + return val !== undefined && val !== ""; + }); +} + +export const BaseTemplateParametersStep: FC< + BaseTemplateParametersStepProps +> = ({ baseId, values, onChangeValues }) => { + const { data } = useQuery(templateBuilderBases()); + const base = data?.bases.find((b) => b.id === baseId); + const variables = base?.variables.filter((v) => !v.sensitive) ?? []; + + const handleChange = (name: string, value: string) => { + onChangeValues({ ...values, [name]: value }); + }; + + const fields: ConfigurationFieldDefinition[] = variables.map((v) => + variableToField(v, values[v.name] ?? "", handleChange), + ); + + return ( + + ); +}; diff --git a/site/src/pages/TemplateBuilder/ConfigurationField.tsx b/site/src/pages/TemplateBuilder/ConfigurationField.tsx index efe2ed6ce3..ff8146d830 100644 --- a/site/src/pages/TemplateBuilder/ConfigurationField.tsx +++ b/site/src/pages/TemplateBuilder/ConfigurationField.tsx @@ -124,14 +124,14 @@ const SelectField: FC = ({ {required && ( <> {" "} - + * )} {description && ( -
+
{description}
)} @@ -172,14 +172,14 @@ const RadioField: FC = ({ {required && ( <> {" "} - + * )} {description && ( -
+
{description}
)} @@ -257,7 +257,7 @@ const SwitchField: FC = ({ {required && ( <> {" "} - + * @@ -270,7 +270,7 @@ const SwitchField: FC = ({ describedBy={description ? descriptionId : undefined} /> {description && ( -
+
{description}
)} @@ -294,14 +294,14 @@ const SwitchGroupField: FC = ({ {required && ( <> {" "} - + * )} {description && ( -
+
{description}
)} diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 6500859f8a..355e962109 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -1,4 +1,6 @@ import { type FC, useReducer, useState } from "react"; +import { useQuery } from "react-query"; +import { templateBuilderBases } from "#/api/queries/templateBuilder"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { Link } from "#/components/Link/Link"; @@ -10,6 +12,10 @@ import { } from "#/components/PageHeader/PageHeader"; import { docs } from "#/utils/docs"; import { BaseInfraSelectStep } from "./BaseInfraSelectStep"; +import { + BaseTemplateParametersStep, + baseParametersComplete, +} from "./BaseTemplateParametersStep"; import { SelectionSummary } from "./SelectionSummary"; import { findNextVisibleIndex, @@ -28,6 +34,7 @@ export const TemplateBuilderPageView: FC = ({ }) => { const [state, dispatch] = useReducer(wizardReducer, initialWizardState); const [stepIndex, setStepIndex] = useState(0); + const basesQuery = useQuery(templateBuilderBases()); const currentIndex = nearestVisible(stepIndex, state); const currentStep = WIZARD_STEPS[currentIndex]; @@ -36,6 +43,14 @@ export const TemplateBuilderPageView: FC = ({ const isFirstStep = prevIndex === -1; const isLastStep = nextIndex === -1; + const canContinue = + currentStep.id !== "base-parameters" || + baseParametersComplete( + basesQuery.data, + state.selectedBase?.id ?? null, + state.baseVariableValues, + ); + const handleBack = () => { setStepIndex(prevIndex); }; @@ -78,6 +93,14 @@ export const TemplateBuilderPageView: FC = ({ selectedBaseId={state.selectedBase?.id ?? null} onSelectBase={(base) => dispatch({ type: "SET_BASE", base })} /> + ) : currentStep.id === "base-parameters" && state.selectedBase ? ( + + dispatch({ type: "SET_BASE_VARIABLES", values }) + } + /> ) : (

@@ -95,7 +118,7 @@ export const TemplateBuilderPageView: FC = ({ Back )} -

diff --git a/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx index 4409c3a5c1..33bcab4387 100644 --- a/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx +++ b/site/src/pages/TemplateBuilder/TemplateConfiguration.tsx @@ -22,7 +22,7 @@ export const TemplateConfiguration: React.FC = ({ return (
-
+
{iconUrl ? ( = ({ )}
-

{name}

-

+

{name}

+

{description}

{detailsUrl && ( @@ -44,7 +44,7 @@ export const TemplateConfiguration: React.FC = ({ target="_blank" rel="noreferrer" size="sm" - className="text-xs font-normal ml-1" + className="text-sm font-normal ml-1" > View details diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts index b9c5312255..b397f22b8e 100644 --- a/site/src/pages/TemplateBuilder/wizardState.test.ts +++ b/site/src/pages/TemplateBuilder/wizardState.test.ts @@ -354,5 +354,19 @@ describe("toComposeRequest", () => { const request = toComposeRequest(initialWizardState); expect(request.base_template_id).toBe(""); expect(request.modules).toEqual([]); + expect(request.base_variable_values).toBeUndefined(); + }); + + it("includes base_variable_values when set", () => { + const state: TemplateBuilderWizardState = { + ...initialWizardState, + baseTemplateId: "kubernetes", + baseVariableValues: { namespace: "default", use_kubeconfig: "false" }, + }; + const request = toComposeRequest(state); + expect(request.base_variable_values).toEqual({ + namespace: "default", + use_kubeconfig: "false", + }); }); }); diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts index 83f7efec83..16ae0d0389 100644 --- a/site/src/pages/TemplateBuilder/wizardState.ts +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -142,6 +142,10 @@ export const toComposeRequest = ( ): TemplateBuilderComposeRequest => { return { base_template_id: state.baseTemplateId ?? "", + base_variable_values: + Object.keys(state.baseVariableValues).length > 0 + ? state.baseVariableValues + : undefined, modules: state.modules, }; };