mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Andrew Aquino
parent
c0c0d1e353
commit
20ed45cac7
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user