fix(site/src/pages/TemplateBuilder): validate customizations form before submit (#28081)

This commit is contained in:
Jeremy Ruppel
2026-08-24 08:48:24 -04:00
committed by GitHub
parent 9b5f47eeb7
commit 3c89b09734
6 changed files with 316 additions and 93 deletions
@@ -17,7 +17,11 @@ import type {
SelectedBaseMeta,
TemplateBuilderWizardState,
} from "./wizardState";
import { toCreateTemplateRequest, toSelectedBaseMeta } from "./wizardState";
import {
type CustomizationsFormValues,
toCreateTemplateRequest,
toSelectedBaseMeta,
} from "./wizardState";
const TemplateBuilderPage: FC = () => {
const navigate = useNavigate();
@@ -109,8 +113,11 @@ const TemplateBuilderPage: FC = () => {
return <Navigate to="/templates/new" replace />;
}
const handleCreate = (state: TemplateBuilderWizardState) => {
const req = toCreateTemplateRequest(state);
const handleCreate = (
state: TemplateBuilderWizardState,
customizations: CustomizationsFormValues,
) => {
const req = toCreateTemplateRequest(state, customizations);
const durationSeconds = (Date.now() - state.enteredAt) / 1000;
createMutation.mutate(req, {
@@ -47,8 +47,12 @@ import {
WIZARD_STEPS,
} from "./steps";
import { TemplateAlternatives } from "./TemplateAlternatives";
import { TemplateCustomizationsStep } from "./TemplateCustomizationsStep";
import {
TEMPLATE_CUSTOMIZATIONS_FORM_ID,
TemplateCustomizationsStep,
} from "./TemplateCustomizationsStep";
import {
type CustomizationsFormValues,
initWizardState,
type SelectedBaseMeta,
type TemplateBuilderWizardState,
@@ -60,7 +64,10 @@ interface TemplateBuilderPageViewProps {
error: unknown;
basesData: TemplateBuilderBasesResponse | undefined;
preselectedBase?: SelectedBaseMeta;
onCreateTemplate: (state: TemplateBuilderWizardState) => void;
onCreateTemplate: (
state: TemplateBuilderWizardState,
customizations: CustomizationsFormValues,
) => void;
createError: Error | null;
isCreating: boolean;
onClearCreateError?: () => void;
@@ -165,10 +172,6 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
};
const handleNext = () => {
if (isLastStep) {
onCreateTemplate(state);
return;
}
navigateToStep(nextIndex);
};
@@ -188,6 +191,10 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
navigateToStep(nearestVisible(target, state));
};
const handleCreate = (values: CustomizationsFormValues) => {
onCreateTemplate(state, values);
};
const handleProvisionerStatusChange = useCallback(
(value: boolean | undefined) => {
dispatch({ type: "SET_HAS_PROVISIONERS", value });
@@ -305,6 +312,7 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
handleProvisionerStatusChange,
handleDeselectModule,
registerModuleRef,
handleCreate,
)}
</div>
@@ -317,9 +325,19 @@ export const TemplateBuilderPageView: FC<TemplateBuilderPageViewProps> = ({
Back
</Button>
)}
<Button onClick={handleNext} disabled={!canContinue}>
{isLastStep ? "Create Template" : "Continue"}
</Button>
{isLastStep ? (
<Button
type="submit"
form={TEMPLATE_CUSTOMIZATIONS_FORM_ID}
disabled={state.hasProvisioners === false}
>
Create Template
</Button>
) : (
<Button onClick={handleNext} disabled={!canContinue}>
Continue
</Button>
)}
</div>
{currentStep.id === "base-infra" && <TemplateAlternatives />}
@@ -361,6 +379,7 @@ function renderStepContent(
onProvisionerStatusChange: (value: boolean | undefined) => void,
onRemoveModule: (moduleId: string) => void,
registerModuleRef: (moduleId: string, node: HTMLDivElement | null) => void,
onCreate: (values: CustomizationsFormValues) => void,
): ReactNode {
switch (stepId) {
case "base-infra":
@@ -416,13 +435,7 @@ function renderStepContent(
{createError != null && <ErrorAlert error={createError} />}
<TemplateCustomizationsStep
state={state}
onChangeField={(field, value) =>
dispatch({
type: "SET_CUSTOMIZATION",
field,
value,
})
}
onCreate={onCreate}
onProvisionerStatusChange={onProvisionerStatusChange}
/>
</>
@@ -455,7 +468,7 @@ function computeCanContinue(
moduleVarMap,
);
case "customizations":
return state.name.trim() !== "" && state.hasProvisioners !== false;
return state.hasProvisioners !== false;
default:
return true;
}
@@ -0,0 +1,143 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, fn, userEvent, within } from "storybook/test";
import {
getProvisionerDaemonsKey,
permittedOrganizations,
} from "#/api/queries/organizations";
import { Button } from "#/components/Button/Button";
import {
MockDefaultOrganization,
MockOrganization,
MockOrganization2,
MockProvisioner,
} from "#/testHelpers/entities";
import {
TEMPLATE_CUSTOMIZATIONS_FORM_ID,
TemplateCustomizationsStep,
} from "./TemplateCustomizationsStep";
import {
initialWizardState,
type TemplateBuilderWizardState,
} from "./wizardState";
const baseState: TemplateBuilderWizardState = {
...initialWizardState,
baseTemplateId: "docker",
selectedBase: {
id: "docker",
name: "Docker Containers",
iconUrl: "/icon/docker.svg",
hasParameters: false,
hasPrerequisites: false,
},
name: "docker",
displayName: "Docker Containers",
description: "Run workspaces as Docker containers",
icon: "/icon/docker.svg",
};
const permittedOrgsKey = permittedOrganizations({
object: { resource_type: "template" },
action: "create",
}).queryKey;
const meta: Meta<typeof TemplateCustomizationsStep> = {
title: "pages/TemplateBuilder/TemplateCustomizationsStep",
component: TemplateCustomizationsStep,
args: {
state: baseState,
onCreate: fn(),
onProvisionerStatusChange: fn(),
},
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockOrganization, MockOrganization2] },
],
},
// The "Create Template" submit button lives in the wizard's shared nav bar,
// outside this component. It is associated with the form via the `form`
// attribute, so the stories render an equivalent button to exercise submit.
decorators: [
(Story) => (
<div className="flex flex-col gap-6">
<Story />
<Button type="submit" form={TEMPLATE_CUSTOMIZATIONS_FORM_ID}>
Create Template
</Button>
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof TemplateCustomizationsStep>;
export const MultipleOrganizations: Story = {};
// Submitting without choosing an organization surfaces an inline error under
// the organization field and does not call onCreate.
export const MissingOrganizationError: Story = {
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByTestId("organization-autocomplete");
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await canvas.findByText("Select an organization to continue.");
await expect(args.onCreate).not.toHaveBeenCalled();
},
};
// A single permitted organization is auto-selected, so a valid form submits and
// forwards the selected organization id to onCreate.
export const SingleOrganizationSubmits: Story = {
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockDefaultOrganization] },
{
key: getProvisionerDaemonsKey(MockDefaultOrganization.id),
data: [MockProvisioner],
},
],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
// Wait for the auto-selected org to render in the autocomplete.
await canvas.findByText(MockDefaultOrganization.display_name);
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await expect(args.onCreate).toHaveBeenCalledWith(
expect.objectContaining({
organization_id: MockDefaultOrganization.id,
name: "docker",
}),
);
},
};
// A missing template ID surfaces the name validation message inline under the
// ID field and blocks submission.
export const MissingNameError: Story = {
args: {
state: { ...baseState, name: "" },
},
parameters: {
queries: [
{ key: permittedOrgsKey, data: [MockDefaultOrganization] },
{
key: getProvisionerDaemonsKey(MockDefaultOrganization.id),
data: [MockProvisioner],
},
],
},
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await canvas.findByText(MockDefaultOrganization.display_name);
await userEvent.click(
canvas.getByRole("button", { name: "Create Template" }),
);
await canvas.findByText("Please enter a template id.");
await expect(args.onCreate).not.toHaveBeenCalled();
},
};
@@ -1,5 +1,7 @@
import { useFormik } from "formik";
import { type FC, useEffect, useState } from "react";
import { useQuery } from "react-query";
import * as Yup from "yup";
import {
permittedOrganizations,
provisionerDaemons,
@@ -7,8 +9,8 @@ import {
import type { Organization } from "#/api/typesGenerated";
import { Alert } from "#/components/Alert/Alert";
import { Avatar } from "#/components/Avatar/Avatar";
import { FormField } from "#/components/FormField/FormField";
import { IconField } from "#/components/IconField/IconField";
import { Input } from "#/components/Input/Input";
import { Label } from "#/components/Label/Label";
import { Link } from "#/components/Link/Link";
import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
@@ -17,24 +19,47 @@ import {
TemplateBuilderSubtitle,
TemplateBuilderTitle,
} from "#/pages/TemplateBuilder/TemplateBuilderHeader";
import { cn } from "#/utils/cn";
import { docs } from "#/utils/docs";
import {
displayNameValidator,
getFormHelpers,
iconValidator,
nameValidator,
} from "#/utils/formUtils";
import type {
CustomizationsFormValues,
SelectedBaseMeta,
TemplateBuilderWizardState,
} from "./wizardState";
export const TEMPLATE_CUSTOMIZATIONS_FORM_ID = "template-customizations-form";
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
const validationSchema = Yup.object({
name: nameValidator("Template ID"),
display_name: displayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
"Please enter a description that is less than or equal to 128 characters.",
),
icon: iconValidator,
// An organization is always required: the page is gated on the create-
// template permission, so there is always at least one permitted org, and
// it is auto-selected when only one is available.
organization_id: Yup.string().required("Select an organization to continue."),
});
interface TemplateCustomizationsStepProps {
state: TemplateBuilderWizardState;
onChangeField: (
field: "organizationId" | "name" | "displayName" | "description" | "icon",
value: string,
) => void;
onCreate: (values: CustomizationsFormValues) => void;
onProvisionerStatusChange: (hasProvisioners: boolean | undefined) => void;
}
export const TemplateCustomizationsStep: FC<
TemplateCustomizationsStepProps
> = ({ state, onChangeField, onProvisionerStatusChange }) => {
> = ({ state, onCreate, onProvisionerStatusChange }) => {
const permittedOrgsQuery = useQuery(
permittedOrganizations({
object: { resource_type: "template" },
@@ -43,6 +68,23 @@ export const TemplateCustomizationsStep: FC<
);
const orgOptions = permittedOrgsQuery.data ?? [];
const form = useFormik<CustomizationsFormValues>({
initialValues: {
organization_id: "",
name: state.name,
display_name: state.displayName,
description: state.description,
icon: state.icon,
},
validationSchema,
onSubmit: (values) => onCreate(values),
});
const getFieldHelpers = getFormHelpers(form);
const descriptionField = getFieldHelpers("description");
const iconField = getFieldHelpers("icon");
const organizationField = getFieldHelpers("organization_id");
// Display object for the autocomplete; the Formik field only stores the id.
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const { data: provisioners } = useQuery({
@@ -62,17 +104,22 @@ export const TemplateCustomizationsStep: FC<
useEffect(() => {
if (orgOptions.length === 1 && !selectedOrg) {
setSelectedOrg(orgOptions[0]);
onChangeField("organizationId", orgOptions[0].id);
void form.setFieldValue("organization_id", orgOptions[0].id);
}
}, [orgOptions, selectedOrg, onChangeField]);
}, [orgOptions, selectedOrg, form]);
const handleOrgChange = (org: Organization | null) => {
setSelectedOrg(org);
onChangeField("organizationId", org?.id ?? "");
void form.setFieldValue("organization_id", org?.id ?? "");
};
return (
<div className="min-w-[654px]">
<form
id={TEMPLATE_CUSTOMIZATIONS_FORM_ID}
onSubmit={form.handleSubmit}
noValidate
className="min-w-[654px]"
>
<TemplateBuilderTitle>Customizations</TemplateBuilderTitle>
<TemplateBuilderSubtitle>
Add additional configurations.
@@ -87,15 +134,12 @@ export const TemplateCustomizationsStep: FC<
{/* 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>
<FormField
field={getFieldHelpers("display_name")}
label="Display name"
id="template-display-name"
placeholder="My Template"
/>
{/* Right column */}
{orgOptions.length > 0 && (
@@ -113,6 +157,11 @@ export const TemplateCustomizationsStep: FC<
onChange={handleOrgChange}
options={orgOptions}
/>
{organizationField.error && (
<span className="text-xs text-content-destructive">
{organizationField.helperText}
</span>
)}
</div>
)}
@@ -120,54 +169,57 @@ export const TemplateCustomizationsStep: FC<
<div className="flex flex-col gap-2">
<Label htmlFor="template-description">Description</Label>
<Textarea
{...form.getFieldProps("description")}
id="template-description"
value={state.description}
onChange={(e) => onChangeField("description", e.target.value)}
placeholder="Describe what this template is for"
rows={3}
aria-invalid={descriptionField.error}
className={cn(
descriptionField.error && "border-border-destructive",
)}
/>
{descriptionField.error && (
<span className="text-xs text-content-destructive">
{descriptionField.helperText}
</span>
)}
<p className="text-xs text-content-secondary">
Used by both humans and Agents to identify templates.
</p>
<IconField
value={state.icon}
value={form.values.icon}
error={iconField.error}
helperText={iconField.helperText}
onChange={(e) => {
const target = e.target as HTMLInputElement;
onChangeField("icon", target.value);
void form.setFieldValue("icon", target.value);
}}
onPickEmoji={(value) => {
void form.setFieldValue("icon", 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>
<FormField
field={getFieldHelpers("name", {
helperText: "Used to identify the template in URLs and the API.",
})}
label="ID"
required
id="template-name"
placeholder="my-template"
/>
</div>
</div>
</div>
</form>
);
};
const ProvisionerWarning: FC = () => {
return (
<Alert severity="warning" prominent className="my-4">
<Alert severity="error" prominent className="my-4">
This organization does not have any provisioners. Before you create a
template, you&apos;ll need to configure a provisioner.{" "}
<Link href={docs("/admin/provisioners#organization-scoped-provisioners")}>
@@ -347,20 +347,14 @@ describe("wizardReducer", () => {
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_CUSTOMIZATIONS", () => {
it("clears org and provisioner state but keeps base-derived fields", () => {
it("clears provisioner state but keeps base-derived fields", () => {
const state = reduce([
{
type: "SET_BASE",
@@ -373,15 +367,9 @@ describe("wizardReducer", () => {
hasPrerequisites: false,
},
},
{
type: "SET_CUSTOMIZATION",
field: "organizationId",
value: "org-123",
},
{ type: "SET_HAS_PROVISIONERS", value: true },
{ type: "RESET_CUSTOMIZATIONS" },
]);
expect(state.organizationId).toBeUndefined();
expect(state.hasProvisioners).toBeUndefined();
// Base-derived fields survive so re-entering the step stays filled.
expect(state.name).toBe("docker");
@@ -524,14 +512,19 @@ describe("toCreateTemplateRequest", () => {
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);
const request = toCreateTemplateRequest(state, {
organization_id: "org-123",
name: "my-template",
display_name: "My Template",
description: "A test template",
icon: "/icon/docker.svg",
});
expect(request.base_template_id).toBe("docker");
expect(request.organization_id).toBe("org-123");
expect(request.name).toBe("my-template");
@@ -547,7 +540,13 @@ describe("toCreateTemplateRequest", () => {
baseTemplateId: "docker",
name: "my-template",
};
const request = toCreateTemplateRequest(state);
const request = toCreateTemplateRequest(state, {
organization_id: "",
name: "my-template",
display_name: "",
description: "",
icon: "",
});
expect(request.display_name).toBeUndefined();
expect(request.description).toBeUndefined();
expect(request.icon).toBeUndefined();
+22 -13
View File
@@ -71,7 +71,6 @@ export type TemplateBuilderWizardState = {
baseTemplateId: string | null;
baseVariableValues: Record<string, string>;
modules: TemplateBuilderComposeModule[];
organizationId?: string;
hasProvisioners: boolean | undefined;
name: string;
displayName: string;
@@ -144,7 +143,7 @@ export type WizardAction =
}
| {
type: "SET_CUSTOMIZATION";
field: "organizationId" | "name" | "displayName" | "description" | "icon";
field: "name" | "displayName" | "description" | "icon";
value: string;
}
| { type: "SET_HAS_PROVISIONERS"; value: boolean | undefined }
@@ -211,12 +210,9 @@ export function wizardReducer(
hasProvisioners: action.value,
};
case "RESET_CUSTOMIZATIONS":
// Reset only organization and provisioner detection so re-entering the
// step re-runs org auto-select cleanly. The base-derived fields are
// left intact (they are re-seeded by SET_BASE when the base changes).
// Re-detect provisioners when the step is re-entered.
return {
...state,
organizationId: undefined,
hasProvisioners: undefined,
};
case "RESET":
@@ -253,18 +249,31 @@ export const toComposeRequest = (
};
/**
* Project wizard state into the API request shape for the
* create-template endpoint.
* Values owned by the Formik-backed customizations step. Kept separate from
* the reducer state, which only seeds their initial (base-derived) defaults.
*/
export type CustomizationsFormValues = {
organization_id: string;
name: string;
display_name: string;
description: string;
icon: string;
};
/**
* Project wizard state and the submitted customization values into the API
* request shape for the create-template endpoint.
*/
export const toCreateTemplateRequest = (
state: TemplateBuilderWizardState,
customizations: CustomizationsFormValues,
): TemplateBuilderCreateTemplateRequest => {
return {
...toComposeRequest(state),
organization_id: state.organizationId ?? "",
name: state.name,
display_name: state.displayName || undefined,
description: state.description || undefined,
icon: state.icon || undefined,
organization_id: customizations.organization_id,
name: customizations.name,
display_name: customizations.display_name || undefined,
description: customizations.description || undefined,
icon: customizations.icon || undefined,
};
};