diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index e1a443b6580..e67d9fef927 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx @@ -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 ; } - 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, { diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx index 53fbacfb471..db5ceb74276 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -46,8 +46,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, @@ -59,7 +63,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; @@ -154,13 +161,16 @@ export const TemplateBuilderPageView: FC = ({ }; const handleNext = () => { - if (isLastStep) { - onCreateTemplate(state); - return; - } navigateToStep(nextIndex); }; + const handleCreate = useCallback( + (values: CustomizationsFormValues) => { + onCreateTemplate(state, values); + }, + [onCreateTemplate, state], + ); + const handleProvisionerStatusChange = useCallback( (value: boolean | undefined) => { dispatch({ type: "SET_HAS_PROVISIONERS", value }); @@ -278,6 +288,7 @@ export const TemplateBuilderPageView: FC = ({ handleProvisionerStatusChange, handleDeselectModule, registerModuleRef, + handleCreate, )} @@ -290,9 +301,19 @@ export const TemplateBuilderPageView: FC = ({ Back )} - + {isLastStep ? ( + + ) : ( + + )} {currentStep.id === "base-infra" && } @@ -332,6 +353,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": @@ -387,13 +409,7 @@ function renderStepContent( {createError != null && } - dispatch({ - type: "SET_CUSTOMIZATION", - field, - value, - }) - } + onCreate={onCreate} onProvisionerStatusChange={onProvisionerStatusChange} /> @@ -426,7 +442,7 @@ function computeCanContinue( moduleVarMap, ); case "customizations": - return state.name.trim() !== "" && state.hasProvisioners !== false; + return state.hasProvisioners !== false; default: return true; } diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx new file mode 100644 index 00000000000..be9d3711a81 --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx @@ -0,0 +1,147 @@ +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 provisionersKey = (organizationId: string) => + getProvisionerDaemonsKey(organizationId); + +const meta: Meta = { + 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) => ( +
+ + +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const MultipleOrganizations: Story = {}; + +// Submitting without choosing an organization surfaces an aggregated error at +// the top of the step instead of an inline field error, 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: provisionersKey(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 at the top of the +// step and blocks submission. +export const MissingNameError: Story = { + args: { + state: { ...baseState, name: "" }, + }, + parameters: { + queries: [ + { key: permittedOrgsKey, data: [MockDefaultOrganization] }, + { + key: provisionersKey(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(); + }, +}; diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx index 22885bef390..0fc68bd9b61 100644 --- a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx +++ b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx @@ -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, @@ -18,23 +20,44 @@ import { TemplateBuilderTitle, } from "#/pages/TemplateBuilder/TemplateBuilderHeader"; import { docs } from "#/utils/docs"; +import { + displayNameValidator, + 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 +66,19 @@ export const TemplateCustomizationsStep: FC< ); const orgOptions = permittedOrgsQuery.data ?? []; + const form = useFormik({ + initialValues: { + organization_id: "", + name: state.name, + display_name: state.displayName, + description: state.description, + icon: state.icon, + }, + validationSchema, + onSubmit: (values) => onCreate(values), + }); + + // Display object for the autocomplete; the Formik field only stores the id. const [selectedOrg, setSelectedOrg] = useState(null); const { data: provisioners } = useQuery({ @@ -59,25 +95,52 @@ export const TemplateCustomizationsStep: FC< }, [hasProvisioners, onProvisionerStatusChange]); // Auto-select when exactly one org is available. + // biome-ignore lint/correctness/useExhaustiveDependencies: form.setFieldValue is stable 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]); const handleOrgChange = (org: Organization | null) => { setSelectedOrg(org); - onChangeField("organizationId", org?.id ?? ""); + void form.setFieldValue("organization_id", org?.id ?? ""); }; + // Aggregate validation messages and show them at the top of the step so the + // horizontal two-column layout is not disturbed by inline field errors. + const errorMessages = Object.values(form.errors).filter( + (message): message is string => Boolean(message), + ); + const showErrorSummary = form.submitCount > 0 && errorMessages.length > 0; + return ( -
+
Customizations Add additional configurations. + {showErrorSummary && ( + + {errorMessages.length === 1 ? ( + errorMessages[0] + ) : ( +
    + {errorMessages.map((message) => ( +
  • {message}
  • + ))} +
+ )} +
+ )} + {showProvisionerWarning && }
@@ -90,10 +153,10 @@ export const TemplateCustomizationsStep: FC<
onChangeField("displayName", e.target.value)} placeholder="My Template" + aria-invalid={Boolean(form.errors.display_name)} />
@@ -120,23 +183,25 @@ export const TemplateCustomizationsStep: FC<