// @vitest-environment jsdom /** * T-142 — TemplateVariableDialog: the dynamic zod schema (built from the * template's typed `variables`) + the apply-with-values flow. * * The schema is the testable core — it mirrors the server's * `resolveTemplateValues` (packages/shared/template-vars.ts): required / * number / select rules, values stay STRINGS (the server coerces). * We mock `react-hook-form` to capture (a) the resolver the dialog built * (the REAL `zodResolver` over its REAL schema — jsdom + RHF native-event * registration is flaky and not what's under test) and (b) the * `handleSubmit` wrapper, so the apply flow (onSubmit → `apply(values)` → * navigate) is exercised exactly as the dialog wires it. Resolver errors are * normalized to RHF's `{ [field]: { message } }` shape (the resolver's raw * shape varies by zod major; RHF does this normalization internally). */ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true; import { act } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => { const state: { errors: Record } = { errors: {} }; return { state, resolver: undefined as | ((values: unknown, ctx: unknown, options: unknown) => Promise<{ values?: unknown; errors?: Record }>) | undefined, submit: undefined as ((values: Record) => Promise) | undefined, onValid: undefined as ((values: Record) => void) | undefined, push: vi.fn(), toast: vi.fn(), apply: vi.fn(async () => ({ id: "new-page-1", title: "Applied" })) }; }); /** Normalize a resolver's raw error map to RHF's `{ [field]: { message } }`. */ function normalize(errs: Record | undefined) { const out: Record = {}; for (const [k, v] of Object.entries(errs ?? {})) { if (typeof v === "string") out[k] = { message: v }; else if (Array.isArray(v) && typeof v[0] === "string") out[k] = { message: v[0] }; else if (v && typeof v === "object" && "message" in (v as object)) out[k] = { message: String((v as { message: unknown }).message) }; } return out; } vi.mock("react-hook-form", () => ({ useForm: (config: { resolver?: unknown }) => { if (config?.resolver) mocks.resolver = config.resolver as typeof mocks.resolver; // RHF v7 shape: handleSubmit(onValid) returns an async fn (values?) — // the footer calls it as `handleSubmit(onSubmit)()`. We capture the // returned fn as `mocks.submit` and also run the resolver inside it. const handleSubmit = (onValid: (values: Record) => void) => { mocks.onValid = onValid; // captured the first time the footer wires it const fn = async (values?: Record) => { const v = values ?? {}; if (!mocks.resolver) throw new Error("no resolver captured"); const res = await mocks.resolver(v, {}, {}); // zodResolver returns `{ errors: {} }` on SUCCESS (truthy!), so check // for actual entries. if (res.errors && Object.keys(res.errors).length > 0) { mocks.state.errors = normalize(res.errors); return false; } mocks.state.errors = {}; onValid(v); return true; }; mocks.submit = fn; return fn; }; return { register: (name: string) => ({ name }), handleSubmit, formState: mocks.state }; } })); vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.push }) })); vi.mock("@/lib/api", () => ({ templateApi: { apply: mocks.apply } })); vi.mock("@/stores/ui-store", () => ({ toast: mocks.toast })); vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }) })); import { TemplateVariableDialog } from "../template-variable-dialog"; let container: HTMLDivElement; let root: Root; beforeEach(() => { document.body.innerHTML = ""; container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); mocks.push.mockReset(); mocks.toast.mockReset(); mocks.apply.mockReset().mockResolvedValue({ id: "new-page-1", title: "Applied" }); mocks.resolver = undefined; mocks.submit = undefined; mocks.onValid = undefined; mocks.state.errors = {}; }); afterEach(() => { act(() => root.unmount()); container.remove(); }); async function render(tpl: { id: string; name: string; variables: unknown }) { root.render( {}} /> ); await act(async () => { await new Promise((r) => setTimeout(r, 20)); }); } /** Run the captured resolver; return the normalized error map ({} = valid). */ async function errorsFor(values: Record) { if (!mocks.resolver) throw new Error("resolver not captured"); const res = await mocks.resolver(values, {}, {}); return normalize(res.errors); } describe("TemplateVariableDialog (T-142)", () => { it("required text: empty → 'Required'; valid submit → apply(values) + navigate", async () => { await render({ id: "tpl-1", name: "Standup", variables: [ { name: "project", label: "Project", type: "text", required: true }, { name: "owner", label: "Owner", type: "text" } ] }); // Both inputs rendered, registered under their token names. expect(document.querySelector("#tpl-var-project")).toBeTruthy(); expect(document.querySelector("#tpl-var-owner")).toBeTruthy(); expect((await errorsFor({ project: "", owner: "" }))["project"]?.message).toBe("Required"); expect(Object.keys(await errorsFor({ project: "Atlas", owner: "Ada" }))).toHaveLength(0); // The dialog's onSubmit path: clicking "Create page" wires // handleSubmit(onSubmit); the returned fn (mocks.submit) then runs the // resolver + calls the captured onValid with the values. await act(async () => { const btn = [...document.querySelectorAll("button")].find((b) => b.textContent === "Create page"); btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(mocks.submit).toBeTruthy(); await act(async () => { await mocks.submit!({ project: "Atlas", owner: "Ada" }); }); expect(mocks.apply).toHaveBeenCalledWith("tpl-1", { workspaceId: "ws1", title: "Standup", values: { project: "Atlas", owner: "Ada" } }); expect(mocks.push).toHaveBeenCalledWith("/w/myws/page/new-page-1"); }); it("number: 'abc' rejected 'Must be a number'; '3' accepted (stays a string)", async () => { await render({ id: "tpl-1", name: "Incident", variables: [{ name: "sev", label: "Severity", type: "number", required: true }] }); expect((await errorsFor({ sev: "abc" }))["sev"]?.message).toBe("Must be a number"); expect(Object.keys(await errorsFor({ sev: "3" }))).toHaveLength(0); // Click "Create page" (wires handleSubmit), then drive the captured fn // with the valid value (jsdom can't fill an input for us). await act(async () => { const btn = [...document.querySelectorAll("button")].find((b) => b.textContent === "Create page"); btn?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); await act(async () => { await mocks.submit!({ sev: "3" }); }); expect(mocks.apply).toHaveBeenCalledWith("tpl-1", expect.objectContaining({ values: { sev: "3" } })); }); it("select: value outside options rejected; valid option + empty optional pass", async () => { await render({ id: "tpl-1", name: "SOP", variables: [ { name: "env", label: "Environment", type: "select", options: ["dev", "prod"], required: true }, { name: "tag", label: "Tag", type: "select", options: ["a", "b"] } ] }); expect(Object.keys(await errorsFor({ env: "staging", tag: "" })).length).toBeGreaterThan(0); expect((await errorsFor({ env: "staging", tag: "" }))["env"]?.message).toBeTruthy(); expect(Object.keys(await errorsFor({ env: "prod", tag: "a" }))).toHaveLength(0); // An optional select accepts the empty value. expect(Object.keys(await errorsFor({ env: "prod", tag: "" }))).toHaveLength(0); }); it("date: non-ISO rejected; ISO date passes", async () => { await render({ id: "tpl-1", name: "Retro", variables: [{ name: "date", label: "Date", type: "date", required: true }] }); expect((await errorsFor({ date: "12/31/2026" }))["date"]?.message).toBe("Must be a date (YYYY-MM-DD)"); expect(Object.keys(await errorsFor({ date: "2026-12-31" }))).toHaveLength(0); }); it("malformed variables (select without options) → no inputs, refusal message", async () => { await render({ id: "tpl-1", name: "Broken", variables: [{ name: "x", label: "X", type: "select" }] }); expect(document.querySelector("#tpl-var-x")).toBeNull(); expect(document.body.textContent).toContain("malformed"); }); });