@fogpipe/forma-react 0.6.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 +277 -0
- package/dist/index.d.ts +668 -0
- package/dist/index.js +1039 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
- package/src/ErrorBoundary.tsx +115 -0
- package/src/FieldRenderer.tsx +258 -0
- package/src/FormRenderer.tsx +470 -0
- package/src/__tests__/FormRenderer.test.tsx +803 -0
- package/src/__tests__/test-utils.tsx +297 -0
- package/src/__tests__/useForma.test.ts +1103 -0
- package/src/context.ts +23 -0
- package/src/index.ts +91 -0
- package/src/types.ts +482 -0
- package/src/useForma.ts +681 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for useForma hook
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, vi } from "vitest";
|
|
6
|
+
import { renderHook, act } from "@testing-library/react";
|
|
7
|
+
import { useForma } from "../useForma.js";
|
|
8
|
+
import { createTestSpec } from "./test-utils.js";
|
|
9
|
+
|
|
10
|
+
describe("useForma", () => {
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Initialization
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
describe("initialization", () => {
|
|
16
|
+
it("should initialize with empty data when no initialData provided", () => {
|
|
17
|
+
const spec = createTestSpec({
|
|
18
|
+
fields: {
|
|
19
|
+
name: { type: "text" },
|
|
20
|
+
age: { type: "number" },
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
25
|
+
|
|
26
|
+
expect(result.current.data).toEqual({});
|
|
27
|
+
expect(result.current.isSubmitted).toBe(false);
|
|
28
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
29
|
+
expect(result.current.isDirty).toBe(false);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should initialize with provided initialData", () => {
|
|
33
|
+
const spec = createTestSpec({
|
|
34
|
+
fields: {
|
|
35
|
+
name: { type: "text" },
|
|
36
|
+
age: { type: "number" },
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const initialData = { name: "John", age: 25 };
|
|
41
|
+
const { result } = renderHook(() =>
|
|
42
|
+
useForma({ spec, initialData })
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
expect(result.current.data).toEqual(initialData);
|
|
46
|
+
expect(result.current.isDirty).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should merge referenceData from options with spec", () => {
|
|
50
|
+
const spec = createTestSpec({
|
|
51
|
+
fields: { value: { type: "number" } },
|
|
52
|
+
referenceData: { existing: { a: 1 } },
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const { result } = renderHook(() =>
|
|
56
|
+
useForma({
|
|
57
|
+
spec,
|
|
58
|
+
referenceData: { added: { b: 2 } },
|
|
59
|
+
})
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
expect(result.current.spec.referenceData).toEqual({
|
|
63
|
+
existing: { a: 1 },
|
|
64
|
+
added: { b: 2 },
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Field Values
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
describe("field values", () => {
|
|
74
|
+
it("should update field value with setFieldValue", () => {
|
|
75
|
+
const spec = createTestSpec({
|
|
76
|
+
fields: { name: { type: "text" } },
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
80
|
+
|
|
81
|
+
act(() => {
|
|
82
|
+
result.current.setFieldValue("name", "Alice");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(result.current.data.name).toBe("Alice");
|
|
86
|
+
expect(result.current.isDirty).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("should update multiple values with setValues", () => {
|
|
90
|
+
const spec = createTestSpec({
|
|
91
|
+
fields: {
|
|
92
|
+
name: { type: "text" },
|
|
93
|
+
age: { type: "number" },
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
98
|
+
|
|
99
|
+
act(() => {
|
|
100
|
+
result.current.setValues({ name: "Bob", age: 30 });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result.current.data).toEqual({ name: "Bob", age: 30 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("should handle nested field paths", () => {
|
|
107
|
+
const spec = createTestSpec({
|
|
108
|
+
fields: { "address.city": { type: "text" } },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
112
|
+
|
|
113
|
+
act(() => {
|
|
114
|
+
result.current.setFieldValue("address.city", "New York");
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.current.data).toEqual({
|
|
118
|
+
address: { city: "New York" },
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should handle array index paths", () => {
|
|
123
|
+
const spec = createTestSpec({
|
|
124
|
+
fields: {
|
|
125
|
+
items: { type: "array" },
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const { result } = renderHook(() =>
|
|
130
|
+
useForma({ spec, initialData: { items: [{ name: "A" }] } })
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
act(() => {
|
|
134
|
+
result.current.setFieldValue("items[0].name", "B");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(result.current.data.items).toEqual([{ name: "B" }]);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should mark field as touched with setFieldTouched", () => {
|
|
141
|
+
const spec = createTestSpec({
|
|
142
|
+
fields: { name: { type: "text" } },
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
146
|
+
|
|
147
|
+
expect(result.current.getFieldProps("name").touched).toBe(false);
|
|
148
|
+
|
|
149
|
+
act(() => {
|
|
150
|
+
result.current.setFieldTouched("name", true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(result.current.getFieldProps("name").touched).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// ============================================================================
|
|
158
|
+
// getFieldProps
|
|
159
|
+
// ============================================================================
|
|
160
|
+
|
|
161
|
+
describe("getFieldProps", () => {
|
|
162
|
+
it("should return correct field props for text field", () => {
|
|
163
|
+
const spec = createTestSpec({
|
|
164
|
+
fields: {
|
|
165
|
+
name: {
|
|
166
|
+
type: "text",
|
|
167
|
+
label: "Full Name",
|
|
168
|
+
description: "Enter your name",
|
|
169
|
+
placeholder: "John Doe",
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const { result } = renderHook(() =>
|
|
175
|
+
useForma({ spec, initialData: { name: "Test" } })
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const props = result.current.getFieldProps("name");
|
|
179
|
+
|
|
180
|
+
expect(props.name).toBe("name");
|
|
181
|
+
expect(props.value).toBe("Test");
|
|
182
|
+
expect(props.type).toBe("text");
|
|
183
|
+
expect(props.label).toBe("Full Name");
|
|
184
|
+
expect(props.description).toBe("Enter your name");
|
|
185
|
+
expect(props.placeholder).toBe("John Doe");
|
|
186
|
+
expect(props.visible).toBe(true);
|
|
187
|
+
expect(props.enabled).toBe(true);
|
|
188
|
+
expect(props.required).toBe(false);
|
|
189
|
+
expect(props.touched).toBe(false);
|
|
190
|
+
expect(props.errors).toEqual([]);
|
|
191
|
+
expect(typeof props.onChange).toBe("function");
|
|
192
|
+
expect(typeof props.onBlur).toBe("function");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("should infer field type from schema", () => {
|
|
196
|
+
const spec = createTestSpec({
|
|
197
|
+
fields: {
|
|
198
|
+
email: { type: "email" },
|
|
199
|
+
age: { type: "number" },
|
|
200
|
+
isActive: { type: "boolean" },
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
205
|
+
|
|
206
|
+
expect(result.current.getFieldProps("email").type).toBe("email");
|
|
207
|
+
expect(result.current.getFieldProps("age").type).toBe("number");
|
|
208
|
+
expect(result.current.getFieldProps("isActive").type).toBe("boolean");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should provide stable handler references (memoization)", () => {
|
|
212
|
+
const spec = createTestSpec({
|
|
213
|
+
fields: { name: { type: "text" } },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const { result, rerender } = renderHook(() => useForma({ spec }));
|
|
217
|
+
|
|
218
|
+
const firstOnChange = result.current.getFieldProps("name").onChange;
|
|
219
|
+
const firstOnBlur = result.current.getFieldProps("name").onBlur;
|
|
220
|
+
|
|
221
|
+
// Force rerender
|
|
222
|
+
rerender();
|
|
223
|
+
|
|
224
|
+
const secondOnChange = result.current.getFieldProps("name").onChange;
|
|
225
|
+
const secondOnBlur = result.current.getFieldProps("name").onBlur;
|
|
226
|
+
|
|
227
|
+
// Handlers should be same references
|
|
228
|
+
expect(firstOnChange).toBe(secondOnChange);
|
|
229
|
+
expect(firstOnBlur).toBe(secondOnBlur);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("should update value when onChange is called", () => {
|
|
233
|
+
const spec = createTestSpec({
|
|
234
|
+
fields: { name: { type: "text" } },
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
238
|
+
|
|
239
|
+
act(() => {
|
|
240
|
+
result.current.getFieldProps("name").onChange("New Value");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
expect(result.current.data.name).toBe("New Value");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("should mark field touched when onBlur is called", () => {
|
|
247
|
+
const spec = createTestSpec({
|
|
248
|
+
fields: { name: { type: "text" } },
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
252
|
+
|
|
253
|
+
act(() => {
|
|
254
|
+
result.current.getFieldProps("name").onBlur();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.current.getFieldProps("name").touched).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ============================================================================
|
|
262
|
+
// getSelectFieldProps
|
|
263
|
+
// ============================================================================
|
|
264
|
+
|
|
265
|
+
describe("getSelectFieldProps", () => {
|
|
266
|
+
it("should return options for select field", () => {
|
|
267
|
+
const spec = createTestSpec({
|
|
268
|
+
fields: {
|
|
269
|
+
country: {
|
|
270
|
+
type: "select",
|
|
271
|
+
options: [
|
|
272
|
+
{ value: "us", label: "United States" },
|
|
273
|
+
{ value: "ca", label: "Canada" },
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
280
|
+
|
|
281
|
+
const props = result.current.getSelectFieldProps("country");
|
|
282
|
+
|
|
283
|
+
expect(props.type).toBe("select");
|
|
284
|
+
expect(props.options).toEqual([
|
|
285
|
+
{ value: "us", label: "United States" },
|
|
286
|
+
{ value: "ca", label: "Canada" },
|
|
287
|
+
]);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("should return array value for multiselect", () => {
|
|
291
|
+
const spec = createTestSpec({
|
|
292
|
+
fields: {
|
|
293
|
+
tags: { type: "multiselect" },
|
|
294
|
+
},
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const { result } = renderHook(() =>
|
|
298
|
+
useForma({ spec, initialData: { tags: ["a", "b"] } })
|
|
299
|
+
);
|
|
300
|
+
|
|
301
|
+
const props = result.current.getSelectFieldProps("tags");
|
|
302
|
+
|
|
303
|
+
expect(props.type).toBe("multiselect");
|
|
304
|
+
expect(props.value).toEqual(["a", "b"]);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// ============================================================================
|
|
309
|
+
// FEEL Expressions - Visibility
|
|
310
|
+
// ============================================================================
|
|
311
|
+
|
|
312
|
+
describe("visibility expressions", () => {
|
|
313
|
+
it("should evaluate visibleWhen expression", () => {
|
|
314
|
+
const spec = createTestSpec({
|
|
315
|
+
fields: {
|
|
316
|
+
hasLicense: { type: "boolean" },
|
|
317
|
+
licenseNumber: {
|
|
318
|
+
type: "text",
|
|
319
|
+
visibleWhen: "hasLicense = true",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const { result } = renderHook(() =>
|
|
325
|
+
useForma({ spec, initialData: { hasLicense: false } })
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Initially hidden
|
|
329
|
+
expect(result.current.getFieldProps("licenseNumber").visible).toBe(false);
|
|
330
|
+
|
|
331
|
+
// Show when condition is met
|
|
332
|
+
act(() => {
|
|
333
|
+
result.current.setFieldValue("hasLicense", true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
expect(result.current.getFieldProps("licenseNumber").visible).toBe(true);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should use visibility map correctly", () => {
|
|
340
|
+
const spec = createTestSpec({
|
|
341
|
+
fields: {
|
|
342
|
+
age: { type: "number" },
|
|
343
|
+
canDrive: {
|
|
344
|
+
type: "boolean",
|
|
345
|
+
visibleWhen: "age >= 16",
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const { result } = renderHook(() =>
|
|
351
|
+
useForma({ spec, initialData: { age: 14 } })
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
expect(result.current.visibility.canDrive).toBe(false);
|
|
355
|
+
|
|
356
|
+
act(() => {
|
|
357
|
+
result.current.setFieldValue("age", 18);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
expect(result.current.visibility.canDrive).toBe(true);
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// ============================================================================
|
|
365
|
+
// FEEL Expressions - Required
|
|
366
|
+
// ============================================================================
|
|
367
|
+
|
|
368
|
+
describe("required expressions", () => {
|
|
369
|
+
it("should evaluate requiredWhen expression", () => {
|
|
370
|
+
const spec = createTestSpec({
|
|
371
|
+
fields: {
|
|
372
|
+
employmentStatus: { type: "select" },
|
|
373
|
+
employerName: {
|
|
374
|
+
type: "text",
|
|
375
|
+
requiredWhen: 'employmentStatus = "employed"',
|
|
376
|
+
},
|
|
377
|
+
},
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
const { result } = renderHook(() =>
|
|
381
|
+
useForma({ spec, initialData: { employmentStatus: "unemployed" } })
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
expect(result.current.getFieldProps("employerName").required).toBe(false);
|
|
385
|
+
|
|
386
|
+
act(() => {
|
|
387
|
+
result.current.setFieldValue("employmentStatus", "employed");
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
expect(result.current.getFieldProps("employerName").required).toBe(true);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// ============================================================================
|
|
395
|
+
// FEEL Expressions - Enabled
|
|
396
|
+
// ============================================================================
|
|
397
|
+
|
|
398
|
+
describe("enabled expressions", () => {
|
|
399
|
+
it("should evaluate enabledWhen expression", () => {
|
|
400
|
+
const spec = createTestSpec({
|
|
401
|
+
fields: {
|
|
402
|
+
isEditable: { type: "boolean" },
|
|
403
|
+
notes: {
|
|
404
|
+
type: "textarea",
|
|
405
|
+
enabledWhen: "isEditable = true",
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const { result } = renderHook(() =>
|
|
411
|
+
useForma({ spec, initialData: { isEditable: false } })
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
expect(result.current.getFieldProps("notes").enabled).toBe(false);
|
|
415
|
+
|
|
416
|
+
act(() => {
|
|
417
|
+
result.current.setFieldValue("isEditable", true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
expect(result.current.getFieldProps("notes").enabled).toBe(true);
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
// ============================================================================
|
|
425
|
+
// Computed Fields
|
|
426
|
+
// ============================================================================
|
|
427
|
+
|
|
428
|
+
describe("computed fields", () => {
|
|
429
|
+
it("should calculate computed values from expressions", () => {
|
|
430
|
+
const spec = createTestSpec({
|
|
431
|
+
fields: {
|
|
432
|
+
quantity: { type: "number" },
|
|
433
|
+
price: { type: "number" },
|
|
434
|
+
},
|
|
435
|
+
computed: {
|
|
436
|
+
total: { expression: "quantity * price" },
|
|
437
|
+
},
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
const { result } = renderHook(() =>
|
|
441
|
+
useForma({ spec, initialData: { quantity: 5, price: 10 } })
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
expect(result.current.computed.total).toBe(50);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("should update computed values when dependencies change", () => {
|
|
448
|
+
const spec = createTestSpec({
|
|
449
|
+
fields: {
|
|
450
|
+
a: { type: "number" },
|
|
451
|
+
b: { type: "number" },
|
|
452
|
+
},
|
|
453
|
+
computed: {
|
|
454
|
+
sum: { expression: "a + b" },
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
const { result } = renderHook(() =>
|
|
459
|
+
useForma({ spec, initialData: { a: 1, b: 2 } })
|
|
460
|
+
);
|
|
461
|
+
|
|
462
|
+
expect(result.current.computed.sum).toBe(3);
|
|
463
|
+
|
|
464
|
+
act(() => {
|
|
465
|
+
result.current.setFieldValue("a", 10);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
expect(result.current.computed.sum).toBe(12);
|
|
469
|
+
});
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
// ============================================================================
|
|
473
|
+
// Validation
|
|
474
|
+
// ============================================================================
|
|
475
|
+
|
|
476
|
+
describe("validation", () => {
|
|
477
|
+
it("should validate required fields from schema", () => {
|
|
478
|
+
const spec = createTestSpec({
|
|
479
|
+
fields: {
|
|
480
|
+
name: { type: "text", required: true },
|
|
481
|
+
},
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
485
|
+
|
|
486
|
+
// Initially no errors shown (not touched)
|
|
487
|
+
expect(result.current.getFieldProps("name").errors).toEqual([]);
|
|
488
|
+
|
|
489
|
+
// After submit, errors should show
|
|
490
|
+
act(() => {
|
|
491
|
+
result.current.submitForm();
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
expect(result.current.isSubmitted).toBe(true);
|
|
495
|
+
expect(result.current.isValid).toBe(false);
|
|
496
|
+
expect(result.current.errors.length).toBeGreaterThan(0);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("should not show errors for untouched fields (validateOn: blur)", () => {
|
|
500
|
+
const spec = createTestSpec({
|
|
501
|
+
fields: {
|
|
502
|
+
name: { type: "text", required: true },
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const { result } = renderHook(() =>
|
|
507
|
+
useForma({ spec, validateOn: "blur" })
|
|
508
|
+
);
|
|
509
|
+
|
|
510
|
+
// Errors exist but not shown
|
|
511
|
+
expect(result.current.isValid).toBe(false);
|
|
512
|
+
expect(result.current.getFieldProps("name").errors).toEqual([]);
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it("should show errors immediately when validateOn: change", () => {
|
|
516
|
+
const spec = createTestSpec({
|
|
517
|
+
fields: {
|
|
518
|
+
name: { type: "text", required: true },
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
const { result } = renderHook(() =>
|
|
523
|
+
useForma({ spec, validateOn: "change" })
|
|
524
|
+
);
|
|
525
|
+
|
|
526
|
+
// Errors shown immediately
|
|
527
|
+
expect(result.current.getFieldProps("name").errors.length).toBeGreaterThan(0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("should validate custom validation rules", () => {
|
|
531
|
+
const spec = createTestSpec({
|
|
532
|
+
fields: {
|
|
533
|
+
age: {
|
|
534
|
+
type: "number",
|
|
535
|
+
validations: [
|
|
536
|
+
{ rule: "value >= 18", message: "Must be 18 or older" },
|
|
537
|
+
],
|
|
538
|
+
},
|
|
539
|
+
},
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
const { result } = renderHook(() =>
|
|
543
|
+
useForma({ spec, initialData: { age: 16 } })
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
// Touch the field to show errors
|
|
547
|
+
act(() => {
|
|
548
|
+
result.current.setFieldTouched("age", true);
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const errors = result.current.getFieldProps("age").errors;
|
|
552
|
+
expect(errors.some((e) => e.message === "Must be 18 or older")).toBe(true);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it("should clear isSubmitted when data changes", () => {
|
|
556
|
+
const spec = createTestSpec({
|
|
557
|
+
fields: { name: { type: "text" } },
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
561
|
+
|
|
562
|
+
act(() => {
|
|
563
|
+
result.current.submitForm();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
expect(result.current.isSubmitted).toBe(true);
|
|
567
|
+
|
|
568
|
+
act(() => {
|
|
569
|
+
result.current.setFieldValue("name", "new value");
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
expect(result.current.isSubmitted).toBe(false);
|
|
573
|
+
});
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ============================================================================
|
|
577
|
+
// Array Fields
|
|
578
|
+
// ============================================================================
|
|
579
|
+
|
|
580
|
+
describe("array fields", () => {
|
|
581
|
+
it("should provide array helpers via getArrayHelpers", () => {
|
|
582
|
+
const spec = createTestSpec({
|
|
583
|
+
fields: {
|
|
584
|
+
items: {
|
|
585
|
+
type: "array",
|
|
586
|
+
itemFields: { name: { type: "text" } },
|
|
587
|
+
},
|
|
588
|
+
},
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
const { result } = renderHook(() =>
|
|
592
|
+
useForma({ spec, initialData: { items: [] } })
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
const helpers = result.current.getArrayHelpers("items");
|
|
596
|
+
|
|
597
|
+
expect(helpers.items).toEqual([]);
|
|
598
|
+
expect(typeof helpers.push).toBe("function");
|
|
599
|
+
expect(typeof helpers.remove).toBe("function");
|
|
600
|
+
expect(typeof helpers.move).toBe("function");
|
|
601
|
+
expect(typeof helpers.swap).toBe("function");
|
|
602
|
+
expect(typeof helpers.insert).toBe("function");
|
|
603
|
+
expect(typeof helpers.getItemFieldProps).toBe("function");
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
it("should add item with push", () => {
|
|
607
|
+
const spec = createTestSpec({
|
|
608
|
+
fields: { items: { type: "array" } },
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const { result } = renderHook(() =>
|
|
612
|
+
useForma({ spec, initialData: { items: [] } })
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
act(() => {
|
|
616
|
+
result.current.getArrayHelpers("items").push({ name: "New" });
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
expect(result.current.data.items).toEqual([{ name: "New" }]);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
it("should remove item at index", () => {
|
|
623
|
+
const spec = createTestSpec({
|
|
624
|
+
fields: { items: { type: "array" } },
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
const { result } = renderHook(() =>
|
|
628
|
+
useForma({
|
|
629
|
+
spec,
|
|
630
|
+
initialData: { items: [{ name: "A" }, { name: "B" }, { name: "C" }] },
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
act(() => {
|
|
635
|
+
result.current.getArrayHelpers("items").remove(1);
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(result.current.data.items).toEqual([{ name: "A" }, { name: "C" }]);
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("should move item from one index to another", () => {
|
|
642
|
+
const spec = createTestSpec({
|
|
643
|
+
fields: { items: { type: "array" } },
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const { result } = renderHook(() =>
|
|
647
|
+
useForma({
|
|
648
|
+
spec,
|
|
649
|
+
initialData: { items: [{ name: "A" }, { name: "B" }, { name: "C" }] },
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
act(() => {
|
|
654
|
+
result.current.getArrayHelpers("items").move(0, 2);
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
expect(result.current.data.items).toEqual([
|
|
658
|
+
{ name: "B" },
|
|
659
|
+
{ name: "C" },
|
|
660
|
+
{ name: "A" },
|
|
661
|
+
]);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it("should swap items at two indices", () => {
|
|
665
|
+
const spec = createTestSpec({
|
|
666
|
+
fields: { items: { type: "array" } },
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const { result } = renderHook(() =>
|
|
670
|
+
useForma({
|
|
671
|
+
spec,
|
|
672
|
+
initialData: { items: [{ name: "A" }, { name: "B" }] },
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
act(() => {
|
|
677
|
+
result.current.getArrayHelpers("items").swap(0, 1);
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
expect(result.current.data.items).toEqual([{ name: "B" }, { name: "A" }]);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it("should insert item at specific index", () => {
|
|
684
|
+
const spec = createTestSpec({
|
|
685
|
+
fields: { items: { type: "array" } },
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const { result } = renderHook(() =>
|
|
689
|
+
useForma({
|
|
690
|
+
spec,
|
|
691
|
+
initialData: { items: [{ name: "A" }, { name: "C" }] },
|
|
692
|
+
})
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
act(() => {
|
|
696
|
+
result.current.getArrayHelpers("items").insert(1, { name: "B" });
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(result.current.data.items).toEqual([
|
|
700
|
+
{ name: "A" },
|
|
701
|
+
{ name: "B" },
|
|
702
|
+
{ name: "C" },
|
|
703
|
+
]);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("should respect minItems constraint", () => {
|
|
707
|
+
const spec = createTestSpec({
|
|
708
|
+
fields: {
|
|
709
|
+
items: { type: "array", minItems: 1 },
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
const { result } = renderHook(() =>
|
|
714
|
+
useForma({
|
|
715
|
+
spec,
|
|
716
|
+
initialData: { items: [{ name: "Only" }] },
|
|
717
|
+
})
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const helpers = result.current.getArrayHelpers("items");
|
|
721
|
+
expect(helpers.canRemove).toBe(false);
|
|
722
|
+
|
|
723
|
+
// Try to remove - should not work
|
|
724
|
+
act(() => {
|
|
725
|
+
helpers.remove(0);
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Item should still be there
|
|
729
|
+
expect(result.current.data.items).toEqual([{ name: "Only" }]);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
it("should respect maxItems constraint", () => {
|
|
733
|
+
const spec = createTestSpec({
|
|
734
|
+
fields: {
|
|
735
|
+
items: { type: "array", maxItems: 2 },
|
|
736
|
+
},
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const { result } = renderHook(() =>
|
|
740
|
+
useForma({
|
|
741
|
+
spec,
|
|
742
|
+
initialData: { items: [{ name: "A" }, { name: "B" }] },
|
|
743
|
+
})
|
|
744
|
+
);
|
|
745
|
+
|
|
746
|
+
const helpers = result.current.getArrayHelpers("items");
|
|
747
|
+
expect(helpers.canAdd).toBe(false);
|
|
748
|
+
|
|
749
|
+
// Try to add - should not work
|
|
750
|
+
act(() => {
|
|
751
|
+
helpers.push({ name: "C" });
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
// Should still have only 2 items
|
|
755
|
+
expect((result.current.data.items as unknown[]).length).toBe(2);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
it("should get item field props", () => {
|
|
759
|
+
const spec = createTestSpec({
|
|
760
|
+
fields: {
|
|
761
|
+
items: {
|
|
762
|
+
type: "array",
|
|
763
|
+
itemFields: {
|
|
764
|
+
name: { type: "text", label: "Item Name" },
|
|
765
|
+
},
|
|
766
|
+
},
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
const { result } = renderHook(() =>
|
|
771
|
+
useForma({
|
|
772
|
+
spec,
|
|
773
|
+
initialData: { items: [{ name: "Test Item" }] },
|
|
774
|
+
})
|
|
775
|
+
);
|
|
776
|
+
|
|
777
|
+
const itemProps = result.current.getArrayHelpers("items").getItemFieldProps(0, "name");
|
|
778
|
+
|
|
779
|
+
expect(itemProps.name).toBe("items[0].name");
|
|
780
|
+
expect(itemProps.value).toBe("Test Item");
|
|
781
|
+
expect(itemProps.label).toBe("Item Name");
|
|
782
|
+
expect(itemProps.type).toBe("text");
|
|
783
|
+
});
|
|
784
|
+
});
|
|
785
|
+
|
|
786
|
+
// ============================================================================
|
|
787
|
+
// Wizard / Multi-page Forms
|
|
788
|
+
// ============================================================================
|
|
789
|
+
|
|
790
|
+
describe("wizard", () => {
|
|
791
|
+
it("should return null when no pages defined", () => {
|
|
792
|
+
const spec = createTestSpec({
|
|
793
|
+
fields: { name: { type: "text" } },
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
797
|
+
|
|
798
|
+
expect(result.current.wizard).toBeNull();
|
|
799
|
+
});
|
|
800
|
+
|
|
801
|
+
it("should provide page state when pages defined", () => {
|
|
802
|
+
const spec = createTestSpec({
|
|
803
|
+
fields: {
|
|
804
|
+
name: { type: "text" },
|
|
805
|
+
email: { type: "email" },
|
|
806
|
+
},
|
|
807
|
+
pages: [
|
|
808
|
+
{ id: "page1", title: "Step 1", fields: ["name"] },
|
|
809
|
+
{ id: "page2", title: "Step 2", fields: ["email"] },
|
|
810
|
+
],
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
814
|
+
|
|
815
|
+
expect(result.current.wizard).not.toBeNull();
|
|
816
|
+
expect(result.current.wizard?.pages.length).toBe(2);
|
|
817
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
818
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page1");
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it("should navigate to next page", () => {
|
|
822
|
+
const spec = createTestSpec({
|
|
823
|
+
fields: {
|
|
824
|
+
name: { type: "text" },
|
|
825
|
+
email: { type: "email" },
|
|
826
|
+
},
|
|
827
|
+
pages: [
|
|
828
|
+
{ id: "page1", title: "Step 1", fields: ["name"] },
|
|
829
|
+
{ id: "page2", title: "Step 2", fields: ["email"] },
|
|
830
|
+
],
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
834
|
+
|
|
835
|
+
act(() => {
|
|
836
|
+
result.current.wizard?.nextPage();
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
840
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page2");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("should navigate to previous page", () => {
|
|
844
|
+
const spec = createTestSpec({
|
|
845
|
+
fields: {
|
|
846
|
+
name: { type: "text" },
|
|
847
|
+
email: { type: "email" },
|
|
848
|
+
},
|
|
849
|
+
pages: [
|
|
850
|
+
{ id: "page1", title: "Step 1", fields: ["name"] },
|
|
851
|
+
{ id: "page2", title: "Step 2", fields: ["email"] },
|
|
852
|
+
],
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
856
|
+
|
|
857
|
+
act(() => {
|
|
858
|
+
result.current.wizard?.goToPage(1);
|
|
859
|
+
});
|
|
860
|
+
|
|
861
|
+
act(() => {
|
|
862
|
+
result.current.wizard?.previousPage();
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
it("should track hasNextPage and hasPreviousPage", () => {
|
|
869
|
+
const spec = createTestSpec({
|
|
870
|
+
fields: {
|
|
871
|
+
a: { type: "text" },
|
|
872
|
+
b: { type: "text" },
|
|
873
|
+
c: { type: "text" },
|
|
874
|
+
},
|
|
875
|
+
pages: [
|
|
876
|
+
{ id: "p1", title: "Page 1", fields: ["a"] },
|
|
877
|
+
{ id: "p2", title: "Page 2", fields: ["b"] },
|
|
878
|
+
{ id: "p3", title: "Page 3", fields: ["c"] },
|
|
879
|
+
],
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
883
|
+
|
|
884
|
+
// First page
|
|
885
|
+
expect(result.current.wizard?.hasPreviousPage).toBe(false);
|
|
886
|
+
expect(result.current.wizard?.hasNextPage).toBe(true);
|
|
887
|
+
expect(result.current.wizard?.isLastPage).toBe(false);
|
|
888
|
+
|
|
889
|
+
// Go to middle page
|
|
890
|
+
act(() => {
|
|
891
|
+
result.current.wizard?.goToPage(1);
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
expect(result.current.wizard?.hasPreviousPage).toBe(true);
|
|
895
|
+
expect(result.current.wizard?.hasNextPage).toBe(true);
|
|
896
|
+
|
|
897
|
+
// Go to last page
|
|
898
|
+
act(() => {
|
|
899
|
+
result.current.wizard?.goToPage(2);
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
expect(result.current.wizard?.hasPreviousPage).toBe(true);
|
|
903
|
+
expect(result.current.wizard?.hasNextPage).toBe(false);
|
|
904
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
905
|
+
});
|
|
906
|
+
|
|
907
|
+
it("should evaluate page visibility", () => {
|
|
908
|
+
const spec = createTestSpec({
|
|
909
|
+
fields: {
|
|
910
|
+
showPage2: { type: "boolean" },
|
|
911
|
+
field1: { type: "text" },
|
|
912
|
+
field2: { type: "text" },
|
|
913
|
+
},
|
|
914
|
+
pages: [
|
|
915
|
+
{ id: "page1", title: "Step 1", fields: ["showPage2", "field1"] },
|
|
916
|
+
{ id: "page2", title: "Step 2", fields: ["field2"], visibleWhen: "showPage2 = true" },
|
|
917
|
+
],
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
const { result } = renderHook(() =>
|
|
921
|
+
useForma({ spec, initialData: { showPage2: false } })
|
|
922
|
+
);
|
|
923
|
+
|
|
924
|
+
// Page 2 should be hidden
|
|
925
|
+
const page2 = result.current.wizard?.pages.find((p) => p.id === "page2");
|
|
926
|
+
expect(page2?.visible).toBe(false);
|
|
927
|
+
|
|
928
|
+
// Show page 2
|
|
929
|
+
act(() => {
|
|
930
|
+
result.current.setFieldValue("showPage2", true);
|
|
931
|
+
});
|
|
932
|
+
|
|
933
|
+
const page2After = result.current.wizard?.pages.find((p) => p.id === "page2");
|
|
934
|
+
expect(page2After?.visible).toBe(true);
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
// ============================================================================
|
|
939
|
+
// Form Actions
|
|
940
|
+
// ============================================================================
|
|
941
|
+
|
|
942
|
+
describe("form actions", () => {
|
|
943
|
+
it("should call onSubmit with data when form is valid", async () => {
|
|
944
|
+
const onSubmit = vi.fn();
|
|
945
|
+
const spec = createTestSpec({
|
|
946
|
+
fields: { name: { type: "text" } },
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
const { result } = renderHook(() =>
|
|
950
|
+
useForma({ spec, initialData: { name: "Test" }, onSubmit })
|
|
951
|
+
);
|
|
952
|
+
|
|
953
|
+
await act(async () => {
|
|
954
|
+
await result.current.submitForm();
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "Test" });
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
it("should not call onSubmit when form is invalid", async () => {
|
|
961
|
+
const onSubmit = vi.fn();
|
|
962
|
+
const spec = createTestSpec({
|
|
963
|
+
fields: { name: { type: "text", required: true } },
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
const { result } = renderHook(() =>
|
|
967
|
+
useForma({ spec, onSubmit })
|
|
968
|
+
);
|
|
969
|
+
|
|
970
|
+
await act(async () => {
|
|
971
|
+
await result.current.submitForm();
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
975
|
+
expect(result.current.isSubmitted).toBe(true);
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
it("should track isSubmitting state during async submit", async () => {
|
|
979
|
+
const onSubmit = vi.fn(
|
|
980
|
+
(): Promise<void> => new Promise((resolve) => setTimeout(resolve, 100))
|
|
981
|
+
);
|
|
982
|
+
const spec = createTestSpec({
|
|
983
|
+
fields: { name: { type: "text" } },
|
|
984
|
+
});
|
|
985
|
+
|
|
986
|
+
const { result } = renderHook(() =>
|
|
987
|
+
useForma({ spec, initialData: { name: "Test" }, onSubmit })
|
|
988
|
+
);
|
|
989
|
+
|
|
990
|
+
let submitPromise: Promise<void>;
|
|
991
|
+
act(() => {
|
|
992
|
+
submitPromise = result.current.submitForm();
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
expect(result.current.isSubmitting).toBe(true);
|
|
996
|
+
|
|
997
|
+
await act(async () => {
|
|
998
|
+
await submitPromise;
|
|
999
|
+
});
|
|
1000
|
+
|
|
1001
|
+
expect(result.current.isSubmitting).toBe(false);
|
|
1002
|
+
});
|
|
1003
|
+
|
|
1004
|
+
it("should reset form to initial state", () => {
|
|
1005
|
+
const spec = createTestSpec({
|
|
1006
|
+
fields: { name: { type: "text" } },
|
|
1007
|
+
});
|
|
1008
|
+
|
|
1009
|
+
const { result } = renderHook(() =>
|
|
1010
|
+
useForma({ spec, initialData: { name: "Original" } })
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
act(() => {
|
|
1014
|
+
result.current.setFieldValue("name", "Changed");
|
|
1015
|
+
result.current.setFieldTouched("name", true);
|
|
1016
|
+
});
|
|
1017
|
+
|
|
1018
|
+
expect(result.current.data.name).toBe("Changed");
|
|
1019
|
+
expect(result.current.isDirty).toBe(true);
|
|
1020
|
+
|
|
1021
|
+
act(() => {
|
|
1022
|
+
result.current.resetForm();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
expect(result.current.data.name).toBe("Original");
|
|
1026
|
+
expect(result.current.isDirty).toBe(false);
|
|
1027
|
+
expect(result.current.getFieldProps("name").touched).toBe(false);
|
|
1028
|
+
});
|
|
1029
|
+
|
|
1030
|
+
it("should call onChange callback when data changes", () => {
|
|
1031
|
+
const onChange = vi.fn();
|
|
1032
|
+
const spec = createTestSpec({
|
|
1033
|
+
fields: { name: { type: "text" } },
|
|
1034
|
+
});
|
|
1035
|
+
|
|
1036
|
+
const { result } = renderHook(() =>
|
|
1037
|
+
useForma({ spec, onChange })
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
act(() => {
|
|
1041
|
+
result.current.setFieldValue("name", "New Value");
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
expect(onChange).toHaveBeenCalledWith(
|
|
1045
|
+
{ name: "New Value" },
|
|
1046
|
+
expect.any(Object) // computed values
|
|
1047
|
+
);
|
|
1048
|
+
});
|
|
1049
|
+
|
|
1050
|
+
it("should not call onChange on initial render", () => {
|
|
1051
|
+
const onChange = vi.fn();
|
|
1052
|
+
const spec = createTestSpec({
|
|
1053
|
+
fields: { name: { type: "text" } },
|
|
1054
|
+
});
|
|
1055
|
+
|
|
1056
|
+
renderHook(() =>
|
|
1057
|
+
useForma({ spec, initialData: { name: "Initial" }, onChange })
|
|
1058
|
+
);
|
|
1059
|
+
|
|
1060
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
|
|
1064
|
+
// ============================================================================
|
|
1065
|
+
// validateForm and validateField
|
|
1066
|
+
// ============================================================================
|
|
1067
|
+
|
|
1068
|
+
describe("validateForm and validateField", () => {
|
|
1069
|
+
it("should return validation result from validateForm", () => {
|
|
1070
|
+
const spec = createTestSpec({
|
|
1071
|
+
fields: {
|
|
1072
|
+
name: { type: "text", required: true },
|
|
1073
|
+
},
|
|
1074
|
+
});
|
|
1075
|
+
|
|
1076
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1077
|
+
|
|
1078
|
+
const validation = result.current.validateForm();
|
|
1079
|
+
|
|
1080
|
+
expect(validation.valid).toBe(false);
|
|
1081
|
+
expect(validation.errors.length).toBeGreaterThan(0);
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
it("should return field errors from validateField", () => {
|
|
1085
|
+
const spec = createTestSpec({
|
|
1086
|
+
fields: {
|
|
1087
|
+
age: {
|
|
1088
|
+
type: "number",
|
|
1089
|
+
validations: [{ rule: "value >= 0", message: "Must be positive" }],
|
|
1090
|
+
},
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
const { result } = renderHook(() =>
|
|
1095
|
+
useForma({ spec, initialData: { age: -5 } })
|
|
1096
|
+
);
|
|
1097
|
+
|
|
1098
|
+
const fieldErrors = result.current.validateField("age");
|
|
1099
|
+
|
|
1100
|
+
expect(fieldErrors.some((e) => e.message === "Must be positive")).toBe(true);
|
|
1101
|
+
});
|
|
1102
|
+
});
|
|
1103
|
+
});
|