sendou.ink/app/form/SendouForm.browser.test.tsx
Kalle fef1ffc955
Design refresh + a bunch of stuff (#2864)
Co-authored-by: hfcRed <hfcred@gmx.net>
2026-03-19 17:51:42 +02:00

1239 lines
32 KiB
TypeScript

import type { ComponentProps } from "react";
import { createMemoryRouter, RouterProvider } from "react-router";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { userEvent } from "vitest/browser";
import { render } from "vitest-browser-react";
import { z } from "zod";
import labelStyles from "~/components/Label.module.css";
import { FormField } from "./FormField";
import {
array,
checkboxGroup,
fieldset,
radioGroup,
select,
selectOptional,
textAreaOptional,
textAreaRequired,
textFieldOptional,
textFieldRequired,
timeRangeOptional,
toggle as toggleField,
userSearch,
} from "./fields";
import { SendouForm, useFormFieldContext } from "./SendouForm";
let mockFetcherData: { fieldErrors?: Record<string, string> } | undefined;
vi.mock("react-router", async () => {
const actual = await vi.importActual("react-router");
return {
...actual,
useFetcher: () => ({
get data() {
return mockFetcherData;
},
state: "idle",
submit: vi.fn(),
load: vi.fn(),
}),
};
});
function renderForm(
schema: z.ZodObject<z.ZodRawShape>,
options?: {
defaultValues?: Record<string, unknown>;
title?: string;
submitButtonText?: string;
autoSubmit?: boolean;
},
) {
const props: ComponentProps<typeof SendouForm<z.ZodRawShape>> = {
schema,
defaultValues: options?.defaultValues,
title: options?.title,
submitButtonText: options?.submitButtonText,
autoSubmit: options?.autoSubmit,
children: ({ names }) => (
<>
{Object.keys(names).map((name) => (
<FormField key={name} name={name} />
))}
</>
),
};
const router = createMemoryRouter(
[
{
path: "/",
element: <SendouForm {...props} />,
},
],
{ initialEntries: ["/"] },
);
return render(<RouterProvider router={router} />);
}
describe("SendouForm", () => {
beforeEach(() => {
mockFetcherData = undefined;
});
describe("basic form rendering", () => {
test("renders form with title", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, { title: "Test Form" });
await expect.element(screen.getByText("Test Form")).toBeVisible();
});
test("renders submit button with default text", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
await expect
.element(screen.getByRole("button", { name: "Submit" }))
.toBeVisible();
});
test("renders submit button with custom text", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
submitButtonText: "Save Changes",
});
await expect
.element(screen.getByRole("button", { name: "Save Changes" }))
.toBeVisible();
});
test("hides submit button when autoSubmit is true", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, { autoSubmit: true });
const submitButton = screen.container.querySelector(
'button[type="submit"]',
);
expect(submitButton).toBeNull();
});
});
describe("text field", () => {
test("renders with label", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
await expect.element(screen.getByLabelText("Name")).toBeVisible();
});
test("typing updates value", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
const input = screen.getByLabelText("Name");
await userEvent.type(input.element(), "Test Value");
await expect.element(input).toHaveValue("Test Value");
});
test("shows error on blur when required field is empty", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
const input = screen.getByLabelText("Name");
await userEvent.click(input.element());
await userEvent.tab();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
test("shows error on submit when required field is empty", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
test("clears error when valid value is entered after submit", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
const input = screen.getByLabelText("Name");
await userEvent.type(input.element(), "Valid Name");
const errorElement = screen.container.querySelector("#name-error");
expect(errorElement).toBeNull();
});
test("optional text field does not show error when empty", async () => {
const schema = z.object({
bio: textFieldOptional({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
const errorElement = screen.container.querySelector('[id$="-error"]');
expect(errorElement?.textContent).toBeFalsy();
});
test("initializes with default value", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const screen = await renderForm(schema, {
defaultValues: { name: "Default Name" },
});
await expect
.element(screen.getByLabelText("Name"))
.toHaveValue("Default Name");
});
});
describe("text area", () => {
test("renders textarea element", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
const textarea = screen.container.querySelector("textarea");
expect(textarea).not.toBeNull();
});
test("displays value counter showing current/max characters", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 100 }),
});
const screen = await renderForm(schema);
await expect.element(screen.getByText("0/100")).toBeVisible();
});
test("value counter updates as user types", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 100 }),
});
const screen = await renderForm(schema);
const textarea = screen.getByLabelText("Bio");
await expect.element(screen.getByText("0/100")).toBeVisible();
await userEvent.type(textarea.element(), "Hello");
await expect.element(screen.getByText("5/100")).toBeVisible();
});
test("value counter shows warning style near max length", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 10 }),
});
const screen = await renderForm(schema, {
defaultValues: { bio: "123456789" },
});
const counter = screen.container.querySelector(`.${labelStyles.value}`);
expect(counter?.classList.contains(labelStyles.valueWarning)).toBe(true);
});
test("value counter shows error style when over max length", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 5 }),
});
const screen = await renderForm(schema, {
defaultValues: { bio: "123456" },
});
const counter = screen.container.querySelector(`.${labelStyles.value}`);
expect(counter?.classList.contains(labelStyles.valueError)).toBe(true);
});
test("typing updates value", async () => {
const schema = z.object({
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
const textarea = screen.getByLabelText("Bio");
await userEvent.type(textarea.element(), "Test bio content");
await expect.element(textarea).toHaveValue("Test bio content");
});
test("required text area shows error when empty", async () => {
const schema = z.object({
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
});
describe("select field", () => {
test("renders with options from schema", async () => {
const schema = z.object({
format: select({
label: "labels.clockFormat",
items: [
{ label: "options.clockFormat.auto", value: "auto" },
{ label: "options.clockFormat.24h", value: "24h" },
{ label: "options.clockFormat.12h", value: "12h" },
],
}),
});
const screen = await renderForm(schema);
const selectElement = screen.getByLabelText("Clock format");
await expect.element(selectElement).toBeVisible();
const options = screen.container.querySelectorAll("option");
expect(options.length).toBe(3);
});
test("selecting option updates value", async () => {
const schema = z.object({
format: select({
label: "labels.clockFormat",
items: [
{ label: "options.clockFormat.auto", value: "auto" },
{ label: "options.clockFormat.24h", value: "24h" },
{ label: "options.clockFormat.12h", value: "12h" },
],
}),
});
const screen = await renderForm(schema);
const selectElement = screen.getByLabelText("Clock format");
await userEvent.selectOptions(selectElement.element(), "24h");
await expect.element(selectElement).toHaveValue("24h");
});
test("initializes with first option as default", async () => {
const schema = z.object({
format: select({
label: "labels.clockFormat",
items: [
{ label: "options.clockFormat.auto", value: "auto" },
{ label: "options.clockFormat.24h", value: "24h" },
],
}),
});
const screen = await renderForm(schema);
const selectElement = screen.getByLabelText("Clock format");
await expect.element(selectElement).toHaveValue("auto");
});
});
describe("optional select field", () => {
test("allows empty selection", async () => {
const schema = z.object({
format: selectOptional({
label: "labels.clockFormat",
items: [
{ label: "options.clockFormat.auto", value: "auto" },
{ label: "options.clockFormat.24h", value: "24h" },
],
}),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
const errorElement = screen.container.querySelector('[id$="-error"]');
expect(errorElement?.textContent).toBeFalsy();
});
});
describe("toggle/switch field", () => {
test("renders toggle with label", async () => {
const schema = z.object({
noScreen: toggleField({ label: "labels.noScreen" }),
});
const screen = await renderForm(schema);
await expect
.element(screen.getByText("[Accessibility] Avoid Splattercolor Screen"))
.toBeVisible();
});
test("clicking toggles value", async () => {
const schema = z.object({
noScreen: toggleField({ label: "labels.noScreen" }),
});
const screen = await renderForm(schema);
const switchElement = screen.getByRole("switch");
await expect.element(switchElement).not.toBeChecked();
const label = screen.getByText(
"[Accessibility] Avoid Splattercolor Screen",
);
await userEvent.click(label.element());
await expect.element(switchElement).toBeChecked();
});
test("initializes with default value", async () => {
const schema = z.object({
noScreen: toggleField({ label: "labels.noScreen" }),
});
const screen = await renderForm(schema, {
defaultValues: { noScreen: true },
});
await expect.element(screen.getByRole("switch")).toBeChecked();
});
});
describe("radio group field", () => {
test("renders radio options", async () => {
const schema = z.object({
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
{ label: "options.voiceChat.listenOnly", value: "LISTEN_ONLY" },
],
}),
});
const screen = await renderForm(schema);
const radios = screen.container.querySelectorAll('input[type="radio"]');
expect(radios.length).toBe(3);
});
test("clicking option updates value", async () => {
const schema = z.object({
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
],
}),
});
const screen = await renderForm(schema);
const noRadio = screen.getByLabelText("No");
await userEvent.click(noRadio.element());
await expect.element(noRadio).toBeChecked();
});
test("initializes with first option selected", async () => {
const schema = z.object({
vc: radioGroup({
label: "labels.voiceChat",
items: [
{ label: "options.voiceChat.yes", value: "YES" },
{ label: "options.voiceChat.no", value: "NO" },
],
}),
});
const screen = await renderForm(schema);
const yesRadio = screen.getByLabelText("Yes");
await expect.element(yesRadio).toBeChecked();
});
});
describe("checkbox group field", () => {
test("renders checkbox options", async () => {
const schema = z.object({
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
{ label: "modes.TC", value: "TC" },
{ label: "modes.RM", value: "RM" },
{ label: "modes.CB", value: "CB" },
],
}),
});
const screen = await renderForm(schema);
const checkboxes = screen.container.querySelectorAll(
'input[type="checkbox"]',
);
expect(checkboxes.length).toBe(5);
});
test("checking options updates array value", async () => {
const schema = z.object({
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
],
}),
});
const screen = await renderForm(schema);
const twCheckbox = screen.getByLabelText("Turf War");
const szCheckbox = screen.getByLabelText("Splat Zones");
await userEvent.click(twCheckbox.element());
await userEvent.click(szCheckbox.element());
await expect.element(twCheckbox).toBeChecked();
await expect.element(szCheckbox).toBeChecked();
});
test("unchecking option removes from array", async () => {
const schema = z.object({
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
],
}),
});
const screen = await renderForm(schema);
const twCheckbox = screen.getByLabelText("Turf War");
await userEvent.click(twCheckbox.element());
await expect.element(twCheckbox).toBeChecked();
await userEvent.click(twCheckbox.element());
await expect.element(twCheckbox).not.toBeChecked();
});
test("shows error when minimum selections not met", async () => {
const schema = z.object({
modes: checkboxGroup({
label: "labels.buildModes",
items: [
{ label: "modes.TW", value: "TW" },
{ label: "modes.SZ", value: "SZ" },
],
minLength: 1,
}),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
});
describe("validation", () => {
test("validates multiple fields on submit", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
bio: textAreaRequired({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Submit" }).click();
const errors = screen.container.querySelectorAll('[id$="-error"]');
const visibleErrors = Array.from(errors).filter(
(e) => e.textContent === "This field is required",
);
expect(visibleErrors.length).toBe(2);
});
});
describe("default values", () => {
test("initializes multiple fields with default values", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
});
const screen = await renderForm(schema, {
defaultValues: {
name: "Test Name",
bio: "Test Bio",
},
});
await expect
.element(screen.getByLabelText("Name"))
.toHaveValue("Test Name");
await expect
.element(screen.getByLabelText("Bio"))
.toHaveValue("Test Bio");
});
test("falls back to schema initial value when no default provided", async () => {
const schema = z.object({
format: select({
label: "labels.clockFormat",
items: [
{ label: "options.clockFormat.auto", value: "auto" },
{ label: "options.clockFormat.24h", value: "24h" },
],
}),
});
const screen = await renderForm(schema);
await expect
.element(screen.getByLabelText("Clock format"))
.toHaveValue("auto");
});
});
describe("server error fallback", () => {
test("shows fallback error when server returns error for field without DOM element", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
fieldErrors: { hiddenField: "forms:errors.required" },
};
const screen = await renderForm(schema, {
defaultValues: { name: "Test" },
});
await expect
.element(screen.getByText("This field is required (hiddenField)"))
.toBeVisible();
});
test("does not show fallback error when server error has corresponding DOM element", async () => {
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
mockFetcherData = {
fieldErrors: { name: "forms:errors.required" },
};
const screen = await renderForm(schema, {
defaultValues: { name: "Test" },
});
const fallbackError = screen.getByTestId("fallback-form-error");
await expect.element(fallbackError).not.toBeInTheDocument();
});
});
describe("time range field", () => {
test("renders two time inputs", async () => {
const schema = z.object({
times: timeRangeOptional({}),
});
const screen = await renderForm(schema);
const timeInputs =
screen.container.querySelectorAll('input[type="time"]');
expect(timeInputs.length).toBe(2);
});
test("initializes with default value", async () => {
const schema = z.object({
times: timeRangeOptional({}),
});
const screen = await renderForm(schema, {
defaultValues: { times: { start: "09:00", end: "17:00" } },
});
const timeInputs =
screen.container.querySelectorAll('input[type="time"]');
expect((timeInputs[0] as HTMLInputElement).value).toBe("09:00");
expect((timeInputs[1] as HTMLInputElement).value).toBe("17:00");
});
test("updating time input changes value", async () => {
const schema = z.object({
times: timeRangeOptional({}),
});
const screen = await renderForm(schema);
const timeInputs =
screen.container.querySelectorAll('input[type="time"]');
const startInput = timeInputs[0] as HTMLInputElement;
await userEvent.fill(startInput, "10:30");
expect(startInput.value).toBe("10:30");
});
});
describe("onApply callback", () => {
test("calls onApply with form values instead of fetcher.submit", async () => {
const onApply = vi.fn();
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm
schema={schema}
defaultValues={{ name: "Test Value" }}
onApply={onApply}
>
{({ names }) => <FormField name={names.name} />}
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
const screen = await render(<RouterProvider router={router} />);
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).toHaveBeenCalledWith({ name: "Test Value" });
});
test("does not call onApply when validation fails", async () => {
const onApply = vi.fn();
const schema = z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
});
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm schema={schema} onApply={onApply}>
{({ names }) => <FormField name={names.name} />}
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
const screen = await render(<RouterProvider router={router} />);
await screen.getByRole("button", { name: "Submit" }).click();
expect(onApply).not.toHaveBeenCalled();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
});
describe("fieldset field", () => {
test("renders fieldset with legend", async () => {
const schema = z.object({
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
});
const screen = await renderForm(schema);
await expect.element(screen.getByText("Member")).toBeVisible();
});
test("renders nested fields inside fieldset", async () => {
const schema = z.object({
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
bio: textAreaOptional({ label: "labels.bio", maxLength: 500 }),
}),
}),
});
const screen = await renderForm(schema);
await expect.element(screen.getByLabelText("Name")).toBeVisible();
await expect.element(screen.getByLabelText("Bio")).toBeVisible();
});
test("typing in nested field updates value", async () => {
const schema = z.object({
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
});
const screen = await renderForm(schema);
const input = screen.getByLabelText("Name");
await userEvent.type(input.element(), "Test Name");
await expect.element(input).toHaveValue("Test Name");
});
test("initializes nested fields with default values", async () => {
const schema = z.object({
member: fieldset({
label: "labels.member",
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { member: { name: "Default Name" } },
});
await expect
.element(screen.getByLabelText("Name"))
.toHaveValue("Default Name");
});
});
describe("array field with primitive items", () => {
test("renders add button", async () => {
const schema = z.object({
urls: array({
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
}),
});
const screen = await renderForm(schema);
await expect
.element(screen.getByRole("button", { name: "Add" }))
.toBeVisible();
});
test("clicking add creates new item", async () => {
const schema = z.object({
urls: array({
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
}),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Add" }).click();
const inputs = screen.container.querySelectorAll('input[type="text"]');
expect(inputs.length).toBe(1);
});
test("renders remove button for each item when above minimum", async () => {
const schema = z.object({
urls: array({
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
}),
});
const screen = await renderForm(schema, {
defaultValues: { urls: ["http://example.com"] },
});
const removeButtons = screen.container.querySelectorAll(
'button[aria-label="Remove item"]',
);
expect(removeButtons.length).toBe(1);
});
test("clicking remove deletes item", async () => {
const schema = z.object({
urls: array({
label: "labels.urls",
min: 0,
max: 5,
field: textFieldRequired({ maxLength: 100 }),
}),
});
const screen = await renderForm(schema, {
defaultValues: { urls: ["http://example.com", "http://test.com"] },
});
let inputs = screen.container.querySelectorAll('input[type="text"]');
expect(inputs.length).toBe(2);
const removeButtons = screen.container.querySelectorAll(
'button[aria-label="Remove item"]',
);
await userEvent.click(removeButtons[0]);
inputs = screen.container.querySelectorAll('input[type="text"]');
expect(inputs.length).toBe(1);
});
test("disables add button when max items reached", async () => {
const schema = z.object({
urls: array({
label: "labels.urls",
min: 0,
max: 2,
field: textFieldRequired({ maxLength: 100 }),
}),
});
const screen = await renderForm(schema, {
defaultValues: { urls: ["http://a.com", "http://b.com"] },
});
const addButton = screen.getByRole("button", { name: "Add" });
await expect.element(addButton).toBeDisabled();
});
});
describe("array field with fieldset items", () => {
test("renders array items as fieldsets", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "Alice" }] },
});
await expect.element(screen.getByText("#1")).toBeVisible();
await expect.element(screen.getByLabelText("Name")).toHaveValue("Alice");
});
test("add button creates new fieldset item", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema);
await screen.getByRole("button", { name: "Add" }).click();
await expect.element(screen.getByText("#1")).toBeVisible();
});
test("remove button removes fieldset item", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "Alice" }, { name: "Bob" }] },
});
const removeButtons = screen.container.querySelectorAll(
'button[aria-label="Remove item"]',
);
expect(removeButtons.length).toBe(2);
await userEvent.click(removeButtons[0]);
const inputs = screen.container.querySelectorAll('input[type="text"]');
expect(inputs.length).toBe(1);
expect((inputs[0] as HTMLInputElement).value).toBe("Bob");
});
test("typing in nested fieldset field updates value", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 0,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "" }] },
});
const input = screen.getByLabelText("Name");
await userEvent.type(input.element(), "New Name");
await expect.element(input).toHaveValue("New Name");
});
test("shows error on specific nested field within array item", async () => {
const schema = z.object({
series: array({
label: "labels.orgSeries",
min: 1,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
description: textAreaOptional({
label: "labels.description",
maxLength: 500,
}),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { series: [{ name: "", description: "some text" }] },
});
await screen.getByRole("button", { name: "Submit" }).click();
const nameInput = screen.getByLabelText("Name");
await expect.element(nameInput).toHaveAttribute("aria-invalid", "true");
});
test("shows 'This field is required' for empty required field in array fieldset", async () => {
const schema = z.object({
series: array({
label: "labels.orgSeries",
min: 1,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldRequired({ label: "labels.name", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { series: [{ name: "" }] },
});
await screen.getByRole("button", { name: "Submit" }).click();
await expect
.element(screen.getByText("This field is required"))
.toBeVisible();
});
test("setItemField batches multiple field updates correctly", async () => {
const schema = z.object({
members: array({
label: "labels.members",
min: 1,
max: 10,
field: fieldset({
fields: z.object({
name: textFieldOptional({ label: "labels.name", maxLength: 100 }),
bio: textFieldOptional({ label: "labels.bio", maxLength: 100 }),
}),
}),
}),
});
const screen = await renderForm(schema, {
defaultValues: { members: [{ name: "", bio: "" }] },
});
const inputA = screen.getByLabelText("Name");
const inputB = screen.getByLabelText("Bio");
await userEvent.type(inputA.element(), "Value A");
await userEvent.type(inputB.element(), "Value B");
await expect.element(inputA).toHaveValue("Value A");
await expect.element(inputB).toHaveValue("Value B");
});
});
describe("array field item removal preserves remaining items", () => {
test("removing a middle member preserves userSearch values of members below", async () => {
let latestValues: Record<string, unknown> = {};
function ValueCapture() {
const ctx = useFormFieldContext();
latestValues = ctx.values;
return null;
}
const schema = z.object({
members: array({
label: "labels.members",
max: 10,
field: fieldset({
fields: z.object({
userId: userSearch({ label: "labels.user" }),
role: select({
label: "labels.orgMemberRole",
items: [
{ label: "options.orgRole.ADMIN", value: "ADMIN" },
{ label: "options.orgRole.MEMBER", value: "MEMBER" },
],
}),
}),
}),
}),
});
const defaultValues = {
members: [
{ userId: 10, role: "ADMIN" },
{ userId: 20, role: "MEMBER" },
{ userId: 30, role: "MEMBER" },
{ userId: 40, role: "MEMBER" },
{ userId: 50, role: "MEMBER" },
],
};
const router = createMemoryRouter(
[
{
path: "/",
element: (
<SendouForm schema={schema} defaultValues={defaultValues}>
{({ names }) => (
<>
<FormField name={names.members} />
<ValueCapture />
</>
)}
</SendouForm>
),
},
],
{ initialEntries: ["/"] },
);
const screen = await render(<RouterProvider router={router} />);
// Verify initial state - 5 members rendered
const removeButtons = screen.container.querySelectorAll(
'button[aria-label="Remove item"]',
);
expect(removeButtons.length).toBe(5);
// Remove the 3rd member (index 2, userId: 30)
await userEvent.click(removeButtons[2]);
// Wait for React effects to settle
await new Promise((resolve) => setTimeout(resolve, 200));
const members = latestValues.members as Array<{
userId: number | null;
role: string;
}>;
expect(members).toHaveLength(4);
expect(members[0].userId).toBe(10);
expect(members[1].userId).toBe(20);
// Bug: UserSearch cleanup effect clears userId for shifted items
expect(members[2].userId).toBe(40);
expect(members[3].userId).toBe(50);
});
});
});