mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user