feat(site/src/pages/TemplateBuilder): add ModuleSettingsStep (#26428)

Implement the module settings wizard step, which renders a
`ModuleConfiguration` card per selected module with variable
configuration fields.

- Map non-sensitive variables to `ConfigurationFieldDefinition` (switch
for bool, text input for string/number)
- Show info notice with `code` tags for sensitive variables that will be
collected from developers at workspace creation
- Disable Continue button until all required non-sensitive variables
across all selected modules have values (`moduleSettingsComplete`
helper)
- Step is automatically skipped when no selected modules have
configurable variables

Relates to [DEVEX-286](https://linear.app/codercom/issue/DEVEX-286).

> [!NOTE]
> This PR was authored with Coder Agents.

---------

Co-authored-by: Andrew Aquino <dawneraq@gmail.com>
This commit is contained in:
Jeremy Ruppel
2026-06-18 13:47:08 -04:00
committed by GitHub
co-authored by Andrew Aquino
parent c0c0d1e353
commit 20ed45cac7
3 changed files with 206 additions and 11 deletions
@@ -27,7 +27,7 @@ export const ModuleConfiguration: React.FC<ModuleConfigurationProps> = ({
<section className="pt-4 px-4 pb-6 rounded bg-surface-secondary">
<header className="flex items-start gap-6 mb-6">
<div className="flex flex-1 items-center gap-3 min-w-0">
<figure className="flex items-center justify-center p-1 rounded-md size-10 shrink-0 bg-surface-secondary border border-solid border-border">
<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}
@@ -38,11 +38,11 @@ export const ModuleConfiguration: React.FC<ModuleConfigurationProps> = ({
<div className="size-7 rounded bg-surface-primary" />
)}
</figure>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-semibold text-content-primary">
<div>
<h3 className="text-md font-semibold text-content-primary my-0">
{name}
</h3>
<p className="text-xs font-normal text-content-secondary inline">
<p className="text-sm font-normal text-content-secondary inline">
{description}
</p>
{detailsUrl && (
@@ -0,0 +1,163 @@
import { InfoIcon } from "lucide-react";
import type { FC } from "react";
import { useQuery } from "react-query";
import { templateBuilderModules } from "#/api/queries/templateBuilder";
import type {
TemplateBuilderModule,
TemplateBuilderModulesResponse,
TemplateBuilderModuleVariable,
} from "#/api/typesGenerated";
import type { ConfigurationFieldDefinition } from "./ConfigurationField";
import { ModuleConfiguration } from "./ModuleConfiguration";
interface ModuleSettingsStepProps {
baseId: string;
selectedModuleIds: string[];
moduleVariables: Record<string, Record<string, string>>;
onChangeModuleVariables: (
moduleId: string,
variables: Record<string, string>,
) => void;
}
function variableToField(
moduleId: string,
variable: TemplateBuilderModuleVariable,
value: string,
onChange: (name: string, value: string) => void,
): ConfigurationFieldDefinition {
const id = `mod-${moduleId}-${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,
},
};
}
function moduleDetailsUrl(moduleId: string): string {
return `https://registry.coder.com/modules/${moduleId}`;
}
/**
* Returns true when all required, non-sensitive variables across all
* selected modules have non-empty values.
*/
export function moduleSettingsComplete(
modulesData: TemplateBuilderModulesResponse | undefined,
selectedModuleIds: string[],
moduleVariables: Record<string, Record<string, string>>,
): boolean {
if (!modulesData) {
return true;
}
const modulesById = new Map(modulesData.modules.map((m) => [m.id, m]));
for (const moduleId of selectedModuleIds) {
const mod = modulesById.get(moduleId);
if (!mod) continue;
const vars = moduleVariables[moduleId] ?? {};
const required = mod.variables.filter((v) => v.required && !v.sensitive);
for (const v of required) {
const val = vars[v.name];
if (val === undefined || val === "") {
return false;
}
}
}
return true;
}
export const ModuleSettingsStep: FC<ModuleSettingsStepProps> = ({
baseId,
selectedModuleIds,
moduleVariables,
onChangeModuleVariables,
}) => {
const { data } = useQuery(templateBuilderModules(baseId));
const modules = data?.modules ?? [];
const selectedModules = selectedModuleIds
.map((id) => modules.find((m) => m.id === id))
.filter((m): m is TemplateBuilderModule => m != null);
const handleChange = (moduleId: string, varName: string, value: string) => {
const current = moduleVariables[moduleId] ?? {};
onChangeModuleVariables(moduleId, { ...current, [varName]: value });
};
return (
<div className="border border-border border-solid p-6 rounded-lg">
<h2 className="text-lg font-semibold mb-1">Configure modules</h2>
<p className="text-sm text-content-secondary mb-4">
Set values for module variables.
</p>
<div className="flex flex-col gap-6">
{selectedModules.map((mod) => {
const configurableVars = mod.variables.filter((v) => !v.sensitive);
const sensitiveVars = mod.variables.filter((v) => v.sensitive);
const vars = moduleVariables[mod.id] ?? {};
const fields: ConfigurationFieldDefinition[] = configurableVars.map(
(v) =>
variableToField(mod.id, v, vars[v.name] ?? "", (name, val) =>
handleChange(mod.id, name, val),
),
);
return (
<div key={mod.id}>
<ModuleConfiguration
name={mod.display_name}
description={mod.description}
iconUrl={mod.icon}
detailsUrl={moduleDetailsUrl(mod.id)}
fields={fields}
/>
{sensitiveVars.length > 0 && (
<div className="flex items-center gap-2 mt-2 p-3 rounded-md text-sm text-content-secondary">
<InfoIcon className="size-icon-sm shrink-0 mt-0.5" />
<p>
{sensitiveVars.map((v) => (
<code
key={v.name}
className="mr-1 px-1.5 py-1 bg-surface-secondary"
>
{v.name}
</code>
))}
will be collected from developers at workspace creation.
</p>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
@@ -1,6 +1,9 @@
import { type FC, useReducer, useState } from "react";
import { useQuery } from "react-query";
import { templateBuilderBases } from "#/api/queries/templateBuilder";
import {
templateBuilderBases,
templateBuilderModules,
} from "#/api/queries/templateBuilder";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
@@ -17,6 +20,10 @@ import {
baseParametersComplete,
} from "./BaseTemplateParametersStep";
import { ModuleSelectStep } from "./ModuleSelectStep";
import {
ModuleSettingsStep,
moduleSettingsComplete,
} from "./ModuleSettingsStep";
import { SelectionSummary } from "./SelectionSummary";
import {
findNextVisibleIndex,
@@ -36,6 +43,11 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
const [stepIndex, setStepIndex] = useState(0);
const basesQuery = useQuery(templateBuilderBases());
const modulesQuery = useQuery(templateBuilderModules(state.selectedBase?.id));
const moduleVarMap = Object.fromEntries(
state.modules.map((m) => [m.id, m.variables ?? {}]),
);
const currentIndex = nearestVisible(stepIndex, state);
const currentStep = WIZARD_STEPS[currentIndex];
@@ -45,12 +57,19 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
const isLastStep = nextIndex === -1;
const canContinue =
currentStep.id !== "base-parameters" ||
baseParametersComplete(
basesQuery.data,
state.selectedBase?.id ?? null,
state.baseVariableValues,
);
currentStep.id === "base-parameters"
? baseParametersComplete(
basesQuery.data,
state.selectedBase?.id ?? null,
state.baseVariableValues,
)
: currentStep.id === "module-settings"
? moduleSettingsComplete(
modulesQuery.data,
state.modules.map((m) => m.id),
moduleVarMap,
)
: true;
const handleBack = () => {
setStepIndex(prevIndex);
@@ -65,6 +84,10 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
};
const handleDeselectModule = (moduleId: string) => {
// If the only module gets deselected, go back to module selection
if (state.modules.length === 1) {
setStepIndex(WIZARD_STEPS.findIndex((s) => s.id === "module-select"));
}
dispatch({
type: "SET_MODULES",
modules: state.modules.filter((m) => m.id !== moduleId),
@@ -110,6 +133,15 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
dispatch({ type: "SET_MODULES", modules, meta })
}
/>
) : currentStep.id === "module-settings" && state.selectedBase ? (
<ModuleSettingsStep
baseId={state.selectedBase.id}
selectedModuleIds={state.modules.map((m) => m.id)}
moduleVariables={moduleVarMap}
onChangeModuleVariables={(moduleId, variables) =>
dispatch({ type: "SET_MODULE_VARIABLES", moduleId, variables })
}
/>
) : (
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">