diff --git a/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx b/site/src/pages/TemplateBuilder/TemplateBuilderPage.tsx index c8a6b606014..40aa4a8397d 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 e73d1a195d9..acb7f1ee2ef 100644 --- a/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx +++ b/site/src/pages/TemplateBuilder/TemplateBuilderPageView.tsx @@ -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 = ({ }; const handleNext = () => { - if (isLastStep) { - onCreateTemplate(state); - return; - } navigateToStep(nextIndex); }; @@ -188,6 +191,10 @@ export const TemplateBuilderPageView: FC = ({ 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 = ({ handleProvisionerStatusChange, handleDeselectModule, registerModuleRef, + handleCreate, )} @@ -317,9 +325,19 @@ export const TemplateBuilderPageView: FC = ({ Back )} - + {isLastStep ? ( + + ) : ( + + )} {currentStep.id === "base-infra" && } @@ -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 && } - 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; } diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx new file mode 100644 index 00000000000..3cda7b0ce1c --- /dev/null +++ b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.stories.tsx @@ -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 = { + 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 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(); + }, +}; diff --git a/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx b/site/src/pages/TemplateBuilder/TemplateCustomizationsStep.tsx index 22885bef390..39e3c9f481a 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, @@ -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({ + 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(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 ( -
+
Customizations Add additional configurations. @@ -87,15 +134,12 @@ export const TemplateCustomizationsStep: FC< {/* Two-column form grid */}
{/* Left column */} -
- - onChangeField("displayName", e.target.value)} - placeholder="My Template" - /> -
+ {/* Right column */} {orgOptions.length > 0 && ( @@ -113,6 +157,11 @@ export const TemplateCustomizationsStep: FC< onChange={handleOrgChange} options={orgOptions} /> + {organizationField.error && ( + + {organizationField.helperText} + + )}
)} @@ -120,54 +169,57 @@ export const TemplateCustomizationsStep: FC<