mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
1239 lines
32 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|