Skip to content

Commit 26b236a

Browse files
authored
Merge pull request #55 from beda-software/context-population
**Changes:** - Unify item- and questionnaire-level variable resolution into a single useVariablesResolver (renamed from useQuestionItemContext, parameterized with variable / prefix / branchItems). Removed the duplicate useQuestionnaireContext. - Resolve questionnaire-level application/x-fhir-query variables (fetched via fhirService) at the root, feeding dependent FHIRPath variables. - Evaluate sdc-questionnaire-itemPopulationContext at runtime (resolveItemPopulationContext) — bound before item variables and available to descendants and the item's own enableWhenExpression. - Reduce calcInitialContext to a pure base-context builder (no variable evaluation). - Tests: x-fhir-query chains, itemPopulationContext binding + dependent variable + enableWhen. **Breaking / API:** - Removed: useQuestionnaireContext, UseQuestionnaireContextArgs. - Added: useVariablesResolver, UseVariablesResolverArgs. - calcInitialContext no longer evaluates questionnaire-level variables — callers resolve them through useVariablesResolver. **Consumer impact:** - root forms now call useVariablesResolver on top of calcInitialContext with branchItems: { qrItems: [QR] }, variable: questionnaire.variable, prefix: 'root' (wired in @beda.software/fhir-questionnaire).
2 parents 8310a8a + cf1ef3a commit 26b236a

6 files changed

Lines changed: 240 additions & 111 deletions

File tree

src/__tests__/useQuestionItemContext.test.tsx

Lines changed: 156 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,10 @@ import type { Questionnaire, QuestionnaireResponse, QuestionnaireResponseItem }
33
import { isSuccess, success } from '@beda.software/remote-data';
44
import { describe, expect, test, vi } from 'vitest';
55

6-
import { useQuestionItemContext } from '../hooks';
6+
import { useVariablesResolver } from '../hooks';
77
import type { ItemContext } from '../types';
88
import type { FCEQuestionnaireItem } from '../fce.types';
9-
import { getBranchItems } from '../utils.js';
9+
import { getBranchItems, getEnabledQuestions } from '../utils.js';
1010

1111
function createInitialContext(questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse): ItemContext {
1212
return {
@@ -29,7 +29,7 @@ function buildFHIRServiceMock(mapping: Record<string, any>) {
2929
});
3030
}
3131

