mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
fix(site/src/pages/TemplateBuilder): validate customizations form before submit (#28081)
This commit is contained in:
@@ -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'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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user