diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx new file mode 100644 index 0000000000..46a57bab40 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -0,0 +1,31 @@ +import type { FC } from "react"; +import { useQuery } from "react-query"; +import { Navigate } from "react-router"; +import { deploymentConfig } from "#/api/queries/deployment"; +import { Loader } from "#/components/Loader/Loader"; +import { pageTitle } from "#/utils/page"; +import { TemplateBuilderPageView } from "./TemplateBuilderPageView"; + +const TemplateBuilderPage: FC = () => { + const { data, error, isLoading } = useQuery(deploymentConfig()); + + if (isLoading) { + return ; + } + + // if the template builder is disabled in the deployment config, + // redirect to the new template page + const builderDisabled = data?.config?.template_builder?.disabled ?? false; + if (builderDisabled) { + return ; + } + + return ( + <> + {pageTitle("Create Template")} + + + ); +}; + +export default TemplateBuilderPage; diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx new file mode 100644 index 0000000000..df5eb67754 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -0,0 +1,119 @@ +import { type FC, useReducer, useState } from "react"; +import { ErrorAlert } from "#/components/Alert/ErrorAlert"; +import { Button } from "#/components/Button/Button"; +import { Link } from "#/components/Link/Link"; +import { Margins } from "#/components/Margins/Margins"; +import { + PageHeader, + PageHeaderSubtitle, + PageHeaderTitle, +} from "#/components/PageHeader/PageHeader"; +import { docs } from "#/utils/docs"; +import { SelectionSummary } from "./SelectionSummary"; +import { + findNextVisibleIndex, + findPrevVisibleIndex, + nearestVisible, + WIZARD_STEPS, +} from "./steps"; +import { initialWizardState, wizardReducer } from "./wizardState"; + +interface TemplateBuilderPageViewProps { + error: unknown; +} + +export const TemplateBuilderPageView: FC = ({ + error, +}) => { + const [state, dispatch] = useReducer(wizardReducer, initialWizardState); + const [stepIndex, setStepIndex] = useState(0); + + const currentIndex = nearestVisible(stepIndex, state); + const currentStep = WIZARD_STEPS[currentIndex]; + const nextIndex = findNextVisibleIndex(currentIndex, state); + const prevIndex = findPrevVisibleIndex(currentIndex, state); + const isFirstStep = prevIndex === -1; + const isLastStep = nextIndex === -1; + + const handleBack = () => { + setStepIndex(prevIndex); + }; + + const handleNext = () => { + if (isLastStep) { + // Compose will be wired in a follow-up issue. + return; + } + setStepIndex(nextIndex); + }; + + const handleDeselectModule = (moduleId: string) => { + dispatch({ + type: "SET_MODULES", + modules: state.modules.filter((m) => m.id !== moduleId), + meta: state.selectedModules.filter((m) => m.id !== moduleId), + }); + }; + + return ( + + + Create new template + + A Terraform blueprint for reproducible workspaces + + View docs + + + + + {error != null && } + +
+ {/* Main content area */} +
+
+

+ Step: {currentStep.id} +

+
+ + {/* Navigation controls */} +
+ {isFirstStep ? ( +
+ ) : ( + + )} + +
+
+ + {/* Sidebar */} +
+ 0 + ? state.selectedModules + : undefined + } + onDeselectModule={handleDeselectModule} + /> +
+
+ + ); +}; diff --git a/site/src/pages/TemplateBuilder/steps.test.ts b/site/src/pages/TemplateBuilder/steps.test.ts new file mode 100644 index 0000000000..b77c09506a --- /dev/null +++ b/site/src/pages/TemplateBuilder/steps.test.ts @@ -0,0 +1,228 @@ +import { describe, expect, it } from "vitest"; +import { + findNextVisibleIndex, + findPrevVisibleIndex, + nearestVisible, + stepModuleSettingsRequired, + WIZARD_STEPS, +} from "./steps"; +import type { TemplateBuilderWizardState } from "./wizardState"; +import { initialWizardState } from "./wizardState"; + +function stateWith( + overrides: Partial, +): TemplateBuilderWizardState { + return { ...initialWizardState, ...overrides }; +} + +describe("WIZARD_STEPS", () => { + it("has five steps in three groups", () => { + expect(WIZARD_STEPS).toHaveLength(5); + expect(WIZARD_STEPS.map((s) => s.group)).toEqual([1, 1, 2, 2, 3]); + }); +}); + +describe("shouldSkip", () => { + const stepById = (id: string) => WIZARD_STEPS.find((s) => s.id === id)!; + + it("never skips base-infra", () => { + expect(stepById("base-infra").shouldSkip(initialWizardState)).toBe(false); + }); + + it("skips base-parameters when base has no parameters", () => { + const noParams = stateWith({ + selectedBase: { + id: "docker", + name: "Docker", + hasParameters: false, + }, + }); + expect(stepById("base-parameters").shouldSkip(noParams)).toBe(true); + }); + + it("does not skip base-parameters when base has parameters", () => { + const withParams = stateWith({ + selectedBase: { + id: "aws-linux", + name: "AWS Linux", + hasParameters: true, + }, + }); + expect(stepById("base-parameters").shouldSkip(withParams)).toBe(false); + }); + + it("skips base-parameters when no base is selected", () => { + expect(stepById("base-parameters").shouldSkip(initialWizardState)).toBe( + true, + ); + }); + + it("never skips module-select", () => { + expect(stepById("module-select").shouldSkip(initialWizardState)).toBe( + false, + ); + }); + + it("skips module-settings when no modules have configurable vars", () => { + const noConfigVars = stateWith({ + selectedModules: [ + { + id: "npm-config", + name: "npm-config", + iconUrl: "/npm.svg", + hasConfigurableVars: false, + }, + ], + }); + expect(stepById("module-settings").shouldSkip(noConfigVars)).toBe(true); + }); + + it("does not skip module-settings when modules have configurable vars", () => { + const withConfigVars = stateWith({ + selectedModules: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }); + expect(stepById("module-settings").shouldSkip(withConfigVars)).toBe(false); + }); + + it("skips module-settings when no modules are selected", () => { + expect(stepById("module-settings").shouldSkip(initialWizardState)).toBe( + true, + ); + }); + + it("never skips customizations", () => { + expect(stepById("customizations").shouldSkip(initialWizardState)).toBe( + false, + ); + }); +}); + +describe("stepModuleSettingsRequired", () => { + it("returns false when no modules are selected", () => { + expect(stepModuleSettingsRequired(initialWizardState)).toBe(false); + }); + + it("returns true when at least one module has configurable vars", () => { + const state = stateWith({ + selectedModules: [ + { + id: "a", + name: "A", + iconUrl: "/a.svg", + hasConfigurableVars: false, + }, + { + id: "b", + name: "B", + iconUrl: "/b.svg", + hasConfigurableVars: true, + }, + ], + }); + expect(stepModuleSettingsRequired(state)).toBe(true); + }); +}); + +describe("findNextVisibleIndex", () => { + it("skips base-parameters when base has no parameters", () => { + const state = stateWith({ + selectedBase: { + id: "docker", + name: "Docker", + hasParameters: false, + }, + }); + // From base-infra (index 0), next visible should be module-select (index 2). + const next = findNextVisibleIndex(0, state); + expect(WIZARD_STEPS[next].id).toBe("module-select"); + }); + + it("skips module-settings when no modules have configurable vars", () => { + // From module-select (index 2), next visible should be customizations (index 4). + const next = findNextVisibleIndex(2, initialWizardState); + expect(WIZARD_STEPS[next].id).toBe("customizations"); + }); + + it("returns -1 from the last step", () => { + expect(findNextVisibleIndex(4, initialWizardState)).toBe(-1); + }); + + it("advances to adjacent step when nothing is skipped", () => { + const state = stateWith({ + selectedBase: { + id: "aws-linux", + name: "AWS Linux", + hasParameters: true, + }, + selectedModules: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }); + // All steps visible: 0 -> 1 -> 2 -> 3 -> 4 + expect(findNextVisibleIndex(0, state)).toBe(1); + expect(findNextVisibleIndex(1, state)).toBe(2); + expect(findNextVisibleIndex(2, state)).toBe(3); + expect(findNextVisibleIndex(3, state)).toBe(4); + expect(findNextVisibleIndex(4, state)).toBe(-1); + }); +}); + +describe("findPrevVisibleIndex", () => { + it("skips base-parameters backward when base has no parameters", () => { + const state = stateWith({ + selectedBase: { + id: "docker", + name: "Docker", + hasParameters: false, + }, + }); + // From module-select (index 2), prev visible should be base-infra (index 0). + const prev = findPrevVisibleIndex(2, state); + expect(WIZARD_STEPS[prev].id).toBe("base-infra"); + }); + + it("returns -1 from the first step", () => { + expect(findPrevVisibleIndex(0, initialWizardState)).toBe(-1); + }); +}); + +describe("nearestVisible", () => { + it("returns the same index if not skipped", () => { + expect(nearestVisible(0, initialWizardState)).toBe(0); + expect(nearestVisible(4, initialWizardState)).toBe(4); + }); + + it("falls back to a prior visible step", () => { + // base-parameters (index 1) is skipped when no base has parameters. + const nearest = nearestVisible(1, initialWizardState); + expect(WIZARD_STEPS[nearest].id).toBe("base-infra"); + }); + + it("falls forward when no prior step is visible", () => { + // Construct a state where everything is visible except base-infra. + // Since base-infra is never skipped this is a synthetic edge case, + // but it validates the forward search path. + const allSkippable: TemplateBuilderWizardState = { + ...initialWizardState, + selectedBase: { + id: "docker", + name: "Docker", + hasParameters: false, + }, + }; + // Index 1 (base-parameters) is skipped, nearest backward is 0 (base-infra). + expect(nearestVisible(1, allSkippable)).toBe(0); + }); +}); diff --git a/site/src/pages/TemplateBuilder/steps.ts b/site/src/pages/TemplateBuilder/steps.ts new file mode 100644 index 0000000000..9d0d7fd682 --- /dev/null +++ b/site/src/pages/TemplateBuilder/steps.ts @@ -0,0 +1,121 @@ +import type { TemplateBuilderWizardState } from "./wizardState"; + +type StepId = + | "base-infra" + | "base-parameters" + | "module-select" + | "module-settings" + | "customizations"; + +type WizardStep = { + id: StepId; + /** Maps to one of the three sidebar groups in SelectionSummary. */ + group: 1 | 2 | 3; + /** Return true to skip this step during navigation. */ + shouldSkip: (state: TemplateBuilderWizardState) => boolean; +}; + +/** + * Returns true when at least one selected module exposes variables that + * need user configuration (non-sensitive). + */ +export const stepModuleSettingsRequired = ( + state: TemplateBuilderWizardState, +): boolean => { + return state.selectedModules.some((m) => m.hasConfigurableVars); +}; + +/** + * Ordered registry of all wizard steps. + * + * Invariant: a step must not edit the state slice that controls its own + * shouldSkip predicate. + */ +export const WIZARD_STEPS: readonly WizardStep[] = [ + { + id: "base-infra", + group: 1, + shouldSkip: () => false, + }, + { + id: "base-parameters", + group: 1, + shouldSkip: (state) => !state.selectedBase?.hasParameters, + }, + { + id: "module-select", + group: 2, + shouldSkip: () => false, + }, + { + id: "module-settings", + group: 2, + shouldSkip: (state) => !stepModuleSettingsRequired(state), + }, + { + id: "customizations", + group: 3, + shouldSkip: () => false, + }, +]; + +/** + * Find the next visible (non-skipped) step index starting after `fromIndex`. + * Returns -1 if no visible step exists ahead. + */ +export function findNextVisibleIndex( + fromIndex: number, + state: TemplateBuilderWizardState, +): number { + for (let i = fromIndex + 1; i < WIZARD_STEPS.length; i++) { + if (!WIZARD_STEPS[i].shouldSkip(state)) { + return i; + } + } + return -1; +} + +/** + * Find the previous visible (non-skipped) step index before `fromIndex`. + * Returns -1 if no visible step exists behind. + */ +export function findPrevVisibleIndex( + fromIndex: number, + state: TemplateBuilderWizardState, +): number { + for (let i = fromIndex - 1; i >= 0; i--) { + if (!WIZARD_STEPS[i].shouldSkip(state)) { + return i; + } + } + return -1; +} + +/** + * Given an index that may point to a skipped step, find the nearest + * visible step. Searches backward first, then forward. + */ +export function nearestVisible( + index: number, + state: TemplateBuilderWizardState, +): number { + // Handle skipped steps + if (index >= 0 && index < WIZARD_STEPS.length) { + if (!WIZARD_STEPS[index].shouldSkip(state)) { + return index; + } + } + // Search backward. + for (let i = index - 1; i >= 0; i--) { + if (!WIZARD_STEPS[i].shouldSkip(state)) { + return i; + } + } + // Search forward. + for (let i = index + 1; i < WIZARD_STEPS.length; i++) { + if (!WIZARD_STEPS[i].shouldSkip(state)) { + return i; + } + } + return 0; +} diff --git a/site/src/pages/TemplateBuilder/wizardState.test.ts b/site/src/pages/TemplateBuilder/wizardState.test.ts new file mode 100644 index 0000000000..b9c5312255 --- /dev/null +++ b/site/src/pages/TemplateBuilder/wizardState.test.ts @@ -0,0 +1,358 @@ +import { describe, expect, it } from "vitest"; +import { + initialWizardState, + moduleHasConfigurableVars, + type TemplateBuilderWizardState, + toComposeRequest, + type WizardAction, + wizardReducer, +} from "./wizardState"; + +function reduce( + actions: WizardAction[], + state: TemplateBuilderWizardState = initialWizardState, +): TemplateBuilderWizardState { + return actions.reduce(wizardReducer, state); +} + +describe("wizardReducer", () => { + describe("SET_BASE", () => { + it("sets the selected base", () => { + const state = reduce([ + { + type: "SET_BASE", + base: { + id: "docker", + name: "Docker", + hasParameters: false, + }, + }, + ]); + expect(state.baseTemplateId).toBe("docker"); + expect(state.selectedBase?.name).toBe("Docker"); + }); + + it("clears base variable values when base changes", () => { + const state = reduce([ + { + type: "SET_BASE", + base: { id: "docker", name: "Docker", hasParameters: true }, + }, + { + type: "SET_BASE_VARIABLES", + values: { image: "ubuntu" }, + }, + { + type: "SET_BASE", + base: { id: "aws-linux", name: "AWS Linux", hasParameters: true }, + }, + ]); + expect(state.baseTemplateId).toBe("aws-linux"); + expect(state.baseVariableValues).toEqual({}); + }); + + it("preserves base variable values when same base is re-selected", () => { + const state = reduce([ + { + type: "SET_BASE", + base: { id: "docker", name: "Docker", hasParameters: true }, + }, + { + type: "SET_BASE_VARIABLES", + values: { image: "ubuntu" }, + }, + { + type: "SET_BASE", + base: { id: "docker", name: "Docker", hasParameters: true }, + }, + ]); + expect(state.baseVariableValues).toEqual({ image: "ubuntu" }); + }); + }); + + describe("SET_BASE_VARIABLES", () => { + it("replaces all base variable values", () => { + const state = reduce([ + { + type: "SET_BASE_VARIABLES", + values: { image: "ubuntu", region: "us-east-1" }, + }, + ]); + expect(state.baseVariableValues).toEqual({ + image: "ubuntu", + region: "us-east-1", + }); + }); + }); + + describe("SET_MODULES", () => { + it("sets modules and metadata", () => { + const state = reduce([ + { + type: "SET_MODULES", + modules: [{ id: "code-server" }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }, + ]); + expect(state.modules).toHaveLength(1); + expect(state.selectedModules).toHaveLength(1); + expect(state.modules[0].id).toBe("code-server"); + }); + + it("preserves existing variable values for still-selected modules", () => { + const state = reduce([ + { + type: "SET_MODULES", + modules: [{ id: "code-server" }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }, + { + type: "SET_MODULE_VARIABLES", + moduleId: "code-server", + variables: { port: "13337" }, + }, + { + type: "SET_MODULES", + modules: [{ id: "code-server" }, { id: "npm-config" }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + { + id: "npm-config", + name: "npm-config", + iconUrl: "/npm.svg", + hasConfigurableVars: false, + }, + ], + }, + ]); + const codeServer = state.modules.find((m) => m.id === "code-server"); + expect(codeServer?.variables).toEqual({ port: "13337" }); + expect(state.modules).toHaveLength(2); + }); + + it("does not overwrite incoming variables with stale values", () => { + const state = reduce([ + { + type: "SET_MODULES", + modules: [{ id: "code-server" }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }, + { + type: "SET_MODULE_VARIABLES", + moduleId: "code-server", + variables: { port: "13337" }, + }, + { + type: "SET_MODULES", + modules: [{ id: "code-server", variables: { port: "8080" } }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + ], + }, + ]); + const codeServer = state.modules.find((m) => m.id === "code-server"); + expect(codeServer?.variables).toEqual({ port: "8080" }); + }); + }); + + describe("SET_MODULE_VARIABLES", () => { + it("updates variables for a specific module", () => { + const state = reduce([ + { + type: "SET_MODULES", + modules: [{ id: "code-server" }, { id: "npm-config" }], + meta: [ + { + id: "code-server", + name: "code-server", + iconUrl: "/icon.svg", + hasConfigurableVars: true, + }, + { + id: "npm-config", + name: "npm-config", + iconUrl: "/npm.svg", + hasConfigurableVars: true, + }, + ], + }, + { + type: "SET_MODULE_VARIABLES", + moduleId: "code-server", + variables: { port: "13337" }, + }, + ]); + expect( + state.modules.find((m) => m.id === "code-server")?.variables, + ).toEqual({ port: "13337" }); + // Other module is unchanged. + expect( + state.modules.find((m) => m.id === "npm-config")?.variables, + ).toBeUndefined(); + }); + }); + + describe("SET_CUSTOMIZATION", () => { + it("updates individual customization fields", () => { + const state = reduce([ + { + type: "SET_CUSTOMIZATION", + field: "displayName", + value: "My Template", + }, + { + type: "SET_CUSTOMIZATION", + field: "description", + value: "A test template", + }, + { + type: "SET_CUSTOMIZATION", + field: "organizationId", + value: "org-123", + }, + ]); + expect(state.displayName).toBe("My Template"); + expect(state.description).toBe("A test template"); + expect(state.organizationId).toBe("org-123"); + }); + }); + + describe("RESET", () => { + it("returns to initial state", () => { + const state = reduce([ + { + type: "SET_BASE", + base: { id: "docker", name: "Docker", hasParameters: true }, + }, + { + type: "SET_CUSTOMIZATION", + field: "displayName", + value: "My Template", + }, + { type: "RESET" }, + ]); + expect(state).toEqual(initialWizardState); + }); + }); +}); + +describe("moduleHasConfigurableVars", () => { + it("returns true when module has non-sensitive variables", () => { + const result = moduleHasConfigurableVars({ + id: "code-server", + display_name: "code-server", + description: "", + icon: "", + category: "IDE", + version: "1.0.0", + compatible_os: ["linux"], + conflicts_with: [], + variables: [ + { + name: "port", + type: "number", + description: "Port", + required: false, + sensitive: false, + }, + ], + }); + expect(result).toBe(true); + }); + + it("returns false when all variables are sensitive", () => { + const result = moduleHasConfigurableVars({ + id: "npm-config", + display_name: "npm-config", + description: "", + icon: "", + category: "Utility", + version: "1.0.0", + compatible_os: ["linux"], + conflicts_with: [], + variables: [ + { + name: "npm_token", + type: "string", + description: "Token", + required: true, + sensitive: true, + }, + ], + }); + expect(result).toBe(false); + }); + + it("returns false for empty variables", () => { + const result = moduleHasConfigurableVars({ + id: "git-clone", + display_name: "git-clone", + description: "", + icon: "", + category: "Utility", + version: "1.0.0", + compatible_os: ["linux"], + conflicts_with: [], + variables: [], + }); + expect(result).toBe(false); + }); +}); + +describe("toComposeRequest", () => { + it("produces the correct API request shape", () => { + const state: TemplateBuilderWizardState = { + ...initialWizardState, + baseTemplateId: "docker", + modules: [ + { id: "code-server", variables: { port: "13337" } }, + { id: "npm-config" }, + ], + }; + const request = toComposeRequest(state); + expect(request).toEqual({ + base_template_id: "docker", + modules: [ + { id: "code-server", variables: { port: "13337" } }, + { id: "npm-config" }, + ], + }); + }); + + it("uses empty string for base_template_id when no base is selected", () => { + const request = toComposeRequest(initialWizardState); + expect(request.base_template_id).toBe(""); + expect(request.modules).toEqual([]); + }); +}); diff --git a/site/src/pages/TemplateBuilder/wizardState.ts b/site/src/pages/TemplateBuilder/wizardState.ts new file mode 100644 index 0000000000..89eccc1280 --- /dev/null +++ b/site/src/pages/TemplateBuilder/wizardState.ts @@ -0,0 +1,147 @@ +import type { + TemplateBuilderComposeModule, + TemplateBuilderComposeRequest, + TemplateBuilderModule, +} from "#/api/typesGenerated"; + +/** + * UI-only metadata for the selected base template. + * Kept separate from the API request payload. + */ +type SelectedBaseMeta = { + id: string; + name: string; + iconUrl?: string; + os?: string; + hasParameters: boolean; +}; + +/** + * UI-only metadata for a selected module. + * Kept separate from the API request payload. + */ +type SelectedModuleMeta = { + id: string; + name: string; + iconUrl: string; + hasConfigurableVars: boolean; +}; + +export type TemplateBuilderWizardState = { + baseTemplateId: string | null; + baseVariableValues: Record; + modules: TemplateBuilderComposeModule[]; + organizationId?: string; + displayName: string; + description: string; + selectedBase: SelectedBaseMeta | null; + selectedModules: SelectedModuleMeta[]; +}; + +export const initialWizardState: TemplateBuilderWizardState = { + baseTemplateId: null, + baseVariableValues: {}, + modules: [], + displayName: "", + description: "", + selectedBase: null, + selectedModules: [], +}; + +export type WizardAction = + | { type: "SET_BASE"; base: SelectedBaseMeta } + | { type: "SET_BASE_VARIABLES"; values: Record } + | { + type: "SET_MODULES"; + modules: TemplateBuilderComposeModule[]; + meta: SelectedModuleMeta[]; + } + | { + type: "SET_MODULE_VARIABLES"; + moduleId: string; + variables: Record; + } + | { + type: "SET_CUSTOMIZATION"; + field: "organizationId" | "displayName" | "description"; + value: string; + } + | { type: "RESET" }; + +export function wizardReducer( + state: TemplateBuilderWizardState, + action: WizardAction, +): TemplateBuilderWizardState { + switch (action.type) { + case "SET_BASE": { + const baseChanged = state.baseTemplateId !== action.base.id; + return { + ...state, + baseTemplateId: action.base.id, + selectedBase: action.base, + // Clear base variable values when base changes. + baseVariableValues: baseChanged ? {} : state.baseVariableValues, + }; + } + case "SET_BASE_VARIABLES": + return { + ...state, + baseVariableValues: action.values, + }; + case "SET_MODULES": { + // Preserve existing variable values for modules that remain selected. + const existingById = new Map(state.modules.map((m) => [m.id, m])); + const merged = action.modules.map((incoming) => { + const existing = existingById.get(incoming.id); + if (existing?.variables && !incoming.variables) { + return { ...incoming, variables: existing.variables }; + } + return incoming; + }); + return { + ...state, + modules: merged, + selectedModules: action.meta, + }; + } + case "SET_MODULE_VARIABLES": { + return { + ...state, + modules: state.modules.map((m) => + m.id === action.moduleId ? { ...m, variables: action.variables } : m, + ), + }; + } + case "SET_CUSTOMIZATION": + return { + ...state, + [action.field]: action.value, + }; + case "RESET": + return initialWizardState; + default: + return state; + } +} + +/** + * Returns true when a module has at least one variable that should be + * shown to the user for configuration (not sensitive, not computed). + */ +export const moduleHasConfigurableVars = ( + module: TemplateBuilderModule, +): boolean => { + return module.variables.some((v) => !v.sensitive); +}; + +/** + * Project wizard state into the API request shape for the compose endpoint. + */ +export const toComposeRequest = ( + state: TemplateBuilderWizardState, +): TemplateBuilderComposeRequest => { + return { + base_template_id: state.baseTemplateId ?? "", + modules: state.modules, + }; +}; diff --git a/site/src/router.tsx b/site/src/router.tsx index 22757783c5..c85ccdb3d4 100644 --- a/site/src/router.tsx +++ b/site/src/router.tsx @@ -201,6 +201,9 @@ const StarterTemplatePage = lazy( const CreateTemplatePage = lazy( () => import("./pages/CreateTemplatePage/CreateTemplatePage"), ); +const TemplateBuilderPage = lazy( + () => import("./pages/TemplateBuilder/TemplateBuilderPage"), +); const TemplateVariablesPage = lazy( () => import( @@ -560,7 +563,10 @@ export const router = createBrowserRouter( } /> - } /> + + } /> + } /> + {templateRouter()} {templateRouter()}