@fogpipe/forma-react 0.8.2 → 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fogpipe/forma-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Headless React form renderer for Forma specifications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"test:coverage": "vitest run --coverage"
|
|
31
31
|
},
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@fogpipe/forma-core": "^0.
|
|
33
|
+
"@fogpipe/forma-core": "^0.9.0"
|
|
34
34
|
},
|
|
35
35
|
"peerDependencies": {
|
|
36
36
|
"react": "^18.0.0 || ^19.0.0"
|
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the diabetes clinical trial enrollment form wizard flow
|
|
3
|
+
*
|
|
4
|
+
* This test suite documents expected wizard behavior for a complex multi-page
|
|
5
|
+
* form with conditional page visibility based on computed eligibility fields.
|
|
6
|
+
*
|
|
7
|
+
* The form has:
|
|
8
|
+
* - 10 pages with conditional visibility
|
|
9
|
+
* - Complex computed field dependency chains for eligibility determination
|
|
10
|
+
* - Conditional signature flows (participant vs LAR, optional witness)
|
|
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 type { Forma } from "@fogpipe/forma-core";
|
|
17
|
+
|
|
18
|
+
// Simplified version of the diabetes trial spec for focused testing
|
|
19
|
+
function createDiabetesTrialSpec(): Forma {
|
|
20
|
+
return {
|
|
21
|
+
version: "1.0",
|
|
22
|
+
meta: {
|
|
23
|
+
id: "diabetes-trial-enrollment",
|
|
24
|
+
title: "Diabetes Medication Clinical Trial Enrollment Form",
|
|
25
|
+
},
|
|
26
|
+
schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
// Study info (pre-filled)
|
|
30
|
+
studyName: { type: "string" },
|
|
31
|
+
protocolNumber: { type: "string" },
|
|
32
|
+
// Participant info
|
|
33
|
+
screeningId: { type: "string" },
|
|
34
|
+
participantInitials: { type: "string" },
|
|
35
|
+
dateOfBirth: { type: "string", format: "date" },
|
|
36
|
+
// Inclusion criteria
|
|
37
|
+
inclusionAge: { type: "boolean" },
|
|
38
|
+
inclusionDiagnosis: { type: "boolean" },
|
|
39
|
+
inclusionVisits: { type: "boolean" },
|
|
40
|
+
inclusionConsent: { type: "boolean" },
|
|
41
|
+
// Exclusion criteria
|
|
42
|
+
exclusionPregnant: { type: "boolean" },
|
|
43
|
+
exclusionAllergy: { type: "boolean" },
|
|
44
|
+
exclusionRecentStudy: { type: "boolean" },
|
|
45
|
+
exclusionKidney: { type: "boolean" },
|
|
46
|
+
exclusionSglt2: { type: "boolean" },
|
|
47
|
+
// Ineligibility
|
|
48
|
+
ineligibilityReason: { type: "string" },
|
|
49
|
+
ineligibilityNotes: { type: "string" },
|
|
50
|
+
// Consents
|
|
51
|
+
consentPurpose: { type: "boolean" },
|
|
52
|
+
consentProcedures: { type: "boolean" },
|
|
53
|
+
consentRisks: { type: "boolean" },
|
|
54
|
+
consentBenefits: { type: "boolean" },
|
|
55
|
+
consentVoluntary: { type: "boolean" },
|
|
56
|
+
consentDataHandling: { type: "boolean" },
|
|
57
|
+
// Optional consents
|
|
58
|
+
optionalSamples: { type: "boolean" },
|
|
59
|
+
optionalContact: { type: "boolean" },
|
|
60
|
+
optionalGenetic: { type: "boolean" },
|
|
61
|
+
// Signatures
|
|
62
|
+
signingOnBehalf: { type: "boolean" },
|
|
63
|
+
participantSignatureName: { type: "string" },
|
|
64
|
+
participantSignatureDateTime: { type: "string" },
|
|
65
|
+
larName: { type: "string" },
|
|
66
|
+
larRelationship: { type: "string" },
|
|
67
|
+
larSignatureDateTime: { type: "string" },
|
|
68
|
+
researcherName: { type: "string" },
|
|
69
|
+
researcherConfirm: { type: "boolean" },
|
|
70
|
+
researcherSignatureDateTime: { type: "string" },
|
|
71
|
+
witnessRequired: { type: "boolean" },
|
|
72
|
+
witnessName: { type: "string" },
|
|
73
|
+
witnessSignatureDateTime: { type: "string" },
|
|
74
|
+
// Enrollment
|
|
75
|
+
enrollmentDate: { type: "string" },
|
|
76
|
+
randomizationNumber: { type: "string" },
|
|
77
|
+
treatmentGroup: { type: "string" },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
fields: {
|
|
81
|
+
studyName: { type: "text", label: "Study Name", enabledWhen: "false" },
|
|
82
|
+
protocolNumber: { type: "text", label: "Protocol Number", enabledWhen: "false" },
|
|
83
|
+
screeningId: { type: "text", label: "Screening ID" },
|
|
84
|
+
participantInitials: { type: "text", label: "Participant Initials" },
|
|
85
|
+
dateOfBirth: { type: "date", label: "Date of Birth" },
|
|
86
|
+
inclusionAge: { type: "boolean", label: "Age between 18-65 years" },
|
|
87
|
+
inclusionDiagnosis: { type: "boolean", label: "Confirmed Type 2 diabetes diagnosis" },
|
|
88
|
+
inclusionVisits: { type: "boolean", label: "Willing to attend all study visits" },
|
|
89
|
+
inclusionConsent: { type: "boolean", label: "Can give informed consent" },
|
|
90
|
+
exclusionPregnant: { type: "boolean", label: "Pregnant or planning to become pregnant" },
|
|
91
|
+
exclusionAllergy: { type: "boolean", label: "Allergy to metformin or similar drugs" },
|
|
92
|
+
exclusionRecentStudy: { type: "boolean", label: "Participated in another drug study in last 30 days" },
|
|
93
|
+
exclusionKidney: { type: "boolean", label: "History of severe kidney disease" },
|
|
94
|
+
exclusionSglt2: { type: "boolean", label: "Currently taking SGLT2 inhibitors" },
|
|
95
|
+
ineligibilityReason: { type: "textarea", label: "Ineligibility Reason", enabledWhen: "false" },
|
|
96
|
+
ineligibilityNotes: { type: "textarea", label: "Additional Notes" },
|
|
97
|
+
consentPurpose: { type: "boolean", label: "I have read and understand the study purpose" },
|
|
98
|
+
consentProcedures: { type: "boolean", label: "I understand the procedures I will undergo" },
|
|
99
|
+
consentRisks: { type: "boolean", label: "I understand the risks involved" },
|
|
100
|
+
consentBenefits: { type: "boolean", label: "I understand the potential benefits" },
|
|
101
|
+
consentVoluntary: { type: "boolean", label: "I understand participation is voluntary" },
|
|
102
|
+
consentDataHandling: { type: "boolean", label: "I understand how my data will be handled" },
|
|
103
|
+
optionalSamples: { type: "boolean", label: "I consent to storing blood samples" },
|
|
104
|
+
optionalContact: { type: "boolean", label: "I consent to being contacted about other studies" },
|
|
105
|
+
optionalGenetic: { type: "boolean", label: "I consent to genetic analysis" },
|
|
106
|
+
signingOnBehalf: { type: "boolean", label: "Is someone signing on behalf of the participant?" },
|
|
107
|
+
participantSignatureName: {
|
|
108
|
+
type: "text",
|
|
109
|
+
label: "Participant Signature Name",
|
|
110
|
+
visibleWhen: "signingOnBehalf = false",
|
|
111
|
+
requiredWhen: "signingOnBehalf = false",
|
|
112
|
+
},
|
|
113
|
+
participantSignatureDateTime: {
|
|
114
|
+
type: "datetime",
|
|
115
|
+
label: "Participant Signature Date and Time",
|
|
116
|
+
visibleWhen: "signingOnBehalf = false",
|
|
117
|
+
requiredWhen: "signingOnBehalf = false",
|
|
118
|
+
},
|
|
119
|
+
larName: {
|
|
120
|
+
type: "text",
|
|
121
|
+
label: "Legally Authorized Representative Name",
|
|
122
|
+
visibleWhen: "signingOnBehalf = true",
|
|
123
|
+
requiredWhen: "signingOnBehalf = true",
|
|
124
|
+
},
|
|
125
|
+
larRelationship: {
|
|
126
|
+
type: "text",
|
|
127
|
+
label: "Relationship to Participant",
|
|
128
|
+
visibleWhen: "signingOnBehalf = true",
|
|
129
|
+
requiredWhen: "signingOnBehalf = true",
|
|
130
|
+
},
|
|
131
|
+
larSignatureDateTime: {
|
|
132
|
+
type: "datetime",
|
|
133
|
+
label: "Representative Signature Date and Time",
|
|
134
|
+
visibleWhen: "signingOnBehalf = true",
|
|
135
|
+
requiredWhen: "signingOnBehalf = true",
|
|
136
|
+
},
|
|
137
|
+
researcherName: { type: "text", label: "Researcher Name" },
|
|
138
|
+
researcherConfirm: { type: "boolean", label: "I confirm I have properly explained the study" },
|
|
139
|
+
researcherSignatureDateTime: { type: "datetime", label: "Researcher Signature Date and Time" },
|
|
140
|
+
witnessRequired: { type: "boolean", label: "Is a witness signature required?" },
|
|
141
|
+
witnessName: {
|
|
142
|
+
type: "text",
|
|
143
|
+
label: "Witness Name",
|
|
144
|
+
visibleWhen: "witnessRequired = true",
|
|
145
|
+
requiredWhen: "witnessRequired = true",
|
|
146
|
+
},
|
|
147
|
+
witnessSignatureDateTime: {
|
|
148
|
+
type: "datetime",
|
|
149
|
+
label: "Witness Signature Date and Time",
|
|
150
|
+
visibleWhen: "witnessRequired = true",
|
|
151
|
+
requiredWhen: "witnessRequired = true",
|
|
152
|
+
},
|
|
153
|
+
enrollmentDate: { type: "date", label: "Enrollment Date" },
|
|
154
|
+
randomizationNumber: { type: "text", label: "Randomization Number" },
|
|
155
|
+
treatmentGroup: {
|
|
156
|
+
type: "select",
|
|
157
|
+
label: "Treatment Group",
|
|
158
|
+
options: [
|
|
159
|
+
{ value: "group-a", label: "Group A - Active Treatment" },
|
|
160
|
+
{ value: "group-b", label: "Group B - Placebo" },
|
|
161
|
+
{ value: "group-c", label: "Group C - Active Comparator" },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
fieldOrder: [
|
|
166
|
+
"studyName", "protocolNumber",
|
|
167
|
+
"screeningId", "participantInitials", "dateOfBirth",
|
|
168
|
+
"inclusionAge", "inclusionDiagnosis", "inclusionVisits", "inclusionConsent",
|
|
169
|
+
"exclusionPregnant", "exclusionAllergy", "exclusionRecentStudy", "exclusionKidney", "exclusionSglt2",
|
|
170
|
+
"ineligibilityReason", "ineligibilityNotes",
|
|
171
|
+
"consentPurpose", "consentProcedures", "consentRisks", "consentBenefits", "consentVoluntary", "consentDataHandling",
|
|
172
|
+
"optionalSamples", "optionalContact", "optionalGenetic",
|
|
173
|
+
"signingOnBehalf", "participantSignatureName", "participantSignatureDateTime",
|
|
174
|
+
"larName", "larRelationship", "larSignatureDateTime",
|
|
175
|
+
"researcherName", "researcherConfirm", "researcherSignatureDateTime",
|
|
176
|
+
"witnessRequired", "witnessName", "witnessSignatureDateTime",
|
|
177
|
+
"enrollmentDate", "randomizationNumber", "treatmentGroup",
|
|
178
|
+
],
|
|
179
|
+
computed: {
|
|
180
|
+
// Inclusion checks
|
|
181
|
+
allInclusionAnswered: {
|
|
182
|
+
expression: "(inclusionAge = true or inclusionAge = false) and (inclusionDiagnosis = true or inclusionDiagnosis = false) and (inclusionVisits = true or inclusionVisits = false) and (inclusionConsent = true or inclusionConsent = false)",
|
|
183
|
+
},
|
|
184
|
+
allInclusionMet: {
|
|
185
|
+
expression: "inclusionAge = true and inclusionDiagnosis = true and inclusionVisits = true and inclusionConsent = true",
|
|
186
|
+
},
|
|
187
|
+
// Exclusion checks
|
|
188
|
+
allExclusionAnswered: {
|
|
189
|
+
expression: "(exclusionPregnant = true or exclusionPregnant = false) and (exclusionAllergy = true or exclusionAllergy = false) and (exclusionRecentStudy = true or exclusionRecentStudy = false) and (exclusionKidney = true or exclusionKidney = false) and (exclusionSglt2 = true or exclusionSglt2 = false)",
|
|
190
|
+
},
|
|
191
|
+
anyExclusionMet: {
|
|
192
|
+
expression: "exclusionPregnant = true or exclusionAllergy = true or exclusionRecentStudy = true or exclusionKidney = true or exclusionSglt2 = true",
|
|
193
|
+
},
|
|
194
|
+
// Eligibility determination
|
|
195
|
+
eligibilityDetermined: {
|
|
196
|
+
expression: "computed.allInclusionAnswered = true and computed.allExclusionAnswered = true",
|
|
197
|
+
},
|
|
198
|
+
eligible: {
|
|
199
|
+
expression: "computed.eligibilityDetermined = true and computed.allInclusionMet = true and computed.anyExclusionMet = false",
|
|
200
|
+
},
|
|
201
|
+
ineligible: {
|
|
202
|
+
expression: "computed.eligibilityDetermined = true and (computed.allInclusionMet = false or computed.anyExclusionMet = true)",
|
|
203
|
+
},
|
|
204
|
+
// Consent checks
|
|
205
|
+
allMainConsentsSigned: {
|
|
206
|
+
expression: "consentPurpose = true and consentProcedures = true and consentRisks = true and consentBenefits = true and consentVoluntary = true and consentDataHandling = true",
|
|
207
|
+
},
|
|
208
|
+
// Signature checks - using null-safe patterns
|
|
209
|
+
hasParticipantSignature: {
|
|
210
|
+
expression: "signingOnBehalf != true and participantSignatureName != null and participantSignatureDateTime != null",
|
|
211
|
+
},
|
|
212
|
+
hasLarSignature: {
|
|
213
|
+
expression: "signingOnBehalf = true and larName != null and larRelationship != null and larSignatureDateTime != null",
|
|
214
|
+
},
|
|
215
|
+
hasValidSignature: {
|
|
216
|
+
expression: "computed.hasParticipantSignature = true or computed.hasLarSignature = true",
|
|
217
|
+
},
|
|
218
|
+
hasResearcherSignature: {
|
|
219
|
+
expression: "researcherName != null and researcherConfirm = true and researcherSignatureDateTime != null",
|
|
220
|
+
},
|
|
221
|
+
hasWitnessSignature: {
|
|
222
|
+
expression: "witnessRequired != true or (witnessName != null and witnessSignatureDateTime != null)",
|
|
223
|
+
},
|
|
224
|
+
allSignaturesComplete: {
|
|
225
|
+
expression: "computed.hasValidSignature = true and computed.hasResearcherSignature = true and computed.hasWitnessSignature = true",
|
|
226
|
+
},
|
|
227
|
+
// Final enrollment gate
|
|
228
|
+
canEnroll: {
|
|
229
|
+
expression: "computed.eligible = true and computed.allMainConsentsSigned = true and computed.allSignaturesComplete = true",
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
pages: [
|
|
233
|
+
{
|
|
234
|
+
id: "study-information",
|
|
235
|
+
title: "Study Information",
|
|
236
|
+
fields: ["studyName", "protocolNumber"],
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "participant-information",
|
|
240
|
+
title: "Participant Information",
|
|
241
|
+
fields: ["screeningId", "participantInitials", "dateOfBirth"],
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
id: "inclusion-criteria",
|
|
245
|
+
title: "Eligibility Screening - Inclusion Criteria",
|
|
246
|
+
fields: ["inclusionAge", "inclusionDiagnosis", "inclusionVisits", "inclusionConsent"],
|
|
247
|
+
},
|
|
248
|
+
{
|
|
249
|
+
id: "exclusion-criteria",
|
|
250
|
+
title: "Eligibility Screening - Exclusion Criteria",
|
|
251
|
+
fields: ["exclusionPregnant", "exclusionAllergy", "exclusionRecentStudy", "exclusionKidney", "exclusionSglt2"],
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
id: "ineligibility-documentation",
|
|
255
|
+
title: "Ineligibility Documentation",
|
|
256
|
+
fields: ["ineligibilityReason", "ineligibilityNotes"],
|
|
257
|
+
visibleWhen: "computed.ineligible = true",
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
id: "main-consents",
|
|
261
|
+
title: "Informed Consent - Main Consents",
|
|
262
|
+
fields: ["consentPurpose", "consentProcedures", "consentRisks", "consentBenefits", "consentVoluntary", "consentDataHandling"],
|
|
263
|
+
visibleWhen: "computed.eligible = true",
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
id: "optional-consents",
|
|
267
|
+
title: "Optional Consents",
|
|
268
|
+
fields: ["optionalSamples", "optionalContact", "optionalGenetic"],
|
|
269
|
+
visibleWhen: "computed.eligible = true",
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
id: "participant-signature",
|
|
273
|
+
title: "Participant Signature",
|
|
274
|
+
fields: ["signingOnBehalf", "participantSignatureName", "participantSignatureDateTime", "larName", "larRelationship", "larSignatureDateTime"],
|
|
275
|
+
visibleWhen: "computed.eligible = true and computed.allMainConsentsSigned = true",
|
|
276
|
+
},
|
|
277
|
+
{
|
|
278
|
+
id: "researcher-witness-signatures",
|
|
279
|
+
title: "Researcher and Witness Signatures",
|
|
280
|
+
fields: ["researcherName", "researcherConfirm", "researcherSignatureDateTime", "witnessRequired", "witnessName", "witnessSignatureDateTime"],
|
|
281
|
+
visibleWhen: "computed.eligible = true and computed.allMainConsentsSigned = true and computed.hasValidSignature = true",
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: "enrollment-details",
|
|
285
|
+
title: "Enrollment Details",
|
|
286
|
+
fields: ["enrollmentDate", "randomizationNumber", "treatmentGroup"],
|
|
287
|
+
visibleWhen: "computed.canEnroll = true",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
describe("diabetes trial enrollment wizard", () => {
|
|
294
|
+
describe("initial page visibility", () => {
|
|
295
|
+
it("should show first 4 pages unconditionally", () => {
|
|
296
|
+
const spec = createDiabetesTrialSpec();
|
|
297
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
298
|
+
|
|
299
|
+
const pages = result.current.wizard?.pages;
|
|
300
|
+
expect(pages).toHaveLength(10);
|
|
301
|
+
|
|
302
|
+
// First 4 pages always visible
|
|
303
|
+
expect(pages?.[0].id).toBe("study-information");
|
|
304
|
+
expect(pages?.[0].visible).toBe(true);
|
|
305
|
+
|
|
306
|
+
expect(pages?.[1].id).toBe("participant-information");
|
|
307
|
+
expect(pages?.[1].visible).toBe(true);
|
|
308
|
+
|
|
309
|
+
expect(pages?.[2].id).toBe("inclusion-criteria");
|
|
310
|
+
expect(pages?.[2].visible).toBe(true);
|
|
311
|
+
|
|
312
|
+
expect(pages?.[3].id).toBe("exclusion-criteria");
|
|
313
|
+
expect(pages?.[3].visible).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("should hide conditional pages initially", () => {
|
|
317
|
+
const spec = createDiabetesTrialSpec();
|
|
318
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
319
|
+
|
|
320
|
+
const pages = result.current.wizard?.pages;
|
|
321
|
+
|
|
322
|
+
// Conditional pages hidden initially
|
|
323
|
+
expect(pages?.[4].id).toBe("ineligibility-documentation");
|
|
324
|
+
expect(pages?.[4].visible).toBe(false);
|
|
325
|
+
|
|
326
|
+
expect(pages?.[5].id).toBe("main-consents");
|
|
327
|
+
expect(pages?.[5].visible).toBe(false);
|
|
328
|
+
|
|
329
|
+
expect(pages?.[6].id).toBe("optional-consents");
|
|
330
|
+
expect(pages?.[6].visible).toBe(false);
|
|
331
|
+
|
|
332
|
+
expect(pages?.[7].id).toBe("participant-signature");
|
|
333
|
+
expect(pages?.[7].visible).toBe(false);
|
|
334
|
+
|
|
335
|
+
expect(pages?.[8].id).toBe("researcher-witness-signatures");
|
|
336
|
+
expect(pages?.[8].visible).toBe(false);
|
|
337
|
+
|
|
338
|
+
expect(pages?.[9].id).toBe("enrollment-details");
|
|
339
|
+
expect(pages?.[9].visible).toBe(false);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe("eligibility determination", () => {
|
|
344
|
+
it("should determine eligibility only after all criteria answered", () => {
|
|
345
|
+
const spec = createDiabetesTrialSpec();
|
|
346
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
347
|
+
|
|
348
|
+
// Initially not determined
|
|
349
|
+
expect(result.current.computed?.eligibilityDetermined).toBeFalsy();
|
|
350
|
+
|
|
351
|
+
// Fill inclusion criteria
|
|
352
|
+
act(() => {
|
|
353
|
+
result.current.setFieldValue("inclusionAge", true);
|
|
354
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
355
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
356
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Still not determined - exclusion not answered
|
|
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
|
|
372
|
+
expect(result.current.computed?.eligibilityDetermined).toBe(true);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("should mark eligible when all inclusion met and no exclusion met", () => {
|
|
376
|
+
const spec = createDiabetesTrialSpec();
|
|
377
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
378
|
+
|
|
379
|
+
// Fill all criteria - eligible scenario
|
|
380
|
+
act(() => {
|
|
381
|
+
// All inclusion = true
|
|
382
|
+
result.current.setFieldValue("inclusionAge", true);
|
|
383
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
384
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
385
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
386
|
+
// All exclusion = false
|
|
387
|
+
result.current.setFieldValue("exclusionPregnant", false);
|
|
388
|
+
result.current.setFieldValue("exclusionAllergy", false);
|
|
389
|
+
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
390
|
+
result.current.setFieldValue("exclusionKidney", false);
|
|
391
|
+
result.current.setFieldValue("exclusionSglt2", false);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
expect(result.current.computed?.eligible).toBe(true);
|
|
395
|
+
expect(result.current.computed?.ineligible).toBe(false);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it("should mark ineligible when any inclusion criterion is false", () => {
|
|
399
|
+
const spec = createDiabetesTrialSpec();
|
|
400
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
401
|
+
|
|
402
|
+
act(() => {
|
|
403
|
+
// One inclusion = false
|
|
404
|
+
result.current.setFieldValue("inclusionAge", false);
|
|
405
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
406
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
407
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
408
|
+
// All exclusion = false
|
|
409
|
+
result.current.setFieldValue("exclusionPregnant", false);
|
|
410
|
+
result.current.setFieldValue("exclusionAllergy", false);
|
|
411
|
+
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
412
|
+
result.current.setFieldValue("exclusionKidney", false);
|
|
413
|
+
result.current.setFieldValue("exclusionSglt2", false);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
417
|
+
expect(result.current.computed?.ineligible).toBe(true);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("should mark ineligible when any exclusion criterion is true", () => {
|
|
421
|
+
const spec = createDiabetesTrialSpec();
|
|
422
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
423
|
+
|
|
424
|
+
act(() => {
|
|
425
|
+
// All inclusion = true
|
|
426
|
+
result.current.setFieldValue("inclusionAge", true);
|
|
427
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
428
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
429
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
430
|
+
// One exclusion = true (pregnant)
|
|
431
|
+
result.current.setFieldValue("exclusionPregnant", true);
|
|
432
|
+
result.current.setFieldValue("exclusionAllergy", false);
|
|
433
|
+
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
434
|
+
result.current.setFieldValue("exclusionKidney", false);
|
|
435
|
+
result.current.setFieldValue("exclusionSglt2", false);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
expect(result.current.computed?.eligible).toBe(false);
|
|
439
|
+
expect(result.current.computed?.ineligible).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
describe("eligible flow - page visibility", () => {
|
|
444
|
+
function setupEligibleParticipant(setFieldValue: (field: string, value: unknown) => void) {
|
|
445
|
+
// All inclusion = true
|
|
446
|
+
setFieldValue("inclusionAge", true);
|
|
447
|
+
setFieldValue("inclusionDiagnosis", true);
|
|
448
|
+
setFieldValue("inclusionVisits", true);
|
|
449
|
+
setFieldValue("inclusionConsent", true);
|
|
450
|
+
// All exclusion = false
|
|
451
|
+
setFieldValue("exclusionPregnant", false);
|
|
452
|
+
setFieldValue("exclusionAllergy", false);
|
|
453
|
+
setFieldValue("exclusionRecentStudy", false);
|
|
454
|
+
setFieldValue("exclusionKidney", false);
|
|
455
|
+
setFieldValue("exclusionSglt2", false);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
it("should show consent pages after eligibility confirmed", () => {
|
|
459
|
+
const spec = createDiabetesTrialSpec();
|
|
460
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
461
|
+
|
|
462
|
+
act(() => {
|
|
463
|
+
setupEligibleParticipant(result.current.setFieldValue);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
const pages = result.current.wizard?.pages;
|
|
467
|
+
expect(pages?.[5].id).toBe("main-consents");
|
|
468
|
+
expect(pages?.[5].visible).toBe(true);
|
|
469
|
+
|
|
470
|
+
expect(pages?.[6].id).toBe("optional-consents");
|
|
471
|
+
expect(pages?.[6].visible).toBe(true);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("should show participant signature page after consents signed", () => {
|
|
475
|
+
const spec = createDiabetesTrialSpec();
|
|
476
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
477
|
+
|
|
478
|
+
act(() => {
|
|
479
|
+
setupEligibleParticipant(result.current.setFieldValue);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// Signature page not yet visible
|
|
483
|
+
expect(result.current.wizard?.pages?.[7].visible).toBe(false);
|
|
484
|
+
|
|
485
|
+
// Sign all main consents
|
|
486
|
+
act(() => {
|
|
487
|
+
result.current.setFieldValue("consentPurpose", true);
|
|
488
|
+
result.current.setFieldValue("consentProcedures", true);
|
|
489
|
+
result.current.setFieldValue("consentRisks", true);
|
|
490
|
+
result.current.setFieldValue("consentBenefits", true);
|
|
491
|
+
result.current.setFieldValue("consentVoluntary", true);
|
|
492
|
+
result.current.setFieldValue("consentDataHandling", true);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
expect(result.current.computed?.allMainConsentsSigned).toBe(true);
|
|
496
|
+
expect(result.current.wizard?.pages?.[7].visible).toBe(true);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it("should show researcher signature page after valid participant signature", () => {
|
|
500
|
+
const spec = createDiabetesTrialSpec();
|
|
501
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
502
|
+
|
|
503
|
+
act(() => {
|
|
504
|
+
setupEligibleParticipant(result.current.setFieldValue);
|
|
505
|
+
// Sign consents
|
|
506
|
+
result.current.setFieldValue("consentPurpose", true);
|
|
507
|
+
result.current.setFieldValue("consentProcedures", true);
|
|
508
|
+
result.current.setFieldValue("consentRisks", true);
|
|
509
|
+
result.current.setFieldValue("consentBenefits", true);
|
|
510
|
+
result.current.setFieldValue("consentVoluntary", true);
|
|
511
|
+
result.current.setFieldValue("consentDataHandling", true);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
// Researcher signature page not yet visible
|
|
515
|
+
expect(result.current.wizard?.pages?.[8].visible).toBe(false);
|
|
516
|
+
|
|
517
|
+
// Provide participant signature (not signing on behalf)
|
|
518
|
+
act(() => {
|
|
519
|
+
result.current.setFieldValue("signingOnBehalf", false);
|
|
520
|
+
result.current.setFieldValue("participantSignatureName", "John Doe");
|
|
521
|
+
result.current.setFieldValue("participantSignatureDateTime", "2024-01-15T10:00:00");
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
expect(result.current.computed?.hasValidSignature).toBe(true);
|
|
525
|
+
expect(result.current.wizard?.pages?.[8].visible).toBe(true);
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it("should show enrollment page only after all signatures complete", () => {
|
|
529
|
+
const spec = createDiabetesTrialSpec();
|
|
530
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
531
|
+
|
|
532
|
+
act(() => {
|
|
533
|
+
setupEligibleParticipant(result.current.setFieldValue);
|
|
534
|
+
// Sign consents
|
|
535
|
+
result.current.setFieldValue("consentPurpose", true);
|
|
536
|
+
result.current.setFieldValue("consentProcedures", true);
|
|
537
|
+
result.current.setFieldValue("consentRisks", true);
|
|
538
|
+
result.current.setFieldValue("consentBenefits", true);
|
|
539
|
+
result.current.setFieldValue("consentVoluntary", true);
|
|
540
|
+
result.current.setFieldValue("consentDataHandling", true);
|
|
541
|
+
// Participant signature
|
|
542
|
+
result.current.setFieldValue("signingOnBehalf", false);
|
|
543
|
+
result.current.setFieldValue("participantSignatureName", "John Doe");
|
|
544
|
+
result.current.setFieldValue("participantSignatureDateTime", "2024-01-15T10:00:00");
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Enrollment page not yet visible
|
|
548
|
+
expect(result.current.wizard?.pages?.[9].visible).toBe(false);
|
|
549
|
+
|
|
550
|
+
// Add researcher signature
|
|
551
|
+
act(() => {
|
|
552
|
+
result.current.setFieldValue("researcherName", "Dr. Smith");
|
|
553
|
+
result.current.setFieldValue("researcherConfirm", true);
|
|
554
|
+
result.current.setFieldValue("researcherSignatureDateTime", "2024-01-15T10:30:00");
|
|
555
|
+
result.current.setFieldValue("witnessRequired", false);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
expect(result.current.computed?.canEnroll).toBe(true);
|
|
559
|
+
expect(result.current.wizard?.pages?.[9].visible).toBe(true);
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
describe("ineligible flow - page visibility", () => {
|
|
564
|
+
it("should show ineligibility documentation when participant is ineligible", () => {
|
|
565
|
+
const spec = createDiabetesTrialSpec();
|
|
566
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
567
|
+
|
|
568
|
+
act(() => {
|
|
569
|
+
// One inclusion = false (not eligible)
|
|
570
|
+
result.current.setFieldValue("inclusionAge", false);
|
|
571
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
572
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
573
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
574
|
+
// All exclusion answered
|
|
575
|
+
result.current.setFieldValue("exclusionPregnant", false);
|
|
576
|
+
result.current.setFieldValue("exclusionAllergy", false);
|
|
577
|
+
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
578
|
+
result.current.setFieldValue("exclusionKidney", false);
|
|
579
|
+
result.current.setFieldValue("exclusionSglt2", false);
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
const pages = result.current.wizard?.pages;
|
|
583
|
+
|
|
584
|
+
// Ineligibility page visible
|
|
585
|
+
expect(pages?.[4].id).toBe("ineligibility-documentation");
|
|
586
|
+
expect(pages?.[4].visible).toBe(true);
|
|
587
|
+
|
|
588
|
+
// Consent pages hidden
|
|
589
|
+
expect(pages?.[5].visible).toBe(false);
|
|
590
|
+
expect(pages?.[6].visible).toBe(false);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
it("should hide eligible-only pages when ineligible", () => {
|
|
594
|
+
const spec = createDiabetesTrialSpec();
|
|
595
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
596
|
+
|
|
597
|
+
act(() => {
|
|
598
|
+
// All inclusion = true
|
|
599
|
+
result.current.setFieldValue("inclusionAge", true);
|
|
600
|
+
result.current.setFieldValue("inclusionDiagnosis", true);
|
|
601
|
+
result.current.setFieldValue("inclusionVisits", true);
|
|
602
|
+
result.current.setFieldValue("inclusionConsent", true);
|
|
603
|
+
// One exclusion = true (pregnant - ineligible)
|
|
604
|
+
result.current.setFieldValue("exclusionPregnant", true);
|
|
605
|
+
result.current.setFieldValue("exclusionAllergy", false);
|
|
606
|
+
result.current.setFieldValue("exclusionRecentStudy", false);
|
|
607
|
+
result.current.setFieldValue("exclusionKidney", false);
|
|
608
|
+
result.current.setFieldValue("exclusionSglt2", false);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
const pages = result.current.wizard?.pages;
|
|
612
|
+
|
|
613
|
+
// Ineligibility visible
|
|
614
|
+
expect(pages?.[4].visible).toBe(true);
|
|
615
|
+
|
|
616
|
+
// All eligible-only pages hidden
|
|
617
|
+
expect(pages?.[5].visible).toBe(false); // main-consents
|
|
618
|
+
expect(pages?.[6].visible).toBe(false); // optional-consents
|
|
619
|
+
expect(pages?.[7].visible).toBe(false); // participant-signature
|
|
620
|
+
expect(pages?.[8].visible).toBe(false); // researcher-witness-signatures
|
|
621
|
+
expect(pages?.[9].visible).toBe(false); // enrollment-details
|
|
622
|
+
});
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
describe("signature flow variations", () => {
|
|
626
|
+
function setupReadyForSignature(setFieldValue: (field: string, value: unknown) => void) {
|
|
627
|
+
// Eligible
|
|
628
|
+
setFieldValue("inclusionAge", true);
|
|
629
|
+
setFieldValue("inclusionDiagnosis", true);
|
|
630
|
+
setFieldValue("inclusionVisits", true);
|
|
631
|
+
setFieldValue("inclusionConsent", true);
|
|
632
|
+
setFieldValue("exclusionPregnant", false);
|
|
633
|
+
setFieldValue("exclusionAllergy", false);
|
|
634
|
+
setFieldValue("exclusionRecentStudy", false);
|
|
635
|
+
setFieldValue("exclusionKidney", false);
|
|
636
|
+
setFieldValue("exclusionSglt2", false);
|
|
637
|
+
// Consents signed
|
|
638
|
+
setFieldValue("consentPurpose", true);
|
|
639
|
+
setFieldValue("consentProcedures", true);
|
|
640
|
+
setFieldValue("consentRisks", true);
|
|
641
|
+
setFieldValue("consentBenefits", true);
|
|
642
|
+
setFieldValue("consentVoluntary", true);
|
|
643
|
+
setFieldValue("consentDataHandling", true);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
it("should show participant fields when not signing on behalf", () => {
|
|
647
|
+
const spec = createDiabetesTrialSpec();
|
|
648
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
649
|
+
|
|
650
|
+
act(() => {
|
|
651
|
+
setupReadyForSignature(result.current.setFieldValue);
|
|
652
|
+
result.current.setFieldValue("signingOnBehalf", false);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
// Participant fields visible
|
|
656
|
+
expect(result.current.visibility.participantSignatureName).toBe(true);
|
|
657
|
+
expect(result.current.visibility.participantSignatureDateTime).toBe(true);
|
|
658
|
+
|
|
659
|
+
// LAR fields hidden
|
|
660
|
+
expect(result.current.visibility.larName).toBe(false);
|
|
661
|
+
expect(result.current.visibility.larRelationship).toBe(false);
|
|
662
|
+
expect(result.current.visibility.larSignatureDateTime).toBe(false);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("should show LAR fields when signing on behalf", () => {
|
|
666
|
+
const spec = createDiabetesTrialSpec();
|
|
667
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
668
|
+
|
|
669
|
+
act(() => {
|
|
670
|
+
setupReadyForSignature(result.current.setFieldValue);
|
|
671
|
+
result.current.setFieldValue("signingOnBehalf", true);
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
// LAR fields visible
|
|
675
|
+
expect(result.current.visibility.larName).toBe(true);
|
|
676
|
+
expect(result.current.visibility.larRelationship).toBe(true);
|
|
677
|
+
expect(result.current.visibility.larSignatureDateTime).toBe(true);
|
|
678
|
+
|
|
679
|
+
// Participant fields hidden
|
|
680
|
+
expect(result.current.visibility.participantSignatureName).toBe(false);
|
|
681
|
+
expect(result.current.visibility.participantSignatureDateTime).toBe(false);
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
it("should show witness fields when witness required", () => {
|
|
685
|
+
const spec = createDiabetesTrialSpec();
|
|
686
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
687
|
+
|
|
688
|
+
act(() => {
|
|
689
|
+
setupReadyForSignature(result.current.setFieldValue);
|
|
690
|
+
result.current.setFieldValue("witnessRequired", true);
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
expect(result.current.visibility.witnessName).toBe(true);
|
|
694
|
+
expect(result.current.visibility.witnessSignatureDateTime).toBe(true);
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
it("should hide witness fields when witness not required", () => {
|
|
698
|
+
const spec = createDiabetesTrialSpec();
|
|
699
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
700
|
+
|
|
701
|
+
act(() => {
|
|
702
|
+
setupReadyForSignature(result.current.setFieldValue);
|
|
703
|
+
result.current.setFieldValue("witnessRequired", false);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
expect(result.current.visibility.witnessName).toBe(false);
|
|
707
|
+
expect(result.current.visibility.witnessSignatureDateTime).toBe(false);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
it("should accept LAR signature as valid signature", () => {
|
|
711
|
+
const spec = createDiabetesTrialSpec();
|
|
712
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
713
|
+
|
|
714
|
+
act(() => {
|
|
715
|
+
setupReadyForSignature(result.current.setFieldValue);
|
|
716
|
+
result.current.setFieldValue("signingOnBehalf", true);
|
|
717
|
+
result.current.setFieldValue("larName", "Jane Doe");
|
|
718
|
+
result.current.setFieldValue("larRelationship", "Spouse");
|
|
719
|
+
result.current.setFieldValue("larSignatureDateTime", "2024-01-15T10:00:00");
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
expect(result.current.computed?.hasLarSignature).toBe(true);
|
|
723
|
+
expect(result.current.computed?.hasValidSignature).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
describe("wizard navigation", () => {
|
|
728
|
+
it("should navigate through visible pages correctly", () => {
|
|
729
|
+
const spec = createDiabetesTrialSpec();
|
|
730
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
731
|
+
|
|
732
|
+
// Start on page 0
|
|
733
|
+
expect(result.current.wizard?.currentPageIndex).toBe(0);
|
|
734
|
+
expect(result.current.wizard?.currentPage?.id).toBe("study-information");
|
|
735
|
+
|
|
736
|
+
// Navigate forward
|
|
737
|
+
act(() => {
|
|
738
|
+
result.current.wizard?.nextPage();
|
|
739
|
+
});
|
|
740
|
+
expect(result.current.wizard?.currentPageIndex).toBe(1);
|
|
741
|
+
expect(result.current.wizard?.currentPage?.id).toBe("participant-information");
|
|
742
|
+
|
|
743
|
+
// Continue to inclusion
|
|
744
|
+
act(() => {
|
|
745
|
+
result.current.wizard?.nextPage();
|
|
746
|
+
});
|
|
747
|
+
expect(result.current.wizard?.currentPage?.id).toBe("inclusion-criteria");
|
|
748
|
+
|
|
749
|
+
// Continue to exclusion
|
|
750
|
+
act(() => {
|
|
751
|
+
result.current.wizard?.nextPage();
|
|
752
|
+
});
|
|
753
|
+
expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
it("should skip hidden pages during navigation", () => {
|
|
757
|
+
const spec = createDiabetesTrialSpec();
|
|
758
|
+
const { result } = renderHook(() => useForma({ spec }));
|
|
759
|
+
|
|
760
|
+
// Go to exclusion page (index 3)
|
|
761
|
+
act(() => {
|
|
762
|
+
result.current.wizard?.goToPage(3);
|
|
763
|
+
});
|
|
764
|
+
expect(result.current.wizard?.currentPage?.id).toBe("exclusion-criteria");
|
|
765
|
+
|
|
766
|
+
// Without eligibility determined, next visible page count is limited
|
|
767
|
+
// The visible pages should be: study-info (0), participant-info (1), inclusion (2), exclusion (3)
|
|
768
|
+
// Pages 4-9 are conditional and hidden
|
|
769
|
+
const visiblePages = result.current.wizard?.pages.filter(p => p.visible);
|
|
770
|
+
expect(visiblePages?.length).toBe(4);
|
|
771
|
+
|
|
772
|
+
// Should be on last visible page
|
|
773
|
+
expect(result.current.wizard?.isLastPage).toBe(true);
|
|
774
|
+
});
|
|
775
|
+
});
|
|
776
|
+
});
|
|
@@ -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
|
+
});
|