feat(site): add wizard shell, step registry, and feature flag gate (#26423)

Add the template builder wizard route at `/templates/new/builder` with
feature flag gating, step navigation, and wizard state management.

- Read `template_builder.disabled` from deployment config and redirect
to `/templates/new` when disabled
- Five-step wizard registry with skip logic for `base-parameters` (no
params) and `module-settings` (no configurable vars)
- Reducer managing base selection, module selection, variable values,
and template customizations with state preservation across navigation
- Two-column layout with step content area, `SelectionSummary` sidebar,
and back/forward navigation
- Page/PageView separation using `Margins`, `PageHeader`, and standard
layout components

Part 1 of the Template Builder stack. Relates to
[DEVEX-282](https://linear.app/codercom/issue/DEVEX-282).

> [!NOTE]
> This PR was authored with Coder Agents.
This commit is contained in:
Jeremy Ruppel
2026-06-16 16:38:51 -04:00
committed by GitHub
parent 0e45ded0ed
commit 0fc25d38ce
7 changed files with 1011 additions and 1 deletions
@@ -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 <Loader />;
}
// 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 <Navigate to="/templates/new" replace />;
}
return (
<>
<title>{pageTitle("Create Template")}</title>
<TemplateBuilderPageView error={error} />
</>
);
};
export default TemplateBuilderPage;
@@ -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<TemplateBuilderPageViewProps> = ({
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 (
<Margins>
<PageHeader>
<PageHeaderTitle>Create new template</PageHeaderTitle>
<PageHeaderSubtitle>
A Terraform blueprint for reproducible workspaces
<Link href={docs("/admin/templates")} className="ml-1">
View docs
</Link>
</PageHeaderSubtitle>
</PageHeader>
{error != null && <ErrorAlert error={error} />}
<div className="flex gap-8">
{/* Main content area */}
<div className="flex-1 min-w-0">
<div className="rounded-lg border border-solid border-border bg-surface-primary p-6 min-h-[400px]">
<p className="text-sm text-content-secondary">
Step: {currentStep.id}
</p>
</div>
{/* Navigation controls */}
<div className="flex justify-end mt-6 gap-2">
{isFirstStep ? (
<div />
) : (
<Button variant="outline" onClick={handleBack}>
Back
</Button>
)}
<Button onClick={handleNext}>
{isLastStep ? "Create Template" : "Continue"}
</Button>
</div>
</div>
{/* Sidebar */}
<div className="w-64 shrink-0 hidden md:block">
<SelectionSummary
currentStep={currentStep.group}
selectedTemplate={
state.selectedBase
? {
name: state.selectedBase.name,
iconUrl: state.selectedBase.iconUrl,
}
: undefined
}
selectedModules={
state.selectedModules.length > 0
? state.selectedModules
: undefined
}
onDeselectModule={handleDeselectModule}
/>
</div>
</div>
</Margins>
);
};
@@ -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>,
): 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);
});
});
+121
View File
@@ -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;
}
@@ -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([]);
});
});
@@ -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<string, string>;
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<string, string> }
| {
type: "SET_MODULES";
modules: TemplateBuilderComposeModule[];
meta: SelectedModuleMeta[];
}
| {
type: "SET_MODULE_VARIABLES";
moduleId: string;
variables: Record<string, string>;
}
| {
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,
};
};
+7 -1
View File
@@ -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(
<Route path="/templates">
<Route index element={<TemplatesPage />} />
<Route path="new" element={<CreateTemplatePage />} />
<Route path="new">
<Route index element={<CreateTemplatePage />} />
<Route path="builder" element={<TemplateBuilderPage />} />
</Route>
<Route path=":organization">{templateRouter()}</Route>
{templateRouter()}
</Route>