1
2
17(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
18import { act } from "react";
19import { createRoot, type Root } from "react-dom/client";
20import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21
22const mocks = vi.hoisted(() => {
23 const state: { errors: Record<string, { message?: string }> } = { errors: {} };
24 return {
25 state,
26 resolver: undefined as
27 | ((values: unknown, ctx: unknown, options: unknown) => Promise<{ values?: unknown; errors?: Record<string, unknown> }>)
28 | undefined,
29 submit: undefined as ((values: Record<string, string>) => Promise<boolean>) | undefined,
30 onValid: undefined as ((values: Record<string, string>) => void) | undefined,
31 push: vi.fn(),
32 toast: vi.fn(),
33 apply: vi.fn(async () => ({ id: "new-page-1", title: "Applied" }))
34 };
35});
36
37
38function normalize(errs: Record<string, unknown> | undefined) {
39 const out: Record<string, { message?: string }> = {};
40 for (const [k, v] of Object.entries(errs ?? {})) {
41 if (typeof v === "string") out[k] = { message: v };
42 else if (Array.isArray(v) && typeof v[0] === "string") out[k] = { message: v[0] };
43 else if (v && typeof v === "object" && "message" in (v as object))
44 out[k] = { message: String((v as { message: unknown }).message) };
45 }
46 return out;
47}
48
49vi.mock("react-hook-form", () => ({
50 useForm: (config: { resolver?: unknown }) => {
51 if (config?.resolver) mocks.resolver = config.resolver as typeof mocks.resolver;
52
53
54
55 const handleSubmit = (onValid: (values: Record<string, string>) => void) => {
56 mocks.onValid = onValid;
57 const fn = async (values?: Record<string, string>) => {
58 const v = values ?? {};
59 if (!mocks.resolver) throw new Error("no resolver captured");
60 const res = await mocks.resolver(v, {}, {});
61
62
63 if (res.errors && Object.keys(res.errors).length > 0) {
64 mocks.state.errors = normalize(res.errors);
65 return false;
66 }
67 mocks.state.errors = {};
68 onValid(v);
69 return true;
70 };
71 mocks.submit = fn;
72 return fn;
73 };
74 return {
75 register: (name: string) => ({ name }),
76 handleSubmit,
77 formState: mocks.state
78 };
79 }
80}));
81vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.push }) }));
82vi.mock("@/lib/api", () => ({ templateApi: { apply: mocks.apply } }));
83vi.mock("@/stores/ui-store", () => ({ toast: mocks.toast }));
84vi.mock("@tanstack/react-query", () => ({ useQueryClient: () => ({ invalidateQueries: vi.fn() }) }));
85
86import { TemplateVariableDialog } from "../template-variable-dialog";
87
88let container: HTMLDivElement;
89let root: Root;
90
91beforeEach(() => {
92 document.body.innerHTML = "";
93 container = document.createElement("div");
94 document.body.appendChild(container);
95 root = createRoot(container);
96 mocks.push.mockReset();
97 mocks.toast.mockReset();
98 mocks.apply.mockReset().mockResolvedValue({ id: "new-page-1", title: "Applied" });
99 mocks.resolver = undefined;
100 mocks.submit = undefined;
101 mocks.onValid = undefined;
102 mocks.state.errors = {};
103});
104
105afterEach(() => {
106 act(() => root.unmount());
107 container.remove();
108});
109
110async function render(tpl: { id: string; name: string; variables: unknown }) {
111 root.render(
112 <TemplateVariableDialog
113 template={tpl as never}
114 workspaceId="ws1"
115 workspaceSlug="myws"
116 onClose={() => {}}
117 />
118 );
119 await act(async () => {
120 await new Promise((r) => setTimeout(r, 20));
121 });
122}
123
124
125async function errorsFor(values: Record<string, string>) {
126 if (!mocks.resolver) throw new Error("resolver not captured");
127 const res = await mocks.resolver(values, {}, {});
128 return normalize(res.errors);
129}
130
131describe("TemplateVariableDialog (T-142)", () => {
132 it("required text: empty → 'Required'; valid submit → apply(values) + navigate", async () => {
133 await render({
134 id: "tpl-1",
135 name: "Standup",
136 variables: [
137 { name: "project", label: "Project", type: "text", required: true },
138 { name: "owner", label: "Owner", type: "text" }
139 ]
140 });
141
142 expect(document.querySelector("#tpl-var-project")).toBeTruthy();
143 expect(document.querySelector("#tpl-var-owner")).toBeTruthy();
144
145 expect((await errorsFor({ project: "", owner: "" }))["project"]?.message).toBe("Required");
146 expect(Object.keys(await errorsFor({ project: "Atlas", owner: "Ada" }))).toHaveLength(0);
147
148
149
150
151 await act(async () => {
152 const btn = [...document.querySelectorAll("button")].find((b) => b.textContent === "Create page");
153 btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
154 });
155 expect(mocks.submit).toBeTruthy();
156 await act(async () => {
157 await mocks.submit!({ project: "Atlas", owner: "Ada" });
158 });
159 expect(mocks.apply).toHaveBeenCalledWith("tpl-1", {
160 workspaceId: "ws1",
161 title: "Standup",
162 values: { project: "Atlas", owner: "Ada" }
163 });
164 expect(mocks.push).toHaveBeenCalledWith("/w/myws/page/new-page-1");
165 });
166
167 it("number: 'abc' rejected 'Must be a number'; '3' accepted (stays a string)", async () => {
168 await render({
169 id: "tpl-1",
170 name: "Incident",
171 variables: [{ name: "sev", label: "Severity", type: "number", required: true }]
172 });
173
174 expect((await errorsFor({ sev: "abc" }))["sev"]?.message).toBe("Must be a number");
175 expect(Object.keys(await errorsFor({ sev: "3" }))).toHaveLength(0);
176
177
178
179 await act(async () => {
180 const btn = [...document.querySelectorAll("button")].find((b) => b.textContent === "Create page");
181 btn?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
182 });
183 await act(async () => {
184 await mocks.submit!({ sev: "3" });
185 });
186 expect(mocks.apply).toHaveBeenCalledWith("tpl-1", expect.objectContaining({ values: { sev: "3" } }));
187 });
188
189 it("select: value outside options rejected; valid option + empty optional pass", async () => {
190 await render({
191 id: "tpl-1",
192 name: "SOP",
193 variables: [
194 { name: "env", label: "Environment", type: "select", options: ["dev", "prod"], required: true },
195 { name: "tag", label: "Tag", type: "select", options: ["a", "b"] }
196 ]
197 });
198
199 expect(Object.keys(await errorsFor({ env: "staging", tag: "" })).length).toBeGreaterThan(0);
200 expect((await errorsFor({ env: "staging", tag: "" }))["env"]?.message).toBeTruthy();
201 expect(Object.keys(await errorsFor({ env: "prod", tag: "a" }))).toHaveLength(0);
202
203 expect(Object.keys(await errorsFor({ env: "prod", tag: "" }))).toHaveLength(0);
204 });
205
206 it("date: non-ISO rejected; ISO date passes", async () => {
207 await render({
208 id: "tpl-1",
209 name: "Retro",
210 variables: [{ name: "date", label: "Date", type: "date", required: true }]
211 });
212
213 expect((await errorsFor({ date: "12/31/2026" }))["date"]?.message).toBe("Must be a date (YYYY-MM-DD)");
214 expect(Object.keys(await errorsFor({ date: "2026-12-31" }))).toHaveLength(0);
215 });
216
217 it("malformed variables (select without options) → no inputs, refusal message", async () => {
218 await render({
219 id: "tpl-1",
220 name: "Broken",
221 variables: [{ name: "x", label: "X", type: "select" }]
222 });
223 expect(document.querySelector("#tpl-var-x")).toBeNull();
224 expect(document.body.textContent).toContain("malformed");
225 });
226});
227