mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user