feat: add form and page platform

This commit is contained in:
donghyeon-ka
2026-07-26 15:22:52 +09:00
parent fdcf0de5bf
commit b327d7370b
54 changed files with 2036 additions and 122 deletions
+199
View File
@@ -0,0 +1,199 @@
// @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();
});
});
+92
View File
@@ -0,0 +1,92 @@
// @vitest-environment jsdom
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import {
CollectionPage,
DetailPage,
FormPage,
StandardPage,
StatusPage,
} from "../../src/presentation/templates/index.js";
describe("page template slot contracts", () => {
it("renders StandardPage minimum and full landmarks with one h1", () => {
const { rerender } = render(
<StandardPage heading={{ title: "Minimum" }}>Content</StandardPage>,
);
expect(screen.getByRole("heading", { level: 1, name: "Minimum" })).toBeVisible();
rerender(
<StandardPage
heading={{ title: "Full", description: "Long heading contract" }}
breadcrumb={<a href="/">Home</a>}
status={<span>Ready</span>}
actions={[
{ kind: "button", label: "Action", onAction: () => {} },
]}
notices={<p>Notice</p>}
feedback={<p role="status">Refreshing</p>}
aside={<p>Aside</p>}
>
Content
</StandardPage>,
);
expect(screen.getAllByRole("heading", { level: 1 })).toHaveLength(1);
expect(screen.getByRole("navigation", { name: "현재 위치" })).toBeVisible();
expect(screen.getByRole("complementary", { name: "관련 정보" })).toBeVisible();
});
it("places collection, detail and form state in stable slots", () => {
const { rerender } = render(
<CollectionPage
heading={{ title: "Collection" }}
toolbar={<button type="button">Filter</button>}
resultCount="12 results"
pagination={<a href="?page=2">Next</a>}
>
Results
</CollectionPage>,
);
expect(screen.getByRole("region", { name: "검색과 필터" })).toBeVisible();
expect(screen.getByRole("navigation", { name: "페이지 탐색" })).toBeVisible();
rerender(
<DetailPage
heading={{ title: "Detail" }}
metadata={<dl><dt>ID</dt><dd>1</dd></dl>}
destructiveAction={<button type="button">Delete</button>}
>
Sections
</DetailPage>,
);
expect(screen.getByRole("region", { name: "요약 정보" })).toBeVisible();
expect(screen.getByRole("region", { name: "위험 작업" })).toBeVisible();
rerender(
<FormPage
heading={{ title: "Form" }}
errorSummary={<p role="alert">Invalid</p>}
fields={<input aria-label="Field" />}
formActions={<button type="button">Save</button>}
/>,
);
expect(screen.getByRole("alert")).toBeVisible();
expect(screen.getByRole("textbox", { name: "Field" })).toBeVisible();
});
it("renders safe status variants without raw failure values", () => {
render(
<StatusPage
variant="offline"
heading={{ title: "Offline", description: "Safe recovery copy" }}
primaryAction={{ kind: "button", label: "Retry", onAction: () => {} }}
supportReference="SAFE-123"
/>,
);
expect(screen.getByRole("heading", { level: 1, name: "Offline" })).toBeVisible();
expect(screen.getByText("SAFE-123")).toBeVisible();
expect(document.body).not.toHaveTextContent("stack");
});
});
+4 -3
View File
@@ -2,9 +2,10 @@ import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test";
import { ROUTE_REGISTRY } from "../../src/features/installed-feature-contracts.js";
for (const route of Object.values(ROUTE_REGISTRY).map((definition) =>
definition.path === "*" ? "/not-found" : definition.path,
)) {
for (const route of Object.values(ROUTE_REGISTRY).map((definition) => {
if (definition.path === "*") return "/not-found";
return definition.path.replace(":resourceId", "reference-1");
})) {
test(`@a11y ${route} has no critical or serious axe violations`, async ({
page,
}) => {
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "@playwright/test";
/** @param {import("@playwright/test").Page} page */
async function openReferenceForm(page) {
await page.goto("/examples/reference-resources/new");
await page.getByRole("button", { name: "로그인 시작" }).click();
await expect(
page.getByRole("heading", { name: "Reference resource 만들기" }),
).toBeVisible();
}
test("validates a reference form and focuses the first invalid field", async ({
page,
}) => {
await openReferenceForm(page);
await page.getByRole("button", { name: "저장" }).click();
const firstField = page.getByRole("textbox", { name: /새 항목 이름/ });
await expect(firstField).toBeFocused();
await expect(firstField).toHaveAttribute("aria-invalid", "true");
await expect(page.getByRole("alert")).toContainText("입력 내용을 확인해 주세요.");
});
test("guards dirty cancellation and restores focus when writing continues", async ({
page,
}) => {
await openReferenceForm(page);
await page
.getByRole("textbox", { name: /새 항목 이름/ })
.fill("Unsaved reference");
const cancel = page.getByRole("button", { name: "취소" });
await cancel.click();
await expect(
page.getByRole("dialog", { name: "저장하지 않은 변경이 있습니다." }),
).toBeVisible();
await page.getByRole("button", { name: "계속 작성" }).click();
await expect(cancel).toBeFocused();
await expect(page).toHaveURL(/\/examples\/reference-resources\/new$/);
});
test("keeps the form template within a 320px viewport", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 720 });
await openReferenceForm(page);
const viewport = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(viewport.scrollWidth).toBeLessThanOrEqual(viewport.clientWidth);
});
@@ -82,10 +82,14 @@ describe("reference feature boundary contracts", () => {
it("owns route, operation and query contributions in one removable contract", () => {
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.routes)).toEqual([
"REFERENCE_RESOURCE_LIST",
"REFERENCE_RESOURCE_DETAIL",
"REFERENCE_RESOURCE_FORM",
"REFERENCE_RESOURCE_STATUS",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.apiOperations)).toEqual([
"LIST_REFERENCE_RESOURCES",
"CREATE_REFERENCE_RESOURCE",
"GET_REFERENCE_RESOURCE",
]);
expect(Object.keys(REFERENCE_FEATURE_CONTRACT.queryRegistry)).toEqual([
"REFERENCE_RESOURCE",
@@ -58,6 +58,14 @@ function inputWith(
createdAtLabel: null,
},
}),
getResource: async (resourceId) => ({
ok: true,
value: {
resourceId,
title: "Detail",
createdAtLabel: null,
},
}),
...overrides,
};
}
@@ -129,7 +137,7 @@ describe("reference feature page states", () => {
);
});
it("deduplicates optimistic create and rolls back a conflict", async () => {
it("deduplicates create, preserves input and surfaces a conflict", async () => {
const user = userEvent.setup();
let finish:
| ((result: ReferenceResult<ReferenceResourceView>) => void)
@@ -141,31 +149,22 @@ describe("reference feature page states", () => {
}),
);
renderReference(
inputWith({
listResources: async () => ({
ok: true,
value: [
{
resourceId: "existing",
title: "Existing",
createdAtLabel: null,
},
],
}),
createResource,
}),
inputWith({ createResource }),
"/examples/reference-resources/new",
);
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Conflicting");
const submit = screen.getByRole("button", { name: "추가" });
await screen.findByRole("heading", {
name: "Reference resource 만들기",
});
await user.type(
screen.getByRole("textbox", { name: /새 항목 이름/ }),
"Conflicting",
);
await user.type(screen.getByLabelText("설명"), "Keep this input");
const submit = screen.getByRole("button", { name: "저장" });
await user.dblClick(submit);
await waitFor(() => expect(createResource).toHaveBeenCalledOnce());
expect(await screen.findByText("Conflicting")).toHaveAttribute(
"data-optimistic",
"true",
);
expect(screen.getByText("mutation-pending")).toBeVisible();
expect(screen.getByRole("button", { name: "저장 중…" })).toBeDisabled();
finish?.({
ok: false,
@@ -175,14 +174,11 @@ describe("reference feature page states", () => {
0,
),
});
expect(await screen.findByText(/다른 변경과 충돌했습니다/)).toBeVisible();
expect(
await screen.findByRole("button", { name: "충돌 해결" }),
).toBeVisible();
expect(screen.queryByText("Conflicting")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "충돌 해결" }));
await waitFor(() =>
expect(screen.queryByText("mutation-conflict")).not.toBeInTheDocument(),
);
screen.getByRole("textbox", { name: /새 항목 이름/ }),
).toHaveValue("Conflicting");
expect(screen.getByLabelText("설명")).toHaveValue("Keep this input");
});
it("keeps stale data visible during refresh failure and recovers on retry", async () => {
@@ -219,8 +215,7 @@ describe("reference feature page states", () => {
});
renderReference(inputWith({ listResources }));
await screen.findByText("Existing");
await user.type(screen.getByLabelText("새 항목 이름"), "Created");
await user.click(screen.getByRole("button", { name: "추가" }));
await user.click(screen.getByRole("button", { name: "새로고침" }));
expect(await screen.findByText("stale-degraded")).toBeVisible();
expect(screen.getByText("Existing")).toBeVisible();
@@ -46,6 +46,9 @@ const releaseManifest = {
"route-examples-states": "assets/states.js",
"route-examples-auth": "assets/auth.js",
"route-reference-resources": "assets/reference.js",
"route-reference-resource-detail": "assets/reference-detail.js",
"route-reference-resource-form": "assets/reference-form.js",
"route-reference-resource-status": "assets/reference-status.js",
"route-not-found": "assets/not-found.js",
},
};
@@ -120,8 +123,14 @@ describe("reference feature production vertical path", () => {
"?limit=5&tags=open&tags=new",
);
await user.type(screen.getByLabelText("새 항목 이름"), " Created ");
await user.click(screen.getByRole("button", { name: "추가" }));
await user.click(screen.getByRole("button", { name: "새 항목 만들기" }));
await user.type(
await screen.findByRole("textbox", { name: /새 항목 이름/ }),
" Created ",
);
await user.click(screen.getByRole("button", { name: "저장" }));
expect(await screen.findByText("저장했습니다.")).toBeVisible();
await user.click(screen.getByRole("button", { name: "목록으로 돌아가기" }));
expect(await screen.findByText("Created")).toBeVisible();
expect(createRequests).toHaveBeenCalledWith({ name: "Created" });
expect(listRequests.mock.calls.length).toBeGreaterThanOrEqual(2);
@@ -0,0 +1,5 @@
import type { ApplicationApi } from "../../../../src/application/ports/in/application-api.js";
export function ForbiddenTemplate(_props: { application: ApplicationApi }) {
return <main>Template must not select an application use case.</main>;
}
+6
View File
@@ -0,0 +1,6 @@
import type { PageActionDefinition } from "../../../src/presentation/templates/index.js";
export const activeButtonWithoutCallback = {
kind: "button",
label: "Unsafe active action",
} satisfies PageActionDefinition;
+39
View File
@@ -106,6 +106,45 @@ describe("shared HTTP client", () => {
});
});
it("projects only approved 422 issue path and code metadata", async () => {
server.use(
http.post("https://api.test/api/entities", () =>
HttpResponse.json(
{
success: false,
error: {
code: "INVALID_INPUT",
message: "raw backend secret",
details: {
issues: [
{ path: "name", code: "REQUIRED", message: "raw field copy" },
{ path: 42, code: "INVALID" },
],
},
},
meta: { requestId: "request-422", traceId: "trace-422" },
},
{ status: 422 },
),
),
);
const client = testClient({ baseUrl: "https://api.test", clock });
const result = await client.execute("CREATE_ENTITY", {
routeId: "TEST_FORM",
body: { name: "Valid" },
});
expect(result).toMatchObject({
ok: false,
error: {
kind: "VALIDATION_REJECTED",
validationIssues: [{ path: "name", code: "REQUIRED" }],
},
});
expect(JSON.stringify(result)).not.toContain("raw backend secret");
expect(JSON.stringify(result)).not.toContain("raw field copy");
});
it("guards mapper exceptions as UNKNOWN_FAILURE", async () => {
server.use(
http.get("https://api.test/api/entities", () =>