@fogpipe/forma-react 0.8.1 → 0.9.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/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__/diabetes-trial-flow.test.ts +776 -0
- package/src/__tests__/null-handling.test.ts +436 -0
- package/src/__tests__/test-utils.tsx +1 -0
- package/src/__tests__/useForma.test.ts +111 -0
- package/src/useForma.ts +38 -10
|
@@ -0,0 +1,436 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests documenting FEEL null handling behavior in visibility expressions
|
|
3
|
+
*
|
|
4
|
+
* These tests document the behavior where FEEL expressions that evaluate to `null`
|
|
5
|
+
* (due to undefined field references) are silently converted to `false` by
|
|
6
|
+
* the forma-core engine. This can cause unexpected behavior where pages and fields
|
|
7
|
+
* become invisible not because the condition is false, but because it couldn't
|
|
8
|
+
* be evaluated.
|
|
9
|
+
*
|
|
10
|
+
* Root cause: forma-core's evaluateBoolean() converts null to false without warning.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from "vitest";
|
|
14
|
+
import { renderHook, act } from "@testing-library/react";
|
|
15
|
+
import { useForma } from "../useForma.js";
|
|
16
|
+
import { createTestSpec } from "./test-utils.js";
|
|
17
|
+
|
|
18
|
+
describe("FEEL null handling in visibility expressions", () => {
|
|
19
|
+
describe("boolean field null checks", () => {
|
|
20
|
+
it("should treat undefined boolean as not matching '= true'", () => {
|
|
21
|
+
const spec = createTestSpec({
|
|
22
|
+
fields: {
|
|
23
|
+
accepted: { type: "boolean", label: "Accept terms" },
|
|
24
|
+
details: {
|
|
25
|
+
type: "text",
|
|
26
|
+
label: "Details",
|
|
27
|
+
visibleWhen: "accepted = true",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
33
|
+
|
|
34
|
+
// Field is undefined initially
|
|
35
|
+
expect(result.current.data.accepted).toBeUndefined();
|
|
36
|
+
|
|
37
|
+
// The visibility expression "accepted = true" evaluates to null when accepted is undefined
|
|
38
|
+
// forma-core converts this null to false, so the field is hidden
|
|
39
|
+
// This happens to be the "correct" behavior by accident
|
|
40
|
+
expect(result.current.visibility.details).toBe(false);
|
|
41
|
+
|
|
42
|
+
// When user explicitly sets to true, field becomes visible
|
|
43
|
+
act(() => {
|
|
44
|
+
result.current.setFieldValue("accepted", true);
|
|
45
|
+
});
|
|
46
|
+
expect(result.current.visibility.details).toBe(true);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("should treat undefined boolean as not matching '= false'", () => {
|
|
50
|
+
const spec = createTestSpec({
|
|
51
|
+
fields: {
|
|
52
|
+
signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
|
|
53
|
+
participantName: {
|
|
54
|
+
type: "text",
|
|
55
|
+
label: "Participant Name",
|
|
56
|
+
visibleWhen: "signingOnBehalf = false",
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
62
|
+
|
|
63
|
+
// Field is undefined initially
|
|
64
|
+
expect(result.current.data.signingOnBehalf).toBeUndefined();
|
|
65
|
+
|
|
66
|
+
// The visibility expression "signingOnBehalf = false" evaluates to null when undefined
|
|
67
|
+
// This means the field is hidden even though the user hasn't made a choice yet
|
|
68
|
+
// This is problematic - the field should arguably be visible until the user chooses "true"
|
|
69
|
+
expect(result.current.visibility.participantName).toBe(false);
|
|
70
|
+
|
|
71
|
+
// Only becomes visible when explicitly set to false
|
|
72
|
+
act(() => {
|
|
73
|
+
result.current.setFieldValue("signingOnBehalf", false);
|
|
74
|
+
});
|
|
75
|
+
expect(result.current.visibility.participantName).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("should demonstrate the != null pattern also returns null on undefined", () => {
|
|
79
|
+
const spec = createTestSpec({
|
|
80
|
+
fields: {
|
|
81
|
+
accepted: { type: "boolean", label: "Accept" },
|
|
82
|
+
},
|
|
83
|
+
computed: {
|
|
84
|
+
hasAnswered: {
|
|
85
|
+
expression: "accepted != null",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
91
|
+
|
|
92
|
+
// When accepted is undefined, "accepted != null" in FEEL returns null (not true or false)
|
|
93
|
+
// This is because FEEL's null comparison semantics are different from JavaScript
|
|
94
|
+
// The computed value becomes null, which may cause downstream issues
|
|
95
|
+
expect(result.current.data.accepted).toBeUndefined();
|
|
96
|
+
|
|
97
|
+
// Note: computed values that are null may show as null or undefined
|
|
98
|
+
// depending on how forma-core handles them
|
|
99
|
+
const hasAnsweredValue = result.current.computed?.hasAnswered;
|
|
100
|
+
// This documents the actual behavior - it may be null instead of false
|
|
101
|
+
expect(hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("computed field dependency chains", () => {
|
|
106
|
+
it("should propagate null through computed field chains", () => {
|
|
107
|
+
const spec = createTestSpec({
|
|
108
|
+
fields: {
|
|
109
|
+
age: { type: "number", label: "Age" },
|
|
110
|
+
income: { type: "number", label: "Income" },
|
|
111
|
+
},
|
|
112
|
+
computed: {
|
|
113
|
+
// First level - checks if age is provided
|
|
114
|
+
hasAge: { expression: "age != null" },
|
|
115
|
+
// Second level - depends on hasAge
|
|
116
|
+
canProceed: { expression: "computed.hasAge = true" },
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
121
|
+
|
|
122
|
+
// Both fields undefined
|
|
123
|
+
expect(result.current.data.age).toBeUndefined();
|
|
124
|
+
|
|
125
|
+
// The chain: age is undefined → hasAge evaluates to null → canProceed evaluates to null
|
|
126
|
+
// Both computed values should be null or false due to the dependency chain
|
|
127
|
+
const computed = result.current.computed;
|
|
128
|
+
expect(computed?.hasAge === null || computed?.hasAge === false).toBe(true);
|
|
129
|
+
expect(computed?.canProceed === null || computed?.canProceed === false).toBe(true);
|
|
130
|
+
|
|
131
|
+
// When we provide a value, the chain resolves
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.setFieldValue("age", 25);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(result.current.computed?.hasAge).toBe(true);
|
|
137
|
+
expect(result.current.computed?.canProceed).toBe(true);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("should cause page visibility issues when computed chains return null", () => {
|
|
141
|
+
const spec = createTestSpec({
|
|
142
|
+
fields: {
|
|
143
|
+
accepted: { type: "boolean", label: "Accepted" },
|
|
144
|
+
details: { type: "text", label: "Details" },
|
|
145
|
+
},
|
|
146
|
+
computed: {
|
|
147
|
+
isAccepted: { expression: "accepted = true" },
|
|
148
|
+
},
|
|
149
|
+
pages: [
|
|
150
|
+
{ id: "page1", title: "Accept", fields: ["accepted"] },
|
|
151
|
+
{
|
|
152
|
+
id: "page2",
|
|
153
|
+
title: "Details",
|
|
154
|
+
fields: ["details"],
|
|
155
|
+
visibleWhen: "computed.isAccepted = true",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
161
|
+
|
|
162
|
+
// With undefined accepted, computed.isAccepted is null
|
|
163
|
+
// Page visibility "computed.isAccepted = true" → null = true → null → false
|
|
164
|
+
const pages = result.current.wizard?.pages;
|
|
165
|
+
expect(pages?.[0].visible).toBe(true); // First page always visible
|
|
166
|
+
expect(pages?.[1].visible).toBe(false); // Second page hidden due to null chain
|
|
167
|
+
|
|
168
|
+
// After accepting, the page becomes visible
|
|
169
|
+
act(() => {
|
|
170
|
+
result.current.setFieldValue("accepted", true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
expect(result.current.wizard?.pages?.[1].visible).toBe(true);
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("string function null handling", () => {
|
|
178
|
+
it("should handle string length on undefined values", () => {
|
|
179
|
+
const spec = createTestSpec({
|
|
180
|
+
fields: {
|
|
181
|
+
name: { type: "text", label: "Name" },
|
|
182
|
+
greeting: {
|
|
183
|
+
type: "text",
|
|
184
|
+
label: "Greeting",
|
|
185
|
+
// This pattern is unsafe - string length(undefined) returns null
|
|
186
|
+
visibleWhen: "string length(name) > 0",
|
|
187
|
+
},
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
192
|
+
|
|
193
|
+
// name is undefined
|
|
194
|
+
expect(result.current.data.name).toBeUndefined();
|
|
195
|
+
|
|
196
|
+
// string length(undefined) returns null in FEEL
|
|
197
|
+
// null > 0 returns null
|
|
198
|
+
// evaluateBoolean converts null to false
|
|
199
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
200
|
+
|
|
201
|
+
// Even empty string returns false (correct behavior)
|
|
202
|
+
act(() => {
|
|
203
|
+
result.current.setFieldValue("name", "");
|
|
204
|
+
});
|
|
205
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
206
|
+
|
|
207
|
+
// Non-empty string works correctly
|
|
208
|
+
act(() => {
|
|
209
|
+
result.current.setFieldValue("name", "John");
|
|
210
|
+
});
|
|
211
|
+
expect(result.current.visibility.greeting).toBe(true);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should work with null-safe string length pattern", () => {
|
|
215
|
+
const spec = createTestSpec({
|
|
216
|
+
fields: {
|
|
217
|
+
name: { type: "text", label: "Name" },
|
|
218
|
+
greeting: {
|
|
219
|
+
type: "text",
|
|
220
|
+
label: "Greeting",
|
|
221
|
+
// Null-safe pattern: check for null before using string length
|
|
222
|
+
visibleWhen: "name != null and string length(name) > 0",
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
228
|
+
|
|
229
|
+
// name is undefined - first part of AND fails, but returns null not false
|
|
230
|
+
// The behavior is the same as the unsafe pattern in this case
|
|
231
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
232
|
+
|
|
233
|
+
act(() => {
|
|
234
|
+
result.current.setFieldValue("name", "John");
|
|
235
|
+
});
|
|
236
|
+
expect(result.current.visibility.greeting).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("alternative patterns for null safety", () => {
|
|
241
|
+
it("should use '!= true' pattern for safer boolean checks", () => {
|
|
242
|
+
const spec = createTestSpec({
|
|
243
|
+
fields: {
|
|
244
|
+
signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
|
|
245
|
+
participantFields: {
|
|
246
|
+
type: "text",
|
|
247
|
+
label: "Participant Name",
|
|
248
|
+
// Using != true instead of = false
|
|
249
|
+
// When undefined: undefined != true → should work better
|
|
250
|
+
visibleWhen: "signingOnBehalf != true",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
256
|
+
|
|
257
|
+
// Test if this pattern is actually safer
|
|
258
|
+
// Note: In FEEL, undefined != true may still return null
|
|
259
|
+
// This test documents the actual behavior
|
|
260
|
+
const visibility = result.current.visibility.participantFields;
|
|
261
|
+
|
|
262
|
+
// Document what actually happens - this may still be false due to null handling
|
|
263
|
+
expect(typeof visibility).toBe("boolean");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("should use explicit 'or' pattern for boolean checks", () => {
|
|
267
|
+
const spec = createTestSpec({
|
|
268
|
+
fields: {
|
|
269
|
+
accepted: { type: "boolean", label: "Accepted" },
|
|
270
|
+
},
|
|
271
|
+
computed: {
|
|
272
|
+
// Safer pattern: explicitly check for both true and false
|
|
273
|
+
hasAnswered: {
|
|
274
|
+
expression: "accepted = true or accepted = false",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
280
|
+
|
|
281
|
+
// When undefined: (undefined = true) or (undefined = false)
|
|
282
|
+
// → null or null → null (FEEL or short-circuits on null)
|
|
283
|
+
// This pattern may not actually help with FEEL's null semantics
|
|
284
|
+
expect(result.current.data.accepted).toBeUndefined();
|
|
285
|
+
|
|
286
|
+
// Set to true
|
|
287
|
+
act(() => {
|
|
288
|
+
result.current.setFieldValue("accepted", true);
|
|
289
|
+
});
|
|
290
|
+
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
291
|
+
|
|
292
|
+
// Set to false
|
|
293
|
+
act(() => {
|
|
294
|
+
result.current.setFieldValue("accepted", false);
|
|
295
|
+
});
|
|
296
|
+
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
describe("page navigation with conditional visibility", () => {
|
|
302
|
+
it("should handle complex eligibility determination pattern", () => {
|
|
303
|
+
// This test reproduces the diabetes trial enrollment pattern
|
|
304
|
+
const spec = createTestSpec({
|
|
305
|
+
fields: {
|
|
306
|
+
// Inclusion criteria (all must be true to be eligible)
|
|
307
|
+
ageOk: { type: "boolean", label: "Age between 18-65" },
|
|
308
|
+
diagnosisOk: { type: "boolean", label: "Has diabetes diagnosis" },
|
|
309
|
+
// Exclusion criteria (all must be false to be eligible)
|
|
310
|
+
pregnant: { type: "boolean", label: "Is pregnant" },
|
|
311
|
+
allergy: { type: "boolean", label: "Has drug allergy" },
|
|
312
|
+
// Consent page field
|
|
313
|
+
consent: { type: "boolean", label: "I consent" },
|
|
314
|
+
},
|
|
315
|
+
computed: {
|
|
316
|
+
// Check if all inclusion answered
|
|
317
|
+
allInclusionAnswered: {
|
|
318
|
+
expression: "ageOk != null and diagnosisOk != null",
|
|
319
|
+
},
|
|
320
|
+
// Check if all exclusion answered
|
|
321
|
+
allExclusionAnswered: {
|
|
322
|
+
expression: "pregnant != null and allergy != null",
|
|
323
|
+
},
|
|
324
|
+
// Eligibility determined when all criteria answered
|
|
325
|
+
eligibilityDetermined: {
|
|
326
|
+
expression:
|
|
327
|
+
"computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
|
|
328
|
+
},
|
|
329
|
+
// All inclusion criteria met
|
|
330
|
+
allInclusionMet: {
|
|
331
|
+
expression: "ageOk = true and diagnosisOk = true",
|
|
332
|
+
},
|
|
333
|
+
// Any exclusion criteria met
|
|
334
|
+
anyExclusionMet: {
|
|
335
|
+
expression: "pregnant = true or allergy = true",
|
|
336
|
+
},
|
|
337
|
+
// Final eligibility
|
|
338
|
+
eligible: {
|
|
339
|
+
expression:
|
|
340
|
+
"computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
pages: [
|
|
344
|
+
{ id: "inclusion", title: "Inclusion Criteria", fields: ["ageOk", "diagnosisOk"] },
|
|
345
|
+
{ id: "exclusion", title: "Exclusion Criteria", fields: ["pregnant", "allergy"] },
|
|
346
|
+
{
|
|
347
|
+
id: "consent",
|
|
348
|
+
title: "Consent",
|
|
349
|
+
fields: ["consent"],
|
|
350
|
+
visibleWhen: "computed.eligible = true",
|
|
351
|
+
},
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
356
|
+
|
|
357
|
+
// Initially, all computed values are null due to undefined fields
|
|
358
|
+
const wizard = result.current.wizard;
|
|
359
|
+
expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
|
|
360
|
+
expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
|
|
361
|
+
expect(wizard?.pages[2].visible).toBe(false); // Consent hidden (computed.eligible is null)
|
|
362
|
+
|
|
363
|
+
// Fill inclusion criteria (both true)
|
|
364
|
+
act(() => {
|
|
365
|
+
result.current.setFieldValue("ageOk", true);
|
|
366
|
+
result.current.setFieldValue("diagnosisOk", true);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Still not eligible - exclusion not answered yet
|
|
370
|
+
expect(result.current.wizard?.pages[2].visible).toBe(false);
|
|
371
|
+
|
|
372
|
+
// Fill exclusion criteria (both false - no exclusions met)
|
|
373
|
+
act(() => {
|
|
374
|
+
result.current.setFieldValue("pregnant", false);
|
|
375
|
+
result.current.setFieldValue("allergy", false);
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// Now eligible - consent page should be visible
|
|
379
|
+
expect(result.current.computed?.eligibilityDetermined).toBe(true);
|
|
380
|
+
expect(result.current.computed?.allInclusionMet).toBe(true);
|
|
381
|
+
expect(result.current.computed?.anyExclusionMet).toBe(false);
|
|
382
|
+
expect(result.current.computed?.eligible).toBe(true);
|
|
383
|
+
expect(result.current.wizard?.pages[2].visible).toBe(true);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("should show ineligibility when exclusion criteria met", () => {
|
|
387
|
+
const spec = createTestSpec({
|
|
388
|
+
fields: {
|
|
389
|
+
ageOk: { type: "boolean", label: "Age OK" },
|
|
390
|
+
pregnant: { type: "boolean", label: "Pregnant" },
|
|
391
|
+
details: { type: "text", label: "Details" },
|
|
392
|
+
ineligibilityReason: { type: "textarea", label: "Reason" },
|
|
393
|
+
},
|
|
394
|
+
computed: {
|
|
395
|
+
eligibilityDetermined: {
|
|
396
|
+
expression: "(ageOk = true or ageOk = false) and (pregnant = true or pregnant = false)",
|
|
397
|
+
},
|
|
398
|
+
eligible: {
|
|
399
|
+
expression: "computed.eligibilityDetermined = true and ageOk = true and pregnant = false",
|
|
400
|
+
},
|
|
401
|
+
ineligible: {
|
|
402
|
+
expression: "computed.eligibilityDetermined = true and (ageOk = false or pregnant = true)",
|
|
403
|
+
},
|
|
404
|
+
},
|
|
405
|
+
pages: [
|
|
406
|
+
{ id: "screening", title: "Screening", fields: ["ageOk", "pregnant"] },
|
|
407
|
+
{
|
|
408
|
+
id: "eligible-flow",
|
|
409
|
+
title: "Continue",
|
|
410
|
+
fields: ["details"],
|
|
411
|
+
visibleWhen: "computed.eligible = true",
|
|
412
|
+
},
|
|
413
|
+
{
|
|
414
|
+
id: "ineligible-flow",
|
|
415
|
+
title: "Ineligible",
|
|
416
|
+
fields: ["ineligibilityReason"],
|
|
417
|
+
visibleWhen: "computed.ineligible = true",
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
423
|
+
|
|
424
|
+
// Mark as pregnant (exclusion criterion met)
|
|
425
|
+
act(() => {
|
|
426
|
+
result.current.setFieldValue("ageOk", true);
|
|
427
|
+
result.current.setFieldValue("pregnant", true);
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Should show ineligible path, not eligible path
|
|
431
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
432
|
+
expect(result.current.computed?.ineligible).toBe(true);
|
|
433
|
+
expect(result.current.wizard?.pages[1].visible).toBe(false); // eligible-flow hidden
|
|
434
|
+
expect(result.current.wizard?.pages[2].visible).toBe(true); // ineligible-flow visible
|
|
435
|
+
});
|
|
436
|
+
});
|
|
@@ -571,6 +571,115 @@ describe("useForma", () => {
|
|
|
571
571
|
|
|
572
572
|
expect(result.current.isSubmitted).toBe(false);
|
|
573
573
|
});
|
|
574
|
+
|
|
575
|
+
describe("validation debouncing", () => {
|
|
576
|
+
it("should debounce validation updates when validationDebounceMs is set", async () => {
|
|
577
|
+
vi.useFakeTimers();
|
|
578
|
+
|
|
579
|
+
const spec = createTestSpec({
|
|
580
|
+
fields: {
|
|
581
|
+
name: { type: "text", required: true },
|
|
582
|
+
},
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
const { result } = renderHook(() =>
|
|
586
|
+
useForma({ spec, validationDebounceMs: 100 })
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
// Initially invalid (required field empty)
|
|
590
|
+
expect(result.current.isValid).toBe(false);
|
|
591
|
+
|
|
592
|
+
// Fill the field - validation should be debounced
|
|
593
|
+
act(() => {
|
|
594
|
+
result.current.setFieldValue("name", "John");
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
// Immediately after change, debounced validation still shows old state
|
|
598
|
+
// (depending on implementation, this might already be updated)
|
|
599
|
+
|
|
600
|
+
// Fast-forward past debounce timeout
|
|
601
|
+
await act(async () => {
|
|
602
|
+
vi.advanceTimersByTime(150);
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
// Now validation should reflect the new state
|
|
606
|
+
expect(result.current.isValid).toBe(true);
|
|
607
|
+
|
|
608
|
+
vi.useRealTimers();
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
it("should use immediate validation on submit even when debouncing", async () => {
|
|
612
|
+
vi.useFakeTimers();
|
|
613
|
+
|
|
614
|
+
const onSubmit = vi.fn();
|
|
615
|
+
const spec = createTestSpec({
|
|
616
|
+
fields: {
|
|
617
|
+
name: { type: "text", required: true },
|
|
618
|
+
},
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
const { result } = renderHook(() =>
|
|
622
|
+
useForma({ spec, validationDebounceMs: 500, onSubmit })
|
|
623
|
+
);
|
|
624
|
+
|
|
625
|
+
// Fill the field
|
|
626
|
+
act(() => {
|
|
627
|
+
result.current.setFieldValue("name", "John");
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
// Submit immediately without waiting for debounce
|
|
631
|
+
await act(async () => {
|
|
632
|
+
await result.current.submitForm();
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
// onSubmit should be called because immediate validation passes
|
|
636
|
+
expect(onSubmit).toHaveBeenCalledWith({ name: "John" });
|
|
637
|
+
|
|
638
|
+
vi.useRealTimers();
|
|
639
|
+
});
|
|
640
|
+
|
|
641
|
+
it("should not call onSubmit when immediate validation fails", async () => {
|
|
642
|
+
const onSubmit = vi.fn();
|
|
643
|
+
const spec = createTestSpec({
|
|
644
|
+
fields: {
|
|
645
|
+
name: { type: "text", required: true },
|
|
646
|
+
},
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
const { result } = renderHook(() =>
|
|
650
|
+
useForma({ spec, validationDebounceMs: 100, onSubmit })
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
// Submit without filling required field
|
|
654
|
+
await act(async () => {
|
|
655
|
+
await result.current.submitForm();
|
|
656
|
+
});
|
|
657
|
+
|
|
658
|
+
// onSubmit should NOT be called
|
|
659
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it("should work without debouncing (validationDebounceMs: 0)", () => {
|
|
663
|
+
const spec = createTestSpec({
|
|
664
|
+
fields: {
|
|
665
|
+
name: { type: "text", required: true },
|
|
666
|
+
},
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
const { result } = renderHook(() =>
|
|
670
|
+
useForma({ spec, validationDebounceMs: 0 })
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
expect(result.current.isValid).toBe(false);
|
|
674
|
+
|
|
675
|
+
act(() => {
|
|
676
|
+
result.current.setFieldValue("name", "John");
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Validation should update immediately
|
|
680
|
+
expect(result.current.isValid).toBe(true);
|
|
681
|
+
});
|
|
682
|
+
});
|
|
574
683
|
});
|
|
575
684
|
|
|
576
685
|
// ============================================================================
|
|
@@ -933,6 +1042,8 @@ describe("useForma", () => {
|
|
|
933
1042
|
const page2After = result.current.wizard?.pages.find((p) => p.id === "page2");
|
|
934
1043
|
expect(page2After?.visible).toBe(true);
|
|
935
1044
|
});
|
|
1045
|
+
|
|
1046
|
+
// Note: Comprehensive canProceed tests are in canProceed.test.ts
|
|
936
1047
|
});
|
|
937
1048
|
|
|
938
1049
|
// ============================================================================
|
package/src/useForma.ts
CHANGED
|
@@ -388,29 +388,57 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
388
388
|
|
|
389
389
|
// For navigation, only count visible pages
|
|
390
390
|
const visiblePages = pages.filter((p) => p.visible);
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
const
|
|
391
|
+
|
|
392
|
+
// Clamp currentPage to valid range (handles case where current page becomes hidden)
|
|
393
|
+
const maxPageIndex = Math.max(0, visiblePages.length - 1);
|
|
394
|
+
const clampedPageIndex = Math.min(Math.max(0, state.currentPage), maxPageIndex);
|
|
395
|
+
|
|
396
|
+
// Auto-correct page index if it's out of bounds
|
|
397
|
+
if (clampedPageIndex !== state.currentPage && visiblePages.length > 0) {
|
|
398
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex });
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const currentPage = visiblePages[clampedPageIndex] || null;
|
|
402
|
+
const hasNextPage = clampedPageIndex < visiblePages.length - 1;
|
|
403
|
+
const hasPreviousPage = clampedPageIndex > 0;
|
|
404
|
+
const isLastPage = clampedPageIndex === visiblePages.length - 1;
|
|
395
405
|
|
|
396
406
|
return {
|
|
397
407
|
pages,
|
|
398
|
-
currentPageIndex:
|
|
408
|
+
currentPageIndex: clampedPageIndex,
|
|
399
409
|
currentPage,
|
|
400
|
-
goToPage: (index: number) =>
|
|
410
|
+
goToPage: (index: number) => {
|
|
411
|
+
// Clamp to valid range
|
|
412
|
+
const validIndex = Math.min(Math.max(0, index), maxPageIndex);
|
|
413
|
+
dispatch({ type: "SET_PAGE", page: validIndex });
|
|
414
|
+
},
|
|
401
415
|
nextPage: () => {
|
|
402
416
|
if (hasNextPage) {
|
|
403
|
-
dispatch({ type: "SET_PAGE", page:
|
|
417
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex + 1 });
|
|
404
418
|
}
|
|
405
419
|
},
|
|
406
420
|
previousPage: () => {
|
|
407
421
|
if (hasPreviousPage) {
|
|
408
|
-
dispatch({ type: "SET_PAGE", page:
|
|
422
|
+
dispatch({ type: "SET_PAGE", page: clampedPageIndex - 1 });
|
|
409
423
|
}
|
|
410
424
|
},
|
|
411
425
|
hasNextPage,
|
|
412
426
|
hasPreviousPage,
|
|
413
|
-
canProceed:
|
|
427
|
+
canProceed: (() => {
|
|
428
|
+
if (!currentPage) return true;
|
|
429
|
+
// Get errors only for visible fields on the current page
|
|
430
|
+
const pageErrors = validation.errors.filter((e) => {
|
|
431
|
+
// Check if field is on current page (including array items like "items[0].name")
|
|
432
|
+
const isOnCurrentPage = currentPage.fields.includes(e.field) ||
|
|
433
|
+
currentPage.fields.some(f => e.field.startsWith(`${f}[`));
|
|
434
|
+
// Only count errors for visible fields
|
|
435
|
+
const isVisible = visibility[e.field] !== false;
|
|
436
|
+
// Only count actual errors, not warnings
|
|
437
|
+
const isError = e.severity === 'error';
|
|
438
|
+
return isOnCurrentPage && isVisible && isError;
|
|
439
|
+
});
|
|
440
|
+
return pageErrors.length === 0;
|
|
441
|
+
})(),
|
|
414
442
|
isLastPage,
|
|
415
443
|
touchCurrentPageFields: () => {
|
|
416
444
|
if (currentPage) {
|
|
@@ -427,7 +455,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
427
455
|
return pageErrors.length === 0;
|
|
428
456
|
},
|
|
429
457
|
};
|
|
430
|
-
}, [spec, state.data, state.currentPage, computed, validation]);
|
|
458
|
+
}, [spec, state.data, state.currentPage, computed, validation, visibility]);
|
|
431
459
|
|
|
432
460
|
// Helper to get value at nested path
|
|
433
461
|
const getValueAtPath = useCallback((path: string): unknown => {
|