@fogpipe/forma-react 0.8.0 → 0.8.2
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/dist/index.d.ts +1 -0
- package/dist/index.js +27 -11
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/__tests__/canProceed.test.ts +874 -0
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/useForma.test.ts +111 -0
- package/src/types.ts +1 -0
- package/src/useForma.ts +38 -10
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Comprehensive tests for canProceed wizard validation
|
|
3
|
+
*
|
|
4
|
+
* Tests the canProceed property of wizard helpers which determines
|
|
5
|
+
* whether the user can proceed to the next page based on validation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import { renderHook, act } from "@testing-library/react";
|
|
10
|
+
import { useForma } from "../useForma.js";
|
|
11
|
+
import { createTestSpec } from "./test-utils.js";
|
|
12
|
+
|
|
13
|
+
describe("canProceed", () => {
|
|
14
|
+
describe("basic validation logic", () => {
|
|
15
|
+
it("returns true when all required fields on current page are filled", () => {
|
|
16
|
+
const spec = createTestSpec({
|
|
17
|
+
fields: {
|
|
18
|
+
name: { type: "text", label: "Name", required: true },
|
|
19
|
+
email: { type: "email", label: "Email", required: true },
|
|
20
|
+
phone: { type: "text", label: "Phone", required: true },
|
|
21
|
+
},
|
|
22
|
+
pages: [
|
|
23
|
+
{ id: "page1", title: "Page 1", fields: ["name", "email"] },
|
|
24
|
+
{ id: "page2", title: "Page 2", fields: ["phone"] },
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const { result } = renderHook(() =>
|
|
29
|
+
useForma({ spec, initialData: { name: "John", email: "john@example.com" } })
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("returns false when required field on current page is empty", () => {
|
|
36
|
+
const spec = createTestSpec({
|
|
37
|
+
fields: {
|
|
38
|
+
name: { type: "text", label: "Name", required: true },
|
|
39
|
+
email: { type: "email", label: "Email", required: true },
|
|
40
|
+
},
|
|
41
|
+
pages: [
|
|
42
|
+
{ id: "page1", title: "Page 1", fields: ["name", "email"] },
|
|
43
|
+
],
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() =>
|
|
47
|
+
useForma({ spec, initialData: { name: "John" } }) // email is missing
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("only validates fields on the current page, not other pages", () => {
|
|
54
|
+
const spec = createTestSpec({
|
|
55
|
+
fields: {
|
|
56
|
+
name: { type: "text", label: "Name", required: true },
|
|
57
|
+
email: { type: "email", label: "Email", required: true },
|
|
58
|
+
phone: { type: "text", label: "Phone", required: true },
|
|
59
|
+
},
|
|
60
|
+
pages: [
|
|
61
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
62
|
+
{ id: "page2", title: "Page 2", fields: ["email", "phone"] },
|
|
63
|
+
],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const { result } = renderHook(() =>
|
|
67
|
+
useForma({ spec, initialData: { name: "John" } }) // page 2 fields empty
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
// On page 1, only name is checked (which is filled)
|
|
71
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("updates canProceed when navigating to next page", () => {
|
|
75
|
+
const spec = createTestSpec({
|
|
76
|
+
fields: {
|
|
77
|
+
name: { type: "text", label: "Name", required: true },
|
|
78
|
+
email: { type: "email", label: "Email", required: true },
|
|
79
|
+
},
|
|
80
|
+
pages: [
|
|
81
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
82
|
+
{ id: "page2", title: "Page 2", fields: ["email"] },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const { result } = renderHook(() =>
|
|
87
|
+
useForma({ spec, initialData: { name: "John" } })
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
// Page 1 is valid
|
|
91
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
92
|
+
|
|
93
|
+
// Navigate to page 2
|
|
94
|
+
act(() => {
|
|
95
|
+
result.current.wizard?.nextPage();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Page 2 is invalid (email is empty)
|
|
99
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("handles array item fields (e.g., items[0].name)", () => {
|
|
103
|
+
const spec = createTestSpec({
|
|
104
|
+
fields: {
|
|
105
|
+
items: {
|
|
106
|
+
type: "array",
|
|
107
|
+
label: "Items",
|
|
108
|
+
required: true,
|
|
109
|
+
minItems: 1,
|
|
110
|
+
itemFields: {
|
|
111
|
+
name: { type: "text", label: "Item Name" },
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
pages: [
|
|
116
|
+
{ id: "page1", title: "Page 1", fields: ["items"] },
|
|
117
|
+
],
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(() =>
|
|
121
|
+
useForma({ spec, initialData: { items: [] } })
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Array is empty but minItems requires at least 1
|
|
125
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
126
|
+
|
|
127
|
+
// Add an item
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.setFieldValue("items", [{ name: "Item 1" }]);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("visibility integration", () => {
|
|
137
|
+
it("ignores hidden fields when calculating canProceed", () => {
|
|
138
|
+
const spec = createTestSpec({
|
|
139
|
+
fields: {
|
|
140
|
+
showEmail: { type: "boolean", label: "Show Email" },
|
|
141
|
+
name: { type: "text", label: "Name", required: true },
|
|
142
|
+
email: { type: "email", label: "Email", required: true, visibleWhen: "showEmail = true" },
|
|
143
|
+
},
|
|
144
|
+
pages: [
|
|
145
|
+
{ id: "page1", title: "Page 1", fields: ["showEmail", "name", "email"] },
|
|
146
|
+
],
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const { result } = renderHook(() =>
|
|
150
|
+
useForma({ spec, initialData: { showEmail: false, name: "John" } })
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Email is required but hidden, so it shouldn't block progression
|
|
154
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("updates canProceed when field visibility changes", () => {
|
|
158
|
+
const spec = createTestSpec({
|
|
159
|
+
fields: {
|
|
160
|
+
showEmail: { type: "boolean", label: "Show Email" },
|
|
161
|
+
name: { type: "text", label: "Name", required: true },
|
|
162
|
+
email: { type: "email", label: "Email", required: true, visibleWhen: "showEmail = true" },
|
|
163
|
+
},
|
|
164
|
+
pages: [
|
|
165
|
+
{ id: "page1", title: "Page 1", fields: ["showEmail", "name", "email"] },
|
|
166
|
+
],
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const { result } = renderHook(() =>
|
|
170
|
+
useForma({ spec, initialData: { showEmail: false, name: "John" } })
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
// Email is hidden
|
|
174
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
175
|
+
|
|
176
|
+
// Show email field
|
|
177
|
+
act(() => {
|
|
178
|
+
result.current.setFieldValue("showEmail", true);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Now email is visible and empty - should block
|
|
182
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
183
|
+
|
|
184
|
+
// Fill email
|
|
185
|
+
act(() => {
|
|
186
|
+
result.current.setFieldValue("email", "john@example.com");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe("required field validation", () => {
|
|
194
|
+
it("validates static required fields (schema.required)", () => {
|
|
195
|
+
const spec = createTestSpec({
|
|
196
|
+
fields: {
|
|
197
|
+
name: { type: "text", label: "Name", required: true },
|
|
198
|
+
age: { type: "number", label: "Age" }, // Not required
|
|
199
|
+
},
|
|
200
|
+
pages: [
|
|
201
|
+
{ id: "page1", title: "Page 1", fields: ["name", "age"] },
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const { result } = renderHook(() =>
|
|
206
|
+
useForma({ spec, initialData: { age: 25 } }) // name missing
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
210
|
+
|
|
211
|
+
act(() => {
|
|
212
|
+
result.current.setFieldValue("name", "John");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("validates conditional required (requiredWhen)", () => {
|
|
219
|
+
const spec = createTestSpec({
|
|
220
|
+
fields: {
|
|
221
|
+
hasSpouse: { type: "boolean", label: "Has Spouse" },
|
|
222
|
+
spouseName: { type: "text", label: "Spouse Name", requiredWhen: "hasSpouse = true" },
|
|
223
|
+
},
|
|
224
|
+
pages: [
|
|
225
|
+
{ id: "page1", title: "Page 1", fields: ["hasSpouse", "spouseName"] },
|
|
226
|
+
],
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
const { result } = renderHook(() =>
|
|
230
|
+
useForma({ spec, initialData: { hasSpouse: false } })
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
// Spouse name not required when hasSpouse is false
|
|
234
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
235
|
+
|
|
236
|
+
// Enable spouse
|
|
237
|
+
act(() => {
|
|
238
|
+
result.current.setFieldValue("hasSpouse", true);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// Now spouse name is required but empty
|
|
242
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
243
|
+
|
|
244
|
+
// Fill spouse name
|
|
245
|
+
act(() => {
|
|
246
|
+
result.current.setFieldValue("spouseName", "Jane");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("required boolean fields - undefined vs false", () => {
|
|
253
|
+
// Note: For boolean fields, "required" means "must have a value" (true or false),
|
|
254
|
+
// NOT "must be true". This is consistent with other field types where required
|
|
255
|
+
// means "not empty". For checkboxes that must be checked (like "Accept Terms"),
|
|
256
|
+
// use a validation rule: { rule: "value = true", message: "Must accept terms" }
|
|
257
|
+
const spec = createTestSpec({
|
|
258
|
+
fields: {
|
|
259
|
+
hasPets: { type: "boolean", label: "Do you have pets?", required: true },
|
|
260
|
+
},
|
|
261
|
+
pages: [
|
|
262
|
+
{ id: "page1", title: "Page 1", fields: ["hasPets"] },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// undefined should be invalid (user hasn't answered)
|
|
267
|
+
const { result: resultUndefined } = renderHook(() =>
|
|
268
|
+
useForma({ spec, initialData: {} })
|
|
269
|
+
);
|
|
270
|
+
expect(resultUndefined.current.wizard?.canProceed).toBe(false);
|
|
271
|
+
|
|
272
|
+
// false should be valid (user answered "no")
|
|
273
|
+
const { result: resultFalse } = renderHook(() =>
|
|
274
|
+
useForma({ spec, initialData: { hasPets: false } })
|
|
275
|
+
);
|
|
276
|
+
expect(resultFalse.current.wizard?.canProceed).toBe(true);
|
|
277
|
+
|
|
278
|
+
// true should be valid (user answered "yes")
|
|
279
|
+
const { result: resultTrue } = renderHook(() =>
|
|
280
|
+
useForma({ spec, initialData: { hasPets: true } })
|
|
281
|
+
);
|
|
282
|
+
expect(resultTrue.current.wizard?.canProceed).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("required select fields - null/undefined detection", () => {
|
|
286
|
+
const spec = createTestSpec({
|
|
287
|
+
fields: {
|
|
288
|
+
country: {
|
|
289
|
+
type: "select",
|
|
290
|
+
label: "Country",
|
|
291
|
+
required: true,
|
|
292
|
+
options: [
|
|
293
|
+
{ label: "USA", value: "us" },
|
|
294
|
+
{ label: "Canada", value: "ca" },
|
|
295
|
+
],
|
|
296
|
+
},
|
|
297
|
+
},
|
|
298
|
+
pages: [
|
|
299
|
+
{ id: "page1", title: "Page 1", fields: ["country"] },
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
const { result } = renderHook(() =>
|
|
304
|
+
useForma({ spec, initialData: {} })
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
308
|
+
|
|
309
|
+
act(() => {
|
|
310
|
+
result.current.setFieldValue("country", "us");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe("field types on page", () => {
|
|
318
|
+
it("text fields - empty string detection", () => {
|
|
319
|
+
const spec = createTestSpec({
|
|
320
|
+
fields: {
|
|
321
|
+
name: { type: "text", label: "Name", required: true },
|
|
322
|
+
},
|
|
323
|
+
pages: [
|
|
324
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
325
|
+
],
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Empty string should be invalid
|
|
329
|
+
const { result: resultEmpty } = renderHook(() =>
|
|
330
|
+
useForma({ spec, initialData: { name: "" } })
|
|
331
|
+
);
|
|
332
|
+
expect(resultEmpty.current.wizard?.canProceed).toBe(false);
|
|
333
|
+
|
|
334
|
+
// Whitespace only should be invalid
|
|
335
|
+
const { result: resultWhitespace } = renderHook(() =>
|
|
336
|
+
useForma({ spec, initialData: { name: " " } })
|
|
337
|
+
);
|
|
338
|
+
expect(resultWhitespace.current.wizard?.canProceed).toBe(false);
|
|
339
|
+
|
|
340
|
+
// Actual value should be valid
|
|
341
|
+
const { result: resultValid } = renderHook(() =>
|
|
342
|
+
useForma({ spec, initialData: { name: "John" } })
|
|
343
|
+
);
|
|
344
|
+
expect(resultValid.current.wizard?.canProceed).toBe(true);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("number fields - null/undefined detection", () => {
|
|
348
|
+
const spec = createTestSpec({
|
|
349
|
+
fields: {
|
|
350
|
+
age: { type: "number", label: "Age", required: true },
|
|
351
|
+
},
|
|
352
|
+
pages: [
|
|
353
|
+
{ id: "page1", title: "Page 1", fields: ["age"] },
|
|
354
|
+
],
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
// null should be invalid
|
|
358
|
+
const { result: resultNull } = renderHook(() =>
|
|
359
|
+
useForma({ spec, initialData: { age: null } })
|
|
360
|
+
);
|
|
361
|
+
expect(resultNull.current.wizard?.canProceed).toBe(false);
|
|
362
|
+
|
|
363
|
+
// undefined should be invalid
|
|
364
|
+
const { result: resultUndefined } = renderHook(() =>
|
|
365
|
+
useForma({ spec, initialData: {} })
|
|
366
|
+
);
|
|
367
|
+
expect(resultUndefined.current.wizard?.canProceed).toBe(false);
|
|
368
|
+
|
|
369
|
+
// 0 should be valid (it's a real number)
|
|
370
|
+
const { result: resultZero } = renderHook(() =>
|
|
371
|
+
useForma({ spec, initialData: { age: 0 } })
|
|
372
|
+
);
|
|
373
|
+
expect(resultZero.current.wizard?.canProceed).toBe(true);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("array fields - minItems validation", () => {
|
|
377
|
+
const spec = createTestSpec({
|
|
378
|
+
fields: {
|
|
379
|
+
items: {
|
|
380
|
+
type: "array",
|
|
381
|
+
label: "Items",
|
|
382
|
+
minItems: 2,
|
|
383
|
+
itemFields: {
|
|
384
|
+
name: { type: "text", label: "Name" },
|
|
385
|
+
},
|
|
386
|
+
},
|
|
387
|
+
},
|
|
388
|
+
pages: [
|
|
389
|
+
{ id: "page1", title: "Page 1", fields: ["items"] },
|
|
390
|
+
],
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
// Empty array with minItems > 0 should be invalid
|
|
394
|
+
const { result: resultEmpty } = renderHook(() =>
|
|
395
|
+
useForma({ spec, initialData: { items: [] } })
|
|
396
|
+
);
|
|
397
|
+
expect(resultEmpty.current.wizard?.canProceed).toBe(false);
|
|
398
|
+
|
|
399
|
+
// 1 item but minItems is 2
|
|
400
|
+
const { result: resultOne } = renderHook(() =>
|
|
401
|
+
useForma({ spec, initialData: { items: [{ name: "Item 1" }] } })
|
|
402
|
+
);
|
|
403
|
+
expect(resultOne.current.wizard?.canProceed).toBe(false);
|
|
404
|
+
|
|
405
|
+
// 2 items should be valid
|
|
406
|
+
const { result: resultTwo } = renderHook(() =>
|
|
407
|
+
useForma({ spec, initialData: { items: [{ name: "Item 1" }, { name: "Item 2" }] } })
|
|
408
|
+
);
|
|
409
|
+
expect(resultTwo.current.wizard?.canProceed).toBe(true);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
describe("edge cases", () => {
|
|
414
|
+
it("empty page (no fields) - should return true", () => {
|
|
415
|
+
const spec = createTestSpec({
|
|
416
|
+
fields: {
|
|
417
|
+
name: { type: "text", label: "Name", required: true },
|
|
418
|
+
},
|
|
419
|
+
pages: [
|
|
420
|
+
{ id: "page1", title: "Empty Page", fields: [] },
|
|
421
|
+
{ id: "page2", title: "Page 2", fields: ["name"] },
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const { result } = renderHook(() =>
|
|
426
|
+
useForma({ spec, initialData: {} })
|
|
427
|
+
);
|
|
428
|
+
|
|
429
|
+
// Empty page should allow progression
|
|
430
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
it("all fields hidden on page - should return true", () => {
|
|
434
|
+
const spec = createTestSpec({
|
|
435
|
+
fields: {
|
|
436
|
+
showFields: { type: "boolean", label: "Show Fields" },
|
|
437
|
+
name: { type: "text", label: "Name", required: true, visibleWhen: "showFields = true" },
|
|
438
|
+
email: { type: "email", label: "Email", required: true, visibleWhen: "showFields = true" },
|
|
439
|
+
},
|
|
440
|
+
pages: [
|
|
441
|
+
{ id: "page1", title: "Page 1", fields: ["showFields", "name", "email"] },
|
|
442
|
+
],
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const { result } = renderHook(() =>
|
|
446
|
+
useForma({ spec, initialData: { showFields: false } })
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
// All required fields are hidden, only showFields is visible (not required)
|
|
450
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it("mixed valid/invalid fields across pages", () => {
|
|
454
|
+
const spec = createTestSpec({
|
|
455
|
+
fields: {
|
|
456
|
+
name: { type: "text", label: "Name", required: true },
|
|
457
|
+
email: { type: "email", label: "Email", required: true },
|
|
458
|
+
phone: { type: "text", label: "Phone", required: true },
|
|
459
|
+
},
|
|
460
|
+
pages: [
|
|
461
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
462
|
+
{ id: "page2", title: "Page 2", fields: ["email"] },
|
|
463
|
+
{ id: "page3", title: "Page 3", fields: ["phone"] },
|
|
464
|
+
],
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const { result } = renderHook(() =>
|
|
468
|
+
useForma({ spec, initialData: { name: "John" } }) // Only page 1 is valid
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
// Page 1 should be valid
|
|
472
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
473
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
474
|
+
|
|
475
|
+
// Navigate to page 2
|
|
476
|
+
act(() => {
|
|
477
|
+
result.current.wizard?.nextPage();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
// Page 2 should be invalid (email empty)
|
|
481
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
482
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("page with only computed fields (display-only)", () => {
|
|
486
|
+
const spec = createTestSpec({
|
|
487
|
+
fields: {
|
|
488
|
+
price: { type: "number", label: "Price", required: true },
|
|
489
|
+
quantity: { type: "number", label: "Quantity", required: true },
|
|
490
|
+
},
|
|
491
|
+
computed: {
|
|
492
|
+
total: { expression: "price * quantity" },
|
|
493
|
+
},
|
|
494
|
+
pages: [
|
|
495
|
+
{ id: "page1", title: "Page 1", fields: ["price", "quantity"] },
|
|
496
|
+
],
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
const { result } = renderHook(() =>
|
|
500
|
+
useForma({ spec, initialData: { price: 10, quantity: 2 } })
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
// Computed fields don't block - only real fields do
|
|
504
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
505
|
+
expect(result.current.computed.total).toBe(20);
|
|
506
|
+
});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
describe("integration with navigation methods", () => {
|
|
510
|
+
it("validateCurrentPage correlates with canProceed", () => {
|
|
511
|
+
const spec = createTestSpec({
|
|
512
|
+
fields: {
|
|
513
|
+
name: { type: "text", label: "Name", required: true },
|
|
514
|
+
},
|
|
515
|
+
pages: [
|
|
516
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
517
|
+
],
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
const { result } = renderHook(() =>
|
|
521
|
+
useForma({ spec, initialData: {} })
|
|
522
|
+
);
|
|
523
|
+
|
|
524
|
+
// Both should return the same result
|
|
525
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
526
|
+
expect(result.current.wizard?.validateCurrentPage()).toBe(false);
|
|
527
|
+
|
|
528
|
+
// Fill the field
|
|
529
|
+
act(() => {
|
|
530
|
+
result.current.setFieldValue("name", "John");
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
534
|
+
expect(result.current.wizard?.validateCurrentPage()).toBe(true);
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it("touchCurrentPageFields reveals errors", () => {
|
|
538
|
+
const spec = createTestSpec({
|
|
539
|
+
fields: {
|
|
540
|
+
name: { type: "text", label: "Name", required: true },
|
|
541
|
+
email: { type: "email", label: "Email", required: true },
|
|
542
|
+
},
|
|
543
|
+
pages: [
|
|
544
|
+
{ id: "page1", title: "Page 1", fields: ["name", "email"] },
|
|
545
|
+
],
|
|
546
|
+
});
|
|
547
|
+
|
|
548
|
+
const { result } = renderHook(() =>
|
|
549
|
+
useForma({ spec, initialData: {}, validateOn: "blur" })
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// Fields not touched yet - errors exist but not visible to user
|
|
553
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
554
|
+
expect(result.current.touched.name).toBeUndefined();
|
|
555
|
+
expect(result.current.touched.email).toBeUndefined();
|
|
556
|
+
|
|
557
|
+
// Touch all fields on current page
|
|
558
|
+
act(() => {
|
|
559
|
+
result.current.wizard?.touchCurrentPageFields();
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
// Now fields are touched - errors should be displayed
|
|
563
|
+
expect(result.current.touched.name).toBe(true);
|
|
564
|
+
expect(result.current.touched.email).toBe(true);
|
|
565
|
+
});
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
describe("canProceed reactivity", () => {
|
|
569
|
+
it("updates reactively when data changes", () => {
|
|
570
|
+
const spec = createTestSpec({
|
|
571
|
+
fields: {
|
|
572
|
+
name: { type: "text", label: "Name", required: true },
|
|
573
|
+
},
|
|
574
|
+
pages: [
|
|
575
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
576
|
+
],
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
const { result } = renderHook(() =>
|
|
580
|
+
useForma({ spec, initialData: {} })
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
584
|
+
|
|
585
|
+
act(() => {
|
|
586
|
+
result.current.setFieldValue("name", "John");
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
590
|
+
|
|
591
|
+
act(() => {
|
|
592
|
+
result.current.setFieldValue("name", "");
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("updates with multiple required fields", () => {
|
|
599
|
+
const spec = createTestSpec({
|
|
600
|
+
fields: {
|
|
601
|
+
firstName: { type: "text", label: "First Name", required: true },
|
|
602
|
+
lastName: { type: "text", label: "Last Name", required: true },
|
|
603
|
+
email: { type: "email", label: "Email", required: true },
|
|
604
|
+
},
|
|
605
|
+
pages: [
|
|
606
|
+
{ id: "page1", title: "Page 1", fields: ["firstName", "lastName", "email"] },
|
|
607
|
+
],
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const { result } = renderHook(() =>
|
|
611
|
+
useForma({ spec, initialData: {} })
|
|
612
|
+
);
|
|
613
|
+
|
|
614
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
615
|
+
|
|
616
|
+
// Fill one field - still invalid
|
|
617
|
+
act(() => {
|
|
618
|
+
result.current.setFieldValue("firstName", "John");
|
|
619
|
+
});
|
|
620
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
621
|
+
|
|
622
|
+
// Fill second field - still invalid
|
|
623
|
+
act(() => {
|
|
624
|
+
result.current.setFieldValue("lastName", "Doe");
|
|
625
|
+
});
|
|
626
|
+
expect(result.current.wizard?.canProceed).toBe(false);
|
|
627
|
+
|
|
628
|
+
// Fill third field - now valid
|
|
629
|
+
act(() => {
|
|
630
|
+
result.current.setFieldValue("email", "john@example.com");
|
|
631
|
+
});
|
|
632
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
633
|
+
});
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
describe("warnings vs errors", () => {
|
|
637
|
+
it("only errors block canProceed, not warnings", () => {
|
|
638
|
+
// Note: This test requires custom FEEL validation rules that produce warnings
|
|
639
|
+
// For now, we test that the error filtering uses severity === 'error'
|
|
640
|
+
const spec = createTestSpec({
|
|
641
|
+
fields: {
|
|
642
|
+
name: { type: "text", label: "Name", required: true },
|
|
643
|
+
},
|
|
644
|
+
pages: [
|
|
645
|
+
{ id: "page1", title: "Page 1", fields: ["name"] },
|
|
646
|
+
],
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const { result } = renderHook(() =>
|
|
650
|
+
useForma({ spec, initialData: { name: "John" } })
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// No errors - should be able to proceed
|
|
654
|
+
expect(result.current.wizard?.canProceed).toBe(true);
|
|
655
|
+
expect(result.current.errors.filter(e => e.severity === "error")).toHaveLength(0);
|
|
656
|
+
});
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
describe("edge cases - hidden pages", () => {
|
|
660
|
+
it("should auto-correct to valid page when current page becomes hidden", () => {
|
|
661
|
+
const spec = createTestSpec({
|
|
662
|
+
fields: {
|
|
663
|
+
showPage2: { type: "boolean", label: "Show Page 2" },
|
|
664
|
+
page1Field: { type: "text", label: "Page 1 Field" },
|
|
665
|
+
page2Field: { type: "text", label: "Page 2 Field", required: true },
|
|
666
|
+
},
|
|
667
|
+
pages: [
|
|
668
|
+
{ id: "page1", title: "Page 1", fields: ["showPage2", "page1Field"] },
|
|
669
|
+
{ id: "page2", title: "Page 2", fields: ["page2Field"], visibleWhen: "showPage2 = true" },
|
|
670
|
+
],
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
const { result } = renderHook(() =>
|
|
674
|
+
useForma({ spec, initialData: { showPage2: true } })
|
|
675
|
+
);
|
|
676
|
+
|
|
677
|
+
// Navigate to page 2
|
|
678
|
+
act(() => {
|
|
679
|
+
result.current.wizard?.nextPage();
|
|
680
|
+
});
|
|
681
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
682
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page2");
|
|
683
|
+
|
|
684
|
+
// Hide page 2 - should auto-correct to page 1
|
|
685
|
+
act(() => {
|
|
686
|
+
result.current.setFieldValue("showPage2", false);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// Should be back on page 1 (the only visible page now)
|
|
690
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
691
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page1");
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it("should skip hidden pages when navigating forward", () => {
|
|
695
|
+
const spec = createTestSpec({
|
|
696
|
+
fields: {
|
|
697
|
+
skipMiddle: { type: "boolean", label: "Skip Middle" },
|
|
698
|
+
page1Field: { type: "text", label: "Page 1 Field" },
|
|
699
|
+
page2Field: { type: "text", label: "Page 2 Field" },
|
|
700
|
+
page3Field: { type: "text", label: "Page 3 Field" },
|
|
701
|
+
},
|
|
702
|
+
pages: [
|
|
703
|
+
{ id: "page1", title: "Page 1", fields: ["skipMiddle", "page1Field"] },
|
|
704
|
+
{ id: "page2", title: "Page 2", fields: ["page2Field"], visibleWhen: "skipMiddle = false" },
|
|
705
|
+
{ id: "page3", title: "Page 3", fields: ["page3Field"] },
|
|
706
|
+
],
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
const { result } = renderHook(() =>
|
|
710
|
+
useForma({ spec, initialData: { skipMiddle: true } })
|
|
711
|
+
);
|
|
712
|
+
|
|
713
|
+
// With skipMiddle=true, page2 is hidden
|
|
714
|
+
// Navigating from page1 should go directly to page3
|
|
715
|
+
act(() => {
|
|
716
|
+
result.current.wizard?.nextPage();
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
// Should be on page3 (which is now index 1 in visible pages)
|
|
720
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page3");
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it("pages array includes all pages with visible property", () => {
|
|
724
|
+
// Note: wizard.pages returns ALL pages defined in spec, with a visible
|
|
725
|
+
// property indicating current visibility state. Consumers should filter
|
|
726
|
+
// by visible when rendering step indicators.
|
|
727
|
+
const spec = createTestSpec({
|
|
728
|
+
fields: {
|
|
729
|
+
showOptional: { type: "boolean", label: "Show Optional" },
|
|
730
|
+
requiredField: { type: "text", label: "Required", required: true },
|
|
731
|
+
optionalField: { type: "text", label: "Optional" },
|
|
732
|
+
},
|
|
733
|
+
pages: [
|
|
734
|
+
{ id: "main", title: "Main", fields: ["showOptional", "requiredField"] },
|
|
735
|
+
{ id: "optional", title: "Optional", fields: ["optionalField"], visibleWhen: "showOptional = true" },
|
|
736
|
+
],
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const { result } = renderHook(() =>
|
|
740
|
+
useForma({ spec, initialData: { showOptional: false } })
|
|
741
|
+
);
|
|
742
|
+
|
|
743
|
+
// All pages are returned
|
|
744
|
+
expect(result.current.wizard?.pages).toHaveLength(2);
|
|
745
|
+
|
|
746
|
+
// Main page is visible
|
|
747
|
+
expect(result.current.wizard?.pages[0].visible).toBe(true);
|
|
748
|
+
// Optional page is hidden when showOptional is false
|
|
749
|
+
expect(result.current.wizard?.pages[1].visible).toBe(false);
|
|
750
|
+
|
|
751
|
+
// Enable optional page
|
|
752
|
+
act(() => {
|
|
753
|
+
result.current.setFieldValue("showOptional", true);
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
// Optional page is now visible
|
|
757
|
+
expect(result.current.wizard?.pages[1].visible).toBe(true);
|
|
758
|
+
});
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
describe("edge cases - navigation bounds", () => {
|
|
762
|
+
it("should not navigate beyond last page", () => {
|
|
763
|
+
const spec = createTestSpec({
|
|
764
|
+
fields: {
|
|
765
|
+
field1: { type: "text", label: "Field 1" },
|
|
766
|
+
field2: { type: "text", label: "Field 2" },
|
|
767
|
+
},
|
|
768
|
+
pages: [
|
|
769
|
+
{ id: "page1", title: "Page 1", fields: ["field1"] },
|
|
770
|
+
{ id: "page2", title: "Page 2", fields: ["field2"] },
|
|
771
|
+
],
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
775
|
+
|
|
776
|
+
// Go to last page
|
|
777
|
+
act(() => {
|
|
778
|
+
result.current.wizard?.nextPage();
|
|
779
|
+
});
|
|
780
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
781
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
782
|
+
|
|
783
|
+
// Try to go beyond - should stay on last page
|
|
784
|
+
act(() => {
|
|
785
|
+
result.current.wizard?.nextPage();
|
|
786
|
+
});
|
|
787
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("should not navigate before first page", () => {
|
|
791
|
+
const spec = createTestSpec({
|
|
792
|
+
fields: {
|
|
793
|
+
field1: { type: "text", label: "Field 1" },
|
|
794
|
+
field2: { type: "text", label: "Field 2" },
|
|
795
|
+
},
|
|
796
|
+
pages: [
|
|
797
|
+
{ id: "page1", title: "Page 1", fields: ["field1"] },
|
|
798
|
+
{ id: "page2", title: "Page 2", fields: ["field2"] },
|
|
799
|
+
],
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
803
|
+
|
|
804
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
805
|
+
expect(result.current.wizard?.hasPreviousPage).toBe(false);
|
|
806
|
+
|
|
807
|
+
// Try to go before first page - should stay on first
|
|
808
|
+
act(() => {
|
|
809
|
+
result.current.wizard?.previousPage();
|
|
810
|
+
});
|
|
811
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
it("goToPage clamps out-of-bounds indices to valid range", () => {
|
|
815
|
+
const spec = createTestSpec({
|
|
816
|
+
fields: {
|
|
817
|
+
field1: { type: "text", label: "Field 1" },
|
|
818
|
+
field2: { type: "text", label: "Field 2" },
|
|
819
|
+
},
|
|
820
|
+
pages: [
|
|
821
|
+
{ id: "page1", title: "Page 1", fields: ["field1"] },
|
|
822
|
+
{ id: "page2", title: "Page 2", fields: ["field2"] },
|
|
823
|
+
],
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
827
|
+
|
|
828
|
+
// goToPage(999) should clamp to last page (index 1)
|
|
829
|
+
act(() => {
|
|
830
|
+
result.current.wizard?.goToPage(999);
|
|
831
|
+
});
|
|
832
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
833
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page2");
|
|
834
|
+
|
|
835
|
+
// goToPage(-5) should clamp to first page (index 0)
|
|
836
|
+
act(() => {
|
|
837
|
+
result.current.wizard?.goToPage(-5);
|
|
838
|
+
});
|
|
839
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
840
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page1");
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it("goToPage should navigate to valid index", () => {
|
|
844
|
+
const spec = createTestSpec({
|
|
845
|
+
fields: {
|
|
846
|
+
field1: { type: "text", label: "Field 1" },
|
|
847
|
+
field2: { type: "text", label: "Field 2" },
|
|
848
|
+
field3: { type: "text", label: "Field 3" },
|
|
849
|
+
},
|
|
850
|
+
pages: [
|
|
851
|
+
{ id: "page1", title: "Page 1", fields: ["field1"] },
|
|
852
|
+
{ id: "page2", title: "Page 2", fields: ["field2"] },
|
|
853
|
+
{ id: "page3", title: "Page 3", fields: ["field3"] },
|
|
854
|
+
],
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
858
|
+
|
|
859
|
+
// Go directly to page 3 (index 2)
|
|
860
|
+
act(() => {
|
|
861
|
+
result.current.wizard?.goToPage(2);
|
|
862
|
+
});
|
|
863
|
+
expect(result.current.wizard?.currentPageIndex).toBe(2);
|
|
864
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page3");
|
|
865
|
+
|
|
866
|
+
// Go back to page 1 (index 0)
|
|
867
|
+
act(() => {
|
|
868
|
+
result.current.wizard?.goToPage(0);
|
|
869
|
+
});
|
|
870
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
871
|
+
expect(result.current.wizard?.currentPage?.id).toBe("page1");
|
|
872
|
+
});
|
|
873
|
+
});
|
|
874
|
+
});
|