@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.
@@ -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,