import { expect, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { type ReactNode, useState } from "react"; import { type Control, Controller, useWatch } from "react-hook-form"; import { Box } from "../containers/Box"; import { ConfirmationMessage } from "../feedback/Message"; import { InputText } from "../inputs/InputText"; import { View } from "../primitives/View"; import { Story } from "../story-components/Story"; import { Form } from "./Form"; import { FormField } from "./FormField"; import { FormFieldArray } from "./FormFieldArray"; import { FormSubmitButton } from "./FormSubmitButton"; interface GuestListValues { guests: { value: string }[]; } interface GuestListFormProps { minSize?: number; details?: string; defaultGuests?: string[]; } function GuestListForm({ minSize, details, defaultGuests = [], }: GuestListFormProps): ReactNode { const [submitted, setSubmitted] = useState(null); return ( <> defaultValues={{ guests: defaultGuests.map((value) => ({ value })), }} render={({ control, submit }) => ( `Remove ${itemLabel}`} render={({ name, label }) => ( ( )} /> )} /> "Please fix the errors above."} onPress={submit} /> )} onSubmit={(values) => { setSubmitted(values.guests.map((guest) => guest.value)); }} /> {submitted ? ( Saved {submitted.length} guest(s): {submitted.join(", ")} ) : null} ); } interface PeopleValues { people: { firstName: string; lastName: string }[]; } interface PeopleFormProps { defaultPeople?: { firstName: string; lastName: string }[]; } function PeopleForm({ defaultPeople = [] }: PeopleFormProps): ReactNode { return ( defaultValues={{ people: defaultPeople }} render={({ control }) => ( ( ( )} /> ( )} /> )} /> )} onSubmit={() => {}} /> ); } interface RawGuestValues { guests: string[]; } interface RawGuestListFormProps { defaultGuests?: string[]; } interface RawGuestFieldsProps { control: Control; } function RawGuestFields({ control }: RawGuestFieldsProps): ReactNode { // Disable adding another row while the last one is still empty, so the list // can't grow into a trail of blank inputs. const guests = useWatch({ control, name: "guests" }); const lastIsEmpty = guests.at(-1)?.trim().length === 0; return ( ( ( )} /> )} /> ); } function RawGuestListForm({ defaultGuests = [], }: RawGuestListFormProps): ReactNode { return ( defaultValues={{ guests: defaultGuests }} render={({ control }) => } onSubmit={() => {}} /> ); } type ThisStory = StoryObj; export default { title: "alouette/Forms/FormFieldArray", component: FormFieldArray, parameters: { componentSubtitle: "A repeatable list of object fields backed by react-hook-form's useFieldArray. Must be used inside
. Owns only the array label, the indented grouping, and add/remove buttons — `render` composes each item's own FormField(s) from the item path, so it stays agnostic about what an item contains.", }, } satisfies Meta; export const FormFieldArrayPreviewStory: ThisStory = { name: "FormFieldArray Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, render: () => ( ), }; export const FormFieldArrayVariantsStory: ThisStory = { name: "FormFieldArray Variants", render: () => ( ), }; export const Tests: ThisStory = { name: "FormFieldArray Tests", parameters: { chromatic: { disableSnapshot: true } }, render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // The first item is seeded and cannot be removed (minSize={1}). const firstGuest = canvas.getByLabelText("Guests 1"); await expect(firstGuest).toHaveDisplayValue("Ada Lovelace"); await expect( canvas.queryByRole("button", { name: "Remove Guests 1" }), ).toBeNull(); // Adding a row appends an empty, removable item. await userEvent.click(canvas.getByRole("button", { name: "Add guest" })); const secondGuest = canvas.getByLabelText("Guests 2"); await expect(secondGuest).toHaveDisplayValue(""); // Leaving an item empty reports a per-item error once it is touched. await userEvent.click(secondGuest); await userEvent.tab(); await expect(canvas.getByText("Guest name is required.")).toBeVisible(); // Removing the offending row drops it and clears its error. await userEvent.click( canvas.getByRole("button", { name: "Remove Guests 2" }), ); await expect(canvas.queryByLabelText("Guests 2")).toBeNull(); await expect(canvas.queryByText("Guest name is required.")).toBeNull(); // The remaining valid item submits. await userEvent.click(canvas.getByRole("button", { name: "Save" })); await waitFor(async () => { await expect( canvas.getByText("Saved 1 guest(s): Ada Lovelace"), ).toBeVisible(); }); }, }; export const MinSizeTests: ThisStory = { name: "FormFieldArray minSize Tests", parameters: { chromatic: { disableSnapshot: true } }, render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Add guest" })); await expect(canvas.getByLabelText("Guests 1")).toBeVisible(); await userEvent.click(canvas.getByRole("button", { name: "Add guest" })); await expect(canvas.getByLabelText("Guests 2")).toBeVisible(); await userEvent.click(canvas.getByRole("button", { name: "Add guest" })); await expect(canvas.getByLabelText("Guests 3")).toBeVisible(); await expect(canvas.queryByLabelText("Guests 4")).toBeNull(); }, }; export const MultiFieldTests: ThisStory = { name: "FormFieldArray multi-field Tests", parameters: { chromatic: { disableSnapshot: true } }, render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); await userEvent.click(canvas.getByRole("button", { name: "Add person" })); await expect(canvas.getByLabelText("People 1 first name")).toBeVisible(); await expect(canvas.getByLabelText("People 1 last name")).toBeVisible(); await userEvent.click(canvas.getByRole("button", { name: "Add person" })); await expect(canvas.getByLabelText("People 2 first name")).toBeVisible(); await expect(canvas.getByLabelText("People 2 last name")).toBeVisible(); // Removing the second person drops both of its fields. await userEvent.click( canvas.getByRole("button", { name: "Remove People 2" }), ); await expect(canvas.queryByLabelText("People 2 first name")).toBeNull(); await expect(canvas.queryByLabelText("People 2 last name")).toBeNull(); }, }; export const RawStringTests: ThisStory = { name: "FormFieldArray raw-string Tests", parameters: { chromatic: { disableSnapshot: true } }, render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const addButton = canvas.getByRole("button", { name: "Add guest" }); // Empty list: adding the first row is allowed. await userEvent.click(addButton); const guest = canvas.getByLabelText("Guests 1"); // The new row is empty, so adding another is disabled until it's filled. await expect(addButton).toBeDisabled(); await userEvent.type(guest, "Grace Hopper"); await expect(guest).toHaveDisplayValue("Grace Hopper"); await expect(addButton).toBeEnabled(); await userEvent.click(addButton); await expect(canvas.getByLabelText("Guests 2")).toHaveDisplayValue(""); await expect(addButton).toBeDisabled(); }, };