feat: TemplateCustomizationsStep and compose POST (DEVEX-287) (#26433)

Part of the Template Builder wizard PR stack.

## Frontend changes

1. **TemplateCustomizationsStep**: Final wizard step with org picker,
icon picker, name/display name/description/icon fields, and redirect on
success.

2. **Refactor**: Moved queries/mutation from PageView to Page container,
extracted `renderStepContent` switch and `computeCanContinue` switch
into standalone functions.

---
> [!NOTE]
> Generated by Coder Agents on behalf of @jeremyruppel
This commit is contained in:
Jeremy Ruppel
2026-06-23 10:23:00 -04:00
committed by GitHub
parent 44f2a77b34
commit c9e94c20cc
8 changed files with 407 additions and 67 deletions
+10
View File
@@ -2495,6 +2495,16 @@ class ApiMethods {
return response.data;
};
createTemplateFromBuilder = async (
req: TypesGen.TemplateBuilderCreateTemplateRequest,
): Promise<TypesGen.TemplateBuilderCreateTemplateResponse> => {
const response = await this.axios.post(
"/api/v2/templatebuilder/compose/template",
req,
);
return response.data;
};
uploadFile = async (file: File): Promise<TypesGen.UploadResponse> => {
const response = await this.axios.post("/api/v2/files", file, {
headers: { "Content-Type": file.type },
+4
View File
@@ -10,3 +10,7 @@ export const templateBuilderModules = (base?: string) => ({
queryFn: () => API.getTemplateBuilderModules(base),
staleTime: Number.POSITIVE_INFINITY,
});
export const createTemplateFromBuilder = () => ({
mutationFn: API.createTemplateFromBuilder,
});
@@ -1,29 +1,59 @@
import type { FC } from "react";
import { useQuery } from "react-query";
import { Navigate } from "react-router";
import { useMutation, useQuery } from "react-query";
import { Navigate, useNavigate } from "react-router";
import { deploymentConfig } from "#/api/queries/deployment";
import {
createTemplateFromBuilder,
templateBuilderBases,
} from "#/api/queries/templateBuilder";
import { Loader } from "#/components/Loader/Loader";
import { linkToTemplate, useLinks } from "#/modules/navigation";
import { pageTitle } from "#/utils/page";
import { TemplateBuilderPageView } from "./TemplateBuilderPageView";
import type { TemplateBuilderWizardState } from "./wizardState";
import { toCreateTemplateRequest } from "./wizardState";
const TemplateBuilderPage: FC = () => {
const navigate = useNavigate();
const getLink = useLinks();
const { data, error, isLoading } = useQuery(deploymentConfig());
const basesQuery = useQuery(templateBuilderBases());
const createMutation = useMutation(createTemplateFromBuilder());
if (isLoading) {
return <Loader />;
}
// if the template builder is disabled in the deployment config,
// redirect to the new template page
// 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 />;
}
const handleCreate = (state: TemplateBuilderWizardState) => {
const req = toCreateTemplateRequest(state);
createMutation.mutate(req, {
onSuccess: (resp) => {
const t = resp.template;
navigate(
`${getLink(linkToTemplate(t.organization_name, t.name))}/files`,
{ state: { justCreated: true } },
);
},
});
};
return (
<>
<title>{pageTitle("Create Template")}</title>
<TemplateBuilderPageView error={error} />
<TemplateBuilderPageView
error={error}
basesData={basesQuery.data}
onCreateTemplate={handleCreate}
createError={createMutation.error}
isCreating={createMutation.isPending}
/>
</>
);
};
@@ -1,9 +1,10 @@
import { type FC, useReducer, useState } from "react";
import { type FC, type ReactNode, useReducer, useState } from "react";
import { useQuery } from "react-query";
import {
templateBuilderBases,
templateBuilderModules,
} from "#/api/queries/templateBuilder";
import { templateBuilderModules } from "#/api/queries/templateBuilder";
import type {
TemplateBuilderBasesResponse,
TemplateBuilderModulesResponse,
} from "#/api/typesGenerated";
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
import { Button } from "#/components/Button/Button";
import { Link } from "#/components/Link/Link";
@@ -29,20 +30,34 @@ import {
findNextVisibleIndex,
findPrevVisibleIndex,
nearestVisible,
type StepId,
WIZARD_STEPS,
} from "./steps";
import { initialWizardState, wizardReducer } from "./wizardState";
import { TemplateCustomizationsStep } from "./TemplateCustomizationsStep";
import {
initialWizardState,
type TemplateBuilderWizardState,
type WizardAction,
wizardReducer,
} from "./wizardState";
interface TemplateBuilderPageViewProps {
error: unknown;
basesData: TemplateBuilderBasesResponse | undefined;
onCreateTemplate: (state: TemplateBuilderWizardState) => void;
createError: Error | null;
isCreating: boolean;
}
export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
error,
basesData,
onCreateTemplate,
createError,
isCreating,
}) => {
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(
@@ -56,20 +71,13 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
const isFirstStep = prevIndex === -1;
const isLastStep = nextIndex === -1;
const canContinue =
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 canContinue = computeCanContinue(
currentStep.id,
state,
basesData,
modulesQuery.data,
moduleVarMap,
);
const handleBack = () => {
setStepIndex(prevIndex);
@@ -77,7 +85,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
const handleNext = () => {
if (isLastStep) {
// Compose will be wired in a follow-up issue.
onCreateTemplate(state);
return;
}
setStepIndex(nextIndex);
@@ -112,42 +120,12 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
<div className="flex gap-8">
{/* Main content area */}
<div className="flex-1 min-w-0">
{currentStep.id === "base-infra" ? (
<BaseInfraSelectStep
selectedBaseId={state.selectedBase?.id ?? null}
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
/>
) : currentStep.id === "base-parameters" && state.selectedBase ? (
<BaseTemplateParametersStep
baseId={state.selectedBase.id}
values={state.baseVariableValues}
onChangeValues={(values) =>
dispatch({ type: "SET_BASE_VARIABLES", values })
}
/>
) : currentStep.id === "module-select" && state.selectedBase ? (
<ModuleSelectStep
baseId={state.selectedBase.id}
selectedModuleIds={state.modules.map((m) => m.id)}
onChangeModules={(modules, meta) =>
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">
Step: {currentStep.id}
</p>
</div>
{renderStepContent(
currentStep.id,
state,
dispatch,
moduleVarMap,
createError,
)}
{/* Navigation controls */}
@@ -159,8 +137,12 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
Back
</Button>
)}
<Button onClick={handleNext} disabled={!canContinue}>
{isLastStep ? "Create Template" : "Continue"}
<Button onClick={handleNext} disabled={!canContinue || isCreating}>
{isCreating
? "Creating..."
: isLastStep
? "Create Template"
: "Continue"}
</Button>
</div>
</div>
@@ -189,3 +171,104 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
</Margins>
);
};
function renderStepContent(
stepId: StepId,
state: TemplateBuilderWizardState,
dispatch: (action: WizardAction) => void,
moduleVarMap: Record<string, Record<string, string>>,
createError: Error | null,
): ReactNode {
switch (stepId) {
case "base-infra":
return (
<BaseInfraSelectStep
selectedBaseId={state.selectedBase?.id ?? null}
onSelectBase={(base) => dispatch({ type: "SET_BASE", base })}
/>
);
case "base-parameters":
if (!state.selectedBase) return null;
return (
<BaseTemplateParametersStep
baseId={state.selectedBase.id}
values={state.baseVariableValues}
onChangeValues={(values) =>
dispatch({ type: "SET_BASE_VARIABLES", values })
}
/>
);
case "module-select":
if (!state.selectedBase) return null;
return (
<ModuleSelectStep
baseId={state.selectedBase.id}
selectedModuleIds={state.modules.map((m) => m.id)}
onChangeModules={(modules, meta) =>
dispatch({ type: "SET_MODULES", modules, meta })
}
/>
);
case "module-settings":
if (!state.selectedBase) return null;
return (
<ModuleSettingsStep
baseId={state.selectedBase.id}
selectedModuleIds={state.modules.map((m) => m.id)}
moduleVariables={moduleVarMap}
onChangeModuleVariables={(moduleId, variables) =>
dispatch({
type: "SET_MODULE_VARIABLES",
moduleId,
variables,
})
}
/>
);
case "customizations":
return (
<>
{createError != null && <ErrorAlert error={createError} />}
<TemplateCustomizationsStep
state={state}
onChangeField={(field, value) =>
dispatch({
type: "SET_CUSTOMIZATION",
field,
value,
})
}
/>
</>
);
default:
return null;
}
}
function computeCanContinue(
stepId: StepId,
state: TemplateBuilderWizardState,
basesData: TemplateBuilderBasesResponse | undefined,
modulesData: TemplateBuilderModulesResponse | undefined,
moduleVarMap: Record<string, Record<string, string>>,
): boolean {
switch (stepId) {
case "base-parameters":
return baseParametersComplete(
basesData,
state.selectedBase?.id ?? null,
state.baseVariableValues,
);
case "module-settings":
return moduleSettingsComplete(
modulesData,
state.modules.map((m) => m.id),
moduleVarMap,
);
case "customizations":
return state.name.trim() !== "";
default:
return true;
}
}
@@ -0,0 +1,155 @@
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import { permittedOrganizations } from "#/api/queries/organizations";
import type { Organization } from "#/api/typesGenerated";
import { IconField } from "#/components/IconField/IconField";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
import { Textarea } from "#/components/Textarea/Textarea";
import type {
SelectedBaseMeta,
TemplateBuilderWizardState,
} from "./wizardState";
interface TemplateCustomizationsStepProps {
state: TemplateBuilderWizardState;
onChangeField: (
field: "organizationId" | "name" | "displayName" | "description" | "icon",
value: string,
) => void;
}
export const TemplateCustomizationsStep: FC<
TemplateCustomizationsStepProps
> = ({ state, onChangeField }) => {
const permittedOrgsQuery = useQuery(
permittedOrganizations({
object: { resource_type: "template" },
action: "create",
}),
);
const orgOptions = permittedOrgsQuery.data ?? [];
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
// Auto-select when exactly one org is available.
useEffect(() => {
if (orgOptions.length === 1 && !selectedOrg) {
setSelectedOrg(orgOptions[0]);
onChangeField("organizationId", orgOptions[0].id);
}
}, [orgOptions, selectedOrg, onChangeField]);
const handleOrgChange = (org: Organization | null) => {
setSelectedOrg(org);
onChangeField("organizationId", org?.id ?? "");
};
return (
<div className="border border-border border-solid p-6 rounded-lg">
<h2 className="text-lg font-semibold mb-1">Customizations</h2>
<p className="text-sm text-content-secondary mb-6">
Add additional configurations.
</p>
<div className="flex gap-8">
{/* Base template card */}
{state.selectedBase && <BaseTemplateCard base={state.selectedBase} />}
{/* Two-column form grid */}
<div className="grid grid-cols-2 gap-x-6 gap-y-6 content-start">
{/* Left column */}
<div className="flex flex-col gap-2">
<Label htmlFor="template-display-name">Display name</Label>
<Input
id="template-display-name"
value={state.displayName}
onChange={(e) => onChangeField("displayName", e.target.value)}
placeholder="My Template"
/>
</div>
{/* Right column */}
{orgOptions.length > 0 && (
<div className="flex flex-col gap-2">
<Label htmlFor="organization">
Organization
<span className="text-xs font-bold text-content-destructive ml-1">
*
</span>
</Label>
<OrganizationAutocomplete
id="organization"
required
value={selectedOrg}
onChange={handleOrgChange}
options={orgOptions}
/>
</div>
)}
{/* Left column */}
<div className="flex flex-col gap-2">
<Label htmlFor="template-description">Description</Label>
<Textarea
id="template-description"
value={state.description}
onChange={(e) => onChangeField("description", e.target.value)}
placeholder="Describe what this template is for"
rows={3}
/>
<p className="text-xs text-content-secondary">
Used by both humans and Agents to identify templates.
</p>
<IconField
value={state.icon}
onChange={(e) => {
const target = e.target as HTMLInputElement;
onChangeField("icon", target.value);
}}
onPickEmoji={(value) => onChangeField("icon", value)}
/>
</div>
{/* Right column */}
<div className="flex flex-col gap-2">
<Label htmlFor="template-name">
ID
<span className="text-xs font-bold text-content-destructive ml-1">
*
</span>
</Label>
<Input
id="template-name"
value={state.name}
onChange={(e) => onChangeField("name", e.target.value)}
placeholder="my-template"
aria-required
/>
<p className="text-xs text-content-secondary">
Used to identify the template in URLs and the API.
</p>
</div>
</div>
</div>
</div>
);
};
const BaseTemplateCard: FC<{ base: SelectedBaseMeta }> = ({ base }) => {
return (
<div className="w-56 shrink-0 rounded-lg bg-surface-secondary p-4 self-start">
{base.iconUrl && (
<div className="w-10 h-10 rounded-md bg-surface-tertiary flex items-center justify-center mb-3">
<img src={base.iconUrl} alt="" className="w-6 h-6" />
</div>
)}
<p className="text-sm font-medium text-content-primary">{base.name}</p>
<p className="text-xs text-content-secondary mt-1">
Preset based on base template
</p>
</div>
);
};
+1 -1
View File
@@ -1,6 +1,6 @@
import type { TemplateBuilderWizardState } from "./wizardState";
type StepId =
export type StepId =
| "base-infra"
| "base-parameters"
| "module-select"
@@ -4,6 +4,7 @@ import {
moduleHasConfigurableVars,
type TemplateBuilderWizardState,
toComposeRequest,
toCreateTemplateRequest,
type WizardAction,
wizardReducer,
} from "./wizardState";
@@ -370,3 +371,38 @@ describe("toComposeRequest", () => {
});
});
});
describe("toCreateTemplateRequest", () => {
it("produces the correct API request shape", () => {
const state: TemplateBuilderWizardState = {
...initialWizardState,
baseTemplateId: "docker",
organizationId: "org-123",
name: "my-template",
displayName: "My Template",
description: "A test template",
icon: "/icon/docker.svg",
modules: [{ id: "code-server" }],
};
const request = toCreateTemplateRequest(state);
expect(request.base_template_id).toBe("docker");
expect(request.organization_id).toBe("org-123");
expect(request.name).toBe("my-template");
expect(request.display_name).toBe("My Template");
expect(request.description).toBe("A test template");
expect(request.icon).toBe("/icon/docker.svg");
expect(request.modules).toEqual([{ id: "code-server" }]);
});
it("omits empty optional fields", () => {
const state: TemplateBuilderWizardState = {
...initialWizardState,
baseTemplateId: "docker",
name: "my-template",
};
const request = toCreateTemplateRequest(state);
expect(request.display_name).toBeUndefined();
expect(request.description).toBeUndefined();
expect(request.icon).toBeUndefined();
});
});
+23 -1
View File
@@ -1,6 +1,7 @@
import type {
TemplateBuilderComposeModule,
TemplateBuilderComposeRequest,
TemplateBuilderCreateTemplateRequest,
TemplateBuilderModule,
} from "#/api/typesGenerated";
@@ -32,8 +33,10 @@ export type TemplateBuilderWizardState = {
baseVariableValues: Record<string, string>;
modules: TemplateBuilderComposeModule[];
organizationId?: string;
name: string;
displayName: string;
description: string;
icon: string;
selectedBase: SelectedBaseMeta | null;
selectedModules: SelectedModuleMeta[];
};
@@ -42,8 +45,10 @@ export const initialWizardState: TemplateBuilderWizardState = {
baseTemplateId: null,
baseVariableValues: {},
modules: [],
name: "",
displayName: "",
description: "",
icon: "",
selectedBase: null,
selectedModules: [],
};
@@ -63,7 +68,7 @@ export type WizardAction =
}
| {
type: "SET_CUSTOMIZATION";
field: "organizationId" | "displayName" | "description";
field: "organizationId" | "name" | "displayName" | "description" | "icon";
value: string;
}
| { type: "RESET" };
@@ -149,3 +154,20 @@ export const toComposeRequest = (
modules: state.modules,
};
};
/**
* Project wizard state into the API request shape for the
* create-template endpoint.
*/
export const toCreateTemplateRequest = (
state: TemplateBuilderWizardState,
): TemplateBuilderCreateTemplateRequest => {
return {
...toComposeRequest(state),
organization_id: state.organizationId ?? "",
name: state.name,
display_name: state.displayName || undefined,
description: state.description || undefined,
icon: state.icon || undefined,
};
};