200 lines
6.9 KiB
TypeScript
200 lines
6.9 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { render, screen, waitFor } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { useState } from "react";
|
|
import { createMemoryRouter, RouterProvider, useNavigate } from "react-router-dom";
|
|
import { z } from "zod";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { createFailure } from "../../src/contracts/errors.js";
|
|
import { Button } from "../../src/presentation/components/ui/button.jsx";
|
|
import {
|
|
DirtyNavigationDialog,
|
|
ErrorSummary,
|
|
Form,
|
|
FormField,
|
|
useAppForm,
|
|
useDirtyNavigationGuard,
|
|
} from "../../src/presentation/forms/index.js";
|
|
|
|
type Values = Readonly<Record<"name" | "note", string>>;
|
|
|
|
const schema = z
|
|
.object({
|
|
name: z.string().trim().min(2),
|
|
note: z.string().trim().default(""),
|
|
})
|
|
.strict();
|
|
const defaults: Values = { name: "", note: "" };
|
|
|
|
function FormHarness(props: Readonly<{
|
|
submit(command: Readonly<{ name: string; note?: string }>): Promise<
|
|
| Readonly<{ ok: true; value: string }>
|
|
| Readonly<{ ok: false; error: ReturnType<typeof createFailure> }>
|
|
>;
|
|
}>) {
|
|
const form = useAppForm({
|
|
schema,
|
|
defaultValues: defaults,
|
|
allowedServerFields: ["name", "note"],
|
|
mapToCommand(values) {
|
|
return {
|
|
name: values.name,
|
|
...(values.note ? { note: values.note } : {}),
|
|
};
|
|
},
|
|
submit: props.submit,
|
|
});
|
|
return (
|
|
<Form pending={form.pending} onSubmit={(event) => void form.submitForm(event)}>
|
|
<ErrorSummary
|
|
fieldErrors={form.fieldErrors}
|
|
formErrors={form.formErrors}
|
|
fieldLabels={{ name: "Name", note: "Note" }}
|
|
fieldId={form.fieldId}
|
|
onFocusField={form.focusField}
|
|
/>
|
|
<FormField {...form.field("name")} label="Name" required />
|
|
<FormField {...form.field("note")} label="Note" />
|
|
<Button type="submit" disabled={form.pending}>
|
|
{form.pending ? "Pending" : "Submit"}
|
|
</Button>
|
|
<Button onClick={() => form.reset()} disabled={!form.dirty}>
|
|
Reset
|
|
</Button>
|
|
<output data-testid="dirty">{String(form.dirty)}</output>
|
|
<output data-testid="result">{form.result}</output>
|
|
</Form>
|
|
);
|
|
}
|
|
|
|
describe("local form facade", () => {
|
|
it("focuses the first invalid field and performs no command", async () => {
|
|
const user = userEvent.setup();
|
|
const submit = vi.fn();
|
|
render(<FormHarness submit={submit} />);
|
|
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
|
|
expect(submit).not.toHaveBeenCalled();
|
|
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveFocus();
|
|
expect(screen.getByRole("alert")).toHaveTextContent("Name");
|
|
});
|
|
|
|
it("submits transformed data once and clears dirty state after success", async () => {
|
|
const user = userEvent.setup();
|
|
let finish: ((value: { ok: true; value: string }) => void) | undefined;
|
|
const submit = vi.fn(
|
|
() =>
|
|
new Promise<{ ok: true; value: string }>((resolve) => {
|
|
finish = resolve;
|
|
}),
|
|
);
|
|
render(<FormHarness submit={submit} />);
|
|
await user.type(screen.getByRole("textbox", { name: /Name/ }), " Ready ");
|
|
await user.type(screen.getByRole("textbox", { name: "Note" }), " Safe ");
|
|
|
|
await user.dblClick(screen.getByRole("button", { name: "Submit" }));
|
|
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
|
|
expect(submit).toHaveBeenCalledWith({ name: "Ready", note: "Safe" });
|
|
expect(screen.getByRole("button", { name: "Pending" })).toBeDisabled();
|
|
finish?.({ ok: true, value: "saved" });
|
|
|
|
await waitFor(() => expect(screen.getByTestId("dirty")).toHaveTextContent("false"));
|
|
expect(screen.getByTestId("result")).toHaveTextContent("success");
|
|
});
|
|
|
|
it("maps only approved 422 fields and never renders backend copy", async () => {
|
|
const user = userEvent.setup();
|
|
const failure = createFailure(
|
|
"VALIDATION_REJECTED",
|
|
"CREATE_ENTITY",
|
|
0,
|
|
{
|
|
validationIssues: [
|
|
{ path: "name", code: "REQUIRED" },
|
|
{ path: "serverOnly", code: "raw-secret-message" },
|
|
],
|
|
},
|
|
);
|
|
render(
|
|
<FormHarness submit={async () => ({ ok: false, error: failure })} />,
|
|
);
|
|
await user.type(screen.getByRole("textbox", { name: /Name/ }), "Valid");
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
|
|
expect(await screen.findByRole("alert")).toHaveTextContent("필수 입력값입니다.");
|
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
|
"서버가 확인하지 못한 입력 항목",
|
|
);
|
|
expect(document.body).not.toHaveTextContent("raw-secret-message");
|
|
});
|
|
|
|
it("keeps conflict input out of URL and storage", async () => {
|
|
const user = userEvent.setup();
|
|
localStorage.clear();
|
|
window.history.replaceState({}, "", "/form-test");
|
|
render(
|
|
<FormHarness
|
|
submit={async () => ({
|
|
ok: false,
|
|
error: createFailure("CONFLICT", "CREATE_ENTITY", 0),
|
|
})}
|
|
/>,
|
|
);
|
|
const secretLike = "token-like-do-not-copy";
|
|
await user.type(screen.getByRole("textbox", { name: /Name/ }), secretLike);
|
|
await user.click(screen.getByRole("button", { name: "Submit" }));
|
|
|
|
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
|
|
expect(screen.getByRole("textbox", { name: /Name/ })).toHaveValue(secretLike);
|
|
expect(window.location.href).not.toContain(secretLike);
|
|
expect(JSON.stringify(localStorage)).not.toContain(secretLike);
|
|
});
|
|
});
|
|
|
|
describe("dirty navigation guard", () => {
|
|
it("blocks navigation, restores focus on stay and proceeds explicitly", async () => {
|
|
const user = userEvent.setup();
|
|
|
|
function GuardedPage() {
|
|
const navigate = useNavigate();
|
|
const [dirty, setDirty] = useState(false);
|
|
const guard = useDirtyNavigationGuard(dirty);
|
|
return (
|
|
<>
|
|
<label htmlFor="guard-field">Guard field</label>
|
|
<input
|
|
id="guard-field"
|
|
onChange={() => setDirty(true)}
|
|
/>
|
|
<Button onClick={() => navigate("/target")}>Leave</Button>
|
|
<DirtyNavigationDialog guard={guard} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
const router = createMemoryRouter(
|
|
[
|
|
{ path: "/", element: <GuardedPage /> },
|
|
{ path: "/target", element: <h1>Target</h1> },
|
|
],
|
|
{ initialEntries: ["/"] },
|
|
);
|
|
render(<RouterProvider router={router} />);
|
|
await user.type(screen.getByRole("textbox", { name: "Guard field" }), "x");
|
|
const leave = screen.getByRole("button", { name: "Leave" });
|
|
await user.click(leave);
|
|
expect(
|
|
screen.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
|
|
).toHaveAttribute("open");
|
|
|
|
await user.click(screen.getByRole("button", { name: "계속 작성" }));
|
|
await waitFor(() => expect(leave).toHaveFocus());
|
|
await user.click(leave);
|
|
await user.click(screen.getByRole("button", { name: "변경 버리고 이동" }));
|
|
expect(await screen.findByRole("heading", { name: "Target" })).toBeVisible();
|
|
});
|
|
});
|