import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { CalendarRegularIcon } from "alouette-icons/phosphor-icons/Calendar"; import { type ReactNode, useState } from "react"; import { Badge } from "../data/Badge"; import type { EditableItemProps } from "../data/EditableItem"; import { InputText } from "../inputs/InputText"; import { Text } from "../primitives/Text"; import { Story, accentsWithoutNeutral } from "../story-components/Story"; import { FormValidationError } from "./Form"; import { FormEditableItem, type FormEditableItemProps, } from "./FormEditableItem"; import { FormField } from "./FormField"; function submitErrorToMessage(error: unknown): string { if (error instanceof FormValidationError) { return "Please fix the errors above."; } return error instanceof Error ? error.message : "Something went wrong."; } interface ProfileFormValues { displayName: string; } type SummaryVariant = "badge" | "mono" | "muted" | "none" | "text"; interface ValueSummaryProps { variant: SummaryVariant; value: string; } /** `summary` is any node — a Badge is only the most compact option. */ function ValueSummary({ variant, value }: ValueSummaryProps): ReactNode { if (variant === "none") return null; if (variant === "text") return {value}; if (variant === "muted") return {value}; if (variant === "mono") { return {value}; } return {value}; } interface ProfileItemProps extends Partial< Pick< FormEditableItemProps, "accent" | "details" | "disabled" | "label" | "size" | "title" > > { editAriaLabel?: string; editIcon?: EditableItemProps["editIcon"]; variant?: EditableItemProps["variant"]; initialValue?: string; summaryVariant?: SummaryVariant; /** Makes onSubmit reject, so the modal stays open on the error. */ failing?: boolean; } /** * Stateful demo: the saved value drives the summary, so the play function can * tell a discarded edit from a saved one by reading the row. */ function ProfileItem({ label = "Display name", editAriaLabel = "Edit display name", initialValue = "Ada Lovelace", summaryVariant = "badge", failing = false, ...itemProps }: ProfileItemProps): ReactNode { const [displayName, setDisplayName] = useState(initialValue); return ( {...itemProps} label={label} editAriaLabel={editAriaLabel} summary={} cancelLabel="Cancel" submitLabel="Save" closeButtonAriaLabel="Close editor" submitErrorToMessage={submitErrorToMessage} defaultValues={{ displayName }} render={({ control }) => ( ( )} /> )} onSubmit={({ displayName: nextName }) => { if (failing) throw new Error("The server rejected the change."); setDisplayName(nextName); }} /> ); } type ThisStory = StoryObj; export default { title: "alouette/Forms/FormEditableItem", component: ProfileItem, parameters: { componentSubtitle: "An EditableItem whose editor is a modal owning its own Form — mounted per open, so cancelling is a plain unmount and the surrounding screen's state is never touched by an abandoned edit.", }, argTypes: { label: { control: "text" }, title: { control: "text" }, details: { control: "text" }, size: { control: "select", options: ["sm", "md", "lg"] }, accent: { control: "select", options: accentsWithoutNeutral }, variant: { control: "select", options: ["tonal", "filled", "soft"], }, summaryVariant: { control: "select", options: ["badge", "text", "muted", "mono", "none"], }, disabled: { control: "boolean" }, }, } satisfies Meta; export const FormEditableItemPreviewStory: ThisStory = { name: "FormEditableItem Preview", parameters: { layout: "padded", chromatic: { disableSnapshot: true }, }, args: { label: "Display name" }, render: (args) => , }; export const FormEditableItemVariantsStory: ThisStory = { name: "FormEditableItem Variants", render: () => ( } /> {accentsWithoutNeutral.map((accent) => ( ))} ), }; export const FormEditableItemTestsStory: ThisStory = { name: "FormEditableItem Tests", render: () => ( ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // The modal portals outside the canvas, so it is queried from `screen`. // react-native-web's own Modal container also takes role="dialog" once it // becomes the active modal, hence the accessible name on every query. async function openEditor( editLabel: string, title: string, ): Promise { await userEvent.click(canvas.getByLabelText(editLabel)); return screen.findByRole("dialog", { name: title }); } async function expectClosed(): Promise { await waitFor(async () => { await expect(screen.queryAllByRole("dialog")).toHaveLength(0); }); } // Cancelling discards the edit: the row still shows the saved value. const dialog = await openEditor("Edit display name", "Display name"); // The submit button's collapsed error message must not lend its width to // the footer row, which would squeeze Cancel until its label is clipped. const cancel = within(dialog).getByRole("button", { name: "Cancel" }); await expect(cancel.scrollWidth).toBeLessThanOrEqual(cancel.clientWidth); const firstInput = within(dialog).getByLabelText("Name"); await userEvent.clear(firstInput); await userEvent.type(firstInput, "Grace Hopper"); await userEvent.click(cancel); await expectClosed(); await expect(canvas.getByText("Ada Lovelace")).toBeInTheDocument(); // Reopening reseeds from defaultValues — the discarded text is gone. const reopened = await openEditor("Edit display name", "Display name"); const input = within(reopened).getByLabelText("Name"); await expect(input).toHaveValue("Ada Lovelace"); // A successful save closes the modal and updates the row. await userEvent.clear(input); await userEvent.type(input, "Grace Hopper"); await userEvent.click( within(reopened).getByRole("button", { name: "Save" }), ); await expectClosed(); await expect(canvas.getByText("Grace Hopper")).toBeInTheDocument(); // An empty required field keeps the modal open and reports inline. A // rejected submit disables the button for settledDisplayDurationMs, so the // retry path is a fresh modal rather than a wait — Cancel stays live. const invalid = await openEditor("Edit display name", "Display name"); await userEvent.clear(within(invalid).getByLabelText("Name")); await userEvent.click( within(invalid).getByRole("button", { name: "Save" }), ); await expect( await within(invalid).findByText("A name is required."), ).toBeVisible(); await userEvent.click( within(invalid).getByRole("button", { name: "Cancel" }), ); await expectClosed(); // A rejected submit leaves the modal open with the error rendered. const failing = await openEditor("Edit handle", "Handle"); await userEvent.click( within(failing).getByRole("button", { name: "Save" }), ); // The message lives in ActionButton's collapsible wrapper, so visibility // only settles once the expand transition has run. await waitFor(async () => { await expect( within(failing).getByText("The server rejected the change."), ).toBeVisible(); }); await expect( screen.getByRole("dialog", { name: "Handle" }), ).toBeInTheDocument(); }, };