Skip to content

Commit fd3fdb9

Browse files
authored
Use optional fhirpath.evaluate from QRFContext (#45)
* Use optional fhirpath.evaluate from QRFContext * Add evaluateFhirpath to parseFhirQueryExpression
1 parent 2783498 commit fd3fdb9

6 files changed

Lines changed: 157 additions & 19 deletions

File tree

src/__tests__/QuestionItem.test.tsx

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import React from 'react';
2+
import fhirpath from 'fhirpath';
3+
import fhirpathR4BModel from 'fhirpath/fhir-context/r4';
4+
25
import { render, waitFor } from '@testing-library/react';
36
import { describe, expect, test, vi } from 'vitest';
47
import type { Questionnaire, QuestionnaireResponse } from 'fhir/r4b';
58
import { success } from '@beda.software/remote-data';
69

710
import { QuestionItem, QuestionnaireResponseFormProvider } from '../components';
8-
import type { ItemContext, QRFContextData, QuestionItemComponent } from '../types';
11+
import type { EvaluateFhirpath, ItemContext, QRFContextData, QuestionItemComponent } from '../types';
912
import type { FCEQuestionnaireItem } from '../fce.types';
1013

1114
function createInitialContext(questionnaire: Questionnaire, questionnaireResponse: QuestionnaireResponse): ItemContext {
@@ -248,3 +251,64 @@ describe('QuestionItem cqf expressions', () => {
248251
expect(lastProps.questionItem.required).toBe(true);
249252
});
250253
});
254+
255+
describe('QuestionnaireResponseFormProvider evaluateFhirpath', () => {
256+
test('uses custom evaluateFhirpath from provider when evaluating calculatedExpression via QuestionItems', async () => {
257+
const questionnaire: Questionnaire = {
258+
resourceType: 'Questionnaire',
259+
status: 'active',
260+
item: [
261+
{
262+
linkId: 'target',
263+
type: 'string',
264+
calculatedExpression: {
265+
language: 'text/fhirpath',
266+
expression: '%resource.item.first().linkId.customFn()',
267+
},
268+
} as FCEQuestionnaireItem,
269+
],
270+
};
271+
272+
const questionnaireResponse: QuestionnaireResponse = {
273+
resourceType: 'QuestionnaireResponse',
274+
status: 'in-progress',
275+
item: [{ linkId: 'target' }],
276+
};
277+
278+
const initialContext = createInitialContext(questionnaire, questionnaireResponse);
279+
280+
const customEvaluateFhirpath: EvaluateFhirpath = (context, path, env) =>
281+
fhirpath.evaluate(context, path, env, fhirpathR4BModel, {
282+
async: false,
283+
userInvocationTable: {
284+
customFn: {
285+
fn: () => {
286+
return ['from-custom-evaluator'];
287+
},
288+
arity: { 0: [], 1: ['String'] },
289+
},
290+
},
291+
});
292+
293+
const providerProps = createQRFProviderProps({
294+
evaluateFhirpath: customEvaluateFhirpath,
295+
});
296+
const setFormValuesSpy = providerProps.setFormValues as ReturnType<typeof vi.fn>;
297+
298+
render(
299+
<QuestionnaireResponseFormProvider {...providerProps}>
300+
<QuestionItem parentPath={[]} context={initialContext} questionItem={questionnaire.item![0]} />
301+
</QuestionnaireResponseFormProvider>,
302+
);
303+
304+
await waitFor(() => {
305+
expect(setFormValuesSpy).toHaveBeenCalled();
306+
});
307+
308+
const lastCall = setFormValuesSpy.mock.calls.at(-1)!;
309+
const answers = lastCall[2] as any[];
310+
311+
expect(answers).toHaveLength(1);
312+
expect(answers).toStrictEqual([{ value: { string: 'from-custom-evaluator' } }]);
313+
});
314+
});

