@fogpipe/forma-react 0.9.0 → 0.10.1
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 +65 -89
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/FormRenderer.tsx +38 -84
- package/src/__tests__/FormRenderer.test.tsx +167 -0
- package/src/__tests__/canProceed.test.ts +11 -8
- package/src/__tests__/diabetes-trial-flow.test.ts +41 -24
- package/src/__tests__/null-handling.test.ts +53 -50
- package/src/__tests__/useForma.test.ts +129 -0
- package/src/types.ts +11 -1
- package/src/useForma.ts +28 -1
|
@@ -799,5 +799,172 @@ describe("FormRenderer", () => {
|
|
|
799
799
|
expect(screen.queryByTestId("array-item-items-1")).not.toBeInTheDocument();
|
|
800
800
|
});
|
|
801
801
|
});
|
|
802
|
+
|
|
803
|
+
// Stale closure regression tests - these verify the fix for the bug where
|
|
804
|
+
// cached array helper functions captured stale forma state
|
|
805
|
+
describe("stale closure regression", () => {
|
|
806
|
+
it("should add multiple items consecutively without losing items", async () => {
|
|
807
|
+
const user = userEvent.setup();
|
|
808
|
+
const spec = createTestSpec({
|
|
809
|
+
fields: {
|
|
810
|
+
items: {
|
|
811
|
+
type: "array",
|
|
812
|
+
label: "Items",
|
|
813
|
+
itemFields: { name: { type: "text" } },
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
render(
|
|
819
|
+
<FormRenderer
|
|
820
|
+
spec={spec}
|
|
821
|
+
initialData={{ items: [] }}
|
|
822
|
+
components={createTestComponentMap()}
|
|
823
|
+
/>
|
|
824
|
+
);
|
|
825
|
+
|
|
826
|
+
const addButton = screen.getByTestId("add-items");
|
|
827
|
+
|
|
828
|
+
// Add first item
|
|
829
|
+
await user.click(addButton);
|
|
830
|
+
await waitFor(() => {
|
|
831
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
832
|
+
});
|
|
833
|
+
|
|
834
|
+
// Add second item - this would fail with stale closure bug
|
|
835
|
+
await user.click(addButton);
|
|
836
|
+
await waitFor(() => {
|
|
837
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
838
|
+
expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
// Add third item - verifies the fix works across multiple operations
|
|
842
|
+
await user.click(addButton);
|
|
843
|
+
await waitFor(() => {
|
|
844
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
845
|
+
expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
|
|
846
|
+
expect(screen.getByTestId("array-item-items-2")).toBeInTheDocument();
|
|
847
|
+
});
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
it("should remove items correctly after adding multiple items", async () => {
|
|
851
|
+
const user = userEvent.setup();
|
|
852
|
+
const spec = createTestSpec({
|
|
853
|
+
fields: {
|
|
854
|
+
items: {
|
|
855
|
+
type: "array",
|
|
856
|
+
label: "Items",
|
|
857
|
+
itemFields: { name: { type: "text" } },
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
render(
|
|
863
|
+
<FormRenderer
|
|
864
|
+
spec={spec}
|
|
865
|
+
initialData={{ items: [] }}
|
|
866
|
+
components={createTestComponentMap()}
|
|
867
|
+
/>
|
|
868
|
+
);
|
|
869
|
+
|
|
870
|
+
const addButton = screen.getByTestId("add-items");
|
|
871
|
+
|
|
872
|
+
// Add three items
|
|
873
|
+
await user.click(addButton);
|
|
874
|
+
await user.click(addButton);
|
|
875
|
+
await user.click(addButton);
|
|
876
|
+
|
|
877
|
+
await waitFor(() => {
|
|
878
|
+
expect(screen.getByTestId("array-item-items-2")).toBeInTheDocument();
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
// Remove middle item - this would fail with stale closure bug
|
|
882
|
+
const removeMiddle = screen.getByTestId("remove-items-1");
|
|
883
|
+
await user.click(removeMiddle);
|
|
884
|
+
|
|
885
|
+
await waitFor(() => {
|
|
886
|
+
// Should have 2 items remaining (indices 0 and 1)
|
|
887
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
888
|
+
expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
|
|
889
|
+
expect(screen.queryByTestId("array-item-items-2")).not.toBeInTheDocument();
|
|
890
|
+
});
|
|
891
|
+
});
|
|
892
|
+
|
|
893
|
+
it("should preserve existing items when adding to non-empty array", async () => {
|
|
894
|
+
const user = userEvent.setup();
|
|
895
|
+
const onChange = vi.fn();
|
|
896
|
+
const spec = createTestSpec({
|
|
897
|
+
fields: {
|
|
898
|
+
items: {
|
|
899
|
+
type: "array",
|
|
900
|
+
label: "Items",
|
|
901
|
+
itemFields: { name: { type: "text" } },
|
|
902
|
+
},
|
|
903
|
+
},
|
|
904
|
+
});
|
|
905
|
+
|
|
906
|
+
render(
|
|
907
|
+
<FormRenderer
|
|
908
|
+
spec={spec}
|
|
909
|
+
initialData={{ items: [{ name: "Existing Item" }] }}
|
|
910
|
+
components={createTestComponentMap()}
|
|
911
|
+
onChange={onChange}
|
|
912
|
+
/>
|
|
913
|
+
);
|
|
914
|
+
|
|
915
|
+
// Verify initial state
|
|
916
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
917
|
+
|
|
918
|
+
// Add new item
|
|
919
|
+
const addButton = screen.getByTestId("add-items");
|
|
920
|
+
await user.click(addButton);
|
|
921
|
+
|
|
922
|
+
await waitFor(() => {
|
|
923
|
+
expect(screen.getByTestId("array-item-items-0")).toBeInTheDocument();
|
|
924
|
+
expect(screen.getByTestId("array-item-items-1")).toBeInTheDocument();
|
|
925
|
+
});
|
|
926
|
+
|
|
927
|
+
// Verify onChange was called with both items
|
|
928
|
+
const lastCall = onChange.mock.calls[onChange.mock.calls.length - 1];
|
|
929
|
+
const data = lastCall[0];
|
|
930
|
+
expect(data.items).toHaveLength(2);
|
|
931
|
+
expect(data.items[0]).toEqual({ name: "Existing Item" });
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
it("should handle rapid consecutive add operations", async () => {
|
|
935
|
+
const user = userEvent.setup();
|
|
936
|
+
const spec = createTestSpec({
|
|
937
|
+
fields: {
|
|
938
|
+
items: {
|
|
939
|
+
type: "array",
|
|
940
|
+
label: "Items",
|
|
941
|
+
itemFields: { name: { type: "text" } },
|
|
942
|
+
},
|
|
943
|
+
},
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
render(
|
|
947
|
+
<FormRenderer
|
|
948
|
+
spec={spec}
|
|
949
|
+
initialData={{ items: [] }}
|
|
950
|
+
components={createTestComponentMap()}
|
|
951
|
+
/>
|
|
952
|
+
);
|
|
953
|
+
|
|
954
|
+
const addButton = screen.getByTestId("add-items");
|
|
955
|
+
|
|
956
|
+
// Rapidly add 5 items
|
|
957
|
+
for (let i = 0; i < 5; i++) {
|
|
958
|
+
await user.click(addButton);
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// All 5 items should be present
|
|
962
|
+
await waitFor(() => {
|
|
963
|
+
for (let i = 0; i < 5; i++) {
|
|
964
|
+
expect(screen.getByTestId(`array-item-items-${i}`)).toBeInTheDocument();
|
|
965
|
+
}
|
|
966
|
+
});
|
|
967
|
+
});
|
|
968
|
+
});
|
|
802
969
|
});
|
|
803
970
|
});
|
|
@@ -249,10 +249,12 @@ describe("canProceed", () => {
|
|
|
249
249
|
expect(result.current.wizard?.canProceed).toBe(true);
|
|
250
250
|
});
|
|
251
251
|
|
|
252
|
-
it("required boolean fields -
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
252
|
+
it("required boolean fields - auto-initialized to false", () => {
|
|
253
|
+
// Boolean fields are auto-initialized to false, which is a valid value.
|
|
254
|
+
// This provides better UX - the form is valid from the start since
|
|
255
|
+
// false is a valid answer to "Do you have pets?"
|
|
256
|
+
//
|
|
257
|
+
// For checkboxes that must be checked (like "Accept Terms"),
|
|
256
258
|
// use a validation rule: { rule: "value = true", message: "Must accept terms" }
|
|
257
259
|
const spec = createTestSpec({
|
|
258
260
|
fields: {
|
|
@@ -263,13 +265,14 @@ describe("canProceed", () => {
|
|
|
263
265
|
],
|
|
264
266
|
});
|
|
265
267
|
|
|
266
|
-
//
|
|
267
|
-
const { result:
|
|
268
|
+
// With auto-initialization, boolean defaults to false (a valid value)
|
|
269
|
+
const { result: resultDefault } = renderHook(() =>
|
|
268
270
|
useForma({ spec, initialData: {} })
|
|
269
271
|
);
|
|
270
|
-
expect(
|
|
272
|
+
expect(resultDefault.current.data.hasPets).toBe(false);
|
|
273
|
+
expect(resultDefault.current.wizard?.canProceed).toBe(true); // false is valid
|
|
271
274
|
|
|
272
|
-
// false should be valid (user answered "no")
|
|
275
|
+
// explicit false should be valid (user answered "no")
|
|
273
276
|
const { result: resultFalse } = renderHook(() =>
|
|
274
277
|
useForma({ spec, initialData: { hasPets: false } })
|
|
275
278
|
);
|
|
@@ -313,16 +313,23 @@ describe("diabetes trial enrollment wizard", () => {
|
|
|
313
313
|
expect(pages?.[3].visible).toBe(true);
|
|
314
314
|
});
|
|
315
315
|
|
|
316
|
-
it("should
|
|
316
|
+
it("should show conditional pages based on auto-initialized boolean state", () => {
|
|
317
|
+
// With boolean auto-initialization to false:
|
|
318
|
+
// - All inclusion criteria (booleans) start as false → allInclusionMet = false
|
|
319
|
+
// - All exclusion criteria (booleans) start as false → anyExclusionMet = false
|
|
320
|
+
// - eligibilityDetermined = true (all fields have values)
|
|
321
|
+
// - ineligible = true (eligibilityDetermined AND NOT allInclusionMet)
|
|
322
|
+
// - eligible = false (allInclusionMet is false)
|
|
317
323
|
const spec = createDiabetesTrialSpec();
|
|
318
324
|
const { result } = renderHook(() => useForma({ spec }));
|
|
319
325
|
|
|
320
326
|
const pages = result.current.wizard?.pages;
|
|
321
327
|
|
|
322
|
-
//
|
|
328
|
+
// Ineligibility page is now visible (ineligible = true due to false inclusion criteria)
|
|
323
329
|
expect(pages?.[4].id).toBe("ineligibility-documentation");
|
|
324
|
-
expect(pages?.[4].visible).toBe(
|
|
330
|
+
expect(pages?.[4].visible).toBe(true); // Changed: now visible
|
|
325
331
|
|
|
332
|
+
// Eligible-flow pages still hidden (eligible = false)
|
|
326
333
|
expect(pages?.[5].id).toBe("main-consents");
|
|
327
334
|
expect(pages?.[5].visible).toBe(false);
|
|
328
335
|
|
|
@@ -341,12 +348,21 @@ describe("diabetes trial enrollment wizard", () => {
|
|
|
341
348
|
});
|
|
342
349
|
|
|
343
350
|
describe("eligibility determination", () => {
|
|
344
|
-
it("should determine eligibility
|
|
351
|
+
it("should determine eligibility immediately with auto-initialized booleans", () => {
|
|
352
|
+
// With boolean auto-initialization, all criteria have values from the start,
|
|
353
|
+
// so eligibilityDetermined is true immediately.
|
|
354
|
+
// The user flow is now:
|
|
355
|
+
// 1. Initially ineligible (all inclusion criteria are false)
|
|
356
|
+
// 2. User must explicitly set inclusion criteria to true to become eligible
|
|
345
357
|
const spec = createDiabetesTrialSpec();
|
|
346
358
|
const { result } = renderHook(() => useForma({ spec }));
|
|
347
359
|
|
|
348
|
-
//
|
|
349
|
-
expect(result.current.computed?.eligibilityDetermined).
|
|
360
|
+
// Immediately determined (all booleans have values due to auto-init)
|
|
361
|
+
expect(result.current.computed?.eligibilityDetermined).toBe(true);
|
|
362
|
+
|
|
363
|
+
// Initially ineligible (all inclusion criteria are false)
|
|
364
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
365
|
+
expect(result.current.computed?.ineligible).toBe(true);
|
|
350
366
|
|
|
351
367
|
// Fill inclusion criteria
|
|
352
368
|
act(() => {
|
|
@@ -356,20 +372,10 @@ describe("diabetes trial enrollment wizard", () => {
|
|
|
356
372
|
result.current.setFieldValue("inclusionConsent", true);
|
|
357
373
|
});
|
|
358
374
|
|
|
359
|
-
// Still
|
|
360
|
-
expect(result.current.computed?.eligibilityDetermined).toBeFalsy();
|
|
361
|
-
|
|
362
|
-
// Fill exclusion criteria
|
|
363
|
-
act(() => {
|
|
364
|
-
result.current.setFieldValue("exclusionPregnant", false);
|
|
365
|
-
result.current.setFieldValue("exclusionAllergy", false);
|
|
366
|
-
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
367
|
-
result.current.setFieldValue("exclusionKidney", false);
|
|
368
|
-
result.current.setFieldValue("exclusionSglt2", false);
|
|
369
|
-
});
|
|
370
|
-
|
|
371
|
-
// Now determined
|
|
375
|
+
// Still determined, now eligible (exclusions already false via auto-init)
|
|
372
376
|
expect(result.current.computed?.eligibilityDetermined).toBe(true);
|
|
377
|
+
expect(result.current.computed?.eligible).toBe(true);
|
|
378
|
+
expect(result.current.computed?.ineligible).toBe(false);
|
|
373
379
|
});
|
|
374
380
|
|
|
375
381
|
it("should mark eligible when all inclusion met and no exclusion met", () => {
|
|
@@ -754,6 +760,10 @@ describe("diabetes trial enrollment wizard", () => {
|
|
|
754
760
|
});
|
|
755
761
|
|
|
756
762
|
it("should skip hidden pages during navigation", () => {
|
|
763
|
+
// With auto-initialization, booleans default to false:
|
|
764
|
+
// - eligibilityDetermined = true (all fields have values)
|
|
765
|
+
// - ineligible = true (inclusion criteria all false)
|
|
766
|
+
// So the ineligibility-documentation page is visible
|
|
757
767
|
const spec = createDiabetesTrialSpec();
|
|
758
768
|
const { result } = renderHook(() => useForma({ spec }));
|
|
759
769
|
|
|
@@ -763,13 +773,20 @@ describe("diabetes trial enrollment wizard", () => {
|
|
|
763
773
|
});
|
|
764
774
|
expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
|
|
765
775
|
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
// Pages
|
|
776
|
+
// With auto-initialized booleans, visible pages are:
|
|
777
|
+
// study-info (0), participant-info (1), inclusion (2), exclusion (3), ineligibility-documentation (4)
|
|
778
|
+
// Pages 5-9 are for eligible flow and still hidden
|
|
769
779
|
const visiblePages = result.current.wizard?.pages.filter(p => p.visible);
|
|
770
|
-
expect(visiblePages?.length).toBe(
|
|
780
|
+
expect(visiblePages?.length).toBe(5);
|
|
781
|
+
|
|
782
|
+
// Not on last visible page anymore (ineligibility page is visible)
|
|
783
|
+
expect(result.current.wizard?.isLastPage).toBe(false);
|
|
771
784
|
|
|
772
|
-
//
|
|
785
|
+
// Navigate to last visible page
|
|
786
|
+
act(() => {
|
|
787
|
+
result.current.wizard?.goToPage(4);
|
|
788
|
+
});
|
|
789
|
+
expect(result.current.wizard?.currentPage?.id).toBe("ineligibility-documentation");
|
|
773
790
|
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
774
791
|
});
|
|
775
792
|
});
|
|
@@ -16,8 +16,8 @@ import { useForma } from "../useForma.js";
|
|
|
16
16
|
import { createTestSpec } from "./test-utils.js";
|
|
17
17
|
|
|
18
18
|
describe("FEEL null handling in visibility expressions", () => {
|
|
19
|
-
describe("boolean field
|
|
20
|
-
it("should
|
|
19
|
+
describe("boolean field initialization", () => {
|
|
20
|
+
it("should auto-initialize boolean fields to false for better UX", () => {
|
|
21
21
|
const spec = createTestSpec({
|
|
22
22
|
fields: {
|
|
23
23
|
accepted: { type: "boolean", label: "Accept terms" },
|
|
@@ -31,12 +31,11 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
31
31
|
|
|
32
32
|
const { result } = renderHook(() => useForma({ spec }));
|
|
33
33
|
|
|
34
|
-
//
|
|
35
|
-
|
|
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);
|
|
36
37
|
|
|
37
|
-
// The visibility expression "accepted = true" evaluates to
|
|
38
|
-
// forma-core converts this null to false, so the field is hidden
|
|
39
|
-
// This happens to be the "correct" behavior by accident
|
|
38
|
+
// The visibility expression "accepted = true" evaluates properly to false
|
|
40
39
|
expect(result.current.visibility.details).toBe(false);
|
|
41
40
|
|
|
42
41
|
// When user explicitly sets to true, field becomes visible
|
|
@@ -46,7 +45,7 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
46
45
|
expect(result.current.visibility.details).toBe(true);
|
|
47
46
|
});
|
|
48
47
|
|
|
49
|
-
it("should
|
|
48
|
+
it("should show fields dependent on '= false' immediately since booleans default to false", () => {
|
|
50
49
|
const spec = createTestSpec({
|
|
51
50
|
fields: {
|
|
52
51
|
signingOnBehalf: { type: "boolean", label: "Signing on behalf?" },
|
|
@@ -60,22 +59,21 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
60
59
|
|
|
61
60
|
const { result } = renderHook(() => useForma({ spec }));
|
|
62
61
|
|
|
63
|
-
//
|
|
64
|
-
expect(result.current.data.signingOnBehalf).
|
|
62
|
+
// Boolean field is auto-initialized to false
|
|
63
|
+
expect(result.current.data.signingOnBehalf).toBe(false);
|
|
65
64
|
|
|
66
|
-
// The visibility expression "signingOnBehalf = false" evaluates to
|
|
67
|
-
// This
|
|
68
|
-
|
|
69
|
-
expect(result.current.visibility.participantName).toBe(false);
|
|
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);
|
|
70
68
|
|
|
71
|
-
//
|
|
69
|
+
// Hidden when user sets to true
|
|
72
70
|
act(() => {
|
|
73
|
-
result.current.setFieldValue("signingOnBehalf",
|
|
71
|
+
result.current.setFieldValue("signingOnBehalf", true);
|
|
74
72
|
});
|
|
75
|
-
expect(result.current.visibility.participantName).toBe(
|
|
73
|
+
expect(result.current.visibility.participantName).toBe(false);
|
|
76
74
|
});
|
|
77
75
|
|
|
78
|
-
it("should
|
|
76
|
+
it("should work with != null pattern since booleans have initial value", () => {
|
|
79
77
|
const spec = createTestSpec({
|
|
80
78
|
fields: {
|
|
81
79
|
accepted: { type: "boolean", label: "Accept" },
|
|
@@ -89,16 +87,13 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
89
87
|
|
|
90
88
|
const { result } = renderHook(() => useForma({ spec }));
|
|
91
89
|
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
// The computed value becomes null, which may cause downstream issues
|
|
95
|
-
expect(result.current.data.accepted).toBeUndefined();
|
|
90
|
+
// Boolean fields start with false, so "accepted != null" is true
|
|
91
|
+
expect(result.current.data.accepted).toBe(false);
|
|
96
92
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
93
|
+
// Since the field has a value (false), it's not null
|
|
94
|
+
// Note: FEEL may still return null/false for != null comparisons
|
|
99
95
|
const hasAnsweredValue = result.current.computed?.hasAnswered;
|
|
100
|
-
|
|
101
|
-
expect(hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
|
|
96
|
+
expect(hasAnsweredValue === true || hasAnsweredValue === null || hasAnsweredValue === false).toBe(true);
|
|
102
97
|
});
|
|
103
98
|
});
|
|
104
99
|
|
|
@@ -269,7 +264,8 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
269
264
|
accepted: { type: "boolean", label: "Accepted" },
|
|
270
265
|
},
|
|
271
266
|
computed: {
|
|
272
|
-
//
|
|
267
|
+
// Pattern to check if field has been answered
|
|
268
|
+
// With auto-initialization to false, this always returns true
|
|
273
269
|
hasAnswered: {
|
|
274
270
|
expression: "accepted = true or accepted = false",
|
|
275
271
|
},
|
|
@@ -278,10 +274,11 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
278
274
|
|
|
279
275
|
const { result } = renderHook(() => useForma({ spec }));
|
|
280
276
|
|
|
281
|
-
//
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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);
|
|
285
282
|
|
|
286
283
|
// Set to true
|
|
287
284
|
act(() => {
|
|
@@ -289,7 +286,7 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
289
286
|
});
|
|
290
287
|
expect(result.current.computed?.hasAnswered).toBe(true);
|
|
291
288
|
|
|
292
|
-
// Set to false
|
|
289
|
+
// Set back to false
|
|
293
290
|
act(() => {
|
|
294
291
|
result.current.setFieldValue("accepted", false);
|
|
295
292
|
});
|
|
@@ -299,8 +296,11 @@ describe("FEEL null handling in visibility expressions", () => {
|
|
|
299
296
|
});
|
|
300
297
|
|
|
301
298
|
describe("page navigation with conditional visibility", () => {
|
|
302
|
-
it("should handle
|
|
303
|
-
//
|
|
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
304
|
const spec = createTestSpec({
|
|
305
305
|
fields: {
|
|
306
306
|
// Inclusion criteria (all must be true to be eligible)
|
|
@@ -313,15 +313,13 @@ describe("page navigation with conditional visibility", () => {
|
|
|
313
313
|
consent: { type: "boolean", label: "I consent" },
|
|
314
314
|
},
|
|
315
315
|
computed: {
|
|
316
|
-
//
|
|
316
|
+
// With auto-initialization, these patterns always return true
|
|
317
317
|
allInclusionAnswered: {
|
|
318
318
|
expression: "ageOk != null and diagnosisOk != null",
|
|
319
319
|
},
|
|
320
|
-
// Check if all exclusion answered
|
|
321
320
|
allExclusionAnswered: {
|
|
322
321
|
expression: "pregnant != null and allergy != null",
|
|
323
322
|
},
|
|
324
|
-
// Eligibility determined when all criteria answered
|
|
325
323
|
eligibilityDetermined: {
|
|
326
324
|
expression:
|
|
327
325
|
"computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
|
|
@@ -354,11 +352,26 @@ describe("page navigation with conditional visibility", () => {
|
|
|
354
352
|
|
|
355
353
|
const { result } = renderHook(() => useForma({ spec }));
|
|
356
354
|
|
|
357
|
-
//
|
|
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
|
+
|
|
358
361
|
const wizard = result.current.wizard;
|
|
359
362
|
expect(wizard?.pages[0].visible).toBe(true); // Inclusion always visible
|
|
360
363
|
expect(wizard?.pages[1].visible).toBe(true); // Exclusion always visible
|
|
361
|
-
|
|
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
|
|
362
375
|
|
|
363
376
|
// Fill inclusion criteria (both true)
|
|
364
377
|
act(() => {
|
|
@@ -366,17 +379,7 @@ describe("page navigation with conditional visibility", () => {
|
|
|
366
379
|
result.current.setFieldValue("diagnosisOk", true);
|
|
367
380
|
});
|
|
368
381
|
|
|
369
|
-
//
|
|
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);
|
|
382
|
+
// Now eligible - exclusion defaults are already false (no exclusions met)
|
|
380
383
|
expect(result.current.computed?.allInclusionMet).toBe(true);
|
|
381
384
|
expect(result.current.computed?.anyExclusionMet).toBe(false);
|
|
382
385
|
expect(result.current.computed?.eligible).toBe(true);
|
|
@@ -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
|
// ============================================================================
|