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();
},
};