src/components.tsx

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ function usePreviousValue<T>(value: T) {
3232

3333
export function QuestionItems(props: QuestionItemsProps) {
3434
const { questionItems, parentPath, context } = props;
35-
const { formValues } = useQuestionnaireResponseFormContext();
35+
const { formValues, evaluateFhirpath } = useQuestionnaireResponseFormContext();
3636

3737
return (
3838
<React.Fragment>
39-
{getEnabledQuestions(questionItems, parentPath, formValues, context).map((item) => {
39+
{getEnabledQuestions(questionItems, parentPath, formValues, context, evaluateFhirpath).map((item) => {
4040
return <QuestionItem key={item.linkId} questionItem={item} context={context} parentPath={parentPath} />;
4141
})}
4242
</React.Fragment>
@@ -51,6 +51,7 @@ export function QuestionItem(props: QuestionItemProps) {
5151
itemControlQuestionItemComponents,
5252
itemControlGroupItemComponents,
5353
fhirService,
54+
evaluateFhirpath,
5455
} = useContext(QRFContext);
5556
const { formValues, setFormValues } = useQuestionnaireResponseFormContext();
5657
const [questionItem, setQuestionItem] = useState(initialQuestionItem);
@@ -86,6 +87,7 @@ export function QuestionItem(props: QuestionItemProps) {
8687
calculatedExpression,
8788
itemContext,
8889
`${linkId}.calculatedExpression`,
90+
evaluateFhirpath,
8991
).map(stripNonEnumerable);
9092

9193
const newAnswers: FormAnswerItems[] | undefined = newValues.length
@@ -124,8 +126,12 @@ export function QuestionItem(props: QuestionItemProps) {
124126
if (itemContext && _text) {
125127
const cqfExpression = _text.cqfExpression;
126128
const calculatedValue =
127-
evaluateFHIRPathExpression(cqfExpression, itemContext, `${linkId}._text.cqfExpression`)[0] ??
128-
initialQuestionItem.text;
129+
evaluateFHIRPathExpression(
130+
cqfExpression,
131+
itemContext,
132+
`${linkId}._text.cqfExpression`,
133+
evaluateFhirpath,
134+
)[0] ?? initialQuestionItem.text;
129135

130136
if (prevQuestionItem?.text !== calculatedValue) {
131137
setQuestionItem((qi) => ({
@@ -138,8 +144,12 @@ export function QuestionItem(props: QuestionItemProps) {
138144
if (itemContext && _readOnly) {
139145
const cqfExpression = _readOnly.cqfExpression;
140146
const calculatedValue =
141-
evaluateFHIRPathExpression(cqfExpression, itemContext, `${linkId}._readOnly.cqfExpression`)[0] ??
142-
initialQuestionItem.readOnly;
147+
evaluateFHIRPathExpression(
148+
cqfExpression,
149+
itemContext,
150+
`${linkId}._readOnly.cqfExpression`,
151+
evaluateFhirpath,
152+
)[0] ?? initialQuestionItem.readOnly;
143153

144154
if (prevQuestionItem?.readOnly !== calculatedValue) {
145155
setQuestionItem((qi) => ({
@@ -152,8 +162,12 @@ export function QuestionItem(props: QuestionItemProps) {
152162
if (itemContext && _required) {
153163
const cqfExpression = _required.cqfExpression;
154164
const calculatedValue =
155-
evaluateFHIRPathExpression(cqfExpression, itemContext, `${linkId}._required.cqfExpression`)[0] ??
156-
initialQuestionItem.required;
165+
evaluateFHIRPathExpression(
166+
cqfExpression,
167+
itemContext,
168+
`${linkId}._required.cqfExpression`,
169+
evaluateFhirpath,
170+
)[0] ?? initialQuestionItem.required;
157171

158172
if (prevQuestionItem?.required !== calculatedValue) {
159173
setQuestionItem((qi) => ({

src/hooks.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { FCEQuestionnaireItem } from './fce.types';
44
import { type RemoteData, isSuccess, loading, success, mapSuccess, sequenceArray } from '@beda.software/remote-data';
55

66
import { QRFContext } from './context';
7-
import { ItemContext } from './types';
7+
import { EvaluateFhirpath, ItemContext } from './types';
88
import { resolveTemplateExpr, evaluateFHIRPathExpression, getBranchItems } from './utils';
99

1010
export function useQuestionnaireResponseFormContext() {
@@ -16,6 +16,7 @@ export type UseQuestionItemContextArgs = {
1616
branchItems: ReturnType<typeof getBranchItems>;
1717
fhirService: (config: AxiosRequestConfig) => Promise<RemoteData<unknown>>;
1818
questionItem: FCEQuestionnaireItem;
19+
evaluateFhirpath?: EvaluateFhirpath;
1920
};
2021

2122
type AsyncState = Record<
@@ -33,7 +34,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
3334
contexts: ItemContext[];
3435
evaluationResponse: RemoteData<ItemContext[]>;
3536
} {
36-
const { initialContext, branchItems, fhirService, questionItem } = props;
37+
const { initialContext, branchItems, fhirService, questionItem, evaluateFhirpath } = props;
3738
const { variable, linkId } = questionItem;
3839
const variables = useMemo(() => variable ?? [], [variable]);
3940
const [asyncState, setAsyncState] = useState<AsyncState>({});
@@ -115,6 +116,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
115116
variable,
116117
workingContext,
117118
`${linkId}.variable.${name}`,
119+
evaluateFhirpath,
118120
);
119121
}
120122
});
@@ -150,6 +152,7 @@ export function useQuestionItemContext(props: UseQuestionItemContextArgs): {
150152
variable,
151153
workingContext,
152154
`${linkId}.variable.${name}`,
155+
evaluateFhirpath,
153156
);
154157
}
155158
});

src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ComponentType } from 'react';
2+
import { Context, Path } from 'fhirpath';
23

34
import {
45
Attachment,
@@ -32,6 +33,8 @@ export type ItemControlGroupItemComponentMapping = {
3233
[code: string]: GroupItemComponent;
3334
};
3435

36+
export type EvaluateFhirpath = (context: Context | undefined, path: string | Path, env: any) => any[];
37+
3538
export type ItemContext = {
3639
// ItemContext contains items in FHIR format, this context is passed to all expressions
3740
resource: QuestionnaireResponse;
@@ -51,6 +54,8 @@ export interface QRFContextData {
5154
fhirService: (config: AxiosRequestConfig) => Promise<RemoteData<unknown>>;
5255
formValues: FormItems;
5356
setFormValues: (values: FormItems, fieldPath: Array<string | number>, value: any) => void;
57+
58+
evaluateFhirpath?: EvaluateFhirpath;
5459
}
5560

5661
export interface QuestionItemsProps {

src/utils.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020

2121
import {
2222
AnswerValue,
23+
EvaluateFhirpath,
2324
FHIRAnswerValue,
2425
FormAnswerItems,
2526
FormGroupItems,
@@ -536,6 +537,7 @@ interface IsQuestionEnabledArgs {
536537
parentPath: string[];
537538
values: FormItems;
538539
context: ItemContext;
540+
evaluateFhirpath?: EvaluateFhirpath;
539541
}
540542
function isQuestionEnabled(args: IsQuestionEnabledArgs) {
541543
const { enableWhen, enableBehavior, enableWhenExpression, linkId } = args.qItem;
@@ -559,6 +561,7 @@ function isQuestionEnabled(args: IsQuestionEnabledArgs) {
559561
enableWhenExpression,
560562
args.context,
561563
`${linkId}.enableWhenExpression`,
564+
args.evaluateFhirpath,
562565
)[0];
563566

564567
if (typeof expressionResult !== 'boolean') {
@@ -601,13 +604,15 @@ export function removeDisabledAnswers(
601604
questionnaire: FCEQuestionnaire,
602605
values: FormItems,
603606
context: ItemContext,
607+
evaluateFhirpath?: EvaluateFhirpath,
604608
): FormItems {
605609
return removeDisabledAnswersRecursive({
606610
questionnaireItems: questionnaire.item ?? [],
607611
parentPath: [],
608612
answersItems: values,
609613
initialValues: {},
610614
context,
615+
evaluateFhirpath,
611616
});
612617
}
613618

@@ -617,6 +622,7 @@ interface RemoveDisabledAnswersRecursiveArgs {
617622
answersItems: FormItems;
618623
initialValues: FormItems;
619624
context: ItemContext;
625+
evaluateFhirpath?: EvaluateFhirpath;
620626
}
621627
function removeDisabledAnswersRecursive(args: RemoveDisabledAnswersRecursiveArgs): FormItems {
622628
return args.questionnaireItems.reduce((acc, questionnaireItem) => {
@@ -635,6 +641,7 @@ function removeDisabledAnswersRecursive(args: RemoveDisabledAnswersRecursiveArgs
635641
parentPath: args.parentPath,
636642
values,
637643
context: args.context,
644+
evaluateFhirpath: args.evaluateFhirpath,
638645
})
639646
) {
640647
return acc;
@@ -657,6 +664,7 @@ function removeDisabledAnswersRecursive(args: RemoveDisabledAnswersRecursiveArgs
657664
answersItems: group,
658665
initialValues: values,
659666
context: args.context,
667+
evaluateFhirpath: args.evaluateFhirpath,
660668
}),
661669
),
662670
},
@@ -672,6 +680,7 @@ function removeDisabledAnswersRecursive(args: RemoveDisabledAnswersRecursiveArgs
672680
answersItems: answers.items,
673681
initialValues: values,
674682
context: args.context,
683+
evaluateFhirpath: args.evaluateFhirpath,
675684
}),
676685
},
677686
};
@@ -688,6 +697,7 @@ function removeDisabledAnswersRecursive(args: RemoveDisabledAnswersRecursiveArgs
688697
answersItems: answer.items,
689698
initialValues: { ...values, [linkId!]: [...answersAcc, { ...answer, items: [] }] },
690699
context: args.context,
700+
evaluateFhirpath: args.evaluateFhirpath,
691701
})
692702
: {};
693703

@@ -702,6 +712,7 @@ export function getEnabledQuestions(
702712
parentPath: string[],
703713
values: FormItems,
704714
context: ItemContext,
715+
evaluateFhirpath?: EvaluateFhirpath,
705716
) {
706717
return _.filter(questionnaireItems, (qItem) => {
707718
const { linkId } = qItem;
@@ -711,7 +722,7 @@ export function getEnabledQuestions(
711722
return false;
712723
}
713724

714-
return isQuestionEnabled({ qItem, parentPath, values, context });
725+
return isQuestionEnabled({ qItem, parentPath, values, context, evaluateFhirpath });
715726
});
716727
}
717728

@@ -750,18 +761,21 @@ export function resolveTemplateExpr(
750761
context: ItemContext,
751762
path: string,
752763
returnNullIfUnresolved?: false,
764+
evaluateFhirpath?: EvaluateFhirpath,
753765
): string;
754766
export function resolveTemplateExpr(
755767
str: string,
756768
context: ItemContext,
757769
path: string,
758770
returnNullIfUnresolved: true,
771+
evaluateFhirpath?: EvaluateFhirpath,
759772
): string | null;
760773
export function resolveTemplateExpr(
761774
str: string,
762775
context: ItemContext,
763776
path: string,
764777
returnNullIfUnresolved: boolean = false,
778+
evaluateFhirpath?: EvaluateFhirpath,
765779
): string | null {
766780
const matches = str.match(/{{[^}]+}}/g);
767781

@@ -783,6 +797,7 @@ export function resolveTemplateExpr(
783797
},
784798
context,
785799
path,
800+
evaluateFhirpath,
786801
);
787802

788803
if (resolvedVar?.length) {
@@ -797,10 +812,14 @@ export function resolveTemplateExpr(
797812
}, str);
798813
}
799814

815+
const defaultFhirpathEvaluate: EvaluateFhirpath = (context, path, env) =>
816+
fhirpath.evaluate(context, path, env, fhirpathR4BModel, { async: false });
817+
800818
export function parseFhirQueryExpression(
801819
expression: string,
802820
context: ItemContext,
803821
path: string = 'unknown',
822+
evaluateFhirpath?: EvaluateFhirpath,
804823
): [string | undefined, Record<string, any>] {
805824
const [resourceType, paramsQS] = expression.split('?', 2);
806825
const searchParams = Object.fromEntries(
@@ -812,8 +831,8 @@ export function parseFhirQueryExpression(
812831
return [
813832
key,
814833
isArray(value)
815-
? value.map((arrValue) => resolveTemplateExpr(arrValue!, context, path))
816-
: resolveTemplateExpr(value, context, path),
834+
? value.map((arrValue) => resolveTemplateExpr(arrValue!, context, path, false, evaluateFhirpath))
835+
: resolveTemplateExpr(value, context, path, false, evaluateFhirpath),
817836
];
818837
}),
819838
);
@@ -825,6 +844,7 @@ export function evaluateFHIRPathExpression(
825844
expression: Expression | undefined,
826845
context: ItemContext,
827846
path: string = 'unknown',
847+
evaluateFhirpath?: EvaluateFhirpath,
828848
) {
829849
if (!expression) {
830850
return [];
@@ -835,10 +855,10 @@ export function evaluateFHIRPathExpression(
835855
return [];
836856
}
837857

858+
const evaluator: EvaluateFhirpath = evaluateFhirpath ?? defaultFhirpathEvaluate;
859+
838860
try {
839-
return fhirpath.evaluate(context.context ?? {}, expression.expression!, context, fhirpathR4BModel, {
840-
async: false,
841-
});
861+
return evaluator(context.context ?? {}, expression.expression!, context);
842862
} catch (err: unknown) {
843863
throw Error(`FHIRPath expression evaluation failure for ${path}: ${err}`);
844864
}

0 commit comments

Comments
 (0)