diff --git a/src/__tests__/useQuestionItemContext.test.tsx b/src/__tests__/useQuestionItemContext.test.tsx index 84859af..7fadbf9 100644 --- a/src/__tests__/useQuestionItemContext.test.tsx +++ b/src/__tests__/useQuestionItemContext.test.tsx @@ -3,10 +3,10 @@ import type { Questionnaire, QuestionnaireResponse, QuestionnaireResponseItem } import { isSuccess, success } from '@beda.software/remote-data'; import { describe, expect, test, vi } from 'vitest'; -import { useQuestionItemContext } from '../hooks'; +import { useVariablesResolver } from '../hooks'; import type { ItemContext } from '../types'; import type { FCEQuestionnaireItem } from '../fce.types'; -import { getBranchItems } from '../utils.js'; +import { getBranchItems, getEnabledQuestions } from '../utils.js'; function createInitialContext(questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse): ItemContext { return { @@ -29,7 +29,7 @@ function buildFHIRServiceMock(mapping: Record) { }); } -describe('useQuestionItemContext', () => { +describe('useVariablesResolver', () => { test('returns single context for non-group question', () => { const questionnaire: Questionnaire = { resourceType: 'Questionnaire', @@ -63,10 +63,11 @@ describe('useQuestionItemContext', () => { }; const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService: vi.fn(), }), ); @@ -152,10 +153,11 @@ describe('useQuestionItemContext', () => { }; const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService: vi.fn(), }), ); @@ -175,7 +177,7 @@ describe('useQuestionItemContext', () => { }); }); -describe('useQuestionItemContext with x-fhir-query', () => { +describe('useVariablesResolver with x-fhir-query', () => { test('evaluates patient/org variable chain', async () => { const questionnaire: Questionnaire = { resourceType: 'Questionnaire', @@ -269,13 +271,18 @@ describe('useQuestionItemContext with x-fhir-query', () => { const initialBranchItems = getBranchItems([orgItem.linkId], questionnaire, initialQuestionnaireResponse); const { result, rerender } = renderHook( - (props: { + ({ + questionItem, + ...props + }: { initialContext: ItemContext; branchItems: ReturnType; questionItem: FCEQuestionnaireItem; }) => - useQuestionItemContext({ + useVariablesResolver({ ...props, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService, }), { @@ -452,10 +459,11 @@ describe('useQuestionItemContext with x-fhir-query', () => { const branchItems = getBranchItems(['row'], questionnaire, questionnaireResponse); const { result } = renderHook(() => - useQuestionItemContext({ + useVariablesResolver({ initialContext, branchItems, - questionItem, + variable: questionItem.variable, + prefix: questionItem.linkId, fhirService, }), ); @@ -489,3 +497,139 @@ describe('useQuestionItemContext with x-fhir-query', () => { ); }); }); + +describe('useVariablesResolver with itemPopulationContext', () => { + const patient = { + resourceType: 'Patient', + address: [ + { use: 'home', line: ['1 Home St'] }, + { type: 'postal', line: ['PO Box 5'] }, + ], + }; + + function buildPostalContext(patientResource: any): ItemContext { + const questionnaire: Questionnaire = { resourceType: 'Questionnaire', status: 'active', item: [] }; + const questionnaireResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }], + }; + + return { + ...createInitialContext(questionnaire, questionnaireResponse), + patient: patientResource, + } as ItemContext; + } + + const postalQuestionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + item: [{ linkId: 'city', type: 'string' }], + } as any, + ], + }; + + const postalQuestionnaireResponse: QuestionnaireResponse = { + resourceType: 'QuestionnaireResponse', + status: 'in-progress', + item: [{ linkId: 'postal', item: [{ linkId: 'city' }] }], + }; + + test('binds the itemPopulationContext variable into the resolved context', () => { + const initialContext = buildPostalContext(patient); + const branchItems = getBranchItems(['postal'], postalQuestionnaire, postalQuestionnaireResponse); + const questionItem = postalQuestionnaire.item![0] as FCEQuestionnaireItem; + + const { result } = renderHook(() => + useVariablesResolver({ + initialContext, + branchItems, + variable: questionItem.variable, + prefix: questionItem.linkId, + fhirService: vi.fn(), + }), + ); + + expect(result.current.contexts).toHaveLength(1); + expect(result.current.contexts[0].PostalAddressArray).toEqual([{ type: 'postal', line: ['PO Box 5'] }]); + }); + + test('a variable can reference the bound itemPopulationContext', () => { + const questionnaire: Questionnaire = { + resourceType: 'Questionnaire', + status: 'active', + item: [ + { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + variable: [ + { + name: 'PostalLine', + language: 'text/fhirpath', + expression: '%PostalAddressArray.line', + }, + ], + item: [{ linkId: 'city', type: 'string' }], + } as any, + ], + }; + + const initialContext = buildPostalContext(patient); + const branchItems = getBranchItems(['postal'], questionnaire, postalQuestionnaireResponse); + const questionItem = questionnaire.item![0] as FCEQuestionnaireItem; + + const { result } = renderHook(() => + useVariablesResolver({ + initialContext, + branchItems, + variable: questionItem.variable, + prefix: questionItem.linkId, + fhirService: vi.fn(), + }), + ); + + expect((result.current.contexts[0].PostalLine as string[])[0]).toEqual('PO Box 5'); + }); + + test('enableWhenExpression can reference an item itemPopulationContext variable', () => { + const item = { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", + }, + enableWhenExpression: { + language: 'text/fhirpath', + expression: '%PostalAddressArray.exists()', + }, + item: [{ linkId: 'city', type: 'string' }], + } as unknown as FCEQuestionnaireItem; + + const enabled = getEnabledQuestions([item], [], {}, buildPostalContext(patient)); + expect(enabled).toHaveLength(1); + + const disabled = getEnabledQuestions( + [item], + [], + {}, + buildPostalContext({ resourceType: 'Patient', address: [{ use: 'home', line: ['1 Home St'] }] }), + ); + expect(disabled).toHaveLength(0); + }); +}); diff --git a/src/components.tsx b/src/components.tsx index 81a8cca..cc8d1e6 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -14,7 +14,7 @@ import { stripNonEnumerable, wrapAnswerValue, } from './utils.js'; -import { useQuestionItemContext } from './hooks'; +import { useVariablesResolver } from './hooks'; function usePreviousValue(value: T) { const prevValue = useRef(value); @@ -65,11 +65,13 @@ export function QuestionItem(props: QuestionItemProps) { () => getBranchItems(fieldPath, initialContext.questionnaire, initialContext.resource), [fieldPath, initialContext.questionnaire, initialContext.resource], ); - const { contexts } = useQuestionItemContext({ + const { contexts } = useVariablesResolver({ initialContext, branchItems, fhirService, - questionItem, + evaluateFhirpath, + variable: questionItem.variable, + prefix: linkId, }); const context = type === 'group' ? contexts : contexts[0]!; const prevAnswers: FormAnswerItems[] | undefined = usePreviousValue(_.get(formValues, fieldPath)); diff --git a/src/hooks.ts b/src/hooks.ts index a60d7d0..4237ce4 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,22 +1,23 @@ import { useContext, useEffect, useMemo, useState } from 'react'; import type { AxiosRequestConfig } from 'axios'; -import { FCEQuestionnaireItem } from './fce.types'; import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray } from '@beda.software/remote-data'; import { QRFContext } from './context'; import { EvaluateFhirpath, ItemContext } from './types'; -import { resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils'; +import { resolveItemPopulationContext, resolveTemplateExpr, evaluateFHIRPathExpression } from './utils'; +import { Expression, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem } from 'fhir/r4b'; export function useQuestionnaireResponseFormContext() { return useContext(QRFContext); } -export type UseQuestionItemContextArgs = { +export type UseVariablesResolverArgs = { initialContext: ItemContext; - branchItems: ReturnType; + branchItems: { qItem?: QuestionnaireItem; qrItems: Array }; fhirService: (config: AxiosRequestConfig) => Promise>; - questionItem: FCEQuestionnaireItem; evaluateFhirpath?: EvaluateFhirpath; + variable?: Expression[]; + prefix?: string; }; type AsyncState = Record< @@ -30,22 +31,24 @@ type AsyncState = Record< > >; -export function useQuestionItemContext(props: UseQuestionItemContextArgs): { +export function useVariablesResolver(props: UseVariablesResolverArgs): { contexts: ItemContext[]; evaluationResponse: RemoteData; } { - const { initialContext, branchItems, fhirService, questionItem, evaluateFhirpath } = props; - const { variable, linkId } = questionItem; + const { initialContext, branchItems, fhirService, evaluateFhirpath, variable, prefix } = props; const variables = useMemo(() => variable ?? [], [variable]); const [asyncState, setAsyncState] = useState({}); useEffect(() => { branchItems.qrItems.forEach((qrItem, branchIndex) => { - const workingContext: ItemContext = { + let workingContext: ItemContext = { ...initialContext, context: qrItem, qitem: branchItems.qItem, }; + workingContext = branchItems.qItem + ? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath) + : workingContext; variables.forEach((variable) => { if (!variable?.name || !variable.expression) { @@ -55,7 +58,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { const { name, expression, language } = variable; if (language === 'application/x-fhir-query') { - const url = resolveTemplateExpr(expression!, workingContext, `${linkId}.variable.${name}`, true); + const url = resolveTemplateExpr(expression!, workingContext, `${prefix}.variable.${name}`, true); if (!url) { workingContext[name] = null; @@ -115,7 +118,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { workingContext[name] = evaluateFHIRPathExpression( variable, workingContext, - `${linkId}.variable.${name}`, + `${prefix}.variable.${name}`, evaluateFhirpath, ); } @@ -125,11 +128,14 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { const contexts = useMemo(() => { return branchItems.qrItems.map((qrItem, branchIndex) => { - const workingContext: ItemContext = { + let workingContext: ItemContext = { ...initialContext, context: qrItem, qitem: branchItems.qItem, }; + workingContext = branchItems.qItem + ? resolveItemPopulationContext(workingContext, branchItems.qItem, evaluateFhirpath) + : workingContext; variables.forEach((variable) => { if (!variable?.name || !variable.expression) { @@ -151,7 +157,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): { workingContext[name] = evaluateFHIRPathExpression( variable, workingContext, - `${linkId}.variable.${name}`, + `${prefix}.variable.${name}`, evaluateFhirpath, ); } diff --git a/src/index.ts b/src/index.ts index f19aea0..d37dc6b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,8 @@ export { removeItemKey, getItemKey, } from './utils'; -export { useQuestionnaireResponseFormContext } from './hooks'; +export { useQuestionnaireResponseFormContext, useVariablesResolver } from './hooks'; +export type { UseVariablesResolverArgs } from './hooks'; export { QuestionItems, QuestionItem, QuestionnaireResponseFormProvider } from './components'; export * from './converter'; export type * from './fce.types'; diff --git a/src/utils.ts b/src/utils.ts index 6fd6e54..0c981d5 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -532,6 +532,33 @@ export function getChecker( return _.constant(true); } +/** + * Evaluates an item's `sdc-questionnaire-itemPopulationContext` (a named text/fhirpath expression) and + * returns a context with that named variable bound, so expressions on the item or its descendants + * (e.g. enableWhenExpression) can reference it. Unsupported/failing contexts are bound to an empty + * collection rather than crashing the form. + */ +export function resolveItemPopulationContext( + context: ItemContext, + qItem: FCEQuestionnaireItem | undefined, + evaluateFhirpath?: EvaluateFhirpath, +): ItemContext { + const ipc = qItem?.itemPopulationContext; + if (!ipc?.name || !ipc.expression || ipc.language !== 'text/fhirpath') { + return context; + } + + return { + ...context, + [ipc.name]: evaluateFHIRPathExpression( + ipc, + context, + `${qItem?.linkId}.itemPopulationContext`, + evaluateFhirpath, + ), + }; +} + interface IsQuestionEnabledArgs { qItem: FCEQuestionnaireItem; parentPath: string[]; @@ -556,10 +583,11 @@ function isQuestionEnabled(args: IsQuestionEnabledArgs) { } if (enableWhenExpression && enableWhenExpression.language === 'text/fhirpath') { + const context = resolveItemPopulationContext(args.context, args.qItem, args.evaluateFhirpath); try { const expressionResult = evaluateFHIRPathExpression( enableWhenExpression, - args.context, + context, `${linkId}.enableWhenExpression`, args.evaluateFhirpath, )[0]; @@ -729,14 +757,13 @@ export function getEnabledQuestions( export function calcInitialContext( qrfDataContext: QuestionnaireResponseFormData['context'], values: FormItems, - evaluateFhirpath?: EvaluateFhirpath, ): ItemContext { const questionnaireResponse = { ...qrfDataContext.questionnaireResponse, ...mapFormToResponse(values, qrfDataContext.questionnaire), }; - const baseContext: ItemContext = { + return { ...qrfDataContext.launchContextParameters.reduce((acc, { name, resource, ...param }) => { const value = getChoiceTypeValue(param, 'value'); @@ -755,23 +782,6 @@ export function calcInitialContext( Questionnaire: qrfDataContext.questionnaire, QuestionnaireResponse: questionnaireResponse, }; - - // Evaluate questionnaire-level FHIRPath variables in declaration order - return (qrfDataContext.fceQuestionnaire.variable ?? []).reduce((ctx: ItemContext, variable) => { - if (!variable?.name || !variable.expression || variable.language !== 'text/fhirpath') { - console.warn(`Only fhirpath variables are supported. Variable ${variable.name} is not supported.`); - return ctx; - } - return { - ...ctx, - [variable.name]: evaluateFHIRPathExpression( - variable, - ctx, - `questionnaire.variable.${variable.name}`, - evaluateFhirpath, - ), - }; - }, baseContext); } export function resolveTemplateExpr( diff --git a/tests/utils.test.ts b/tests/utils.test.ts index 0528740..699441a 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -23,6 +23,7 @@ import { findAnswersForQuestion, ITEM_KEY, stripNonEnumerable, + resolveItemPopulationContext, } from '../src/utils'; import { ParametersParameter, @@ -2549,74 +2550,39 @@ describe('calcInitialContext', () => { StringValue: 'string', }); }); +}); - test('evaluates questionnaire-level variables and adds them to the context', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [{ name: 'Greeting', language: 'text/fhirpath', expression: "'Hello'" }], - }, - }, - formValues, - ); - - expect(result.Greeting).toStrictEqual(['Hello']); - }); - - test('evaluates variables in declaration order so later ones can reference earlier ones', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { name: 'First', language: 'text/fhirpath', expression: "'First'" }, - { name: 'Second', language: 'text/fhirpath', expression: "%First + 'Second'" }, - ], - }, - }, - formValues, - ); - - expect(result.First).toStrictEqual(['First']); - expect(result.Second).toStrictEqual(['FirstSecond']); - }); +describe('resolveItemPopulationContext', () => { + const baseContext: ItemContext = { + questionnaire: { resourceType: 'Questionnaire', status: 'active' }, + resource: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + context: { resourceType: 'QuestionnaireResponse', status: 'in-progress' }, + patient: { + resourceType: 'Patient', + address: [ + { use: 'home', line: ['1 Home St'] }, + { type: 'postal', line: ['PO Box 5'] }, + ], + }, + } as any; - test('variables can reference launch context and questionnaire response values', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [{ name: 'PatientType', language: 'text/fhirpath', expression: '%Patient.resourceType' }], - }, + test('binds the itemPopulationContext variable so dependent expressions can use it', () => { + const result = resolveItemPopulationContext(baseContext, { + linkId: 'postal', + type: 'group', + itemPopulationContext: { + name: 'PostalAddressArray', + language: 'text/fhirpath', + expression: "%patient.address.where(type='postal')", }, - formValues, - ); + } as any); - expect(result.PatientType).toStrictEqual(['Patient']); + expect(result.PostalAddressArray).toEqual([{ type: 'postal', line: ['PO Box 5'] }]); }); - test('skips variables that are not text/fhirpath or are missing name/expression', () => { - const result = calcInitialContext( - { - ...qrfDataContext, - fceQuestionnaire: { - ...questionnaire, - variable: [ - { name: 'Query', language: 'application/x-fhir-query', expression: '/Patient' } as any, - { language: 'text/fhirpath', expression: "'no-name'" } as any, - { name: 'NoExpression', language: 'text/fhirpath' } as any, - ], - }, - }, - formValues, - ); - - expect(result).not.toHaveProperty('Query'); - expect(result).not.toHaveProperty('NoExpression'); + test('returns the context unchanged when there is no itemPopulationContext', () => { + const result = resolveItemPopulationContext(baseContext, { linkId: 'x', type: 'string' } as any); + expect(result).toBe(baseContext); }); });