@fogpipe/forma-react 0.8.2 → 0.10.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.d.ts +11 -1
- package/dist/index.js +45 -19
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FormRenderer.tsx +11 -3
- package/src/__tests__/canProceed.test.ts +11 -8
- package/src/__tests__/diabetes-trial-flow.test.ts +793 -0
- package/src/__tests__/null-handling.test.ts +439 -0
- package/src/__tests__/useForma.test.ts +129 -0
- package/src/types.ts +11 -1
- package/src/useForma.ts +28 -1
|
@@ -0,0 +1,439 @@
|
|
|
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 initialization", () => {
|
|
20
|
+
it("should auto-initialize boolean fields to false for better UX", () => {
|
|
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
|
+
// Boolean fields are auto-initialized to false (not undefined)
|
|
35
|
+
// This provides better UX - false is a valid answer for "Do you smoke?"
|
|
36
|
+
expect(result.current.data.accepted).toBe(false);
|
|
37
|
+
|
|
38
|
+
// The visibility expression "accepted = true" evaluates properly to false
|
|
39
|
+
expect(result.current.visibility.details).toBe(false);
|
|
40
|
+
|
|
41
|
+
// When user explicitly sets to true, field becomes visible
|
|
42
|
+
act(() => {
|
|
43
|
+
result.current.setFieldValue("accepted", true);
|
|
44
|
+
});
|
|
45
|
+
expect(result.current.visibility.details).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should show fields dependent on '= false' immediately since booleans default to false", () => {
|
|
49
|
+
const spec = createTestSpec({
|
|
50
|
+
fields: {
|
|
51
|
+
signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
|
|
52
|
+
participantName: {
|
|
53
|
+
type: "text",
|
|
54
|
+
label: "Participant Name",
|
|
55
|
+
visibleWhen: "signingOnBehalf = false",
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
61
|
+
|
|
62
|
+
// Boolean field is auto-initialized to false
|
|
63
|
+
expect(result.current.data.signingOnBehalf).toBe(false);
|
|
64
|
+
|
|
65
|
+
// The visibility expression "signingOnBehalf = false" evaluates to true
|
|
66
|
+
// This is the improved UX - participant fields are visible by default
|
|
67
|
+
expect(result.current.visibility.participantName).toBe(true);
|
|
68
|
+
|
|
69
|
+
// Hidden when user sets to true
|
|
70
|
+
act(() => {
|
|
71
|
+
result.current.setFieldValue("signingOnBehalf", true);
|
|
72
|
+
});
|
|
73
|
+
expect(result.current.visibility.participantName).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should work with != null pattern since booleans have initial value", () => {
|
|
77
|
+
const spec = createTestSpec({
|
|
78
|
+
fields: {
|
|
79
|
+
accepted: { type: "boolean", label: "Accept" },
|
|
80
|
+
},
|
|
81
|
+
computed: {
|
|
82
|
+
hasAnswered: {
|
|
83
|
+
expression: "accepted != null",
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
89
|
+
|
|
90
|
+
// Boolean fields start with false, so "accepted != null" is true
|
|
91
|
+
expect(result.current.data.accepted).toBe(false);
|
|
92
|
+
|
|
93
|
+
// Since the field has a value (false), it's not null
|
|
94
|
+
// Note: FEEL may still return null/false for != null comparisons
|
|
95
|
+
const hasAnsweredValue = result.current.computed?.hasAnswered;
|
|
96
|
+
expect(hasAnsweredValue === true || hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("computed field dependency chains", () => {
|
|
101
|
+
it("should propagate null through computed field chains", () => {
|
|
102
|
+
const spec = createTestSpec({
|
|
103
|
+
fields: {
|
|
104
|
+
age: { type: "number", label: "Age" },
|
|
105
|
+
income: { type: "number", label: "Income" },
|
|
106
|
+
},
|
|
107
|
+
computed: {
|
|
108
|
+
// First level - checks if age is provided
|
|
109
|
+
hasAge: { expression: "age != null" },
|
|
110
|
+
// Second level - depends on hasAge
|
|
111
|
+
canProceed: { expression: "computed.hasAge = true" },
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
116
|
+
|
|
117
|
+
// Both fields undefined
|
|
118
|
+
expect(result.current.data.age).toBeUndefined();
|
|
119
|
+
|
|
120
|
+
// The chain: age is undefined → hasAge evaluates to null → canProceed evaluates to null
|
|
121
|
+
// Both computed values should be null or false due to the dependency chain
|
|
122
|
+
const computed = result.current.computed;
|
|
123
|
+
expect(computed?.hasAge === null || computed?.hasAge === false).toBe(true);
|
|
124
|
+
expect(computed?.canProceed === null || computed?.canProceed === false).toBe(true);
|
|
125
|
+
|
|
126
|
+
// When we provide a value, the chain resolves
|
|
127
|
+
act(() => {
|
|
128
|
+
result.current.setFieldValue("age", 25);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(result.current.computed?.hasAge).toBe(true);
|
|
132
|
+
expect(result.current.computed?.canProceed).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should cause page visibility issues when computed chains return null", () => {
|
|
136
|
+
const spec = createTestSpec({
|
|
137
|
+
fields: {
|
|
138
|
+
accepted: { type: "boolean", label: "Accepted" },
|
|
139
|
+
details: { type: "text", label: "Details" },
|
|
140
|
+
},
|
|
141
|
+
computed: {
|
|
142
|
+
isAccepted: { expression: "accepted = true" },
|
|
143
|
+
},
|
|
144
|
+
pages: [
|
|
145
|
+
{ id: "page1", title: "Accept", fields: ["accepted"] },
|
|
146
|
+
{
|
|
147
|
+
id: "page2",
|
|
148
|
+
title: "Details",
|
|
149
|
+
fields: ["details"],
|
|
150
|
+
visibleWhen: "computed.isAccepted = true",
|
|
151
|
+
},
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
156
|
+
|
|
157
|
+
// With undefined accepted, computed.isAccepted is null
|
|
158
|
+
// Page visibility "computed.isAccepted = true" → null = true → null → false
|
|
159
|
+
const pages = result.current.wizard?.pages;
|
|
160
|
+
expect(pages?.[0].visible).toBe(true); // First page always visible
|
|
161
|
+
expect(pages?.[1].visible).toBe(false); // Second page hidden due to null chain
|
|
162
|
+
|
|
163
|
+
// After accepting, the page becomes visible
|
|
164
|
+
act(() => {
|
|
165
|
+
result.current.setFieldValue("accepted", true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
expect(result.current.wizard?.pages?.[1].visible).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("string function null handling", () => {
|
|
173
|
+
it("should handle string length on undefined values", () => {
|
|
174
|
+
const spec = createTestSpec({
|
|
175
|
+
fields: {
|
|
176
|
+
name: { type: "text", label: "Name" },
|
|
177
|
+
greeting: {
|
|
178
|
+
type: "text",
|
|
179
|
+
label: "Greeting",
|
|
180
|
+
// This pattern is unsafe - string length(undefined) returns null
|
|
181
|
+
visibleWhen: "string length(name) > 0",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
187
|
+
|
|
188
|
+
// name is undefined
|
|
189
|
+
expect(result.current.data.name).toBeUndefined();
|
|
190
|
+
|
|
191
|
+
// string length(undefined) returns null in FEEL
|
|
192
|
+
// null > 0 returns null
|
|
193
|
+
// evaluateBoolean converts null to false
|
|
194
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
195
|
+
|
|
196
|
+
// Even empty string returns false (correct behavior)
|
|
197
|
+
act(() => {
|
|
198
|
+
result.current.setFieldValue("name", "");
|
|
199
|
+
});
|
|
200
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
201
|
+
|
|
202
|
+
// Non-empty string works correctly
|
|
203
|
+
act(() => {
|
|
204
|
+
result.current.setFieldValue("name", "John");
|
|
205
|
+
});
|
|
206
|
+
expect(result.current.visibility.greeting).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("should work with null-safe string length pattern", () => {
|
|
210
|
+
const spec = createTestSpec({
|
|
211
|
+
fields: {
|
|
212
|
+
name: { type: "text", label: "Name" },
|
|
213
|
+
greeting: {
|
|
214
|
+
type: "text",
|
|
215
|
+
label: "Greeting",
|
|
216
|
+
// Null-safe pattern: check for null before using string length
|
|
217
|
+
visibleWhen: "name != null and string length(name) > 0",
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
223
|
+
|
|
224
|
+
// name is undefined - first part of AND fails, but returns null not false
|
|
225
|
+
// The behavior is the same as the unsafe pattern in this case
|
|
226
|
+
expect(result.current.visibility.greeting).toBe(false);
|
|
227
|
+
|
|
228
|
+
act(() => {
|
|
229
|
+
result.current.setFieldValue("name", "John");
|
|
230
|
+
});
|
|
231
|
+
expect(result.current.visibility.greeting).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
describe("alternative patterns for null safety", () => {
|
|
236
|
+
it("should use '!= true' pattern for safer boolean checks", () => {
|
|
237
|
+
const spec = createTestSpec({
|
|
238
|
+
fields: {
|
|
239
|
+
signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
|
|
240
|
+
participantFields: {
|
|
241
|
+
type: "text",
|
|
242
|
+
label: "Participant Name",
|
|
243
|
+
// Using != true instead of = false
|
|
244
|
+
// When undefined: undefined != true → should work better
|
|
245
|
+
visibleWhen: "signingOnBehalf != true",
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
251
|
+
|
|
252
|
+
// Test if this pattern is actually safer
|
|
253
|
+
// Note: In FEEL, undefined != true may still return null
|
|
254
|
+
// This test documents the actual behavior
|
|
255
|
+
const visibility = result.current.visibility.participantFields;
|
|
256
|
+
|
|
257
|
+
// Document what actually happens - this may still be false due to null handling
|
|
258
|
+
expect(typeof visibility).toBe("boolean");
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should use explicit 'or' pattern for boolean checks", () => {
|
|
262
|
+
const spec = createTestSpec({
|
|
263
|
+
fields: {
|
|
264
|
+
accepted: { type: "boolean", label: "Accepted" },
|
|
265
|
+
},
|
|
266
|
+
computed: {
|
|
267
|
+
// Pattern to check if field has been answered
|
|
268
|
+
// With auto-initialization to false, this always returns true
|
|
269
|
+
hasAnswered: {
|
|
270
|
+
expression: "accepted = true or accepted = false",
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
276
|
+
|
|
277
|
+
// Boolean fields are auto-initialized to false
|
|
278
|
+
expect(result.current.data.accepted).toBe(false);
|
|
279
|
+
|
|
280
|
+
// Since accepted is false, "accepted = true or accepted = false" is true
|
|
281
|
+
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
282
|
+
|
|
283
|
+
// Set to true
|
|
284
|
+
act(() => {
|
|
285
|
+
result.current.setFieldValue("accepted", true);
|
|
286
|
+
});
|
|
287
|
+
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
288
|
+
|
|
289
|
+
// Set back to false
|
|
290
|
+
act(() => {
|
|
291
|
+
result.current.setFieldValue("accepted", false);
|
|
292
|
+
});
|
|
293
|
+
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe("page navigation with conditional visibility", () => {
|
|
299
|
+
it("should handle eligibility pattern with boolean auto-initialization", () => {
|
|
300
|
+
// With boolean auto-initialization to false, the eligibility pattern changes:
|
|
301
|
+
// - All booleans start as false
|
|
302
|
+
// - "field = true or field = false" is immediately true (since false = false is true)
|
|
303
|
+
// - Eligibility is determined immediately since all booleans have values
|
|
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
|
+
// With auto-initialization, these patterns always return true
|
|
317
|
+
allInclusionAnswered: {
|
|
318
|
+
expression: "ageOk != null and diagnosisOk != null",
|
|
319
|
+
},
|
|
320
|
+
allExclusionAnswered: {
|
|
321
|
+
expression: "pregnant != null and allergy != null",
|
|
322
|
+
},
|
|
323
|
+
eligibilityDetermined: {
|
|
324
|
+
expression:
|
|
325
|
+
"computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
|
|
326
|
+
},
|
|
327
|
+
// All inclusion criteria met
|
|
328
|
+
allInclusionMet: {
|
|
329
|
+
expression: "ageOk = true and diagnosisOk = true",
|
|
330
|
+
},
|
|
331
|
+
// Any exclusion criteria met
|
|
332
|
+
anyExclusionMet: {
|
|
333
|
+
expression: "pregnant = true or allergy = true",
|
|
334
|
+
},
|
|
335
|
+
// Final eligibility
|
|
336
|
+
eligible: {
|
|
337
|
+
expression:
|
|
338
|
+
"computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
pages: [
|
|
342
|
+
{ id: "inclusion", title: "Inclusion Criteria", fields: ["ageOk", "diagnosisOk"] },
|
|
343
|
+
{ id: "exclusion", title: "Exclusion Criteria", fields: ["pregnant", "allergy"] },
|
|
344
|
+
{
|
|
345
|
+
id: "consent",
|
|
346
|
+
title: "Consent",
|
|
347
|
+
fields: ["consent"],
|
|
348
|
+
visibleWhen: "computed.eligible = true",
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
354
|
+
|
|
355
|
+
// All booleans start as false
|
|
356
|
+
expect(result.current.data.ageOk).toBe(false);
|
|
357
|
+
expect(result.current.data.diagnosisOk).toBe(false);
|
|
358
|
+
expect(result.current.data.pregnant).toBe(false);
|
|
359
|
+
expect(result.current.data.allergy).toBe(false);
|
|
360
|
+
|
|
361
|
+
const wizard = result.current.wizard;
|
|
362
|
+
expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
|
|
363
|
+
expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
|
|
364
|
+
|
|
365
|
+
// With auto-initialization:
|
|
366
|
+
// - eligibilityDetermined is true (all fields have values)
|
|
367
|
+
// - allInclusionMet is false (ageOk and diagnosisOk are false)
|
|
368
|
+
// - anyExclusionMet is false (pregnant and allergy are false)
|
|
369
|
+
// - eligible is false (allInclusionMet is false)
|
|
370
|
+
expect(result.current.computed?.eligibilityDetermined).toBe(true);
|
|
371
|
+
expect(result.current.computed?.allInclusionMet).toBe(false);
|
|
372
|
+
expect(result.current.computed?.anyExclusionMet).toBe(false);
|
|
373
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
374
|
+
expect(wizard?.pages[2].visible).toBe(false); // Consent hidden - not eligible yet
|
|
375
|
+
|
|
376
|
+
// Fill inclusion criteria (both true)
|
|
377
|
+
act(() => {
|
|
378
|
+
result.current.setFieldValue("ageOk", true);
|
|
379
|
+
result.current.setFieldValue("diagnosisOk", true);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
// Now eligible - exclusion defaults are already false (no exclusions met)
|
|
383
|
+
expect(result.current.computed?.allInclusionMet).toBe(true);
|
|
384
|
+
expect(result.current.computed?.anyExclusionMet).toBe(false);
|
|
385
|
+
expect(result.current.computed?.eligible).toBe(true);
|
|
386
|
+
expect(result.current.wizard?.pages[2].visible).toBe(true);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("should show ineligibility when exclusion criteria met", () => {
|
|
390
|
+
const spec = createTestSpec({
|
|
391
|
+
fields: {
|
|
392
|
+
ageOk: { type: "boolean", label: "Age OK" },
|
|
393
|
+
pregnant: { type: "boolean", label: "Pregnant" },
|
|
394
|
+
details: { type: "text", label: "Details" },
|
|
395
|
+
ineligibilityReason: { type: "textarea", label: "Reason" },
|
|
396
|
+
},
|
|
397
|
+
computed: {
|
|
398
|
+
eligibilityDetermined: {
|
|
399
|
+
expression: "(ageOk = true or ageOk = false) and (pregnant = true or pregnant = false)",
|
|
400
|
+
},
|
|
401
|
+
eligible: {
|
|
402
|
+
expression: "computed.eligibilityDetermined = true and ageOk = true and pregnant = false",
|
|
403
|
+
},
|
|
404
|
+
ineligible: {
|
|
405
|
+
expression: "computed.eligibilityDetermined = true and (ageOk = false or pregnant = true)",
|
|
406
|
+
},
|
|
407
|
+
},
|
|
408
|
+
pages: [
|
|
409
|
+
{ id: "screening", title: "Screening", fields: ["ageOk", "pregnant"] },
|
|
410
|
+
{
|
|
411
|
+
id: "eligible-flow",
|
|
412
|
+
title: "Continue",
|
|
413
|
+
fields: ["details"],
|
|
414
|
+
visibleWhen: "computed.eligible = true",
|
|
415
|
+
},
|
|
416
|
+
{
|
|
417
|
+
id: "ineligible-flow",
|
|
418
|
+
title: "Ineligible",
|
|
419
|
+
fields: ["ineligibilityReason"],
|
|
420
|
+
visibleWhen: "computed.ineligible = true",
|
|
421
|
+
},
|
|
422
|
+
],
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
426
|
+
|
|
427
|
+
// Mark as pregnant (exclusion criterion met)
|
|
428
|
+
act(() => {
|
|
429
|
+
result.current.setFieldValue("ageOk", true);
|
|
430
|
+
result.current.setFieldValue("pregnant", true);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
// Should show ineligible path, not eligible path
|
|
434
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
435
|
+
expect(result.current.computed?.ineligible).toBe(true);
|
|
436
|
+
expect(result.current.wizard?.pages[1].visible).toBe(false); // eligible-flow hidden
|
|
437
|
+
expect(result.current.wizard?.pages[2].visible).toBe(true); // ineligible-flow visible
|
|
438
|
+
});
|
|
439
|
+
});
|
|
@@ -1172,6 +1172,135 @@ describe("useForma", () => {
|
|
|
1172
1172
|
});
|
|
1173
1173
|
});
|
|
1174
1174
|
|
|
1175
|
+
// ============================================================================
|
|
1176
|
+
// Boolean Field Handling
|
|
1177
|
+
// ============================================================================
|
|
1178
|
+
|
|
1179
|
+
describe("boolean field handling", () => {
|
|
1180
|
+
it("should auto-initialize boolean fields to false", () => {
|
|
1181
|
+
const spec = createTestSpec({
|
|
1182
|
+
fields: {
|
|
1183
|
+
acceptTerms: { type: "boolean" },
|
|
1184
|
+
name: { type: "text" },
|
|
1185
|
+
},
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1189
|
+
|
|
1190
|
+
expect(result.current.data.acceptTerms).toBe(false);
|
|
1191
|
+
expect(result.current.data.name).toBeUndefined();
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
it("should respect explicit initialData for booleans", () => {
|
|
1195
|
+
const spec = createTestSpec({
|
|
1196
|
+
fields: { acceptTerms: { type: "boolean" } },
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1199
|
+
const { result } = renderHook(() =>
|
|
1200
|
+
useForma({ spec, initialData: { acceptTerms: true } })
|
|
1201
|
+
);
|
|
1202
|
+
|
|
1203
|
+
expect(result.current.data.acceptTerms).toBe(true);
|
|
1204
|
+
});
|
|
1205
|
+
|
|
1206
|
+
it("should set showRequiredIndicator=false for required boolean fields without validation (binary question)", () => {
|
|
1207
|
+
// Binary question pattern: "Do you smoke?" - false is a valid answer
|
|
1208
|
+
const spec = createTestSpec({
|
|
1209
|
+
fields: {
|
|
1210
|
+
isSmoker: { type: "boolean", label: "Do you smoke?", required: true },
|
|
1211
|
+
},
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1215
|
+
const props = result.current.getFieldProps("isSmoker");
|
|
1216
|
+
|
|
1217
|
+
expect(props.required).toBe(true);
|
|
1218
|
+
expect(props.showRequiredIndicator).toBe(false); // No asterisk for binary questions
|
|
1219
|
+
});
|
|
1220
|
+
|
|
1221
|
+
it("should set showRequiredIndicator=true for required boolean fields with validation (consent pattern)", () => {
|
|
1222
|
+
// Consent pattern: "I accept terms" - must explicitly check the box
|
|
1223
|
+
const spec = createTestSpec({
|
|
1224
|
+
fields: {
|
|
1225
|
+
acceptTerms: {
|
|
1226
|
+
type: "boolean",
|
|
1227
|
+
label: "I accept the terms",
|
|
1228
|
+
required: true,
|
|
1229
|
+
validations: [{ rule: "value = true", message: "You must accept the terms" }],
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
});
|
|
1233
|
+
|
|
1234
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1235
|
+
const props = result.current.getFieldProps("acceptTerms");
|
|
1236
|
+
|
|
1237
|
+
expect(props.required).toBe(true);
|
|
1238
|
+
expect(props.showRequiredIndicator).toBe(true); // Show asterisk for consent checkboxes
|
|
1239
|
+
});
|
|
1240
|
+
|
|
1241
|
+
it("should set showRequiredIndicator=true for required non-boolean fields", () => {
|
|
1242
|
+
const spec = createTestSpec({
|
|
1243
|
+
fields: {
|
|
1244
|
+
name: { type: "text", required: true },
|
|
1245
|
+
},
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1249
|
+
const props = result.current.getFieldProps("name");
|
|
1250
|
+
|
|
1251
|
+
expect(props.required).toBe(true);
|
|
1252
|
+
expect(props.showRequiredIndicator).toBe(true);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
it("should set showRequiredIndicator=false for non-required fields", () => {
|
|
1256
|
+
const spec = createTestSpec({
|
|
1257
|
+
fields: {
|
|
1258
|
+
name: { type: "text" },
|
|
1259
|
+
},
|
|
1260
|
+
});
|
|
1261
|
+
|
|
1262
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1263
|
+
const props = result.current.getFieldProps("name");
|
|
1264
|
+
|
|
1265
|
+
expect(props.required).toBe(false);
|
|
1266
|
+
expect(props.showRequiredIndicator).toBe(false);
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
it("should initialize multiple boolean fields to false", () => {
|
|
1270
|
+
const spec = createTestSpec({
|
|
1271
|
+
fields: {
|
|
1272
|
+
hasInsurance: { type: "boolean" },
|
|
1273
|
+
isSmoker: { type: "boolean" },
|
|
1274
|
+
hasAllergies: { type: "boolean" },
|
|
1275
|
+
name: { type: "text" },
|
|
1276
|
+
},
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1280
|
+
|
|
1281
|
+
expect(result.current.data.hasInsurance).toBe(false);
|
|
1282
|
+
expect(result.current.data.isSmoker).toBe(false);
|
|
1283
|
+
expect(result.current.data.hasAllergies).toBe(false);
|
|
1284
|
+
expect(result.current.data.name).toBeUndefined();
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it("should pass validation for required boolean field with false value", () => {
|
|
1288
|
+
const spec = createTestSpec({
|
|
1289
|
+
fields: {
|
|
1290
|
+
acceptTerms: { type: "boolean", required: true },
|
|
1291
|
+
},
|
|
1292
|
+
});
|
|
1293
|
+
|
|
1294
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
1295
|
+
|
|
1296
|
+
// Boolean field is auto-initialized to false
|
|
1297
|
+
expect(result.current.data.acceptTerms).toBe(false);
|
|
1298
|
+
|
|
1299
|
+
// Form should be valid since false is a valid present value
|
|
1300
|
+
expect(result.current.isValid).toBe(true);
|
|
1301
|
+
});
|
|
1302
|
+
});
|
|
1303
|
+
|
|
1175
1304
|
// ============================================================================
|
|
1176
1305
|
// validateForm and validateField
|
|
1177
1306
|
// ============================================================================
|
package/src/types.ts
CHANGED
|
@@ -300,6 +300,11 @@ export interface FieldWrapperProps {
|
|
|
300
300
|
errors: FieldError[];
|
|
301
301
|
touched: boolean;
|
|
302
302
|
required: boolean;
|
|
303
|
+
/**
|
|
304
|
+
* Whether to show the required indicator in the UI.
|
|
305
|
+
* False for boolean fields since false is a valid answer.
|
|
306
|
+
*/
|
|
307
|
+
showRequiredIndicator: boolean;
|
|
303
308
|
visible: boolean;
|
|
304
309
|
}
|
|
305
310
|
|
|
@@ -422,8 +427,13 @@ export interface GetFieldPropsResult {
|
|
|
422
427
|
visible: boolean;
|
|
423
428
|
/** Whether field is enabled (not disabled) */
|
|
424
429
|
enabled: boolean;
|
|
425
|
-
/** Whether field is required */
|
|
430
|
+
/** Whether field is required (for validation) */
|
|
426
431
|
required: boolean;
|
|
432
|
+
/**
|
|
433
|
+
* Whether to show the required indicator in the UI.
|
|
434
|
+
* False for boolean fields since false is a valid answer.
|
|
435
|
+
*/
|
|
436
|
+
showRequiredIndicator: boolean;
|
|
427
437
|
/** Whether field has been touched */
|
|
428
438
|
touched: boolean;
|
|
429
439
|
/** Validation errors for this field */
|
package/src/useForma.ts
CHANGED
|
@@ -193,6 +193,23 @@ function formReducer(state: FormState, action: FormAction): FormState {
|
|
|
193
193
|
}
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Get default initial values for boolean fields.
|
|
198
|
+
* Boolean fields default to false to avoid undefined state,
|
|
199
|
+
* which provides better UX since false is a valid answer.
|
|
200
|
+
*/
|
|
201
|
+
function getDefaultBooleanValues(spec: Forma): Record<string, boolean> {
|
|
202
|
+
const defaults: Record<string, boolean> = {};
|
|
203
|
+
for (const fieldPath of spec.fieldOrder) {
|
|
204
|
+
const schemaProperty = spec.schema.properties?.[fieldPath];
|
|
205
|
+
const fieldDef = spec.fields[fieldPath];
|
|
206
|
+
if (schemaProperty?.type === "boolean" || fieldDef?.type === "boolean") {
|
|
207
|
+
defaults[fieldPath] = false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return defaults;
|
|
211
|
+
}
|
|
212
|
+
|
|
196
213
|
/**
|
|
197
214
|
* Main Forma hook
|
|
198
215
|
*/
|
|
@@ -212,7 +229,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
212
229
|
}, [inputSpec, referenceData]);
|
|
213
230
|
|
|
214
231
|
const [state, dispatch] = useReducer(formReducer, {
|
|
215
|
-
data: initialData,
|
|
232
|
+
data: { ...getDefaultBooleanValues(spec), ...initialData }, // Boolean defaults merged UNDER initialData
|
|
216
233
|
touched: {},
|
|
217
234
|
isSubmitting: false,
|
|
218
235
|
isSubmitted: false,
|
|
@@ -569,6 +586,14 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
569
586
|
const hasErrors = displayedErrors.length > 0;
|
|
570
587
|
const isRequired = required[path] ?? false;
|
|
571
588
|
|
|
589
|
+
// Boolean fields: hide asterisk unless they have validation rules (consent pattern)
|
|
590
|
+
// - Binary question ("Do you smoke?"): no validation → false is valid → hide asterisk
|
|
591
|
+
// - Consent checkbox ("I accept terms"): has validation rule → show asterisk
|
|
592
|
+
const schemaProperty = spec.schema.properties[path];
|
|
593
|
+
const isBooleanField = schemaProperty?.type === "boolean" || fieldDef?.type === "boolean";
|
|
594
|
+
const hasValidationRules = (fieldDef?.validations?.length ?? 0) > 0;
|
|
595
|
+
const showRequiredIndicator = isRequired && (!isBooleanField || hasValidationRules);
|
|
596
|
+
|
|
572
597
|
return {
|
|
573
598
|
name: path,
|
|
574
599
|
value: getValueAtPath(path),
|
|
@@ -579,6 +604,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
579
604
|
visible: visibility[path] !== false,
|
|
580
605
|
enabled: enabled[path] !== false,
|
|
581
606
|
required: isRequired,
|
|
607
|
+
showRequiredIndicator,
|
|
582
608
|
touched: isTouched,
|
|
583
609
|
errors: displayedErrors,
|
|
584
610
|
onChange: handlers.onChange,
|
|
@@ -634,6 +660,7 @@ export function useForma(options: UseFormaOptions): UseFormaReturn {
|
|
|
634
660
|
visible: true,
|
|
635
661
|
enabled: enabled[path] !== false,
|
|
636
662
|
required: false, // TODO: Evaluate item field required
|
|
663
|
+
showRequiredIndicator: false, // Item fields don't show required indicator
|
|
637
664
|
touched: isTouched,
|
|
638
665
|
errors: showErrors ? fieldErrors : [],
|
|
639
666
|
onChange: handlers.onChange,
|