32-
describe('useQuestionItemContext', () => {
32+
describe('useVariablesResolver', () => {
3333
test('returns single context for non-group question', () => {
3434
const questionnaire: Questionnaire = {
3535
resourceType: 'Questionnaire',
@@ -63,10 +63,11 @@ describe('useQuestionItemContext', () => {
6363
};
6464

6565
const { result } = renderHook(() =>
66-
useQuestionItemContext({
66+
useVariablesResolver({
6767
initialContext,
6868
branchItems,
69-
questionItem,
69+
variable: questionItem.variable,
70+
prefix: questionItem.linkId,
7071
fhirService: vi.fn(),
7172
}),
7273
);
@@ -152,10 +153,11 @@ describe('useQuestionItemContext', () => {
152153
};
153154

154155
const { result } = renderHook(() =>
155-
useQuestionItemContext({
156+
useVariablesResolver({
156157
initialContext,
157158
branchItems,
158-
questionItem,
159+
variable: questionItem.variable,
160+
prefix: questionItem.linkId,
159161
fhirService: vi.fn(),
160162
}),
161163
);
@@ -175,7 +177,7 @@ describe('useQuestionItemContext', () => {
175177
});
176178
});
177179

178-
describe('useQuestionItemContext with x-fhir-query', () => {
180+
describe('useVariablesResolver with x-fhir-query', () => {
179181
test('evaluates patient/org variable chain', async () => {
180182
const questionnaire: Questionnaire = {
181183
resourceType: 'Questionnaire',
@@ -269,13 +271,18 @@ describe('useQuestionItemContext with x-fhir-query', () => {
269271
const initialBranchItems = getBranchItems([orgItem.linkId], questionnaire, initialQuestionnaireResponse);
270272

271273
const { result, rerender } = renderHook(
272-
(props: {
274+
({
275+
questionItem,
276+
...props
277+
}: {
273278
initialContext: ItemContext;
274279
branchItems: ReturnType<typeof getBranchItems>;
275280
questionItem: FCEQuestionnaireItem;
276281
}) =>
277-
useQuestionItemContext({
282+
useVariablesResolver({
278283
...props,
284+
variable: questionItem.variable,
285+
prefix: questionItem.linkId,
279286
fhirService,
280287
}),
281288
{
@@ -452,10 +459,11 @@ describe('useQuestionItemContext with x-fhir-query', () => {
452459
const branchItems = getBranchItems(['row'], questionnaire, questionnaireResponse);
453460

454461
const { result } = renderHook(() =>
455-
useQuestionItemContext({
462+
useVariablesResolver({
456463
initialContext,
457464
branchItems,
458-
questionItem,
465+
variable: questionItem.variable,
466+
prefix: questionItem.linkId,
459467
fhirService,
460468
}),
461469
);
@@ -489,3 +497,139 @@ describe('useQuestionItemContext with x-fhir-query', () => {
489497
);
490498
});
491499
});
500+
501+
describe('useVariablesResolver with itemPopulationContext', () => {
502+
const patient = {
503+
resourceType: 'Patient',
504+
address: [
505+
{ use: 'home', line: ['1 Home St'] },
506+
{ type: 'postal', line: ['PO Box 5'] },
507+
],
508+
};
509+
510+
function buildPostalContext(patientResource: any): ItemContext {
511+
const questionnaire: Questionnaire = { resourceType: 'Questionnaire', status: 'active', item: [] };
512+
const questionnaireResponse: QuestionnaireResponse = {
513+
resourceType: 'QuestionnaireResponse',
514+
status: 'in-progress',
515+
item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }],
516+
};
517+
518+
return {
519+
...createInitialContext(questionnaire, questionnaireResponse),
520+
patient: patientResource,
521+
} as ItemContext;
522+
}
523+
524+
const postalQuestionnaire: Questionnaire = {
525+
resourceType: 'Questionnaire',
526+
status: 'active',
527+
item: [
528+
{
529+
linkId: 'postal',
530+
type: 'group',
531+
itemPopulationContext: {
532+
name: 'PostalAddressArray',
533+
language: 'text/fhirpath',
534+
expression: "%patient.address.where(type='postal')",
535+
},
536+
item: [{ linkId: 'city', type: 'string' }],
537+
} as any,
538+
],
539+
};
540+
541+
const postalQuestionnaireResponse: QuestionnaireResponse = {
542+
resourceType: 'QuestionnaireResponse',
543+
status: 'in-progress',
544+
item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }],
545+
};
546+
547+
test('binds the itemPopulationContext variable into the resolved context', () => {
548+
const initialContext = buildPostalContext(patient);
549+
const branchItems = getBranchItems(['postal'], postalQuestionnaire, postalQuestionnaireResponse);
550+
const questionItem = postalQuestionnaire.item![0] as FCEQuestionnaireItem;
551+
552+
const { result } = renderHook(() =>
553+
useVariablesResolver({
554+
initialContext,
555+
branchItems,
556+
variable: questionItem.variable,
557+
prefix: questionItem.linkId,
558+
fhirService: vi.fn(),
559+
}),
560+
);
561+
562+
expect(result.current.contexts).toHaveLength(1);
563+
expect(result.current.contexts[0].PostalAddressArray).toEqual([{ type: 'postal', line: ['PO Box 5'] }]);
564+
});
565+
566+
test('a variable can reference the bound itemPopulationContext', () => {
567+
const questionnaire: Questionnaire = {
568+
resourceType: 'Questionnaire',
569+
status: 'active',
570+
item: [
571+
{
572+
linkId: 'postal',
573+
type: 'group',
574+
itemPopulationContext: {
575+
name: 'PostalAddressArray',
576+
language: 'text/fhirpath',
577+
expression: "%patient.address.where(type='postal')",
578+
},
579+
variable: [
580+
{
581+
name: 'PostalLine',
582+
language: 'text/fhirpath',
583+
expression: '%PostalAddressArray.line',
584+
},
585+
],
586+
item: [{ linkId: 'city', type: 'string' }],
587+
} as any,
588+
],
589+
};
590+
591+
const initialContext = buildPostalContext(patient);
592+
const branchItems = getBranchItems(['postal'], questionnaire, postalQuestionnaireResponse);
593+
const questionItem = questionnaire.item![0] as FCEQuestionnaireItem;
594+
595+
const { result } = renderHook(() =>
596+
useVariablesResolver({
597+
initialContext,
598+
branchItems,
599+
variable: questionItem.variable,
600+
prefix: questionItem.linkId,
601+
fhirService: vi.fn(),
602+
}),
603+
);
604+
605+
expect((result.current.contexts[0].PostalLine as string[])[0]).toEqual('PO Box 5');
606+
});
607+
608+
test('enableWhenExpression can reference an item itemPopulationContext variable', () => {
609+
const item = {
610+
linkId: 'postal',
611+
type: 'group',
612+
itemPopulationContext: {
613+
name: 'PostalAddressArray',
614+
language: 'text/fhirpath',
615+
expression: "%patient.address.where(type='postal')",
616+
},
617+
enableWhenExpression: {
618+
language: 'text/fhirpath',
619+
expression: '%PostalAddressArray.exists()',
620+
},
621+
item: [{ linkId: 'city', type: 'string' }],
622+
} as unknown as FCEQuestionnaireItem;
623+
624+
const enabled = getEnabledQuestions([item], [], {}, buildPostalContext(patient));
625+
expect(enabled).toHaveLength(1);
626+
627+
const disabled = getEnabledQuestions(
628+
[item],
629+
[],
630+
{},
631+
buildPostalContext({ resourceType: 'Patient', address: [{ use: 'home', line: ['1 Home St'] }] }),
632+
);
633+
expect(disabled).toHaveLength(0);
634+
});
635+
});

src/components.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
stripNonEnumerable,
1515
wrapAnswerValue,
1616
} from './utils.js';
17-
import { useQuestionItemContext } from './hooks';
17+
import { useVariablesResolver } from './hooks';
1818

1919
function usePreviousValue<T>(value: T) {
2020
const prevValue = useRef<T | undefined>(value);
@@ -65,11 +65,13 @@ export function QuestionItem(props: QuestionItemProps) {
6565
() => getBranchItems(fieldPath, initialContext.questionnaire, initialContext.resource),
6666
[fieldPath, initialContext.questionnaire, initialContext.resource],
6767
);
68-
const { contexts } = useQuestionItemContext({
68+
const { contexts } = useVariablesResolver({
6969
initialContext,
7070
branchItems,
7171
fhirService,
72-
questionItem,
72+
evaluateFhirpath,
73+
variable: questionItem.variable,
74+
prefix: linkId,
7375
});
7476
const context = type === 'group' ? contexts : contexts[0]!;
7577
const prevAnswers: FormAnswerItems[] | undefined = usePreviousValue(_.get(formValues, fieldPath));

src/hooks.ts

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
import { useContext, useEffect, useMemo, useState } from 'react';
22
import type { AxiosRequestConfig } from 'axios';
3-
import { FCEQuestionnaireItem } from './fce.types';
43
import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray } from '@beda.software/remote-data';
54

65
import { QRFContext } from './context';
76
import { EvaluateFhirpath, ItemContext } from './types';
8-
import { resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils';
7+
import { resolveItemPopulationContext, resolveTemplateExpr, evaluateFHIRPathExpression } from './utils';
8+
import { Expression, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4b';
99

1010
export function useQuestionnaireResponseFormContext() {
1111
return useContext(QRFContext);
1212
}
1313

14-
export type UseQuestionItemContextArgs = {
14+
export type UseVariablesResolverArgs = {
1515
initialContext: ItemContext;
16-
branchItems: ReturnType<typeof getBranchItems>;
16+
branchItems: { qItem?: QuestionnaireItem; qrItems: Array<QuestionnaireResponseItem | QuestionnaireResponse> };
1717
fhirService: (config: AxiosRequestConfig) => Promise<RemoteData<unknown>>;
18-
questionItem: FCEQuestionnaireItem;
1918
evaluateFhirpath?: EvaluateFhirpath;
19+
variable?: Expression[];
20+
prefix?: string;
2021
};
2122

2223
type AsyncState = Record<
@@ -30,22 +31,24 @@ type AsyncState = Record<
3031
>
3132
>;
3233

33-
export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
34+
export function useVariablesResolver(props: UseVariablesResolverArgs): {
3435
contexts: ItemContext[];
3536
evaluationResponse: RemoteData<ItemContext[]>;
3637
} {
37-
const { initialContext, branchItems, fhirService, questionItem, evaluateFhirpath } = props;
38-
const { variable, linkId } = questionItem;
38+
const { initialContext, branchItems, fhirService, evaluateFhirpath, variable, prefix } = props;
3939
const variables = useMemo(() => variable ?? [], [variable]);
4040
const [asyncState, setAsyncState] = useState<AsyncState>({});
4141

4242
useEffect(() => {
4343
branchItems.qrItems.forEach((qrItem, branchIndex) => {
44-
const workingContext: ItemContext = {
44+
let workingContext: ItemContext = {
4545
...initialContext,
4646
context: qrItem,
4747
qitem: branchItems.qItem,
4848
};
49+
workingContext = branchItems.qItem
50+
? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath)
51+
: workingContext;
4952

5053
variables.forEach((variable) => {
5154
if (!variable?.name || !variable.expression) {
@@ -55,7 +58,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
5558
const { name, expression, language } = variable;
5659

5760
if (language === 'application/x-fhir-query') {
58-
const url = resolveTemplateExpr(expression!, workingContext, `${linkId}.variable.${name}`, true);
61+
const url = resolveTemplateExpr(expression!, workingContext, `${prefix}.variable.${name}`, true);
5962

6063
if (!url) {
6164
workingContext[name] = null;
@@ -115,7 +118,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
115118
workingContext[name] = evaluateFHIRPathExpression(
116119
variable,
117120
workingContext,
118-
`${linkId}.variable.${name}`,
121+
`${prefix}.variable.${name}`,
119122
evaluateFhirpath,
120123
);
121124
}
@@ -125,11 +128,14 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
125128

126129
const contexts = useMemo(() => {
127130
return branchItems.qrItems.map<ItemContext>((qrItem, branchIndex) => {
128-
const workingContext: ItemContext = {
131+
let workingContext: ItemContext = {
129132
...initialContext,
130133
context: qrItem,
131134
qitem: branchItems.qItem,
132135
};
136+
workingContext = branchItems.qItem
137+
? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath)
138+
: workingContext;
133139

134140
variables.forEach((variable) => {
135141
if (!variable?.name || !variable.expression) {
@@ -151,7 +157,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
151157
workingContext[name] = evaluateFHIRPathExpression(
152158
variable,
153159
workingContext,
154-
`${linkId}.variable.${name}`,
160+
`${prefix}.variable.${name}`,
155161
evaluateFhirpath,
156162
);
157163
}

src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ export {
1717
removeItemKey,
1818
getItemKey,
1919
} from './utils';
20-
export { useQuestionnaireResponseFormContext } from './hooks';
20+
export { useQuestionnaireResponseFormContext, useVariablesResolver } from './hooks';
21+
export type { UseVariablesResolverArgs } from './hooks';
2122
export { QuestionItems, QuestionItem, QuestionnaireResponseFormProvider } from './components';
2223
export * from './converter';
2324
export type * from './fce.types';

0 commit comments

Comments
 (0)