From a4587bf3fd97df1f8128fecf2932be48d79adb24 Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 15:03:39 +0100 Subject: [PATCH 1/8] fix tests and split scoring --- package.json | 2 - preview/skjema/q.json | 2 +- src/actions/generateQuestionnaireResponse.ts | 2 +- src/actions/newValue.ts | 24 +- src/components/__tests__/copy-from-spec.tsx | 3 +- .../formcomponents/choice/choice.tsx | 18 +- .../formcomponents/repeat/RepeatButton.tsx | 8 +- src/constants/scoringItemType.ts | 1 - src/hooks/useFhirPathQrUpdater.tsx | 140 ++++++++++ src/hooks/useGetAnswer.ts | 67 +---- src/hooks/useOnAnswerChange.tsx | 12 +- src/hooks/useScoringCalculator.ts | 26 +- src/reducers/form.ts | 17 ++ src/util/FhirPathExtensions.ts | 258 ++++++++++++++++++ src/util/actionRequester.ts | 29 +- src/util/fhirpathHelper.ts | 16 +- src/util/sanitize/domPurifyHelper.ts | 2 +- src/util/scoring.ts | 4 - src/util/scoringCalculator.ts | 44 +-- 19 files changed, 530 insertions(+), 145 deletions(-) create mode 100644 src/hooks/useFhirPathQrUpdater.tsx create mode 100644 src/util/FhirPathExtensions.ts diff --git a/package.json b/package.json index 25053d11..30199572 100644 --- a/package.json +++ b/package.json @@ -81,7 +81,6 @@ "@types/node": "^20.14.8", "@types/react-collapse": "^5.0.4", "@types/react-dom": "^18.3.0", - "@types/redux-mock-store": "^1.0.6", "@types/rollup-plugin-generate-package-json": "^3.2.9", "@types/rollup-plugin-peer-deps-external": "^2.2.5", "@types/uuid": "^2.0.35", @@ -108,7 +107,6 @@ "pretty-quick": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "redux-mock-store": "^1.5.4", "redux-thunk": "^3.1.0", "resolve": "^1.22.8", "rollup-plugin-copy": "^3.5.0", diff --git a/preview/skjema/q.json b/preview/skjema/q.json index 183c6df8..cdbc750a 100644 --- a/preview/skjema/q.json +++ b/preview/skjema/q.json @@ -85,7 +85,7 @@ "concept": [ { "code": "1", - "display": "Ja" + "display": "Mann" }, { "code": "2", diff --git a/src/actions/generateQuestionnaireResponse.ts b/src/actions/generateQuestionnaireResponse.ts index e4d769f5..6dff877f 100644 --- a/src/actions/generateQuestionnaireResponse.ts +++ b/src/actions/generateQuestionnaireResponse.ts @@ -101,7 +101,7 @@ export function createQuestionnaireResponseItem(item: QuestionnaireItem): Questi return responseItem; } -function evaluateCalculatedExpressions(questionnaire: Questionnaire, response: QuestionnaireResponse): QuestionnaireResponse { +export function evaluateCalculatedExpressions(questionnaire: Questionnaire, response: QuestionnaireResponse): QuestionnaireResponse { function traverseItems(qItems: QuestionnaireItem[], qrItems: QuestionnaireResponseItem[]): void { qItems.forEach((qItem, index) => { const qrItem = qrItems[index]; diff --git a/src/actions/newValue.ts b/src/actions/newValue.ts index 24dccbf0..d0335d8b 100644 --- a/src/actions/newValue.ts +++ b/src/actions/newValue.ts @@ -1,4 +1,4 @@ -import { Coding, QuestionnaireItem, Attachment, QuestionnaireResponseItem, Quantity } from 'fhir/r4'; +import { Coding, QuestionnaireItem, Attachment, QuestionnaireResponseItem, Quantity, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { AppDispatch, GlobalState } from '../reducers'; @@ -11,6 +11,8 @@ export const NEW_CODINGSTRING_VALUE: NEW_CODINGSTRING_VALUE = 'refero/NEW_CODING export type REMOVE_CODINGSTRING_VALUE = 'refero/REMOVE_CODINGSTRING_VALUE'; export const REMOVE_CODINGSTRING_VALUE: REMOVE_CODINGSTRING_VALUE = 'refero/REMOVE_CODINGSTRING_VALUE'; export const REMOVE_CODING_VALUE = 'refero/REMOVE_CODING_VALUE'; +export type NEW_ANSWER_VALUE = 'refero/NEW_ANSWER_VALUE'; +export const NEW_ANSWER_VALUE: NEW_ANSWER_VALUE = 'refero/NEW_ANSWER_VALUE'; export type ADD_REPEAT_ITEM = 'refero/ADD_REPEAT_ITEM'; export const ADD_REPEAT_ITEM: ADD_REPEAT_ITEM = 'refero/ADD_REPEAT_ITEM'; export type DELETE_REPEAT_ITEM = 'refero/DELETE_REPEAT_ITEM'; @@ -34,6 +36,7 @@ export type NewValuePayload = { item?: QuestionnaireItem; responseItems?: Array; multipleAnswers?: boolean; + newAnswer?: QuestionnaireResponseItemAnswer[]; }; export type RemoveAttachmentPayload = Pick; export type NewAttachmentPayload = Pick; @@ -51,6 +54,8 @@ export type DateItemPayload = Pick; export type DateTimeItemPayload = Pick; export type DeleteRepeatItemPayload = Pick; +export type AnswerValueItemPayload = Pick; + export const newValue = createAction(NEW_VALUE); export const newAttachmentAction = createAction(NEW_VALUE); @@ -109,6 +114,16 @@ export function newBooleanValueAsync(itemPath: Array, value: boolean, item return await Promise.resolve(getState()); }; } +export const newAnswerValueAction = createAction(NEW_ANSWER_VALUE); +// /* +// * @deprecated this will be removed in a future version, use newCodingValueAction instead +// */ +// export const newCodingsValue = ( +// itemPath: CodingValueItemPayload['itemPath'], +// value: CodingValueItemPayload['valueCoding'], +// item: CodingValueItemPayload['item'], +// multipleAnswers?: CodingValueItemPayload['multipleAnswers'] +// ): PayloadAction => newCodingsValueAction({ itemPath, valueCodings: value, item, multipleAnswers }); export const newCodingValueAction = createAction(NEW_VALUE); /* @@ -307,6 +322,13 @@ export function newDateTimeValueAsync(itemPath: Array, value: string, item } export const addRepeatItemAction = createAction(ADD_REPEAT_ITEM); + +export const addRepeatItemAsync = (parentPath?: Path[], item?: QuestionnaireItem, responseItems?: QuestionnaireResponseItem[]) => { + return async (dispatch: AppDispatch, getState: () => GlobalState): Promise => { + dispatch(addRepeatItemAction({ parentPath, item, responseItems })); + return await Promise.resolve(getState()); + }; +}; /* * @deprecated this will be removed in a future version, use addRepeatItemAction instead */ diff --git a/src/components/__tests__/copy-from-spec.tsx b/src/components/__tests__/copy-from-spec.tsx index c68f02f2..4cc3106d 100644 --- a/src/components/__tests__/copy-from-spec.tsx +++ b/src/components/__tests__/copy-from-spec.tsx @@ -237,10 +237,11 @@ describe('Copy value from item', () => { }); }); describe('should copy OPEN-CHOICE value', () => { - it('should copy CHECKBOX value', async () => { + it.only('should copy CHECKBOX value', async () => { const sender = createSenderChoiceItem(ItemType.OPENCHOICE, createItemControlExtension(ItemControlConstants.CHECKBOX)); const reciever = createReciverChoiceItem(ItemType.OPENCHOICE, ItemControlConstants.CHECKBOX); const q = createQuestionnaire(sender, reciever); + console.log(JSON.stringify(q, null, 2)); const { getByLabelText, queryByTestId, getByTestId, findByTestId } = createWrapper(q); expect(queryByTestId(/item_2/i)).not.toBeInTheDocument(); expect(getByLabelText(/Mann/i)).toBeInTheDocument(); diff --git a/src/components/formcomponents/choice/choice.tsx b/src/components/formcomponents/choice/choice.tsx index 80f85876..4ece478d 100644 --- a/src/components/formcomponents/choice/choice.tsx +++ b/src/components/formcomponents/choice/choice.tsx @@ -72,15 +72,16 @@ export const Choice = (props: ChoiceProps): JSX.Element | null => { // }, [item]); const getPDFValue = (): string => { - const getDataReceiverValue = (answer: Array): (string | undefined)[] => { - return answer.map((el: QuestionnaireResponseItemAnswer) => { - if (el && el.valueCoding && el.valueCoding.display) { - return el.valueCoding.display; - } - }); - }; if (isDataReceiver(item)) { - return getDataReceiverValue(answer as Array).join(', '); + return Array.isArray(answer) + ? answer + .map((el: QuestionnaireResponseItemAnswer) => { + if (el && el.valueCoding && el.valueCoding.display) { + return el.valueCoding.display; + } + }) + ?.join(', ') + : (answer && answer.valueCoding && answer.valueCoding.display) || resources?.ikkeBesvart || ''; } const value = getAnswerValue(); if (!value || value.length === 0) { @@ -158,6 +159,7 @@ export const Choice = (props: ChoiceProps): JSX.Element | null => { const renderComponentBasedOnType = (): JSX.Element | null => { const pdfValue = getPDFValue(); + const itemControlValue = getItemControlValue(item); if (!itemControlValue) return null; diff --git a/src/components/formcomponents/repeat/RepeatButton.tsx b/src/components/formcomponents/repeat/RepeatButton.tsx index ca6dc4a1..622f7b21 100644 --- a/src/components/formcomponents/repeat/RepeatButton.tsx +++ b/src/components/formcomponents/repeat/RepeatButton.tsx @@ -4,11 +4,12 @@ import Button from '@helsenorge/designsystem-react/components/Button'; import Icon from '@helsenorge/designsystem-react/components/Icon'; import PlusLarge from '@helsenorge/designsystem-react/components/Icons/PlusLarge'; -import { addRepeatItemAction } from '../../../actions/newValue'; +import { addRepeatItemAsync } from '../../../actions/newValue'; import { useAppDispatch } from '../../../reducers'; import { getRepeatsTextExtension } from '../../../util/extension'; import { Path } from '../../../util/refero-core'; import { useExternalRenderContext } from '@/context/externalRenderContext'; +import useOnAnswerChange from '@/hooks/useOnAnswerChange'; interface Props { item?: QuestionnaireItem; @@ -20,9 +21,12 @@ interface Props { export const RepeatButton = ({ item, parentPath, responseItems, disabled }: Props): JSX.Element => { const dispatch = useAppDispatch(); const { resources } = useExternalRenderContext(); + const onAnswerChange = useOnAnswerChange(); const onAddRepeatItem = (): void => { if (dispatch && item) { - dispatch(addRepeatItemAction({ parentPath, item, responseItems })); + dispatch(addRepeatItemAsync(parentPath, item, responseItems))?.then(newState => { + return onAnswerChange && onAnswerChange(newState, item); + }); } }; const text = getRepeatsTextExtension(item); diff --git a/src/constants/scoringItemType.ts b/src/constants/scoringItemType.ts index 0c571af6..b851c7ba 100644 --- a/src/constants/scoringItemType.ts +++ b/src/constants/scoringItemType.ts @@ -2,6 +2,5 @@ export enum ScoringItemType { TOTAL_SCORE = 'TOTAL_SCORE', SECTION_SCORE = 'SECTION_SCORE', QUESTION_SCORE = 'QUESTION_SCORE', - QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', NONE = 'NONE', } diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx new file mode 100644 index 00000000..5349b540 --- /dev/null +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -0,0 +1,140 @@ +import ItemType from '@/constants/itemType'; +import { GlobalState } from '@/reducers'; +import { getFormDefinition } from '@/reducers/form'; +import { getDecimalValue } from '@/util'; +import { ActionRequester } from '@/util/actionRequester'; +import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; +import { AnswerPad, FhirPathExtensions } from '@/util/FhirPathExtensions'; +import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; +import { Coding, Quantity, Questionnaire, QuestionnaireResponse } from 'fhir/r4'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +export const useFhirPathQrUpdater = (): { + runFhirPathQrUpdater: ( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ) => void; +} => { + const formDefinition = useSelector((state: GlobalState) => getFormDefinition(state)); + const [fhirPathUpdater, setFhirPathUpdater] = useState(); + + useEffect(() => { + if (formDefinition?.Content) { + setFhirPathUpdater(new FhirPathExtensions(formDefinition.Content)); + } + }, [formDefinition?.Content]); + + const runFhirPathQrUpdater = ( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ): void => { + if (!questionnaire || !questionnaireResponse || !fhirPathUpdater) return; + + // Evaluate all expressions and get the updated response + const updatedResponse = fhirPathUpdater.evaluateAllExpressions(questionnaireResponse); + // if (JSON.stringify(updatedResponse) === JSON.stringify(questionnaireResponse)) { + // return; + // } + // Calculate FHIR scores using the same updated response + const fhirScores = fhirPathUpdater.calculateFhirScore(updatedResponse); + updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); + }; + + const updateQuestionnaireResponseWithScore = ( + scores: AnswerPad, + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ): void => { + for (const linkId in scores) { + const item = getQuestionnaireDefinitionItem(linkId, questionnaire.item); + if (!item) continue; + const itemsAndPaths = getResponseItemAndPathWithLinkId(linkId, questionnaireResponse); + const value = scores[linkId]; + switch (item.type) { + case ItemType.QUANTITY: { + const extension = getQuestionnaireUnitExtensionValue(item); + if (!extension) continue; + + const quantity: Quantity = { + unit: extension.display, + system: extension.system, + code: extension.code, + value: getDecimalValue(item, value as number), + }; + for (const itemAndPath of itemsAndPaths) { + actionRequester.addQuantityAnswer(linkId, quantity as Quantity, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.DECIMAL: { + for (const itemAndPath of itemsAndPaths) { + const decimalValue = getDecimalValue(item, value as number); + actionRequester.addDecimalAnswer(linkId, decimalValue, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.INTEGER: { + for (const itemAndPath of itemsAndPaths) { + actionRequester.addIntegerAnswer(linkId, value as number, itemAndPath.path[0]?.index); + } + + break; + } + case ItemType.BOOLEAN: { + for (const itemAndPath of itemsAndPaths) { + actionRequester.addBooleanAnswer(linkId, value as boolean, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.STRING: + case ItemType.TEXT: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addStringAnswer(linkId, (value as string) ?? '', itemAndPath.path[0]?.index); + } + break; + case ItemType.CHOICE: { + for (const itemAndPath of itemsAndPaths) { + if (actionRequester.isCheckbox(item)) { + const answer = value ? (value as Coding[])?.map(x => ({ valueCoding: x })) : []; + actionRequester.setNewAnswer(linkId, answer, itemAndPath.path[0]?.index); + } else { + actionRequester.addChoiceAnswer(linkId, value as Coding, itemAndPath.path[0]?.index); + } + } + break; + } + case ItemType.OPENCHOICE: { + for (const itemAndPath of itemsAndPaths) { + if (actionRequester.isCheckbox(item)) { + const answer = value ? (value as Coding[])?.map(x => (typeof x === 'string' ? { valueString: x } : { valueCoding: x })) : []; + actionRequester.setNewAnswer(linkId, answer, itemAndPath.path[0]?.index); + } else { + actionRequester.addOpenChoiceAnswer(linkId, value as Coding | string, itemAndPath.path[0]?.index); + } + } + break; + } + case ItemType.DATETIME: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addDateTimeAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + break; + case ItemType.DATE: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addDateAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + break; + case ItemType.TIME: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addTimeAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + } + } + }; + + return { runFhirPathQrUpdater }; +}; diff --git a/src/hooks/useGetAnswer.ts b/src/hooks/useGetAnswer.ts index 369c4bb8..c948fef2 100644 --- a/src/hooks/useGetAnswer.ts +++ b/src/hooks/useGetAnswer.ts @@ -1,75 +1,16 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import ItemType from '@/constants/itemType'; import { GlobalState } from '@/reducers'; -import { findQuestionnaireItem, getResponseItemWithPathSelector, questionnaireResponseSelector } from '@/reducers/selectors'; -import { getCalculatedExpressionExtension, getCopyExtension } from '@/util/extension'; -import { evaluateFhirpathExpressionToGetString } from '@/util/fhirpathHelper'; +import { getResponseItemWithPathSelector } from '@/reducers/selectors'; import { getAnswerFromResponseItem, Path } from '@/util/refero-core'; -import { Extension, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; +import { QuestionnaireResponseItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { useSelector } from 'react-redux'; -function getAnswerIfDataReceiver( - questionnaireResponse: QuestionnaireResponse | null | undefined, - item: QuestionnaireItem, - extension: Extension -): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] | undefined { - const result = evaluateFhirpathExpressionToGetString(extension, questionnaireResponse); - const processedResult = getCalculatedExpressionExtension(item) ? result.map((res: any) => res?.value ?? res) : result; - - return getQuestionnaireResponseItemAnswer(item.type, processedResult); -} - -function getQuestionnaireResponseItemAnswer( - type: string, - result: any[] -): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] { - if (type === ItemType.BOOLEAN) { - return { valueBoolean: result[0] }; - } - return result.map((answer: any) => { - switch (String(type)) { - case ItemType.TEXT: - case ItemType.STRING: - return { valueString: answer }; - case ItemType.INTEGER: - return { valueInteger: answer }; - case ItemType.DECIMAL: - return { valueDecimal: answer }; - - case ItemType.QUANTITY: - return { valueQuantity: answer }; - case ItemType.DATETIME: - return { valueDateTime: answer }; - case ItemType.DATE: - return { valueDate: answer }; - case ItemType.TIME: - return { valueTime: answer }; - default: { - if (typeof answer === 'string') { - return { valueString: answer }; - } else { - return { valueCoding: answer }; - } - } - } - }); -} - export const useGetAnswer = ( - linkId?: string, + _linkId?: string, path?: Path[] ): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] | undefined => { - const questionnaireResponse = useSelector(questionnaireResponseSelector); - const item = useSelector(state => findQuestionnaireItem(state, linkId)); const responseItem = useSelector(state => getResponseItemWithPathSelector(state, path) ); - const dataRecieverExtension = item && getCopyExtension(item); - - const answer = dataRecieverExtension - ? getAnswerIfDataReceiver(questionnaireResponse, item, dataRecieverExtension) - : getAnswerFromResponseItem(responseItem); - - return answer; + return getAnswerFromResponseItem(responseItem); }; diff --git a/src/hooks/useOnAnswerChange.tsx b/src/hooks/useOnAnswerChange.tsx index 6440c0e6..488fe10c 100644 --- a/src/hooks/useOnAnswerChange.tsx +++ b/src/hooks/useOnAnswerChange.tsx @@ -3,6 +3,7 @@ import { ActionRequester, IActionRequester } from '@/util/actionRequester'; import { QuestionnaireItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { IQuestionnaireInspector, QuestionniareInspector } from '@/util/questionnaireInspector'; import { GlobalState, useAppDispatch } from '@/reducers'; +import { useFhirPathQrUpdater } from './useFhirPathQrUpdater'; const useOnAnswerChange = ( onChange?: ( @@ -11,22 +12,25 @@ const useOnAnswerChange = ( actionRequester: IActionRequester, questionnaireInspector: IQuestionnaireInspector ) => void -): ((state: GlobalState, item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer) => void) => { +): ((state: GlobalState, item: QuestionnaireItem, answer?: QuestionnaireResponseItemAnswer) => void) => { const dispatch = useAppDispatch(); const { runScoringCalculator } = useScoringCalculator(); + const { runFhirPathQrUpdater } = useFhirPathQrUpdater(); - return (state: GlobalState, item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer): void => { + return (state: GlobalState, item: QuestionnaireItem, answer?: QuestionnaireResponseItemAnswer): void => { const questionnaire = state.refero.form.FormDefinition.Content; const questionnaireResponse = state.refero.form.FormData.Content; if (questionnaire && questionnaireResponse) { const actionRequester = new ActionRequester(questionnaire, questionnaireResponse); + runFhirPathQrUpdater(questionnaire, questionnaireResponse, actionRequester); + runScoringCalculator(questionnaire, questionnaireResponse, actionRequester); + const questionnaireInspector = new QuestionniareInspector(questionnaire, questionnaireResponse); - onChange && onChange(item, answer, actionRequester, questionnaireInspector); + onChange && answer && item && onChange(item, answer, actionRequester, questionnaireInspector); for (const action of actionRequester.getActions()) { dispatch(action); } } - runScoringCalculator(questionnaire, questionnaireResponse); }; }; export default useOnAnswerChange; diff --git a/src/hooks/useScoringCalculator.ts b/src/hooks/useScoringCalculator.ts index 41dc8aac..54364bdd 100644 --- a/src/hooks/useScoringCalculator.ts +++ b/src/hooks/useScoringCalculator.ts @@ -3,6 +3,7 @@ import ItemType from '@/constants/itemType'; import { GlobalState, useAppDispatch } from '@/reducers'; import { getFormDefinition } from '@/reducers/form'; import { getDecimalValue } from '@/util'; +import { ActionRequester } from '@/util/actionRequester'; import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; import { AnswerPad, ScoringCalculator } from '@/util/scoringCalculator'; @@ -11,7 +12,11 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; export const useScoringCalculator = (): { - runScoringCalculator: (questionnaire?: Questionnaire | null, questionnaireResponse?: QuestionnaireResponse | null) => void; + runScoringCalculator: ( + questionnaire?: Questionnaire | null, + questionnaireResponse?: QuestionnaireResponse | null, + actionRequester?: ActionRequester + ) => Promise; } => { const formDefinition = useSelector((state: GlobalState) => getFormDefinition(state)); const dispatch = useAppDispatch(); @@ -23,20 +28,22 @@ export const useScoringCalculator = (): { } }, [formDefinition?.Content]); - const runScoringCalculator = (questionnaire?: Questionnaire | null, questionnaireResponse?: QuestionnaireResponse | null): void => { - if (!questionnaire || !questionnaireResponse || !scoringCalculator || !questionnaireHasScoring()) return; + const runScoringCalculator = async ( + questionnaire?: Questionnaire | null, + questionnaireResponse?: QuestionnaireResponse | null, + actionRequester?: ActionRequester + ): Promise => { + if (!questionnaire || !questionnaireResponse || !scoringCalculator || !questionnaireHasScoring() || !actionRequester) return; + // Calculate scores using the updated response const scores = scoringCalculator.calculateScore(questionnaireResponse); - updateQuestionnaireResponseWithScore(scores, questionnaire, questionnaireResponse); - - const fhirScores = scoringCalculator.calculateFhirScore(questionnaireResponse); - updateQuestionnaireResponseWithScore(fhirScores, questionnaire, questionnaireResponse); + updateQuestionnaireResponseWithScore(scores, questionnaire, questionnaireResponse, actionRequester); }; - const updateQuestionnaireResponseWithScore = ( scores: AnswerPad, questionnaire: Questionnaire, - questionnaireResponse: QuestionnaireResponse + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester ): void => { for (const linkId in scores) { const item = getQuestionnaireDefinitionItem(linkId, questionnaire.item); @@ -56,6 +63,7 @@ export const useScoringCalculator = (): { value: getDecimalValue(item, value), }; for (const itemAndPath of itemsAndPaths) { + actionRequester; dispatch(newQuantityValueAction({ itemPath: itemAndPath.path, valueQuantity: quantity, item })); } break; diff --git a/src/reducers/form.ts b/src/reducers/form.ts index c90f644f..d546fb0d 100644 --- a/src/reducers/form.ts +++ b/src/reducers/form.ts @@ -28,8 +28,10 @@ import { deleteRepeatItemAction, RemoveAttachmentPayload, CodingStringPayload, + AnswerValueItemPayload, RemoveCodingStringPayload, RemoveCodingValuePayload, + newAnswerValueAction, } from '@/actions/newValue'; import { syncQuestionnaireResponse } from '@/actions/syncQuestionnaireResponse'; import itemType from '@/constants/itemType'; @@ -98,6 +100,9 @@ const formSlice = createSlice({ .addCase(newValue, (state, action: PayloadAction) => { processNewValueAction(action.payload, state); }) + .addCase(newAnswerValueAction, (state, action: PayloadAction) => { + processNewAnswerValueAction(action.payload, state); + }) .addCase(newCodingStringValueAction, (state, action: PayloadAction) => { processNewCodingStringValueAction(action.payload, state); }) @@ -429,6 +434,18 @@ function processRemoveAttachmentValueAction(action: NewValuePayload, state: Form } return state; } +function processNewAnswerValueAction(payload: AnswerValueItemPayload, state: Form): Form { + const responseItem = getResponseItemWithPath(payload.itemPath || [], state.FormData); + + if (!responseItem) { + return state; + } + const answer = payload.newAnswer; + responseItem.answer = answer; + runEnableWhen(payload, state); + + return state; +} function processNewValueAction(payload: NewValuePayload, state: Form): Form { const responseItem = getResponseItemWithPath(payload.itemPath || [], state.FormData); diff --git a/src/util/FhirPathExtensions.ts b/src/util/FhirPathExtensions.ts new file mode 100644 index 00000000..ab165f74 --- /dev/null +++ b/src/util/FhirPathExtensions.ts @@ -0,0 +1,258 @@ +import { + Questionnaire, + QuestionnaireItem, + QuestionnaireResponse, + QuestionnaireResponseItemAnswer, + QuestionnaireResponseItem, + Extension, + Coding, +} from 'fhir/r4'; + +import { getCalculatedExpressionExtension, getCopyExtension } from './extension'; +import { evaluateFhirpathExpressionToGetString } from './fhirpathHelper'; +import itemType from '../constants/itemType'; +import { Extensions } from '@/constants/extensions'; +import { createDummySectionScoreItem } from './scoring'; +import { getItemControlValue } from './choice'; +import ItemControlConstants from '@/constants/itemcontrol'; + +export interface AnswerPad { + [linkId: string]: number | undefined | string | Coding | boolean | Coding[]; +} +export enum FhirPathItemType { + QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', + QUESTION_FHIRPATH_COPY = 'QUESTION_FHIRPATH_COPY', + NONE = 'NONE', +} + +export function fhirPathItemType(item: QuestionnaireItem): FhirPathItemType { + if (item.extension) { + for (const extension of item.extension) { + if (extension.url === Extensions.COPY_EXPRESSION_URL) { + return FhirPathItemType.QUESTION_FHIRPATH_COPY; + } + if (extension.url === Extensions.CALCULATED_EXPRESSION_URL) { + return FhirPathItemType.QUESTION_FHIRPATH_SCORE; + } + } + } + + return FhirPathItemType.NONE; +} +export class FhirPathExtensions { + private questionnaire: Questionnaire; + private fhirScoreCache: Map = new Map(); + + constructor(questionnaire: Questionnaire) { + this.questionnaire = questionnaire; + this.initializeCaches(questionnaire); + } + + private initializeCaches(questionnaire: Questionnaire): void { + this.traverseQuestionnaire(questionnaire); + } + + private traverseQuestionnaire(qItem: Questionnaire | QuestionnaireItem, level: number = 0): void { + if (qItem.item) { + for (const subItem of qItem.item) { + this.traverseQuestionnaire(subItem, level + 1); + } + } + + if (level === 0) { + const itm = createDummySectionScoreItem(); + this.traverseQuestionnaire(itm, level + 1); + } + + return this.processItem(qItem); + } + + private processItem(qItem: Questionnaire | QuestionnaireItem): void { + if (!this.isOfTypeQuestionnaireItem(qItem)) { + return; + } + + const type = fhirPathItemType(qItem); + + switch (type) { + case FhirPathItemType.QUESTION_FHIRPATH_COPY: + case FhirPathItemType.QUESTION_FHIRPATH_SCORE: + this.fhirScoreCache.set(qItem.linkId, qItem); + break; + default: + break; + } + } + + private isOfTypeQuestionnaireItem(item: Questionnaire | QuestionnaireItem): item is QuestionnaireItem { + return 'type' in item; + } + public evaluateAllExpressions(questionnaireResponse: QuestionnaireResponse): QuestionnaireResponse { + return this.evaluateCalculatedExpressions(questionnaireResponse); + } + + public calculateFhirScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { + const answerPad: AnswerPad = {}; + for (const [key, value] of this.fhirScoreCache) { + answerPad[key] = this.valueOfQuestionFhirpathItem(value, questionnaireResponse); + } + return answerPad; + } + + private valueOfQuestionFhirpathItem( + item: QuestionnaireItem, + questionnaireResponse: QuestionnaireResponse + ): number | undefined | string | Coding | boolean | Coding[] { + const expressionExtension = getCalculatedExpressionExtension(item) || getCopyExtension(item); + if (!expressionExtension) return undefined; + + const result = evaluateFhirpathExpressionToGetString(expressionExtension, questionnaireResponse); + if (!result.length) return undefined; + + if (item.type === itemType.INTEGER) { + return isNaN(result[0]) || !isFinite(result[0]) ? undefined : Math.round(result[0]); + } + if (item.type === itemType.CHOICE || item.type === itemType.OPENCHOICE) { + if (this.isCheckbox(item)) { + return result; + } + } + return result[0]; + } + + public hasFhirPaths(): boolean { + const hasScoringInItem = (item: QuestionnaireItem): boolean => { + if (fhirPathItemType(item) !== FhirPathItemType.NONE) { + return true; + } + if (item.item && item.item.length > 0) { + return item.item.some(nestedItem => hasScoringInItem(nestedItem)); + } + return false; + }; + + return this.questionnaire?.item?.some(item => hasScoringInItem(item)) ?? false; + } + + private evaluateCalculatedExpressions(questionnaireResponse: QuestionnaireResponse): QuestionnaireResponse { + // Function to evaluate an expression and return a new answer + const evaluateExpression = ( + qItem: QuestionnaireItem, + expressionExtension: Extension, + response: QuestionnaireResponse + ): QuestionnaireResponseItemAnswer | null => { + if (expressionExtension && expressionExtension.valueString) { + const result = evaluateFhirpathExpressionToGetString(expressionExtension, response); + if (result.length > 0) { + const calculatedValue = result[0]; + + let newAnswer: QuestionnaireResponseItemAnswer = {}; + switch (qItem.type) { + case itemType.BOOLEAN: + newAnswer = { valueBoolean: Boolean(calculatedValue) }; + break; + case itemType.DECIMAL: + newAnswer = { valueDecimal: Number(calculatedValue) }; + break; + case itemType.INTEGER: + newAnswer = { valueInteger: Number(calculatedValue) }; + break; + case itemType.QUANTITY: + newAnswer = { valueQuantity: calculatedValue }; + break; + case itemType.DATE: + newAnswer = { valueDate: String(calculatedValue) }; + break; + case itemType.DATETIME: + newAnswer = { valueDateTime: String(calculatedValue) }; + break; + case itemType.TIME: + newAnswer = { valueTime: String(calculatedValue) }; + break; + case itemType.STRING: + case itemType.TEXT: + newAnswer = { valueString: String(calculatedValue) }; + break; + case itemType.CHOICE: + case itemType.OPENCHOICE: + newAnswer = { valueCoding: calculatedValue }; + break; + case itemType.ATTATCHMENT: + newAnswer = { valueAttachment: calculatedValue }; + break; + default: + break; + } + return newAnswer; + } + } + return null; + }; + + const traverseItems = ( + qItems: QuestionnaireItem[], + qrItems: QuestionnaireResponseItem[], + response: QuestionnaireResponse + ): QuestionnaireResponseItem[] => { + const newQrItems: QuestionnaireResponseItem[] = []; + + for (const qItem of qItems) { + // Find all qrItems with matching linkId + const matchingQrItems = qrItems.filter(qrItem => qrItem.linkId === qItem.linkId); + + if (matchingQrItems.length === 0) { + // Handle case where there's no matching qrItem + // Optional: Create a new qrItem if needed + } else { + const updatedQrItems = matchingQrItems.map(qrItem => { + let newQrItem: QuestionnaireResponseItem = { ...qrItem }; + + const calculatedExpression = getCalculatedExpressionExtension(qItem); + const copyExtension = getCopyExtension(qItem); + + let newAnswer: QuestionnaireResponseItemAnswer | null = null; + + if (calculatedExpression && !copyExtension) { + newAnswer = evaluateExpression(qItem, calculatedExpression, response); + } + + if (copyExtension) { + newAnswer = evaluateExpression(qItem, copyExtension, response); + } + + if (newAnswer) { + newQrItem = { + ...newQrItem, + answer: [newAnswer], + }; + } + + if (qItem.item && qrItem.item) { + newQrItem = { + ...newQrItem, + item: traverseItems(qItem.item, qrItem.item, response), + }; + } + + return newQrItem; + }); + + newQrItems.push(...updatedQrItems); + } + } + + return newQrItems; + }; + + const newQuestionnaireResponse: QuestionnaireResponse = { + ...questionnaireResponse, + item: questionnaireResponse.item + ? traverseItems(this.questionnaire.item || [], questionnaireResponse.item, questionnaireResponse) + : undefined, + }; + return newQuestionnaireResponse; + } + private isCheckbox(item: QuestionnaireItem): boolean { + return getItemControlValue(item) === ItemControlConstants.CHECKBOX; + } +} diff --git a/src/util/actionRequester.ts b/src/util/actionRequester.ts index 29a17209..40070995 100644 --- a/src/util/actionRequester.ts +++ b/src/util/actionRequester.ts @@ -1,4 +1,4 @@ -import { Questionnaire, QuestionnaireResponse, QuestionnaireItem, Coding, Quantity } from 'fhir/r4'; +import { Questionnaire, QuestionnaireResponse, QuestionnaireItem, Coding, Quantity, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { getItemControlValue } from './choice'; import { getResponseItemAndPathWithLinkId, getQuestionnaireDefinitionItem, Path } from './refero-core'; @@ -16,6 +16,7 @@ import { removeCodingValueAction, removeCodingStringValueAction, NewValuePayload, + newAnswerValueAction, } from '@/actions/newValue'; import itemControlConstants from '@/constants/itemcontrol'; import { PayloadAction } from '@reduxjs/toolkit'; @@ -45,7 +46,7 @@ export interface IActionRequester { removeOpenChoiceAnswer(linkId: string, value: Coding | string, index?: number): void; } -class ItemAndPath { +class ItemAndPathInt { public item: QuestionnaireItem; public path: Path[]; @@ -76,7 +77,7 @@ export class ActionRequester implements IActionRequester { this.addIntegerAnswer(linkId, Number.NaN, index); } - public addDecimalAnswer(linkId: string, value: number, index: number = 0): void { + public addDecimalAnswer(linkId: string, value?: number, index: number = 0): void { const itemAndPath = this.getItemAndPath(linkId, index); if (itemAndPath) { this.actions.push(newDecimalValueAction({ itemPath: itemAndPath.path, valueDecimal: value, item: itemAndPath.item })); @@ -87,6 +88,19 @@ export class ActionRequester implements IActionRequester { this.addDecimalAnswer(linkId, Number.NaN, index); } + public setNewAnswer(linkId: string, value: QuestionnaireResponseItemAnswer[], index: number = 0): void { + const itemAndPath = this.getItemAndPath(linkId, index); + if (itemAndPath) { + this.actions.push( + newAnswerValueAction({ + itemPath: itemAndPath.path, + newAnswer: value, + item: itemAndPath.item, + }) + ); + } + } + public addChoiceAnswer(linkId: string, value: Coding, index: number = 0): void { const itemAndPath = this.getItemAndPath(linkId, index); if (itemAndPath) { @@ -207,7 +221,7 @@ export class ActionRequester implements IActionRequester { return this.actions; } - private getItemAndPath(linkId: string, index: number): ItemAndPath | undefined { + private getItemAndPath(linkId: string, index: number): ItemAndPathInt | undefined { const item = getQuestionnaireDefinitionItem(linkId, this.questionnaire.item); const itemsAndPaths = getResponseItemAndPathWithLinkId(linkId, this.questionnaireResponse); @@ -215,10 +229,13 @@ export class ActionRequester implements IActionRequester { return; } - return new ItemAndPath(item, itemsAndPaths[index].path); + return new ItemAndPathInt(item, itemsAndPaths[index].path); } - private isCheckbox(item: QuestionnaireItem): boolean { + public isCheckbox(item: QuestionnaireItem): boolean { return getItemControlValue(item) === itemControlConstants.CHECKBOX; } + public addManyActions(actions: PayloadAction[]): void { + this.actions.push(...actions); + } } diff --git a/src/util/fhirpathHelper.ts b/src/util/fhirpathHelper.ts index 5efedf92..c3331c4d 100644 --- a/src/util/fhirpathHelper.ts +++ b/src/util/fhirpathHelper.ts @@ -23,8 +23,8 @@ export async function getAnswerFromResponseItem(responseItem?: QuestionnaireResp } } -export async function getResonseItem(linkId: string, responseItem: QuestionnaireResponseItem): Promise { - if (!linkId || !responseItem) { +export async function getResonseItem(linkId: string, response: QuestionnaireResponse): Promise { + if (!linkId || !response) { return undefined; } try { @@ -32,7 +32,7 @@ export async function getResonseItem(linkId: string, responseItem: Questionnaire `item.descendants().where(linkId='${linkId}') | answer.item.descendants().where(linkId='${linkId}')`, fhirpath_r4_model ); - return compiledExpression(responseItem); + return compiledExpression(response); } catch (e) { console.log(e); return undefined; @@ -81,7 +81,15 @@ export function evaluateFhirpathExpressionToGetString(fhirExtension: Extension, return []; } } - +export async function evaluateFhirpathExpression(expression: string, context: any): Promise { + try { + const compiledExpression = fhirpath.compile(expression, fhirpath_r4_model); + return compiledExpression(context); + } catch (error) { + console.error(`Error evaluating FHIRPath expression "${expression}":`, error); + return []; + } +} export function evaluateExtension(path: string | fhirpath.Path, questionnare?: QuestionnaireResponse | null, context?: Context): unknown { const qCopy = structuredClone(questionnare); /** diff --git a/src/util/sanitize/domPurifyHelper.ts b/src/util/sanitize/domPurifyHelper.ts index 42f0725e..655c8b2b 100644 --- a/src/util/sanitize/domPurifyHelper.ts +++ b/src/util/sanitize/domPurifyHelper.ts @@ -6,5 +6,5 @@ export function SanitizeText(textToSanitize: string): string { ADD_ATTR: ['target'], }); - return sanitizedResult?.toString(); + return sanitizedResult as string; } diff --git a/src/util/scoring.ts b/src/util/scoring.ts index ed454807..bbfd6f8c 100644 --- a/src/util/scoring.ts +++ b/src/util/scoring.ts @@ -1,7 +1,6 @@ import { QuestionnaireItem, Coding } from 'fhir/r4'; import * as uuid from 'uuid'; -import { getCalculatedExpressionExtension } from './extension'; import { Extensions } from '../constants/extensions'; import ItemType from '../constants/itemType'; import { SCORING, SCORING_CODE, SCORING_FORMULAS, ScoringTypes, Type } from '../constants/scoring'; @@ -44,9 +43,6 @@ export function scoringItemType(item: QuestionnaireItem): ScoringItemType { default: return ScoringItemType.NONE; } - } else if (item.extension) { - const calculatedExpressionExtension = getCalculatedExpressionExtension(item); - return calculatedExpressionExtension ? ScoringItemType.QUESTION_FHIRPATH_SCORE : ScoringItemType.NONE; } return ScoringItemType.NONE; diff --git a/src/util/scoringCalculator.ts b/src/util/scoringCalculator.ts index 9dd0d1c1..00fe2679 100644 --- a/src/util/scoringCalculator.ts +++ b/src/util/scoringCalculator.ts @@ -6,12 +6,10 @@ import { QuestionnaireItemAnswerOption, } from 'fhir/r4'; -import { getExtension, getCalculatedExpressionExtension } from './extension'; -import { evaluateFhirpathExpressionToGetString } from './fhirpathHelper'; +import { getExtension } from './extension'; import { getQuestionnaireResponseItemsWithLinkId } from './refero-core'; import { createDummySectionScoreItem, scoringItemType } from './scoring'; import { Extensions } from '../constants/extensions'; -import itemType from '../constants/itemType'; import { ScoringItemType } from '../constants/scoringItemType'; export interface AnswerPad { @@ -47,7 +45,6 @@ export class ScoringCalculator { private totalScoreCache: Map = new Map(); private totalScoreItem: QuestionnaireItem | undefined; private itemCache: Map = new Map(); - private fhirScoreCache: Map = new Map(); private isScoringQuestionnaire: boolean = false; constructor(questionnaire: Questionnaire) { @@ -123,9 +120,6 @@ export class ScoringCalculator { case ScoringItemType.QUESTION_SCORE: newScores.questionScores.push(qItem, ...calculatedScores.questionScores); break; - case ScoringItemType.QUESTION_FHIRPATH_SCORE: - this.fhirScoreCache.set(qItem.linkId, qItem); - break; default: newScores.questionScores.push(...calculatedScores.questionScores); break; @@ -137,18 +131,6 @@ export class ScoringCalculator { private isOfTypeQuestionnaireItem(item: Questionnaire | QuestionnaireItem): item is QuestionnaireItem { return 'type' in item; } - - public calculateScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { - const answerPad: AnswerPad = {}; - - const sectionScoresCalculated = this.calculateAllSectionScores(answerPad, questionnaireResponse); - const allScoresCalculated = this.calculateAllTotalScores(sectionScoresCalculated, questionnaireResponse); - - delete allScoresCalculated[this.totalScoreItem!.linkId]; - - return allScoresCalculated; - } - private calculateAllSectionScores(answerPad: AnswerPad, questionnaireResponse: QuestionnaireResponse): AnswerPad { const tempAnswerPad: AnswerPad = answerPad; const keys = this.sectionScoreCache.keys(); @@ -170,14 +152,15 @@ export class ScoringCalculator { return tempAnswerPad; } - public calculateFhirScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { + public calculateScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { const answerPad: AnswerPad = {}; - for (const [key, value] of this.fhirScoreCache) { - answerPad[key] = this.valueOfQuestionFhirpathScoreItem(value, questionnaireResponse); - } + const sectionScoresCalculated = this.calculateAllSectionScores(answerPad, questionnaireResponse); + const allScoresCalculated = this.calculateAllTotalScores(sectionScoresCalculated, questionnaireResponse); + + delete allScoresCalculated[this.totalScoreItem!.linkId]; - return answerPad; + return allScoresCalculated; } private calculateSectionScore(linkId: string, questionnaireResponse: QuestionnaireResponse, answerPad: AnswerPad): number | undefined { @@ -207,19 +190,6 @@ export class ScoringCalculator { } } - private valueOfQuestionFhirpathScoreItem(item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse): number | undefined { - const expressionExtension = getCalculatedExpressionExtension(item); - if (!expressionExtension) return undefined; - - const result = evaluateFhirpathExpressionToGetString(expressionExtension, questionnaireResponse); - if (!result.length) return undefined; - - let value = (result[0] as number) ?? 0; - value = item.type === itemType.INTEGER ? Math.round(value) : value; - - return isNaN(value) || !isFinite(value) ? undefined : value; - } - private valueOfQuestionScoreItem(item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse): number | undefined { let sum = 0; let hasCalculatedAtLeastOneAnswer = false; From 5da7a5f6951066d92ef281e0fe64424d484a9352 Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 15:09:44 +0100 Subject: [PATCH 2/8] comments --- src/hooks/useFhirPathQrUpdater.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx index 5349b540..86382615 100644 --- a/src/hooks/useFhirPathQrUpdater.tsx +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -35,11 +35,14 @@ export const useFhirPathQrUpdater = (): { // Evaluate all expressions and get the updated response const updatedResponse = fhirPathUpdater.evaluateAllExpressions(questionnaireResponse); + //TODO: Figure out a way to not run this on all changes // if (JSON.stringify(updatedResponse) === JSON.stringify(questionnaireResponse)) { // return; // } // Calculate FHIR scores using the same updated response + const fhirScores = fhirPathUpdater.calculateFhirScore(updatedResponse); + updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); }; From 71c61bcfe129a9041b539b5422f198c9ba7bc70a Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 22:04:01 +0100 Subject: [PATCH 3/8] update quantity --- src/components/__tests__/copy-from-spec.tsx | 3 +-- src/hooks/useFhirPathQrUpdater.tsx | 25 +++++++++++++-------- src/util/FhirPathExtensions.ts | 5 +++-- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/__tests__/copy-from-spec.tsx b/src/components/__tests__/copy-from-spec.tsx index 4cc3106d..c68f02f2 100644 --- a/src/components/__tests__/copy-from-spec.tsx +++ b/src/components/__tests__/copy-from-spec.tsx @@ -237,11 +237,10 @@ describe('Copy value from item', () => { }); }); describe('should copy OPEN-CHOICE value', () => { - it.only('should copy CHECKBOX value', async () => { + it('should copy CHECKBOX value', async () => { const sender = createSenderChoiceItem(ItemType.OPENCHOICE, createItemControlExtension(ItemControlConstants.CHECKBOX)); const reciever = createReciverChoiceItem(ItemType.OPENCHOICE, ItemControlConstants.CHECKBOX); const q = createQuestionnaire(sender, reciever); - console.log(JSON.stringify(q, null, 2)); const { getByLabelText, queryByTestId, getByTestId, findByTestId } = createWrapper(q); expect(queryByTestId(/item_2/i)).not.toBeInTheDocument(); expect(getByLabelText(/Mann/i)).toBeInTheDocument(); diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx index 86382615..e8099cb4 100644 --- a/src/hooks/useFhirPathQrUpdater.tsx +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -6,7 +6,7 @@ import { ActionRequester } from '@/util/actionRequester'; import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; import { AnswerPad, FhirPathExtensions } from '@/util/FhirPathExtensions'; import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; -import { Coding, Quantity, Questionnaire, QuestionnaireResponse } from 'fhir/r4'; +import { Coding, Quantity, Questionnaire, QuestionnaireItem, QuestionnaireResponse } from 'fhir/r4'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; @@ -45,7 +45,14 @@ export const useFhirPathQrUpdater = (): { updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); }; - + const createQuantity = (item: QuestionnaireItem, extension: Coding, value: number): Quantity => { + return { + unit: extension.display, + system: extension.system, + code: extension.code, + value: getDecimalValue(item, value), + }; + }; const updateQuestionnaireResponseWithScore = ( scores: AnswerPad, questionnaire: Questionnaire, @@ -62,14 +69,14 @@ export const useFhirPathQrUpdater = (): { const extension = getQuestionnaireUnitExtensionValue(item); if (!extension) continue; - const quantity: Quantity = { - unit: extension.display, - system: extension.system, - code: extension.code, - value: getDecimalValue(item, value as number), - }; for (const itemAndPath of itemsAndPaths) { - actionRequester.addQuantityAnswer(linkId, quantity as Quantity, itemAndPath.path[0]?.index); + actionRequester.addQuantityAnswer( + linkId, + typeof value === 'string' || typeof value === 'number' + ? createQuantity(item, extension, value as number) + : (value as Quantity), + itemAndPath.path[0]?.index + ); } break; } diff --git a/src/util/FhirPathExtensions.ts b/src/util/FhirPathExtensions.ts index ab165f74..57c45d52 100644 --- a/src/util/FhirPathExtensions.ts +++ b/src/util/FhirPathExtensions.ts @@ -6,6 +6,7 @@ import { QuestionnaireResponseItem, Extension, Coding, + Quantity, } from 'fhir/r4'; import { getCalculatedExpressionExtension, getCopyExtension } from './extension'; @@ -17,7 +18,7 @@ import { getItemControlValue } from './choice'; import ItemControlConstants from '@/constants/itemcontrol'; export interface AnswerPad { - [linkId: string]: number | undefined | string | Coding | boolean | Coding[]; + [linkId: string]: number | undefined | string | Coding | boolean | Coding[] | Quantity; } export enum FhirPathItemType { QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', @@ -102,7 +103,7 @@ export class FhirPathExtensions { private valueOfQuestionFhirpathItem( item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse - ): number | undefined | string | Coding | boolean | Coding[] { + ): number | undefined | string | Coding | boolean | Coding[] | Quantity { const expressionExtension = getCalculatedExpressionExtension(item) || getCopyExtension(item); if (!expressionExtension) return undefined; From e30495f490eef0f5b48a7785439f6346955e15bd Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 15:03:39 +0100 Subject: [PATCH 4/8] fix tests and split scoring --- package.json | 2 - preview/skjema/q.json | 41 +++ src/actions/generateQuestionnaireResponse.ts | 2 +- src/actions/newValue.ts | 24 +- src/components/__tests__/copy-from-spec.tsx | 3 +- .../formcomponents/choice/choice.tsx | 18 +- .../formcomponents/repeat/RepeatButton.tsx | 8 +- src/constants/scoringItemType.ts | 1 - src/hooks/useFhirPathQrUpdater.tsx | 140 ++++++++++ src/hooks/useGetAnswer.ts | 67 +---- src/hooks/useOnAnswerChange.tsx | 12 +- src/hooks/useScoringCalculator.ts | 26 +- src/reducers/form.ts | 17 ++ src/util/FhirPathExtensions.ts | 258 ++++++++++++++++++ src/util/actionRequester.ts | 29 +- src/util/fhirpathHelper.ts | 16 +- src/util/scoring.ts | 4 - src/util/scoringCalculator.ts | 44 +-- 18 files changed, 569 insertions(+), 143 deletions(-) create mode 100644 src/hooks/useFhirPathQrUpdater.tsx create mode 100644 src/util/FhirPathExtensions.ts diff --git a/package.json b/package.json index 5464856e..d31e3d28 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "@types/node": "^20.14.8", "@types/react-collapse": "^5.0.4", "@types/react-dom": "^18.3.0", - "@types/redux-mock-store": "^1.0.6", "@types/rollup-plugin-generate-package-json": "^3.2.9", "@types/rollup-plugin-peer-deps-external": "^2.2.5", "@types/uuid": "^2.0.35", @@ -111,7 +110,6 @@ "pretty-quick": "^4.0.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "redux-mock-store": "^1.5.4", "redux-thunk": "^3.1.0", "resolve": "^1.22.8", "rollup-plugin-copy": "^3.5.0", diff --git a/preview/skjema/q.json b/preview/skjema/q.json index a1a59601..10642289 100644 --- a/preview/skjema/q.json +++ b/preview/skjema/q.json @@ -57,6 +57,47 @@ } ] } + }, + { + "url": "http://helsenorge.no/fhir/StructureDefinition/sdf-presentationbuttons", + "valueCoding": { + "system": "http://helsenorge.no/fhir/ValueSet/presentationbuttons", + "code": "sticky" + } + } + ], + "id": "6f9db0cf-1d11-4e16-efa1-686d4fa90564", + "contained": [ + { + "url": "http://ehelse.no/fhir/ValueSet/Predefined", + "resourceType": "ValueSet", + "id": "1102", + "version": "1.0", + "name": "urn:oid:1102", + "title": "Ja / Nei / Vet ikke (structor)", + "status": "draft", + "publisher": "Direktoratet for e-helse", + "compose": { + "include": [ + { + "system": "urn:oid:2.16.578.1.12.4.1.1102", + "concept": [ + { + "code": "1", + "display": "Mann" + }, + { + "code": "2", + "display": "Nei" + }, + { + "code": "3", + "display": "Vet ikke" + } + ] + } + ] + } } ], "id": "a93bf3e9-d09c-4625-8b92-7c2b80e78ab2", diff --git a/src/actions/generateQuestionnaireResponse.ts b/src/actions/generateQuestionnaireResponse.ts index e4d769f5..6dff877f 100644 --- a/src/actions/generateQuestionnaireResponse.ts +++ b/src/actions/generateQuestionnaireResponse.ts @@ -101,7 +101,7 @@ export function createQuestionnaireResponseItem(item: QuestionnaireItem): Questi return responseItem; } -function evaluateCalculatedExpressions(questionnaire: Questionnaire, response: QuestionnaireResponse): QuestionnaireResponse { +export function evaluateCalculatedExpressions(questionnaire: Questionnaire, response: QuestionnaireResponse): QuestionnaireResponse { function traverseItems(qItems: QuestionnaireItem[], qrItems: QuestionnaireResponseItem[]): void { qItems.forEach((qItem, index) => { const qrItem = qrItems[index]; diff --git a/src/actions/newValue.ts b/src/actions/newValue.ts index 24dccbf0..d0335d8b 100644 --- a/src/actions/newValue.ts +++ b/src/actions/newValue.ts @@ -1,4 +1,4 @@ -import { Coding, QuestionnaireItem, Attachment, QuestionnaireResponseItem, Quantity } from 'fhir/r4'; +import { Coding, QuestionnaireItem, Attachment, QuestionnaireResponseItem, Quantity, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { createAction, PayloadAction } from '@reduxjs/toolkit'; import { AppDispatch, GlobalState } from '../reducers'; @@ -11,6 +11,8 @@ export const NEW_CODINGSTRING_VALUE: NEW_CODINGSTRING_VALUE = 'refero/NEW_CODING export type REMOVE_CODINGSTRING_VALUE = 'refero/REMOVE_CODINGSTRING_VALUE'; export const REMOVE_CODINGSTRING_VALUE: REMOVE_CODINGSTRING_VALUE = 'refero/REMOVE_CODINGSTRING_VALUE'; export const REMOVE_CODING_VALUE = 'refero/REMOVE_CODING_VALUE'; +export type NEW_ANSWER_VALUE = 'refero/NEW_ANSWER_VALUE'; +export const NEW_ANSWER_VALUE: NEW_ANSWER_VALUE = 'refero/NEW_ANSWER_VALUE'; export type ADD_REPEAT_ITEM = 'refero/ADD_REPEAT_ITEM'; export const ADD_REPEAT_ITEM: ADD_REPEAT_ITEM = 'refero/ADD_REPEAT_ITEM'; export type DELETE_REPEAT_ITEM = 'refero/DELETE_REPEAT_ITEM'; @@ -34,6 +36,7 @@ export type NewValuePayload = { item?: QuestionnaireItem; responseItems?: Array; multipleAnswers?: boolean; + newAnswer?: QuestionnaireResponseItemAnswer[]; }; export type RemoveAttachmentPayload = Pick; export type NewAttachmentPayload = Pick; @@ -51,6 +54,8 @@ export type DateItemPayload = Pick; export type DateTimeItemPayload = Pick; export type DeleteRepeatItemPayload = Pick; +export type AnswerValueItemPayload = Pick; + export const newValue = createAction(NEW_VALUE); export const newAttachmentAction = createAction(NEW_VALUE); @@ -109,6 +114,16 @@ export function newBooleanValueAsync(itemPath: Array, value: boolean, item return await Promise.resolve(getState()); }; } +export const newAnswerValueAction = createAction(NEW_ANSWER_VALUE); +// /* +// * @deprecated this will be removed in a future version, use newCodingValueAction instead +// */ +// export const newCodingsValue = ( +// itemPath: CodingValueItemPayload['itemPath'], +// value: CodingValueItemPayload['valueCoding'], +// item: CodingValueItemPayload['item'], +// multipleAnswers?: CodingValueItemPayload['multipleAnswers'] +// ): PayloadAction => newCodingsValueAction({ itemPath, valueCodings: value, item, multipleAnswers }); export const newCodingValueAction = createAction(NEW_VALUE); /* @@ -307,6 +322,13 @@ export function newDateTimeValueAsync(itemPath: Array, value: string, item } export const addRepeatItemAction = createAction(ADD_REPEAT_ITEM); + +export const addRepeatItemAsync = (parentPath?: Path[], item?: QuestionnaireItem, responseItems?: QuestionnaireResponseItem[]) => { + return async (dispatch: AppDispatch, getState: () => GlobalState): Promise => { + dispatch(addRepeatItemAction({ parentPath, item, responseItems })); + return await Promise.resolve(getState()); + }; +}; /* * @deprecated this will be removed in a future version, use addRepeatItemAction instead */ diff --git a/src/components/__tests__/copy-from-spec.tsx b/src/components/__tests__/copy-from-spec.tsx index c68f02f2..4cc3106d 100644 --- a/src/components/__tests__/copy-from-spec.tsx +++ b/src/components/__tests__/copy-from-spec.tsx @@ -237,10 +237,11 @@ describe('Copy value from item', () => { }); }); describe('should copy OPEN-CHOICE value', () => { - it('should copy CHECKBOX value', async () => { + it.only('should copy CHECKBOX value', async () => { const sender = createSenderChoiceItem(ItemType.OPENCHOICE, createItemControlExtension(ItemControlConstants.CHECKBOX)); const reciever = createReciverChoiceItem(ItemType.OPENCHOICE, ItemControlConstants.CHECKBOX); const q = createQuestionnaire(sender, reciever); + console.log(JSON.stringify(q, null, 2)); const { getByLabelText, queryByTestId, getByTestId, findByTestId } = createWrapper(q); expect(queryByTestId(/item_2/i)).not.toBeInTheDocument(); expect(getByLabelText(/Mann/i)).toBeInTheDocument(); diff --git a/src/components/formcomponents/choice/choice.tsx b/src/components/formcomponents/choice/choice.tsx index 80f85876..4ece478d 100644 --- a/src/components/formcomponents/choice/choice.tsx +++ b/src/components/formcomponents/choice/choice.tsx @@ -72,15 +72,16 @@ export const Choice = (props: ChoiceProps): JSX.Element | null => { // }, [item]); const getPDFValue = (): string => { - const getDataReceiverValue = (answer: Array): (string | undefined)[] => { - return answer.map((el: QuestionnaireResponseItemAnswer) => { - if (el && el.valueCoding && el.valueCoding.display) { - return el.valueCoding.display; - } - }); - }; if (isDataReceiver(item)) { - return getDataReceiverValue(answer as Array).join(', '); + return Array.isArray(answer) + ? answer + .map((el: QuestionnaireResponseItemAnswer) => { + if (el && el.valueCoding && el.valueCoding.display) { + return el.valueCoding.display; + } + }) + ?.join(', ') + : (answer && answer.valueCoding && answer.valueCoding.display) || resources?.ikkeBesvart || ''; } const value = getAnswerValue(); if (!value || value.length === 0) { @@ -158,6 +159,7 @@ export const Choice = (props: ChoiceProps): JSX.Element | null => { const renderComponentBasedOnType = (): JSX.Element | null => { const pdfValue = getPDFValue(); + const itemControlValue = getItemControlValue(item); if (!itemControlValue) return null; diff --git a/src/components/formcomponents/repeat/RepeatButton.tsx b/src/components/formcomponents/repeat/RepeatButton.tsx index ca6dc4a1..622f7b21 100644 --- a/src/components/formcomponents/repeat/RepeatButton.tsx +++ b/src/components/formcomponents/repeat/RepeatButton.tsx @@ -4,11 +4,12 @@ import Button from '@helsenorge/designsystem-react/components/Button'; import Icon from '@helsenorge/designsystem-react/components/Icon'; import PlusLarge from '@helsenorge/designsystem-react/components/Icons/PlusLarge'; -import { addRepeatItemAction } from '../../../actions/newValue'; +import { addRepeatItemAsync } from '../../../actions/newValue'; import { useAppDispatch } from '../../../reducers'; import { getRepeatsTextExtension } from '../../../util/extension'; import { Path } from '../../../util/refero-core'; import { useExternalRenderContext } from '@/context/externalRenderContext'; +import useOnAnswerChange from '@/hooks/useOnAnswerChange'; interface Props { item?: QuestionnaireItem; @@ -20,9 +21,12 @@ interface Props { export const RepeatButton = ({ item, parentPath, responseItems, disabled }: Props): JSX.Element => { const dispatch = useAppDispatch(); const { resources } = useExternalRenderContext(); + const onAnswerChange = useOnAnswerChange(); const onAddRepeatItem = (): void => { if (dispatch && item) { - dispatch(addRepeatItemAction({ parentPath, item, responseItems })); + dispatch(addRepeatItemAsync(parentPath, item, responseItems))?.then(newState => { + return onAnswerChange && onAnswerChange(newState, item); + }); } }; const text = getRepeatsTextExtension(item); diff --git a/src/constants/scoringItemType.ts b/src/constants/scoringItemType.ts index 0c571af6..b851c7ba 100644 --- a/src/constants/scoringItemType.ts +++ b/src/constants/scoringItemType.ts @@ -2,6 +2,5 @@ export enum ScoringItemType { TOTAL_SCORE = 'TOTAL_SCORE', SECTION_SCORE = 'SECTION_SCORE', QUESTION_SCORE = 'QUESTION_SCORE', - QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', NONE = 'NONE', } diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx new file mode 100644 index 00000000..5349b540 --- /dev/null +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -0,0 +1,140 @@ +import ItemType from '@/constants/itemType'; +import { GlobalState } from '@/reducers'; +import { getFormDefinition } from '@/reducers/form'; +import { getDecimalValue } from '@/util'; +import { ActionRequester } from '@/util/actionRequester'; +import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; +import { AnswerPad, FhirPathExtensions } from '@/util/FhirPathExtensions'; +import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; +import { Coding, Quantity, Questionnaire, QuestionnaireResponse } from 'fhir/r4'; +import { useEffect, useState } from 'react'; +import { useSelector } from 'react-redux'; + +export const useFhirPathQrUpdater = (): { + runFhirPathQrUpdater: ( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ) => void; +} => { + const formDefinition = useSelector((state: GlobalState) => getFormDefinition(state)); + const [fhirPathUpdater, setFhirPathUpdater] = useState(); + + useEffect(() => { + if (formDefinition?.Content) { + setFhirPathUpdater(new FhirPathExtensions(formDefinition.Content)); + } + }, [formDefinition?.Content]); + + const runFhirPathQrUpdater = ( + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ): void => { + if (!questionnaire || !questionnaireResponse || !fhirPathUpdater) return; + + // Evaluate all expressions and get the updated response + const updatedResponse = fhirPathUpdater.evaluateAllExpressions(questionnaireResponse); + // if (JSON.stringify(updatedResponse) === JSON.stringify(questionnaireResponse)) { + // return; + // } + // Calculate FHIR scores using the same updated response + const fhirScores = fhirPathUpdater.calculateFhirScore(updatedResponse); + updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); + }; + + const updateQuestionnaireResponseWithScore = ( + scores: AnswerPad, + questionnaire: Questionnaire, + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester + ): void => { + for (const linkId in scores) { + const item = getQuestionnaireDefinitionItem(linkId, questionnaire.item); + if (!item) continue; + const itemsAndPaths = getResponseItemAndPathWithLinkId(linkId, questionnaireResponse); + const value = scores[linkId]; + switch (item.type) { + case ItemType.QUANTITY: { + const extension = getQuestionnaireUnitExtensionValue(item); + if (!extension) continue; + + const quantity: Quantity = { + unit: extension.display, + system: extension.system, + code: extension.code, + value: getDecimalValue(item, value as number), + }; + for (const itemAndPath of itemsAndPaths) { + actionRequester.addQuantityAnswer(linkId, quantity as Quantity, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.DECIMAL: { + for (const itemAndPath of itemsAndPaths) { + const decimalValue = getDecimalValue(item, value as number); + actionRequester.addDecimalAnswer(linkId, decimalValue, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.INTEGER: { + for (const itemAndPath of itemsAndPaths) { + actionRequester.addIntegerAnswer(linkId, value as number, itemAndPath.path[0]?.index); + } + + break; + } + case ItemType.BOOLEAN: { + for (const itemAndPath of itemsAndPaths) { + actionRequester.addBooleanAnswer(linkId, value as boolean, itemAndPath.path[0]?.index); + } + break; + } + case ItemType.STRING: + case ItemType.TEXT: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addStringAnswer(linkId, (value as string) ?? '', itemAndPath.path[0]?.index); + } + break; + case ItemType.CHOICE: { + for (const itemAndPath of itemsAndPaths) { + if (actionRequester.isCheckbox(item)) { + const answer = value ? (value as Coding[])?.map(x => ({ valueCoding: x })) : []; + actionRequester.setNewAnswer(linkId, answer, itemAndPath.path[0]?.index); + } else { + actionRequester.addChoiceAnswer(linkId, value as Coding, itemAndPath.path[0]?.index); + } + } + break; + } + case ItemType.OPENCHOICE: { + for (const itemAndPath of itemsAndPaths) { + if (actionRequester.isCheckbox(item)) { + const answer = value ? (value as Coding[])?.map(x => (typeof x === 'string' ? { valueString: x } : { valueCoding: x })) : []; + actionRequester.setNewAnswer(linkId, answer, itemAndPath.path[0]?.index); + } else { + actionRequester.addOpenChoiceAnswer(linkId, value as Coding | string, itemAndPath.path[0]?.index); + } + } + break; + } + case ItemType.DATETIME: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addDateTimeAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + break; + case ItemType.DATE: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addDateAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + break; + case ItemType.TIME: + for (const itemAndPath of itemsAndPaths) { + actionRequester.addTimeAnswer(linkId, value as string, itemAndPath.path[0]?.index); + } + } + } + }; + + return { runFhirPathQrUpdater }; +}; diff --git a/src/hooks/useGetAnswer.ts b/src/hooks/useGetAnswer.ts index 369c4bb8..c948fef2 100644 --- a/src/hooks/useGetAnswer.ts +++ b/src/hooks/useGetAnswer.ts @@ -1,75 +1,16 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import ItemType from '@/constants/itemType'; import { GlobalState } from '@/reducers'; -import { findQuestionnaireItem, getResponseItemWithPathSelector, questionnaireResponseSelector } from '@/reducers/selectors'; -import { getCalculatedExpressionExtension, getCopyExtension } from '@/util/extension'; -import { evaluateFhirpathExpressionToGetString } from '@/util/fhirpathHelper'; +import { getResponseItemWithPathSelector } from '@/reducers/selectors'; import { getAnswerFromResponseItem, Path } from '@/util/refero-core'; -import { Extension, QuestionnaireItem, QuestionnaireResponse, QuestionnaireResponseItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; +import { QuestionnaireResponseItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { useSelector } from 'react-redux'; -function getAnswerIfDataReceiver( - questionnaireResponse: QuestionnaireResponse | null | undefined, - item: QuestionnaireItem, - extension: Extension -): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] | undefined { - const result = evaluateFhirpathExpressionToGetString(extension, questionnaireResponse); - const processedResult = getCalculatedExpressionExtension(item) ? result.map((res: any) => res?.value ?? res) : result; - - return getQuestionnaireResponseItemAnswer(item.type, processedResult); -} - -function getQuestionnaireResponseItemAnswer( - type: string, - result: any[] -): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] { - if (type === ItemType.BOOLEAN) { - return { valueBoolean: result[0] }; - } - return result.map((answer: any) => { - switch (String(type)) { - case ItemType.TEXT: - case ItemType.STRING: - return { valueString: answer }; - case ItemType.INTEGER: - return { valueInteger: answer }; - case ItemType.DECIMAL: - return { valueDecimal: answer }; - - case ItemType.QUANTITY: - return { valueQuantity: answer }; - case ItemType.DATETIME: - return { valueDateTime: answer }; - case ItemType.DATE: - return { valueDate: answer }; - case ItemType.TIME: - return { valueTime: answer }; - default: { - if (typeof answer === 'string') { - return { valueString: answer }; - } else { - return { valueCoding: answer }; - } - } - } - }); -} - export const useGetAnswer = ( - linkId?: string, + _linkId?: string, path?: Path[] ): QuestionnaireResponseItemAnswer | QuestionnaireResponseItemAnswer[] | undefined => { - const questionnaireResponse = useSelector(questionnaireResponseSelector); - const item = useSelector(state => findQuestionnaireItem(state, linkId)); const responseItem = useSelector(state => getResponseItemWithPathSelector(state, path) ); - const dataRecieverExtension = item && getCopyExtension(item); - - const answer = dataRecieverExtension - ? getAnswerIfDataReceiver(questionnaireResponse, item, dataRecieverExtension) - : getAnswerFromResponseItem(responseItem); - - return answer; + return getAnswerFromResponseItem(responseItem); }; diff --git a/src/hooks/useOnAnswerChange.tsx b/src/hooks/useOnAnswerChange.tsx index 6440c0e6..488fe10c 100644 --- a/src/hooks/useOnAnswerChange.tsx +++ b/src/hooks/useOnAnswerChange.tsx @@ -3,6 +3,7 @@ import { ActionRequester, IActionRequester } from '@/util/actionRequester'; import { QuestionnaireItem, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { IQuestionnaireInspector, QuestionniareInspector } from '@/util/questionnaireInspector'; import { GlobalState, useAppDispatch } from '@/reducers'; +import { useFhirPathQrUpdater } from './useFhirPathQrUpdater'; const useOnAnswerChange = ( onChange?: ( @@ -11,22 +12,25 @@ const useOnAnswerChange = ( actionRequester: IActionRequester, questionnaireInspector: IQuestionnaireInspector ) => void -): ((state: GlobalState, item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer) => void) => { +): ((state: GlobalState, item: QuestionnaireItem, answer?: QuestionnaireResponseItemAnswer) => void) => { const dispatch = useAppDispatch(); const { runScoringCalculator } = useScoringCalculator(); + const { runFhirPathQrUpdater } = useFhirPathQrUpdater(); - return (state: GlobalState, item: QuestionnaireItem, answer: QuestionnaireResponseItemAnswer): void => { + return (state: GlobalState, item: QuestionnaireItem, answer?: QuestionnaireResponseItemAnswer): void => { const questionnaire = state.refero.form.FormDefinition.Content; const questionnaireResponse = state.refero.form.FormData.Content; if (questionnaire && questionnaireResponse) { const actionRequester = new ActionRequester(questionnaire, questionnaireResponse); + runFhirPathQrUpdater(questionnaire, questionnaireResponse, actionRequester); + runScoringCalculator(questionnaire, questionnaireResponse, actionRequester); + const questionnaireInspector = new QuestionniareInspector(questionnaire, questionnaireResponse); - onChange && onChange(item, answer, actionRequester, questionnaireInspector); + onChange && answer && item && onChange(item, answer, actionRequester, questionnaireInspector); for (const action of actionRequester.getActions()) { dispatch(action); } } - runScoringCalculator(questionnaire, questionnaireResponse); }; }; export default useOnAnswerChange; diff --git a/src/hooks/useScoringCalculator.ts b/src/hooks/useScoringCalculator.ts index 41dc8aac..54364bdd 100644 --- a/src/hooks/useScoringCalculator.ts +++ b/src/hooks/useScoringCalculator.ts @@ -3,6 +3,7 @@ import ItemType from '@/constants/itemType'; import { GlobalState, useAppDispatch } from '@/reducers'; import { getFormDefinition } from '@/reducers/form'; import { getDecimalValue } from '@/util'; +import { ActionRequester } from '@/util/actionRequester'; import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; import { AnswerPad, ScoringCalculator } from '@/util/scoringCalculator'; @@ -11,7 +12,11 @@ import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; export const useScoringCalculator = (): { - runScoringCalculator: (questionnaire?: Questionnaire | null, questionnaireResponse?: QuestionnaireResponse | null) => void; + runScoringCalculator: ( + questionnaire?: Questionnaire | null, + questionnaireResponse?: QuestionnaireResponse | null, + actionRequester?: ActionRequester + ) => Promise; } => { const formDefinition = useSelector((state: GlobalState) => getFormDefinition(state)); const dispatch = useAppDispatch(); @@ -23,20 +28,22 @@ export const useScoringCalculator = (): { } }, [formDefinition?.Content]); - const runScoringCalculator = (questionnaire?: Questionnaire | null, questionnaireResponse?: QuestionnaireResponse | null): void => { - if (!questionnaire || !questionnaireResponse || !scoringCalculator || !questionnaireHasScoring()) return; + const runScoringCalculator = async ( + questionnaire?: Questionnaire | null, + questionnaireResponse?: QuestionnaireResponse | null, + actionRequester?: ActionRequester + ): Promise => { + if (!questionnaire || !questionnaireResponse || !scoringCalculator || !questionnaireHasScoring() || !actionRequester) return; + // Calculate scores using the updated response const scores = scoringCalculator.calculateScore(questionnaireResponse); - updateQuestionnaireResponseWithScore(scores, questionnaire, questionnaireResponse); - - const fhirScores = scoringCalculator.calculateFhirScore(questionnaireResponse); - updateQuestionnaireResponseWithScore(fhirScores, questionnaire, questionnaireResponse); + updateQuestionnaireResponseWithScore(scores, questionnaire, questionnaireResponse, actionRequester); }; - const updateQuestionnaireResponseWithScore = ( scores: AnswerPad, questionnaire: Questionnaire, - questionnaireResponse: QuestionnaireResponse + questionnaireResponse: QuestionnaireResponse, + actionRequester: ActionRequester ): void => { for (const linkId in scores) { const item = getQuestionnaireDefinitionItem(linkId, questionnaire.item); @@ -56,6 +63,7 @@ export const useScoringCalculator = (): { value: getDecimalValue(item, value), }; for (const itemAndPath of itemsAndPaths) { + actionRequester; dispatch(newQuantityValueAction({ itemPath: itemAndPath.path, valueQuantity: quantity, item })); } break; diff --git a/src/reducers/form.ts b/src/reducers/form.ts index c90f644f..d546fb0d 100644 --- a/src/reducers/form.ts +++ b/src/reducers/form.ts @@ -28,8 +28,10 @@ import { deleteRepeatItemAction, RemoveAttachmentPayload, CodingStringPayload, + AnswerValueItemPayload, RemoveCodingStringPayload, RemoveCodingValuePayload, + newAnswerValueAction, } from '@/actions/newValue'; import { syncQuestionnaireResponse } from '@/actions/syncQuestionnaireResponse'; import itemType from '@/constants/itemType'; @@ -98,6 +100,9 @@ const formSlice = createSlice({ .addCase(newValue, (state, action: PayloadAction) => { processNewValueAction(action.payload, state); }) + .addCase(newAnswerValueAction, (state, action: PayloadAction) => { + processNewAnswerValueAction(action.payload, state); + }) .addCase(newCodingStringValueAction, (state, action: PayloadAction) => { processNewCodingStringValueAction(action.payload, state); }) @@ -429,6 +434,18 @@ function processRemoveAttachmentValueAction(action: NewValuePayload, state: Form } return state; } +function processNewAnswerValueAction(payload: AnswerValueItemPayload, state: Form): Form { + const responseItem = getResponseItemWithPath(payload.itemPath || [], state.FormData); + + if (!responseItem) { + return state; + } + const answer = payload.newAnswer; + responseItem.answer = answer; + runEnableWhen(payload, state); + + return state; +} function processNewValueAction(payload: NewValuePayload, state: Form): Form { const responseItem = getResponseItemWithPath(payload.itemPath || [], state.FormData); diff --git a/src/util/FhirPathExtensions.ts b/src/util/FhirPathExtensions.ts new file mode 100644 index 00000000..ab165f74 --- /dev/null +++ b/src/util/FhirPathExtensions.ts @@ -0,0 +1,258 @@ +import { + Questionnaire, + QuestionnaireItem, + QuestionnaireResponse, + QuestionnaireResponseItemAnswer, + QuestionnaireResponseItem, + Extension, + Coding, +} from 'fhir/r4'; + +import { getCalculatedExpressionExtension, getCopyExtension } from './extension'; +import { evaluateFhirpathExpressionToGetString } from './fhirpathHelper'; +import itemType from '../constants/itemType'; +import { Extensions } from '@/constants/extensions'; +import { createDummySectionScoreItem } from './scoring'; +import { getItemControlValue } from './choice'; +import ItemControlConstants from '@/constants/itemcontrol'; + +export interface AnswerPad { + [linkId: string]: number | undefined | string | Coding | boolean | Coding[]; +} +export enum FhirPathItemType { + QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', + QUESTION_FHIRPATH_COPY = 'QUESTION_FHIRPATH_COPY', + NONE = 'NONE', +} + +export function fhirPathItemType(item: QuestionnaireItem): FhirPathItemType { + if (item.extension) { + for (const extension of item.extension) { + if (extension.url === Extensions.COPY_EXPRESSION_URL) { + return FhirPathItemType.QUESTION_FHIRPATH_COPY; + } + if (extension.url === Extensions.CALCULATED_EXPRESSION_URL) { + return FhirPathItemType.QUESTION_FHIRPATH_SCORE; + } + } + } + + return FhirPathItemType.NONE; +} +export class FhirPathExtensions { + private questionnaire: Questionnaire; + private fhirScoreCache: Map = new Map(); + + constructor(questionnaire: Questionnaire) { + this.questionnaire = questionnaire; + this.initializeCaches(questionnaire); + } + + private initializeCaches(questionnaire: Questionnaire): void { + this.traverseQuestionnaire(questionnaire); + } + + private traverseQuestionnaire(qItem: Questionnaire | QuestionnaireItem, level: number = 0): void { + if (qItem.item) { + for (const subItem of qItem.item) { + this.traverseQuestionnaire(subItem, level + 1); + } + } + + if (level === 0) { + const itm = createDummySectionScoreItem(); + this.traverseQuestionnaire(itm, level + 1); + } + + return this.processItem(qItem); + } + + private processItem(qItem: Questionnaire | QuestionnaireItem): void { + if (!this.isOfTypeQuestionnaireItem(qItem)) { + return; + } + + const type = fhirPathItemType(qItem); + + switch (type) { + case FhirPathItemType.QUESTION_FHIRPATH_COPY: + case FhirPathItemType.QUESTION_FHIRPATH_SCORE: + this.fhirScoreCache.set(qItem.linkId, qItem); + break; + default: + break; + } + } + + private isOfTypeQuestionnaireItem(item: Questionnaire | QuestionnaireItem): item is QuestionnaireItem { + return 'type' in item; + } + public evaluateAllExpressions(questionnaireResponse: QuestionnaireResponse): QuestionnaireResponse { + return this.evaluateCalculatedExpressions(questionnaireResponse); + } + + public calculateFhirScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { + const answerPad: AnswerPad = {}; + for (const [key, value] of this.fhirScoreCache) { + answerPad[key] = this.valueOfQuestionFhirpathItem(value, questionnaireResponse); + } + return answerPad; + } + + private valueOfQuestionFhirpathItem( + item: QuestionnaireItem, + questionnaireResponse: QuestionnaireResponse + ): number | undefined | string | Coding | boolean | Coding[] { + const expressionExtension = getCalculatedExpressionExtension(item) || getCopyExtension(item); + if (!expressionExtension) return undefined; + + const result = evaluateFhirpathExpressionToGetString(expressionExtension, questionnaireResponse); + if (!result.length) return undefined; + + if (item.type === itemType.INTEGER) { + return isNaN(result[0]) || !isFinite(result[0]) ? undefined : Math.round(result[0]); + } + if (item.type === itemType.CHOICE || item.type === itemType.OPENCHOICE) { + if (this.isCheckbox(item)) { + return result; + } + } + return result[0]; + } + + public hasFhirPaths(): boolean { + const hasScoringInItem = (item: QuestionnaireItem): boolean => { + if (fhirPathItemType(item) !== FhirPathItemType.NONE) { + return true; + } + if (item.item && item.item.length > 0) { + return item.item.some(nestedItem => hasScoringInItem(nestedItem)); + } + return false; + }; + + return this.questionnaire?.item?.some(item => hasScoringInItem(item)) ?? false; + } + + private evaluateCalculatedExpressions(questionnaireResponse: QuestionnaireResponse): QuestionnaireResponse { + // Function to evaluate an expression and return a new answer + const evaluateExpression = ( + qItem: QuestionnaireItem, + expressionExtension: Extension, + response: QuestionnaireResponse + ): QuestionnaireResponseItemAnswer | null => { + if (expressionExtension && expressionExtension.valueString) { + const result = evaluateFhirpathExpressionToGetString(expressionExtension, response); + if (result.length > 0) { + const calculatedValue = result[0]; + + let newAnswer: QuestionnaireResponseItemAnswer = {}; + switch (qItem.type) { + case itemType.BOOLEAN: + newAnswer = { valueBoolean: Boolean(calculatedValue) }; + break; + case itemType.DECIMAL: + newAnswer = { valueDecimal: Number(calculatedValue) }; + break; + case itemType.INTEGER: + newAnswer = { valueInteger: Number(calculatedValue) }; + break; + case itemType.QUANTITY: + newAnswer = { valueQuantity: calculatedValue }; + break; + case itemType.DATE: + newAnswer = { valueDate: String(calculatedValue) }; + break; + case itemType.DATETIME: + newAnswer = { valueDateTime: String(calculatedValue) }; + break; + case itemType.TIME: + newAnswer = { valueTime: String(calculatedValue) }; + break; + case itemType.STRING: + case itemType.TEXT: + newAnswer = { valueString: String(calculatedValue) }; + break; + case itemType.CHOICE: + case itemType.OPENCHOICE: + newAnswer = { valueCoding: calculatedValue }; + break; + case itemType.ATTATCHMENT: + newAnswer = { valueAttachment: calculatedValue }; + break; + default: + break; + } + return newAnswer; + } + } + return null; + }; + + const traverseItems = ( + qItems: QuestionnaireItem[], + qrItems: QuestionnaireResponseItem[], + response: QuestionnaireResponse + ): QuestionnaireResponseItem[] => { + const newQrItems: QuestionnaireResponseItem[] = []; + + for (const qItem of qItems) { + // Find all qrItems with matching linkId + const matchingQrItems = qrItems.filter(qrItem => qrItem.linkId === qItem.linkId); + + if (matchingQrItems.length === 0) { + // Handle case where there's no matching qrItem + // Optional: Create a new qrItem if needed + } else { + const updatedQrItems = matchingQrItems.map(qrItem => { + let newQrItem: QuestionnaireResponseItem = { ...qrItem }; + + const calculatedExpression = getCalculatedExpressionExtension(qItem); + const copyExtension = getCopyExtension(qItem); + + let newAnswer: QuestionnaireResponseItemAnswer | null = null; + + if (calculatedExpression && !copyExtension) { + newAnswer = evaluateExpression(qItem, calculatedExpression, response); + } + + if (copyExtension) { + newAnswer = evaluateExpression(qItem, copyExtension, response); + } + + if (newAnswer) { + newQrItem = { + ...newQrItem, + answer: [newAnswer], + }; + } + + if (qItem.item && qrItem.item) { + newQrItem = { + ...newQrItem, + item: traverseItems(qItem.item, qrItem.item, response), + }; + } + + return newQrItem; + }); + + newQrItems.push(...updatedQrItems); + } + } + + return newQrItems; + }; + + const newQuestionnaireResponse: QuestionnaireResponse = { + ...questionnaireResponse, + item: questionnaireResponse.item + ? traverseItems(this.questionnaire.item || [], questionnaireResponse.item, questionnaireResponse) + : undefined, + }; + return newQuestionnaireResponse; + } + private isCheckbox(item: QuestionnaireItem): boolean { + return getItemControlValue(item) === ItemControlConstants.CHECKBOX; + } +} diff --git a/src/util/actionRequester.ts b/src/util/actionRequester.ts index 29a17209..40070995 100644 --- a/src/util/actionRequester.ts +++ b/src/util/actionRequester.ts @@ -1,4 +1,4 @@ -import { Questionnaire, QuestionnaireResponse, QuestionnaireItem, Coding, Quantity } from 'fhir/r4'; +import { Questionnaire, QuestionnaireResponse, QuestionnaireItem, Coding, Quantity, QuestionnaireResponseItemAnswer } from 'fhir/r4'; import { getItemControlValue } from './choice'; import { getResponseItemAndPathWithLinkId, getQuestionnaireDefinitionItem, Path } from './refero-core'; @@ -16,6 +16,7 @@ import { removeCodingValueAction, removeCodingStringValueAction, NewValuePayload, + newAnswerValueAction, } from '@/actions/newValue'; import itemControlConstants from '@/constants/itemcontrol'; import { PayloadAction } from '@reduxjs/toolkit'; @@ -45,7 +46,7 @@ export interface IActionRequester { removeOpenChoiceAnswer(linkId: string, value: Coding | string, index?: number): void; } -class ItemAndPath { +class ItemAndPathInt { public item: QuestionnaireItem; public path: Path[]; @@ -76,7 +77,7 @@ export class ActionRequester implements IActionRequester { this.addIntegerAnswer(linkId, Number.NaN, index); } - public addDecimalAnswer(linkId: string, value: number, index: number = 0): void { + public addDecimalAnswer(linkId: string, value?: number, index: number = 0): void { const itemAndPath = this.getItemAndPath(linkId, index); if (itemAndPath) { this.actions.push(newDecimalValueAction({ itemPath: itemAndPath.path, valueDecimal: value, item: itemAndPath.item })); @@ -87,6 +88,19 @@ export class ActionRequester implements IActionRequester { this.addDecimalAnswer(linkId, Number.NaN, index); } + public setNewAnswer(linkId: string, value: QuestionnaireResponseItemAnswer[], index: number = 0): void { + const itemAndPath = this.getItemAndPath(linkId, index); + if (itemAndPath) { + this.actions.push( + newAnswerValueAction({ + itemPath: itemAndPath.path, + newAnswer: value, + item: itemAndPath.item, + }) + ); + } + } + public addChoiceAnswer(linkId: string, value: Coding, index: number = 0): void { const itemAndPath = this.getItemAndPath(linkId, index); if (itemAndPath) { @@ -207,7 +221,7 @@ export class ActionRequester implements IActionRequester { return this.actions; } - private getItemAndPath(linkId: string, index: number): ItemAndPath | undefined { + private getItemAndPath(linkId: string, index: number): ItemAndPathInt | undefined { const item = getQuestionnaireDefinitionItem(linkId, this.questionnaire.item); const itemsAndPaths = getResponseItemAndPathWithLinkId(linkId, this.questionnaireResponse); @@ -215,10 +229,13 @@ export class ActionRequester implements IActionRequester { return; } - return new ItemAndPath(item, itemsAndPaths[index].path); + return new ItemAndPathInt(item, itemsAndPaths[index].path); } - private isCheckbox(item: QuestionnaireItem): boolean { + public isCheckbox(item: QuestionnaireItem): boolean { return getItemControlValue(item) === itemControlConstants.CHECKBOX; } + public addManyActions(actions: PayloadAction[]): void { + this.actions.push(...actions); + } } diff --git a/src/util/fhirpathHelper.ts b/src/util/fhirpathHelper.ts index 5efedf92..c3331c4d 100644 --- a/src/util/fhirpathHelper.ts +++ b/src/util/fhirpathHelper.ts @@ -23,8 +23,8 @@ export async function getAnswerFromResponseItem(responseItem?: QuestionnaireResp } } -export async function getResonseItem(linkId: string, responseItem: QuestionnaireResponseItem): Promise { - if (!linkId || !responseItem) { +export async function getResonseItem(linkId: string, response: QuestionnaireResponse): Promise { + if (!linkId || !response) { return undefined; } try { @@ -32,7 +32,7 @@ export async function getResonseItem(linkId: string, responseItem: Questionnaire `item.descendants().where(linkId='${linkId}') | answer.item.descendants().where(linkId='${linkId}')`, fhirpath_r4_model ); - return compiledExpression(responseItem); + return compiledExpression(response); } catch (e) { console.log(e); return undefined; @@ -81,7 +81,15 @@ export function evaluateFhirpathExpressionToGetString(fhirExtension: Extension, return []; } } - +export async function evaluateFhirpathExpression(expression: string, context: any): Promise { + try { + const compiledExpression = fhirpath.compile(expression, fhirpath_r4_model); + return compiledExpression(context); + } catch (error) { + console.error(`Error evaluating FHIRPath expression "${expression}":`, error); + return []; + } +} export function evaluateExtension(path: string | fhirpath.Path, questionnare?: QuestionnaireResponse | null, context?: Context): unknown { const qCopy = structuredClone(questionnare); /** diff --git a/src/util/scoring.ts b/src/util/scoring.ts index ed454807..bbfd6f8c 100644 --- a/src/util/scoring.ts +++ b/src/util/scoring.ts @@ -1,7 +1,6 @@ import { QuestionnaireItem, Coding } from 'fhir/r4'; import * as uuid from 'uuid'; -import { getCalculatedExpressionExtension } from './extension'; import { Extensions } from '../constants/extensions'; import ItemType from '../constants/itemType'; import { SCORING, SCORING_CODE, SCORING_FORMULAS, ScoringTypes, Type } from '../constants/scoring'; @@ -44,9 +43,6 @@ export function scoringItemType(item: QuestionnaireItem): ScoringItemType { default: return ScoringItemType.NONE; } - } else if (item.extension) { - const calculatedExpressionExtension = getCalculatedExpressionExtension(item); - return calculatedExpressionExtension ? ScoringItemType.QUESTION_FHIRPATH_SCORE : ScoringItemType.NONE; } return ScoringItemType.NONE; diff --git a/src/util/scoringCalculator.ts b/src/util/scoringCalculator.ts index 9dd0d1c1..00fe2679 100644 --- a/src/util/scoringCalculator.ts +++ b/src/util/scoringCalculator.ts @@ -6,12 +6,10 @@ import { QuestionnaireItemAnswerOption, } from 'fhir/r4'; -import { getExtension, getCalculatedExpressionExtension } from './extension'; -import { evaluateFhirpathExpressionToGetString } from './fhirpathHelper'; +import { getExtension } from './extension'; import { getQuestionnaireResponseItemsWithLinkId } from './refero-core'; import { createDummySectionScoreItem, scoringItemType } from './scoring'; import { Extensions } from '../constants/extensions'; -import itemType from '../constants/itemType'; import { ScoringItemType } from '../constants/scoringItemType'; export interface AnswerPad { @@ -47,7 +45,6 @@ export class ScoringCalculator { private totalScoreCache: Map = new Map(); private totalScoreItem: QuestionnaireItem | undefined; private itemCache: Map = new Map(); - private fhirScoreCache: Map = new Map(); private isScoringQuestionnaire: boolean = false; constructor(questionnaire: Questionnaire) { @@ -123,9 +120,6 @@ export class ScoringCalculator { case ScoringItemType.QUESTION_SCORE: newScores.questionScores.push(qItem, ...calculatedScores.questionScores); break; - case ScoringItemType.QUESTION_FHIRPATH_SCORE: - this.fhirScoreCache.set(qItem.linkId, qItem); - break; default: newScores.questionScores.push(...calculatedScores.questionScores); break; @@ -137,18 +131,6 @@ export class ScoringCalculator { private isOfTypeQuestionnaireItem(item: Questionnaire | QuestionnaireItem): item is QuestionnaireItem { return 'type' in item; } - - public calculateScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { - const answerPad: AnswerPad = {}; - - const sectionScoresCalculated = this.calculateAllSectionScores(answerPad, questionnaireResponse); - const allScoresCalculated = this.calculateAllTotalScores(sectionScoresCalculated, questionnaireResponse); - - delete allScoresCalculated[this.totalScoreItem!.linkId]; - - return allScoresCalculated; - } - private calculateAllSectionScores(answerPad: AnswerPad, questionnaireResponse: QuestionnaireResponse): AnswerPad { const tempAnswerPad: AnswerPad = answerPad; const keys = this.sectionScoreCache.keys(); @@ -170,14 +152,15 @@ export class ScoringCalculator { return tempAnswerPad; } - public calculateFhirScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { + public calculateScore(questionnaireResponse: QuestionnaireResponse): AnswerPad { const answerPad: AnswerPad = {}; - for (const [key, value] of this.fhirScoreCache) { - answerPad[key] = this.valueOfQuestionFhirpathScoreItem(value, questionnaireResponse); - } + const sectionScoresCalculated = this.calculateAllSectionScores(answerPad, questionnaireResponse); + const allScoresCalculated = this.calculateAllTotalScores(sectionScoresCalculated, questionnaireResponse); + + delete allScoresCalculated[this.totalScoreItem!.linkId]; - return answerPad; + return allScoresCalculated; } private calculateSectionScore(linkId: string, questionnaireResponse: QuestionnaireResponse, answerPad: AnswerPad): number | undefined { @@ -207,19 +190,6 @@ export class ScoringCalculator { } } - private valueOfQuestionFhirpathScoreItem(item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse): number | undefined { - const expressionExtension = getCalculatedExpressionExtension(item); - if (!expressionExtension) return undefined; - - const result = evaluateFhirpathExpressionToGetString(expressionExtension, questionnaireResponse); - if (!result.length) return undefined; - - let value = (result[0] as number) ?? 0; - value = item.type === itemType.INTEGER ? Math.round(value) : value; - - return isNaN(value) || !isFinite(value) ? undefined : value; - } - private valueOfQuestionScoreItem(item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse): number | undefined { let sum = 0; let hasCalculatedAtLeastOneAnswer = false; From 91e9348c47d5e5468b1fecab9675e40736cacf61 Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 15:09:44 +0100 Subject: [PATCH 5/8] comments --- src/hooks/useFhirPathQrUpdater.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx index 5349b540..86382615 100644 --- a/src/hooks/useFhirPathQrUpdater.tsx +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -35,11 +35,14 @@ export const useFhirPathQrUpdater = (): { // Evaluate all expressions and get the updated response const updatedResponse = fhirPathUpdater.evaluateAllExpressions(questionnaireResponse); + //TODO: Figure out a way to not run this on all changes // if (JSON.stringify(updatedResponse) === JSON.stringify(questionnaireResponse)) { // return; // } // Calculate FHIR scores using the same updated response + const fhirScores = fhirPathUpdater.calculateFhirScore(updatedResponse); + updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); }; From 2d880fb33de458f73a272e45d52b670d8b17873d Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Wed, 4 Dec 2024 22:04:01 +0100 Subject: [PATCH 6/8] update quantity --- src/components/__tests__/copy-from-spec.tsx | 3 +-- src/hooks/useFhirPathQrUpdater.tsx | 25 +++++++++++++-------- src/util/FhirPathExtensions.ts | 5 +++-- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/components/__tests__/copy-from-spec.tsx b/src/components/__tests__/copy-from-spec.tsx index 4cc3106d..c68f02f2 100644 --- a/src/components/__tests__/copy-from-spec.tsx +++ b/src/components/__tests__/copy-from-spec.tsx @@ -237,11 +237,10 @@ describe('Copy value from item', () => { }); }); describe('should copy OPEN-CHOICE value', () => { - it.only('should copy CHECKBOX value', async () => { + it('should copy CHECKBOX value', async () => { const sender = createSenderChoiceItem(ItemType.OPENCHOICE, createItemControlExtension(ItemControlConstants.CHECKBOX)); const reciever = createReciverChoiceItem(ItemType.OPENCHOICE, ItemControlConstants.CHECKBOX); const q = createQuestionnaire(sender, reciever); - console.log(JSON.stringify(q, null, 2)); const { getByLabelText, queryByTestId, getByTestId, findByTestId } = createWrapper(q); expect(queryByTestId(/item_2/i)).not.toBeInTheDocument(); expect(getByLabelText(/Mann/i)).toBeInTheDocument(); diff --git a/src/hooks/useFhirPathQrUpdater.tsx b/src/hooks/useFhirPathQrUpdater.tsx index 86382615..e8099cb4 100644 --- a/src/hooks/useFhirPathQrUpdater.tsx +++ b/src/hooks/useFhirPathQrUpdater.tsx @@ -6,7 +6,7 @@ import { ActionRequester } from '@/util/actionRequester'; import { getQuestionnaireUnitExtensionValue } from '@/util/extension'; import { AnswerPad, FhirPathExtensions } from '@/util/FhirPathExtensions'; import { getQuestionnaireDefinitionItem, getResponseItemAndPathWithLinkId } from '@/util/refero-core'; -import { Coding, Quantity, Questionnaire, QuestionnaireResponse } from 'fhir/r4'; +import { Coding, Quantity, Questionnaire, QuestionnaireItem, QuestionnaireResponse } from 'fhir/r4'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; @@ -45,7 +45,14 @@ export const useFhirPathQrUpdater = (): { updateQuestionnaireResponseWithScore(fhirScores, questionnaire, updatedResponse, actionRequester); }; - + const createQuantity = (item: QuestionnaireItem, extension: Coding, value: number): Quantity => { + return { + unit: extension.display, + system: extension.system, + code: extension.code, + value: getDecimalValue(item, value), + }; + }; const updateQuestionnaireResponseWithScore = ( scores: AnswerPad, questionnaire: Questionnaire, @@ -62,14 +69,14 @@ export const useFhirPathQrUpdater = (): { const extension = getQuestionnaireUnitExtensionValue(item); if (!extension) continue; - const quantity: Quantity = { - unit: extension.display, - system: extension.system, - code: extension.code, - value: getDecimalValue(item, value as number), - }; for (const itemAndPath of itemsAndPaths) { - actionRequester.addQuantityAnswer(linkId, quantity as Quantity, itemAndPath.path[0]?.index); + actionRequester.addQuantityAnswer( + linkId, + typeof value === 'string' || typeof value === 'number' + ? createQuantity(item, extension, value as number) + : (value as Quantity), + itemAndPath.path[0]?.index + ); } break; } diff --git a/src/util/FhirPathExtensions.ts b/src/util/FhirPathExtensions.ts index ab165f74..57c45d52 100644 --- a/src/util/FhirPathExtensions.ts +++ b/src/util/FhirPathExtensions.ts @@ -6,6 +6,7 @@ import { QuestionnaireResponseItem, Extension, Coding, + Quantity, } from 'fhir/r4'; import { getCalculatedExpressionExtension, getCopyExtension } from './extension'; @@ -17,7 +18,7 @@ import { getItemControlValue } from './choice'; import ItemControlConstants from '@/constants/itemcontrol'; export interface AnswerPad { - [linkId: string]: number | undefined | string | Coding | boolean | Coding[]; + [linkId: string]: number | undefined | string | Coding | boolean | Coding[] | Quantity; } export enum FhirPathItemType { QUESTION_FHIRPATH_SCORE = 'QUESTION_FHIRPATH_SCORE', @@ -102,7 +103,7 @@ export class FhirPathExtensions { private valueOfQuestionFhirpathItem( item: QuestionnaireItem, questionnaireResponse: QuestionnaireResponse - ): number | undefined | string | Coding | boolean | Coding[] { + ): number | undefined | string | Coding | boolean | Coding[] | Quantity { const expressionExtension = getCalculatedExpressionExtension(item) || getCopyExtension(item); if (!expressionExtension) return undefined; From 5b812292f10a961fdadb664049b719e1139577a8 Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Tue, 7 Jan 2025 10:25:53 +0100 Subject: [PATCH 7/8] fix schema --- preview/skjema/q.json | 1 - 1 file changed, 1 deletion(-) diff --git a/preview/skjema/q.json b/preview/skjema/q.json index 10642289..d380e01f 100644 --- a/preview/skjema/q.json +++ b/preview/skjema/q.json @@ -100,7 +100,6 @@ } } ], - "id": "a93bf3e9-d09c-4625-8b92-7c2b80e78ab2", "item": [ { "linkId": "3c1d0ce2-0092-47f5-80d9-49ee4c408e9a", From 70976a9dc45940db4e7d3798189b73e5b0a3de39 Mon Sep 17 00:00:00 2001 From: Reuben Ringdal Date: Tue, 7 Jan 2025 10:36:22 +0100 Subject: [PATCH 8/8] remove comment --- src/actions/newValue.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/actions/newValue.ts b/src/actions/newValue.ts index d0335d8b..47fba218 100644 --- a/src/actions/newValue.ts +++ b/src/actions/newValue.ts @@ -115,15 +115,6 @@ export function newBooleanValueAsync(itemPath: Array, value: boolean, item }; } export const newAnswerValueAction = createAction(NEW_ANSWER_VALUE); -// /* -// * @deprecated this will be removed in a future version, use newCodingValueAction instead -// */ -// export const newCodingsValue = ( -// itemPath: CodingValueItemPayload['itemPath'], -// value: CodingValueItemPayload['valueCoding'], -// item: CodingValueItemPayload['item'], -// multipleAnswers?: CodingValueItemPayload['multipleAnswers'] -// ): PayloadAction => newCodingsValueAction({ itemPath, valueCodings: value, item, multipleAnswers }); export const newCodingValueAction = createAction(NEW_VALUE); /*