diff --git a/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx index 70f01a564c..4052f5ea78 100644 --- a/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx +++ b/site/src/pages/TemplateBuilder/ModuleConfiguration.tsx @@ -27,7 +27,7 @@ export const ModuleConfiguration: React.FC = ({
-
+
{iconUrl ? ( = ({
)}
-
-

+
+

{name}

-

+

{description}

{detailsUrl && ( diff --git a/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx new file mode 100644 index 0000000000..71046a449e --- /dev/null +++ b/site/src/pages/TemplateBuilder/ModuleSettingsStep.tsx @@ -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>; + onChangeModuleVariables: ( + moduleId: string, + variables: Record, + ) => 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>, +): 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 = ({ + 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 ( +
+

Configure modules

+

+ Set values for module variables. +

+ +
+ {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 ( +
+ + {sensitiveVars.length > 0 && ( +
+ +

+ {sensitiveVars.map((v) => ( + + {v.name} + + ))} + will be collected from developers at workspace creation. +

+
+ )} +
+ ); + })} +
+
+ ); +}; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index dc464c76b6..01149ffba1 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -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 = ({ 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 = ({ 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 = ({ }; 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 = ({ dispatch({ type: "SET_MODULES", modules, meta }) } /> + ) : currentStep.id === "module-settings" && state.selectedBase ? ( + m.id)} + moduleVariables={moduleVarMap} + onChangeModuleVariables={(moduleId, variables) => + dispatch({ type: "SET_MODULE_VARIABLES", moduleId, variables }) + } + /> ) : (