From df3c4732a61a7f77d2cc8039e43770555b015ffa Mon Sep 17 00:00:00 2001 From: CynthiaKamau Date: Wed, 3 Jul 2024 23:31:30 +0300 Subject: [PATCH] (fix) O3-2252: Calculated values shouldn't be overwritten by the Encounter's values (#318) --- .../use-initial-values/encounter.mock.json | 68 ++++ src/hooks/useInitialValues.test.ts | 305 +++++++++++------- src/hooks/useInitialValues.ts | 40 ++- 3 files changed, 288 insertions(+), 125 deletions(-) diff --git a/__mocks__/use-initial-values/encounter.mock.json b/__mocks__/use-initial-values/encounter.mock.json index 4a305fdbe..1e7b71fe6 100644 --- a/__mocks__/use-initial-values/encounter.mock.json +++ b/__mocks__/use-initial-values/encounter.mock.json @@ -798,6 +798,74 @@ } ], "resourceVersion": "2.1" + }, + { + "uuid": "30f1206c-4354-4deb-9cfa-bcb18e934144", + "obsDatetime": "2024-06-11T11:43:33.000+0000", + "comment": null, + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-height", + "concept": { + "uuid": "5090AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": { + "uuid": "d17c467f-e5c1-30dc-b916-e7d5f9a8935a", + "name": "Height (cm)" + } + }, + "value": 176.0 + }, + { + "uuid": "037aaaca-14f1-460c-bc59-579817569794", + "obsDatetime": "2024-06-11T11:43:33.000+0000", + "comment": null, + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-weight", + "concept": { + "uuid": "5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": { + "uuid": "c1015fd1-729b-33c7-82e8-0b14a5a824ed", + "name": "Weight (kg)" + } + }, + "value": 56.0 + }, + { + "uuid": "5c281bdc-3798-4d18-b780-34e177e2fdb4", + "obsDatetime": "2024-06-11T11:43:33.000+0000", + "comment": null, + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-bmi", + "concept": { + "uuid": "1342AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": { + "uuid": "1431BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "name": "Body mass index" + } + }, + "value": 2 + }, + { + "uuid": "50729006-9b87-4bfb-80c0-4c53379b49a7", + "obsDatetime": "2024-06-26T14:51:42.000+0000", + "comment": null, + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-bodyWeight", + "concept": { + "uuid": "159428AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": { + "uuid": "106514BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", + "name": "Weight percentile" + } + }, + "valueNumeric": 2 } ], "orders": [ diff --git a/src/hooks/useInitialValues.test.ts b/src/hooks/useInitialValues.test.ts index 521d194dc..10955aab6 100644 --- a/src/hooks/useInitialValues.test.ts +++ b/src/hooks/useInitialValues.test.ts @@ -5,6 +5,7 @@ import testEncounter from '__mocks__/use-initial-values/encounter.mock.json'; import testPatient from '__mocks__/use-initial-values/patient.mock.json'; import { ObsSubmissionHandler } from '../submission-handlers/obsHandler'; import { TestOrderSubmissionHandler } from '../submission-handlers/testOrderHandler'; +import { CommonExpressionHelpers } from 'src/utils/common-expression-helpers'; const obsGroupMembers: Array = [ { @@ -148,38 +149,42 @@ jest.mock('../utils/expression-runner', () => { }; }); -describe('useInitialValues', () => { - const encounterDate = new Date(); +const encounterDate = new Date(); + +const renderUseInitialValuesHook = async (encounter, formFields) => { + let hook = null; + + await act(async () => { + hook = renderHook(() => + useInitialValues( + [...formFields], + encounter, + false, + { + encounter, + patient: testPatient, + location, + sessionMode: 'enter', + encounterDate: encounterDate, + setEncounterDate: jest.fn, + encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', + setEncounterProvider: jest.fn, + setEncounterLocation: jest.fn, + encounterRole: '', + setEncounterRole: jest.fn, + }, + formFieldHandlers, + ), + ); + }); + return hook.result.current; +}; + +describe('useInitialValues', () => { it('should return empty meaningful defaults in "enter" mode', async () => { - let hook = null; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(null, allFormFields); - await act(async () => { - hook = renderHook(() => - useInitialValues( - [...allFormFields], - null, - false, - { - encounter: null, - patient: testPatient, - location, - sessionMode: 'enter', - encounterDate: encounterDate, - setEncounterDate: jest.fn, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '', - setEncounterRole: jest.fn - }, - formFieldHandlers, - ), - ); - }); - const { - current: { initialValues, isBindingComplete }, - } = hook.result; expect(isBindingComplete).toBe(true); expect(initialValues).toEqual({ number_of_babies: '', @@ -191,34 +196,8 @@ describe('useInitialValues', () => { }); it('should return existing encounter values in "edit" mode', async () => { - let hook = null; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(encounter, allFormFields); - await act(async () => { - hook = renderHook(() => - useInitialValues( - [...allFormFields], - encounter, - false, - { - encounter: encounter, - patient: testPatient, - location, - sessionMode: 'enter', - encounterDate: encounterDate, - setEncounterDate: jest.fn, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '', - setEncounterRole: jest.fn - }, - formFieldHandlers, - ), - ); - }); - const { - current: { initialValues, isBindingComplete }, - } = hook.result; expect(isBindingComplete).toBe(true); const initialValuesWithFormatedDateValues = { ...initialValues, @@ -241,7 +220,6 @@ describe('useInitialValues', () => { }); it('should verify that the "isBindingComplete" flag is set to true only when the resolution of calculated values is completed', async () => { - let hook = null; const fieldWithCalculateExpression: FormField = { label: 'Latest mother HIV status', type: 'obs', @@ -255,76 +233,19 @@ describe('useInitialValues', () => { }, id: 'latest_mother_hiv_status', }; - allFormFields.push(fieldWithCalculateExpression); - await act(async () => { - hook = renderHook(() => - useInitialValues( - [...allFormFields], - null, - false, - { - encounter: undefined, - patient: testPatient, - location, - sessionMode: 'enter', - encounterDate: encounterDate, - setEncounterDate: jest.fn, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '', - setEncounterRole: jest.fn - }, - formFieldHandlers, - ), - ); - }); - const { - current: { initialValues, isBindingComplete }, - } = hook.result; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(undefined, [ + fieldWithCalculateExpression, + ]); expect(isBindingComplete).toBe(true); expect(initialValues).toEqual({ - number_of_babies: '', - notes: '', - screening_methods: [], - date_of_birth: '', - infant_name: '', latest_mother_hiv_status: '664AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', }); }); it('should hydrate test orders', async () => { - let hook = null; - - await act(async () => { - hook = renderHook(() => - useInitialValues( - [testOrder], - encounter, - false, - { - encounter: encounter, - patient: testPatient, - location, - sessionMode: 'enter', - encounterDate: encounterDate, - setEncounterDate: jest.fn, - encounterProvider: '2c95f6f5-788e-4e73-9079-5626911231fa', - setEncounterProvider: jest.fn, - setEncounterLocation: jest.fn, - encounterRole: '8cb3a399-d18b-4b62-aefb-5a0f948a3809', - setEncounterRole: jest.fn - }, - formFieldHandlers, - ), - ); - }); - const { - current: { initialValues, isBindingComplete }, - } = hook.result; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(encounter, [testOrder]); expect(isBindingComplete).toBe(true); - expect(initialValues).toEqual({ testOrder: '30e2da8f-34ca-4c93-94c8-d429f22d381c', testOrder_1: '87b3f6a1-6d79-4923-9485-200dfd937782', @@ -333,4 +254,152 @@ describe('useInitialValues', () => { expect(allFormFields.find((field) => field.id === 'testOrder_1')).not.toBeNull(); expect(allFormFields.find((field) => field.id === 'testOrder_2')).not.toBeNull(); }); + + it('should return synchronous calculated values for calculated fields in "edit" mode', async () => { + let formFields: Array = [ + { + label: 'Height (cm)', + type: 'obs', + required: false, + id: 'height', + questionOptions: { + rendering: 'number', + concept: '5090AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + }, + { + label: 'Weight (Kgs)', + type: 'obs', + required: false, + id: 'weight', + questionOptions: { + rendering: 'number', + concept: '5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + validators: [], + }, + { + label: 'BMI:Kg/M2 (Function calcBMI | useFieldValue)', + type: 'obs', + required: false, + id: 'bmi', + questionOptions: { + rendering: 'number', + defaultValue: 0, + concept: '1342AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + isTransient: true, + disallowDecimals: false, + calculate: { + calculateExpression: 'calcBMI(height,weight)', + }, + }, + validators: [], + questionInfo: 'this calculates BMI using calcBMI function and useFieldValue of weight and height', + }, + ]; + const allFields = JSON.parse(JSON.stringify(formFields)); + const allFieldsKeys = allFields.map((f) => f.id); + let valuesMap = { + height: '', + wight: '', + bmi: '', + }; + + const helper = new CommonExpressionHelpers( + { value: allFields[1], type: 'field' }, + {}, + allFields, + valuesMap, + allFieldsKeys, + ); + + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(encounter, formFields); + expect(isBindingComplete).toBe(true); + + const heightVal = initialValues['height']; + const weightVal = initialValues['weight']; + + const calculatedBmi = helper.calcBMI(heightVal, weightVal); + + expect(initialValues['height']).toBe(176); + expect(initialValues['weight']).toBe(56); + expect(initialValues['bmi']).toBe(calculatedBmi); + }); + + it('should return asynchronous calculated values for calculated fields in "edit" mode', async () => { + const fieldWithCalculateExpression: FormField = { + label: 'Latest mother HIV status', + type: 'obs', + questionOptions: { + rendering: 'fixed-value', + concept: 'af7c1fe6-d669-414e-b066-e9733f0de7a8', + calculate: { + calculateExpression: + "resolve(api.getLatestObs(patient.id, '159427AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', '2549af50-75c8-4aeb-87ca-4bb2cef6c69a'))?.valueCodeableConcept?.coding[0]?.code", + }, + }, + id: 'latest_mother_hiv_status', + }; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(encounter, [ + fieldWithCalculateExpression, + ]); + + expect(isBindingComplete).toBe(true); + expect(initialValues['latest_mother_hiv_status']).toBe('664AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); + }); + + it('should fall back to encounter value if the calculated expression result is null or undefined', async () => { + let formFields: Array = [ + { + label: 'Height (cm)', + type: 'obs', + required: false, + id: 'height', + questionOptions: { + rendering: 'number', + concept: '5090AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + }, + { + label: 'Weight (Kgs)', + type: 'obs', + required: false, + id: 'weight', + questionOptions: { + rendering: 'number', + concept: '5089AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + validators: [], + }, + { + label: 'BMI:Kg/M2 (Function calcBMI | useFieldValue)', + type: 'obs', + required: false, + id: 'bmi', + questionOptions: { + rendering: 'number', + defaultValue: 0, + concept: '1342AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + isTransient: true, + disallowDecimals: false, + calculate: { + calculateExpression: 'whoops()', + }, + }, + validators: [], + questionInfo: 'this calculates BMI using calcBMI function and useFieldValue of weight and height', + }, + ]; + const { initialValues, isBindingComplete } = await renderUseInitialValuesHook(encounter, formFields); + + expect(isBindingComplete).toBe(true); + + expect(initialValues['height']).toBe(176); + expect(initialValues['weight']).toBe(56); + expect(initialValues['bmi']).toBe(2); + }); }); diff --git a/src/hooks/useInitialValues.ts b/src/hooks/useInitialValues.ts index 3154c54f3..afb3259a4 100644 --- a/src/hooks/useInitialValues.ts +++ b/src/hooks/useInitialValues.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { type EncounterContext, inferInitialValueFromDefaultFieldValue, isEmpty } from '..'; import { type FormField, type OpenmrsEncounter, type SubmissionHandler } from '../types'; -import { evaluateAsyncExpression } from '../utils/expression-runner'; +import { evaluateAsyncExpression, evaluateExpression, FormNode } from '../utils/expression-runner'; import { hydrateRepeatField } from '../components/repeat/helpers'; import { hasRendering } from '../utils/common-utils'; @@ -23,7 +23,7 @@ export function useInitialValues( 'encounterDatetime', 'encounterLocation', 'patientIdentifier', - 'encounterRole' + 'encounterRole', ]; useEffect(() => { @@ -35,13 +35,20 @@ export function useInitialValues( Promise.all(asyncItemsKeys.map((key) => asyncInitValues[key])).then((results) => { asyncItemsKeys.forEach((key, index) => { const result = isEmpty(results[index]) ? '' : results[index]; - initialValues[key] = result; const field = formFields.find((field) => field.id === key); + initialValues[key] = result; try { if (!isEmpty(result)) { formFieldHandlers[field.type].handleFieldSubmission(field, result, encounterContext); } } catch (error) { + const encounterValue = formFieldHandlers[field.type]?.getInitialValue( + encounter, + field, + formFields, + encounterContext, + ); + formFieldHandlers[field.type].handleFieldSubmission(field, encounterValue, encounterContext); console.error(error); } }); @@ -60,6 +67,8 @@ export function useInitialValues( toggle: false, default: '', }; + const tempAsyncValues = {}; + if (!Object.keys(formFieldHandlers).length || isLoadingContextDependencies) { return; } @@ -77,10 +86,29 @@ export function useInitialValues( formFields, encounterContext, ); + + if (field.questionOptions.calculate?.calculateExpression) { + const expression = field.questionOptions.calculate.calculateExpression; + const node: FormNode = { value: field, type: 'field' }; + const context = { + mode: encounterContext.sessionMode, + patient: encounterContext.patient, + }; + if (field.questionOptions.calculate.calculateExpression.includes('resolve(')) { + tempAsyncValues[field.id] = evaluateAsyncExpression(expression, node, formFields, initialValues, context); + } else { + const evaluatedValue = evaluateExpression(expression, node, formFields, initialValues, context); + existingVal = evaluatedValue ?? existingVal; + } + } if (field.type === 'obsGroup') { return; } - if (isEmpty(existingVal) && !isEmpty(field.questionOptions.defaultValue)) { + if ( + isEmpty(existingVal) && + !isEmpty(field.questionOptions.defaultValue) && + !field.questionOptions.calculate?.calculateExpression + ) { existingVal = inferInitialValueFromDefaultFieldValue( field, encounterContext, @@ -100,10 +128,8 @@ export function useInitialValues( ); formFields.push(...flattenedFields); setIsEncounterBindingComplete(true); - // TODO: Address behaviour in edit mode; see: https://issues.openmrs.org/browse/O3-2252 - setAsyncInitValues({}); + setAsyncInitValues({ ...(asyncInitValues ?? {}), ...tempAsyncValues }); } else { - const tempAsyncValues = {}; formFields .filter((field) => field.questionOptions.rendering !== 'group' && field.type !== 'obsGroup') .forEach((field) => {