@fogpipe/forma-react 0.13.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -9
- package/dist/index.d.ts +90 -1
- package/dist/index.js +274 -50
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/events.test.ts +752 -0
- package/src/events.ts +186 -0
- package/src/index.ts +5 -0
- package/src/types.ts +7 -0
- package/src/useForma.ts +269 -58
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event system tests for @fogpipe/forma-react
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
6
|
+
import { renderHook, act } from "@testing-library/react";
|
|
7
|
+
import { useForma } from "../useForma.js";
|
|
8
|
+
import { FormaEventEmitter } from "../events.js";
|
|
9
|
+
import type { FormaEventMap } from "../events.js";
|
|
10
|
+
import { createTestSpec } from "./test-utils.js";
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// FormaEventEmitter unit tests
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
describe("FormaEventEmitter", () => {
|
|
17
|
+
let emitter: FormaEventEmitter;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
emitter = new FormaEventEmitter();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("should register listener and fire event with correct payload", () => {
|
|
24
|
+
const listener = vi.fn();
|
|
25
|
+
emitter.on("fieldChanged", listener);
|
|
26
|
+
|
|
27
|
+
const payload: FormaEventMap["fieldChanged"] = {
|
|
28
|
+
path: "name",
|
|
29
|
+
value: "John",
|
|
30
|
+
previousValue: "",
|
|
31
|
+
source: "user",
|
|
32
|
+
};
|
|
33
|
+
emitter.fire("fieldChanged", payload);
|
|
34
|
+
|
|
35
|
+
expect(listener).toHaveBeenCalledWith(payload);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should fire multiple listeners in registration order", () => {
|
|
39
|
+
const calls: number[] = [];
|
|
40
|
+
emitter.on("fieldChanged", () => { calls.push(1); });
|
|
41
|
+
emitter.on("fieldChanged", () => { calls.push(2); });
|
|
42
|
+
emitter.on("fieldChanged", () => { calls.push(3); });
|
|
43
|
+
|
|
44
|
+
emitter.fire("fieldChanged", {
|
|
45
|
+
path: "x",
|
|
46
|
+
value: 1,
|
|
47
|
+
previousValue: 0,
|
|
48
|
+
source: "user",
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
expect(calls).toEqual([1, 2, 3]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("should unsubscribe only the target listener", () => {
|
|
55
|
+
const listenerA = vi.fn();
|
|
56
|
+
const listenerB = vi.fn();
|
|
57
|
+
const unsub = emitter.on("fieldChanged", listenerA);
|
|
58
|
+
emitter.on("fieldChanged", listenerB);
|
|
59
|
+
|
|
60
|
+
unsub();
|
|
61
|
+
|
|
62
|
+
emitter.fire("fieldChanged", {
|
|
63
|
+
path: "x",
|
|
64
|
+
value: 1,
|
|
65
|
+
previousValue: 0,
|
|
66
|
+
source: "user",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(listenerA).not.toHaveBeenCalled();
|
|
70
|
+
expect(listenerB).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("should report hasListeners correctly", () => {
|
|
74
|
+
expect(emitter.hasListeners("fieldChanged")).toBe(false);
|
|
75
|
+
|
|
76
|
+
const unsub = emitter.on("fieldChanged", () => {});
|
|
77
|
+
expect(emitter.hasListeners("fieldChanged")).toBe(true);
|
|
78
|
+
|
|
79
|
+
unsub();
|
|
80
|
+
expect(emitter.hasListeners("fieldChanged")).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should not throw when firing with no listeners", () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
emitter.fire("fieldChanged", {
|
|
86
|
+
path: "x",
|
|
87
|
+
value: 1,
|
|
88
|
+
previousValue: 0,
|
|
89
|
+
source: "user",
|
|
90
|
+
});
|
|
91
|
+
}).not.toThrow();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("should catch listener errors without breaking other listeners", () => {
|
|
95
|
+
const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
96
|
+
const goodListener = vi.fn();
|
|
97
|
+
|
|
98
|
+
emitter.on("fieldChanged", () => {
|
|
99
|
+
throw new Error("boom");
|
|
100
|
+
});
|
|
101
|
+
emitter.on("fieldChanged", goodListener);
|
|
102
|
+
|
|
103
|
+
emitter.fire("fieldChanged", {
|
|
104
|
+
path: "x",
|
|
105
|
+
value: 1,
|
|
106
|
+
previousValue: 0,
|
|
107
|
+
source: "user",
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
expect(goodListener).toHaveBeenCalled();
|
|
111
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
112
|
+
consoleSpy.mockRestore();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should await async listeners sequentially in fireAsync", async () => {
|
|
116
|
+
const order: number[] = [];
|
|
117
|
+
|
|
118
|
+
emitter.on("preSubmit", async () => {
|
|
119
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
120
|
+
order.push(1);
|
|
121
|
+
});
|
|
122
|
+
emitter.on("preSubmit", async () => {
|
|
123
|
+
order.push(2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
await emitter.fireAsync("preSubmit", {
|
|
127
|
+
data: {},
|
|
128
|
+
computed: {},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(order).toEqual([1, 2]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should clear all listeners", () => {
|
|
135
|
+
emitter.on("fieldChanged", () => {});
|
|
136
|
+
emitter.on("preSubmit", () => {});
|
|
137
|
+
emitter.clear();
|
|
138
|
+
|
|
139
|
+
expect(emitter.hasListeners("fieldChanged")).toBe(false);
|
|
140
|
+
expect(emitter.hasListeners("preSubmit")).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ============================================================================
|
|
145
|
+
// fieldChanged event tests
|
|
146
|
+
// ============================================================================
|
|
147
|
+
|
|
148
|
+
describe("fieldChanged event", () => {
|
|
149
|
+
it("should fire with correct payload on setFieldValue", async () => {
|
|
150
|
+
const listener = vi.fn();
|
|
151
|
+
const spec = createTestSpec({
|
|
152
|
+
fields: { name: { type: "text" } },
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() =>
|
|
156
|
+
useForma({
|
|
157
|
+
spec,
|
|
158
|
+
initialData: { name: "old" },
|
|
159
|
+
on: { fieldChanged: listener },
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
result.current.setFieldValue("name", "new");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// fieldChanged fires in useEffect (after render)
|
|
168
|
+
expect(listener).toHaveBeenCalledWith({
|
|
169
|
+
path: "name",
|
|
170
|
+
value: "new",
|
|
171
|
+
previousValue: "old",
|
|
172
|
+
source: "user",
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("should have source 'setValues' for setValues", () => {
|
|
177
|
+
const listener = vi.fn();
|
|
178
|
+
const spec = createTestSpec({
|
|
179
|
+
fields: {
|
|
180
|
+
name: { type: "text" },
|
|
181
|
+
age: { type: "number" },
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const { result } = renderHook(() =>
|
|
186
|
+
useForma({
|
|
187
|
+
spec,
|
|
188
|
+
initialData: { name: "old", age: 20 },
|
|
189
|
+
on: { fieldChanged: listener },
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
act(() => {
|
|
194
|
+
result.current.setValues({ name: "new", age: 30 });
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
expect(listener).toHaveBeenCalledWith(
|
|
198
|
+
expect.objectContaining({ path: "name", source: "setValues" }),
|
|
199
|
+
);
|
|
200
|
+
expect(listener).toHaveBeenCalledWith(
|
|
201
|
+
expect.objectContaining({ path: "age", source: "setValues" }),
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("should have source 'reset' for resetForm", () => {
|
|
206
|
+
const listener = vi.fn();
|
|
207
|
+
const spec = createTestSpec({
|
|
208
|
+
fields: { name: { type: "text" } },
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const { result } = renderHook(() =>
|
|
212
|
+
useForma({
|
|
213
|
+
spec,
|
|
214
|
+
initialData: { name: "initial" },
|
|
215
|
+
on: { fieldChanged: listener },
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
// Change value first
|
|
220
|
+
act(() => {
|
|
221
|
+
result.current.setFieldValue("name", "changed");
|
|
222
|
+
});
|
|
223
|
+
listener.mockClear();
|
|
224
|
+
|
|
225
|
+
// Reset
|
|
226
|
+
act(() => {
|
|
227
|
+
result.current.resetForm();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(listener).toHaveBeenCalledWith(
|
|
231
|
+
expect.objectContaining({
|
|
232
|
+
path: "name",
|
|
233
|
+
value: "initial",
|
|
234
|
+
previousValue: "changed",
|
|
235
|
+
source: "reset",
|
|
236
|
+
}),
|
|
237
|
+
);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it("should NOT fire on initial mount", () => {
|
|
241
|
+
const listener = vi.fn();
|
|
242
|
+
const spec = createTestSpec({
|
|
243
|
+
fields: { name: { type: "text" } },
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
renderHook(() =>
|
|
247
|
+
useForma({
|
|
248
|
+
spec,
|
|
249
|
+
initialData: { name: "hello" },
|
|
250
|
+
on: { fieldChanged: listener },
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
expect(listener).not.toHaveBeenCalled();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("should NOT fire when value is set to same value", () => {
|
|
258
|
+
const listener = vi.fn();
|
|
259
|
+
const spec = createTestSpec({
|
|
260
|
+
fields: { name: { type: "text" } },
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
const { result } = renderHook(() =>
|
|
264
|
+
useForma({
|
|
265
|
+
spec,
|
|
266
|
+
initialData: { name: "same" },
|
|
267
|
+
on: { fieldChanged: listener },
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
act(() => {
|
|
272
|
+
result.current.setFieldValue("name", "same");
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
expect(listener).not.toHaveBeenCalled();
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("should work with imperative forma.on()", () => {
|
|
279
|
+
const listener = vi.fn();
|
|
280
|
+
const spec = createTestSpec({
|
|
281
|
+
fields: { name: { type: "text" } },
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const { result } = renderHook(() =>
|
|
285
|
+
useForma({ spec, initialData: { name: "old" } }),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
// Register imperatively
|
|
289
|
+
let unsub: () => void;
|
|
290
|
+
act(() => {
|
|
291
|
+
unsub = result.current.on("fieldChanged", listener);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
act(() => {
|
|
295
|
+
result.current.setFieldValue("name", "new");
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
expect(listener).toHaveBeenCalledWith(
|
|
299
|
+
expect.objectContaining({ path: "name", value: "new" }),
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
// Unsubscribe
|
|
303
|
+
listener.mockClear();
|
|
304
|
+
act(() => {
|
|
305
|
+
unsub();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
act(() => {
|
|
309
|
+
result.current.setFieldValue("name", "newer");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
expect(listener).not.toHaveBeenCalled();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("should not cause infinite loop when setFieldValue called inside handler", () => {
|
|
316
|
+
const spec = createTestSpec({
|
|
317
|
+
fields: {
|
|
318
|
+
name: { type: "text" },
|
|
319
|
+
mirror: { type: "text" },
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
const calls: string[] = [];
|
|
324
|
+
|
|
325
|
+
const { result } = renderHook(() =>
|
|
326
|
+
useForma({
|
|
327
|
+
spec,
|
|
328
|
+
initialData: { name: "", mirror: "" },
|
|
329
|
+
on: {
|
|
330
|
+
fieldChanged: (event) => {
|
|
331
|
+
calls.push(event.path);
|
|
332
|
+
// This would cause infinite loop without recursion guard
|
|
333
|
+
if (event.path === "name") {
|
|
334
|
+
result.current.setFieldValue("mirror", event.value);
|
|
335
|
+
}
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
}),
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
act(() => {
|
|
342
|
+
result.current.setFieldValue("name", "hello");
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Only "name" fires (mirror is blocked by recursion guard)
|
|
346
|
+
expect(calls).toEqual(["name"]);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
// ============================================================================
|
|
351
|
+
// preSubmit event tests
|
|
352
|
+
// ============================================================================
|
|
353
|
+
|
|
354
|
+
describe("preSubmit event", () => {
|
|
355
|
+
it("should fire before validation with current data", async () => {
|
|
356
|
+
const listener = vi.fn();
|
|
357
|
+
const onSubmit = vi.fn();
|
|
358
|
+
const spec = createTestSpec({
|
|
359
|
+
fields: { name: { type: "text" } },
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const { result } = renderHook(() =>
|
|
363
|
+
useForma({
|
|
364
|
+
spec,
|
|
365
|
+
initialData: { name: "John" },
|
|
366
|
+
onSubmit,
|
|
367
|
+
on: { preSubmit: listener },
|
|
368
|
+
}),
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
await act(async () => {
|
|
372
|
+
await result.current.submitForm();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
expect(listener).toHaveBeenCalledWith({
|
|
376
|
+
data: expect.objectContaining({ name: "John" }),
|
|
377
|
+
computed: expect.any(Object),
|
|
378
|
+
});
|
|
379
|
+
expect(listener).toHaveBeenCalledBefore(onSubmit);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("should allow mutating data before validation/submit", async () => {
|
|
383
|
+
const onSubmit = vi.fn();
|
|
384
|
+
const spec = createTestSpec({
|
|
385
|
+
fields: { name: { type: "text" } },
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
const { result } = renderHook(() =>
|
|
389
|
+
useForma({
|
|
390
|
+
spec,
|
|
391
|
+
initialData: { name: "John" },
|
|
392
|
+
onSubmit,
|
|
393
|
+
on: {
|
|
394
|
+
preSubmit: (event) => {
|
|
395
|
+
event.data.token = "injected-token";
|
|
396
|
+
},
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
);
|
|
400
|
+
|
|
401
|
+
await act(async () => {
|
|
402
|
+
await result.current.submitForm();
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
406
|
+
expect.objectContaining({ token: "injected-token" }),
|
|
407
|
+
);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
it("should await async handler", async () => {
|
|
411
|
+
const onSubmit = vi.fn();
|
|
412
|
+
const spec = createTestSpec({
|
|
413
|
+
fields: { name: { type: "text" } },
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
const { result } = renderHook(() =>
|
|
417
|
+
useForma({
|
|
418
|
+
spec,
|
|
419
|
+
initialData: { name: "John" },
|
|
420
|
+
onSubmit,
|
|
421
|
+
on: {
|
|
422
|
+
preSubmit: async (event) => {
|
|
423
|
+
event.data.asyncToken = await Promise.resolve("async-value");
|
|
424
|
+
},
|
|
425
|
+
},
|
|
426
|
+
}),
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
await act(async () => {
|
|
430
|
+
await result.current.submitForm();
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
434
|
+
expect.objectContaining({ asyncToken: "async-value" }),
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("should work without preSubmit listener (backward compat)", async () => {
|
|
439
|
+
const onSubmit = vi.fn();
|
|
440
|
+
const spec = createTestSpec({
|
|
441
|
+
fields: { name: { type: "text" } },
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const { result } = renderHook(() =>
|
|
445
|
+
useForma({ spec, initialData: { name: "John" }, onSubmit }),
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
await act(async () => {
|
|
449
|
+
await result.current.submitForm();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
expect(onSubmit).toHaveBeenCalledWith(
|
|
453
|
+
expect.objectContaining({ name: "John" }),
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// ============================================================================
|
|
459
|
+
// postSubmit event tests
|
|
460
|
+
// ============================================================================
|
|
461
|
+
|
|
462
|
+
describe("postSubmit event", () => {
|
|
463
|
+
it("should fire with success:true after successful submit", async () => {
|
|
464
|
+
const listener = vi.fn();
|
|
465
|
+
const spec = createTestSpec({
|
|
466
|
+
fields: { name: { type: "text" } },
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const { result } = renderHook(() =>
|
|
470
|
+
useForma({
|
|
471
|
+
spec,
|
|
472
|
+
initialData: { name: "John" },
|
|
473
|
+
onSubmit: async () => {},
|
|
474
|
+
on: { postSubmit: listener },
|
|
475
|
+
}),
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
await act(async () => {
|
|
479
|
+
await result.current.submitForm();
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
expect(listener).toHaveBeenCalledWith(
|
|
483
|
+
expect.objectContaining({ success: true }),
|
|
484
|
+
);
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it("should fire with success:false and error when onSubmit throws", async () => {
|
|
488
|
+
const listener = vi.fn();
|
|
489
|
+
const spec = createTestSpec({
|
|
490
|
+
fields: { name: { type: "text" } },
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const { result } = renderHook(() =>
|
|
494
|
+
useForma({
|
|
495
|
+
spec,
|
|
496
|
+
initialData: { name: "John" },
|
|
497
|
+
onSubmit: async () => {
|
|
498
|
+
throw new Error("submit failed");
|
|
499
|
+
},
|
|
500
|
+
on: { postSubmit: listener },
|
|
501
|
+
}),
|
|
502
|
+
);
|
|
503
|
+
|
|
504
|
+
await act(async () => {
|
|
505
|
+
await result.current.submitForm();
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
expect(listener).toHaveBeenCalledWith(
|
|
509
|
+
expect.objectContaining({
|
|
510
|
+
success: false,
|
|
511
|
+
error: expect.any(Error),
|
|
512
|
+
}),
|
|
513
|
+
);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("should fire with validationErrors when validation fails", async () => {
|
|
517
|
+
const listener = vi.fn();
|
|
518
|
+
const spec = createTestSpec({
|
|
519
|
+
fields: { name: { type: "text", required: true } },
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const { result } = renderHook(() =>
|
|
523
|
+
useForma({
|
|
524
|
+
spec,
|
|
525
|
+
onSubmit: async () => {},
|
|
526
|
+
on: { postSubmit: listener },
|
|
527
|
+
}),
|
|
528
|
+
);
|
|
529
|
+
|
|
530
|
+
await act(async () => {
|
|
531
|
+
await result.current.submitForm();
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(listener).toHaveBeenCalledWith(
|
|
535
|
+
expect.objectContaining({
|
|
536
|
+
success: false,
|
|
537
|
+
validationErrors: expect.any(Array),
|
|
538
|
+
}),
|
|
539
|
+
);
|
|
540
|
+
});
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
// ============================================================================
|
|
544
|
+
// formReset event tests
|
|
545
|
+
// ============================================================================
|
|
546
|
+
|
|
547
|
+
describe("formReset event", () => {
|
|
548
|
+
it("should fire after resetForm()", () => {
|
|
549
|
+
const listener = vi.fn();
|
|
550
|
+
const spec = createTestSpec({
|
|
551
|
+
fields: { name: { type: "text" } },
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const { result } = renderHook(() =>
|
|
555
|
+
useForma({
|
|
556
|
+
spec,
|
|
557
|
+
initialData: { name: "initial" },
|
|
558
|
+
on: { formReset: listener },
|
|
559
|
+
}),
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// Change something first to make form dirty
|
|
563
|
+
act(() => {
|
|
564
|
+
result.current.setFieldValue("name", "changed");
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
act(() => {
|
|
568
|
+
result.current.resetForm();
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(listener).toHaveBeenCalled();
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it("should fire fieldChanged before formReset (ordering)", () => {
|
|
575
|
+
const events: string[] = [];
|
|
576
|
+
const spec = createTestSpec({
|
|
577
|
+
fields: { name: { type: "text" } },
|
|
578
|
+
});
|
|
579
|
+
|
|
580
|
+
const { result } = renderHook(() =>
|
|
581
|
+
useForma({
|
|
582
|
+
spec,
|
|
583
|
+
initialData: { name: "initial" },
|
|
584
|
+
on: {
|
|
585
|
+
fieldChanged: () => { events.push("fieldChanged"); },
|
|
586
|
+
formReset: () => { events.push("formReset"); },
|
|
587
|
+
},
|
|
588
|
+
}),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
act(() => {
|
|
592
|
+
result.current.setFieldValue("name", "changed");
|
|
593
|
+
});
|
|
594
|
+
events.length = 0; // clear initial fieldChanged
|
|
595
|
+
|
|
596
|
+
act(() => {
|
|
597
|
+
result.current.resetForm();
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
expect(events).toEqual(["fieldChanged", "formReset"]);
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
// ============================================================================
|
|
605
|
+
// pageChanged event tests
|
|
606
|
+
// ============================================================================
|
|
607
|
+
|
|
608
|
+
describe("pageChanged event", () => {
|
|
609
|
+
const wizardSpec = createTestSpec({
|
|
610
|
+
fields: {
|
|
611
|
+
name: { type: "text" },
|
|
612
|
+
age: { type: "number" },
|
|
613
|
+
email: { type: "text" },
|
|
614
|
+
},
|
|
615
|
+
pages: [
|
|
616
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
617
|
+
{ id: "page2", title: "Page 2", fields: ["age"] },
|
|
618
|
+
{ id: "page3", title: "Page 3", fields: ["email"] },
|
|
619
|
+
],
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("should fire on nextPage()", () => {
|
|
623
|
+
const listener = vi.fn();
|
|
624
|
+
|
|
625
|
+
const { result } = renderHook(() =>
|
|
626
|
+
useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
act(() => {
|
|
630
|
+
result.current.wizard!.nextPage();
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
expect(listener).toHaveBeenCalledWith(
|
|
634
|
+
expect.objectContaining({
|
|
635
|
+
fromIndex: 0,
|
|
636
|
+
toIndex: 1,
|
|
637
|
+
}),
|
|
638
|
+
);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("should fire on previousPage()", () => {
|
|
642
|
+
const listener = vi.fn();
|
|
643
|
+
|
|
644
|
+
const { result } = renderHook(() =>
|
|
645
|
+
useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
|
|
646
|
+
);
|
|
647
|
+
|
|
648
|
+
// Go to page 2 first
|
|
649
|
+
act(() => {
|
|
650
|
+
result.current.wizard!.nextPage();
|
|
651
|
+
});
|
|
652
|
+
listener.mockClear();
|
|
653
|
+
|
|
654
|
+
act(() => {
|
|
655
|
+
result.current.wizard!.previousPage();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
expect(listener).toHaveBeenCalledWith(
|
|
659
|
+
expect.objectContaining({
|
|
660
|
+
fromIndex: 1,
|
|
661
|
+
toIndex: 0,
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
it("should fire on goToPage()", () => {
|
|
667
|
+
const listener = vi.fn();
|
|
668
|
+
|
|
669
|
+
const { result } = renderHook(() =>
|
|
670
|
+
useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
act(() => {
|
|
674
|
+
result.current.wizard!.goToPage(2);
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
expect(listener).toHaveBeenCalledWith(
|
|
678
|
+
expect.objectContaining({
|
|
679
|
+
fromIndex: 0,
|
|
680
|
+
toIndex: 2,
|
|
681
|
+
}),
|
|
682
|
+
);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it("should NOT fire on initial render", () => {
|
|
686
|
+
const listener = vi.fn();
|
|
687
|
+
|
|
688
|
+
renderHook(() =>
|
|
689
|
+
useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
|
|
690
|
+
);
|
|
691
|
+
|
|
692
|
+
expect(listener).not.toHaveBeenCalled();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
it("should include page state in payload", () => {
|
|
696
|
+
const listener = vi.fn();
|
|
697
|
+
|
|
698
|
+
const { result } = renderHook(() =>
|
|
699
|
+
useForma({ spec: wizardSpec, on: { pageChanged: listener } }),
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
act(() => {
|
|
703
|
+
result.current.wizard!.nextPage();
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
expect(listener).toHaveBeenCalledWith(
|
|
707
|
+
expect.objectContaining({
|
|
708
|
+
page: expect.objectContaining({
|
|
709
|
+
id: "page2",
|
|
710
|
+
title: "Page 2",
|
|
711
|
+
}),
|
|
712
|
+
}),
|
|
713
|
+
);
|
|
714
|
+
});
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
// ============================================================================
|
|
718
|
+
// Backward compatibility
|
|
719
|
+
// ============================================================================
|
|
720
|
+
|
|
721
|
+
describe("backward compatibility", () => {
|
|
722
|
+
it("should work identically without on option", () => {
|
|
723
|
+
const spec = createTestSpec({
|
|
724
|
+
fields: { name: { type: "text" } },
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
const { result } = renderHook(() =>
|
|
728
|
+
useForma({ spec, initialData: { name: "test" } }),
|
|
729
|
+
);
|
|
730
|
+
|
|
731
|
+
// Basic operations should work
|
|
732
|
+
act(() => {
|
|
733
|
+
result.current.setFieldValue("name", "changed");
|
|
734
|
+
});
|
|
735
|
+
expect(result.current.data.name).toBe("changed");
|
|
736
|
+
|
|
737
|
+
act(() => {
|
|
738
|
+
result.current.resetForm();
|
|
739
|
+
});
|
|
740
|
+
expect(result.current.data.name).toBe("test");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("should expose on() method on return object", () => {
|
|
744
|
+
const spec = createTestSpec({
|
|
745
|
+
fields: { name: { type: "text" } },
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
749
|
+
|
|
750
|
+
expect(typeof result.current.on).toBe("function");
|
|
751
|
+
});
|
|
752
|
+
});
|