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.
This commit is contained in:
Jeremy Ruppel
2026-06-17 14:56:52 -04:00
committed by GitHub
parent 787392ef16
commit 1546a0a58f
7 changed files with 166 additions and 14 deletions
@@ -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),
};
}
@@ -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<string, string>;
onChangeValues: (values: Record<string, string>) => 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<string, string>,
): 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 (
<TemplateConfiguration
name={base?.name ?? "Base Template"}
description={base?.description ?? ""}
iconUrl={base?.icon}
detailsUrl={detailsUrl(baseId)}
fields={fields}
/>
);
};
@@ -124,14 +124,14 @@ const SelectField: FC<SelectFieldDefinition> = ({
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
<div id={descriptionId} className="text-sm text-content-secondary">
{description}
</div>
)}
@@ -172,14 +172,14 @@ const RadioField: FC<RadioFieldDefinition> = ({
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
<div id={descriptionId} className="text-sm text-content-secondary">
{description}
</div>
)}
@@ -257,7 +257,7 @@ const SwitchField: FC<SwitchFieldDefinition> = ({
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
@@ -270,7 +270,7 @@ const SwitchField: FC<SwitchFieldDefinition> = ({
describedBy={description ? descriptionId : undefined}
/>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
<div id={descriptionId} className="text-sm text-content-secondary">
{description}
</div>
)}
@@ -294,14 +294,14 @@ const SwitchGroupField: FC<SwitchGroupFieldDefinition> = ({
{required && (
<>
{" "}
<span className="text-xs font-bold text-content-destructive">
<span className="text-sm font-bold text-content-destructive">
*
</span>
</>
)}
</Label>
{description && (
<div id={descriptionId} className="text-xs text-content-secondary">
<div id={descriptionId} className="text-sm text-content-secondary">
{description}
</div>
)}
@@ -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<TemplateBuilderPageViewProps> = ({
}) => {
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<TemplateBuilderPageViewProps> = ({
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<TemplateBuilderPageViewProps> = ({
selectedBaseId={state.selectedBase?.id ?? null}
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
/>
) : currentStep.id === "base-parameters" && state.selectedBase ? (
<BaseTemplateParametersStep
baseId={state.selectedBase.id}
values={state.baseVariableValues}
onChangeValues={(values) =>
dispatch({ type: "SET_BASE_VARIABLES", values })
}
/>
) : (
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">
@@ -95,7 +118,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
Back
</Button>
)}
<Button onClick={handleNext}>
<Button onClick={handleNext} disabled={!canContinue}>
{isLastStep ? "Create Template" : "Continue"}
</Button>
</div>
@@ -22,7 +22,7 @@ export const TemplateConfiguration: React.FC<TemplateConfigurationProps> = ({
return (
<section className="pt-4 px-4 pb-6 rounded bg-surface-secondary">
<header className="mb-6">
<figure className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border mb-3">
<figure className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border m-0 mb-3">
{iconUrl ? (
<img
src={iconUrl}
@@ -34,8 +34,8 @@ export const TemplateConfiguration: React.FC<TemplateConfigurationProps> = ({
)}
</figure>
<div>
<h3 className="text-sm font-semibold text-content-primary">{name}</h3>
<p className="text-xs font-normal text-content-secondary inline">
<h3 className="text-md font-semibold text-content-primary">{name}</h3>
<p className="text-sm font-normal text-content-secondary inline">
{description}
</p>
{detailsUrl && (
@@ -44,7 +44,7 @@ export const TemplateConfiguration: React.FC<TemplateConfigurationProps> = ({
target="_blank"
rel="noreferrer"
size="sm"
className="text-xs font-normal ml-1"
className="text-sm font-normal ml-1"
>
View details
</Link>
@@ -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",
});
});
});
@@ -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,
};
};