From e9a87ae93a53016030f1577cdbd2b5f3e42dd66a Mon Sep 17 00:00:00 2001 From: Samuel Male Date: Tue, 25 Apr 2023 23:45:05 +0300 Subject: [PATCH] Simplify the ExpressionRunner's logic and enhance support for custom data sources (#47) --- .../encounter/ohri-encounter-form.tsx | 15 +- src/utils/common-expression-helpers.ts | 146 ++------------- src/utils/expression-parser.test.ts | 166 +++++++++++++++++- src/utils/expression-parser.ts | 112 ++++++++++++ src/utils/expression-runner.test.ts | 10 +- src/utils/expression-runner.ts | 84 +++------ 6 files changed, 331 insertions(+), 202 deletions(-) diff --git a/src/components/encounter/ohri-encounter-form.tsx b/src/components/encounter/ohri-encounter-form.tsx index 8b6d1a786..9184900f4 100644 --- a/src/components/encounter/ohri-encounter-form.tsx +++ b/src/components/encounter/ohri-encounter-form.tsx @@ -22,7 +22,7 @@ import OHRIFormPage from '../page/ohri-form-page'; import { InstantEffect } from '../../utils/instant-effect'; import { FormSubmissionHandler } from '../../ohri-form.component'; import { isTrue } from '../../utils/boolean-utils'; -import { evaluateExpression } from '../../utils/expression-runner'; +import { evaluateAsyncExpression, evaluateExpression } from '../../utils/expression-runner'; import { getPreviousEncounter, saveEncounter } from '../../api/api'; import { scrollIntoView } from '../../utils/ohri-sidebar'; import { useEncounter } from '../../hooks/useEncounter'; @@ -458,7 +458,7 @@ export const OHRIEncounterForm: React.FC = ({ const dependant = fields.find(f => f.id == dep); // evaluate calculated value if (!dependant.isHidden && dependant.questionOptions.calculate?.calculateExpression) { - let result = evaluateExpression( + evaluateAsyncExpression( dependant.questionOptions.calculate.calculateExpression, { value: dependant, type: 'field' }, fields, @@ -467,11 +467,12 @@ export const OHRIEncounterForm: React.FC = ({ mode: sessionMode, patient, }, - ); - result = isEmpty(result) ? '' : result; - values[dependant.id] = result; - setFieldValue(dependant.id, result); - getHandler(dependant.type).handleFieldSubmission(dependant, result, encounterContext); + ).then(result => { + result = isEmpty(result) ? '' : result; + values[dependant.id] = result; + setFieldValue(dependant.id, result); + getHandler(dependant.type).handleFieldSubmission(dependant, result, encounterContext); + }); } // evaluate hide if (dependant.hide) { diff --git a/src/utils/common-expression-helpers.ts b/src/utils/common-expression-helpers.ts index 172a9bc2c..0f1f7bbc0 100644 --- a/src/utils/common-expression-helpers.ts +++ b/src/utils/common-expression-helpers.ts @@ -1,4 +1,3 @@ -'use '; import moment from 'moment'; import { OHRIFormField } from '../api/types'; import { FormNode } from './expression-runner'; @@ -12,6 +11,7 @@ export class CommonExpressionHelpers { allFieldValues: Record = {}; allFieldsKeys: string[] = []; api = apiFunctions; + isEmpty = isValueEmpty; constructor( node: FormNode, @@ -27,30 +27,12 @@ export class CommonExpressionHelpers { this.patient = patient; } - isEmpty = value => { - if (this.allFieldsKeys.includes(value)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == value), - ); - return isValueEmpty(this.allFieldValues[value]); - } - return isValueEmpty(value); - }; - today() { return new Date(); } - includes = (questionId, value) => { - if (this.allFieldsKeys.includes(questionId)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id === questionId), - ); - return this.allFieldValues[questionId]?.includes(value); - } - return false; + includes = (collection: any[], value: any) => { + return collection?.includes(value); }; isDateBefore = (left: Date, right: string | Date, format?: string) => { @@ -105,17 +87,7 @@ export class CommonExpressionHelpers { return null; }; - calcBMI = (heightQuestionId, weightQuestionId) => { - const height = this.allFieldValues[heightQuestionId]; - const weight = this.allFieldValues[weightQuestionId]; - [heightQuestionId, weightQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcBMI = (height, weight) => { let r; if (height && weight) { r = (weight / (((height / 100) * height) / 100)).toFixed(1); @@ -128,16 +100,7 @@ export class CommonExpressionHelpers { * @param lmpQuestionId * @returns */ - calcEDD = lmpQuestionId => { - const lmp = this.allFieldValues[lmpQuestionId]; - [lmpQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcEDD = lmp => { let resultEdd = {}; if (lmp) { resultEdd = new Date(lmp.getTime() + 280 * 24 * 60 * 60 * 1000); @@ -145,35 +108,17 @@ export class CommonExpressionHelpers { return lmp ? resultEdd : null; }; - calcMonthsOnART = artStartDateQuestionId => { + calcMonthsOnART = artStartDate => { let today = new Date(); - const artStartDate = this.allFieldValues[artStartDateQuestionId] || today; - [artStartDateQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); let resultMonthsOnART; - let artInDays = Math.round((today.getTime() - artStartDate.getTime()) / 86400000); + let artInDays = Math.round((today.getTime() - artStartDate.getTime?.()) / 86400000); if (artStartDate && artInDays >= 30) { resultMonthsOnART = Math.floor(artInDays / 30); } return artStartDate ? resultMonthsOnART : null; }; - calcViralLoadStatus = viralLoadCountQuestionId => { - const viralLoadCount = this.allFieldValues[viralLoadCountQuestionId]; - [viralLoadCountQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcViralLoadStatus = viralLoadCount => { let resultViralLoadStatus; if (viralLoadCount) { if (viralLoadCount > 50) { @@ -185,17 +130,7 @@ export class CommonExpressionHelpers { return viralLoadCount ? resultViralLoadStatus : null; }; - calcNextVisitDate = (followupDateQuestionId, arvDispensedInDaysQuestionId) => { - const followupDate = this.allFieldValues[followupDateQuestionId]; - const arvDispensedInDays = this.allFieldValues[arvDispensedInDaysQuestionId]; - [followupDateQuestionId, arvDispensedInDaysQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcNextVisitDate = (followupDate, arvDispensedInDays) => { let resultNextVisitDate = {}; if (followupDate && arvDispensedInDays) { resultNextVisitDate = new Date(followupDate.getTime() + arvDispensedInDays * 24 * 60 * 60 * 1000); @@ -203,25 +138,7 @@ export class CommonExpressionHelpers { return followupDate && arvDispensedInDays ? resultNextVisitDate : null; }; - calcTreatmentEndDate = ( - followupDateQuestionId, - arvDispensedInDaysQuestionId, - patientStatusQuestionId, - treatmentEndDateQuestionId, - ) => { - const followupDate = this.allFieldValues[followupDateQuestionId]; - const arvDispensedInDays = this.allFieldValues[arvDispensedInDaysQuestionId]; - const patientStatus = this.allFieldValues[patientStatusQuestionId]; - [followupDateQuestionId, arvDispensedInDaysQuestionId, patientStatusQuestionId, treatmentEndDateQuestionId].forEach( - entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }, - ); + calcTreatmentEndDate = (followupDate, arvDispensedInDays, patientStatus) => { let resultTreatmentEndDate = {}; let extraDaysAdded = 30 + arvDispensedInDays; if (followupDate && arvDispensedInDays && patientStatus == '160429AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA') { @@ -232,19 +149,10 @@ export class CommonExpressionHelpers { : null; }; - calcAgeBasedOnDate = questionId => { - const value = this.allFieldValues[questionId]; - [questionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcAgeBasedOnDate = dateValue => { let targetYear = null; - if (value) { - targetYear = new Date(value).getFullYear(); + if (dateValue) { + targetYear = new Date(dateValue).getFullYear(); } else { targetYear = new Date().getFullYear(); } @@ -252,19 +160,9 @@ export class CommonExpressionHelpers { let calculatedYear = targetYear - birthDate; return calculatedYear; }; + //Ampath Helper Functions - calcBSA = (heightQuestionId, weightQuestionId) => { - const height = this.allFieldValues[heightQuestionId]; - const weight = this.allFieldValues[weightQuestionId]; - - [heightQuestionId, weightQuestionId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); + calcBSA = (height, weight) => { let result; if (height && weight) { result = Math.sqrt((height * weight) / 3600).toFixed(2); @@ -351,17 +249,8 @@ export class CommonExpressionHelpers { return daySinceLastCircumcision; } - calcTimeDifference = (obsDateId, timeFrame) => { + calcTimeDifference = (obsDate, timeFrame) => { let daySinceLastObs; - let obsDate = this.allFieldValues[obsDateId]; - [obsDateId].forEach(entry => { - if (this.allFieldsKeys.includes(entry)) { - registerDependency( - this.node, - this.allFields.find(candidate => candidate.id == entry), - ); - } - }); const endDate = moment(new Date()); const duration = moment.duration(endDate.diff(obsDate)); @@ -384,6 +273,9 @@ export class CommonExpressionHelpers { } export function registerDependency(node: FormNode, determinant: OHRIFormField) { + if (!node || !determinant) { + return; + } switch (node.type) { case 'page': if (!determinant.pageDependants) { diff --git a/src/utils/expression-parser.test.ts b/src/utils/expression-parser.test.ts index b0744d158..c12a5956e 100644 --- a/src/utils/expression-parser.test.ts +++ b/src/utils/expression-parser.test.ts @@ -1,4 +1,12 @@ -import { parseExpression } from './expression-parser'; +import { OHRIFormField } from '../api/types'; +import { ConceptFalse } from '../constants'; +import { + findAndRegisterReferencedFields, + linkReferencedFieldValues, + parseExpression, + replaceFieldRefWithValuePath, +} from './expression-parser'; +import { testFields } from './expression-runner.test'; describe('Expression parsing', () => { it('should split expression 1 into parts correctly', () => { @@ -53,3 +61,159 @@ describe('Expression parsing', () => { expect(parseExpression(input)).toEqual(expectedOutput); }); }); + +describe('replaceFieldRefWithValuePath', () => { + const field1: OHRIFormField = { + label: 'Visit Count', + type: 'obs', + questionOptions: { + rendering: 'number', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'htsVisitCount', + }; + + const field2: OHRIFormField = { + label: 'Notes', + type: 'obs', + questionOptions: { + rendering: 'text', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'notes', + }; + + const field3: OHRIFormField = { + label: 'Was HIV tested?', + type: 'obs', + questionOptions: { + rendering: 'toggle', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'wasHivTested', + }; + + it("should replace 'htsVisitCount' with value path", () => { + // setup + const token = "isEmpty('htsVisitCount')"; + // replay + const result = replaceFieldRefWithValuePath(field1, 10, token); + // verify + expect(result).toEqual('isEmpty(fieldValues.htsVisitCount)'); + }); + + it('should replace "notes" with value path', () => { + // setup + const token = 'api.getValue(notes)'; + // replay + const result = replaceFieldRefWithValuePath(field2, 'Some notes', token); + // verify + expect(result).toEqual('api.getValue(fieldValues.notes)'); + }); + + it('should replace "wasHivTested" with the system encoded boolean value for toggle rendering types', () => { + const token = "isEmpty('wasHivTested')"; + const result = replaceFieldRefWithValuePath(field3, false, token); + expect(result).toEqual(`isEmpty('${ConceptFalse}')`); + }); +}); + +describe('linkReferencedFieldValues', () => { + const field1: OHRIFormField = { + label: 'Visit Count', + type: 'obs', + questionOptions: { + rendering: 'number', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'htsVisitCount', + }; + + const field2: OHRIFormField = { + label: 'Notes', + type: 'obs', + questionOptions: { + rendering: 'text', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'notes', + }; + + const field3: OHRIFormField = { + label: 'Was HIV tested?', + type: 'obs', + questionOptions: { + rendering: 'toggle', + concept: '162576AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + answers: [], + }, + id: 'wasHivTested', + }; + + const valuesMap = { + htsVisitCount: 10, + notes: 'Some notes', + wasHivTested: false, + }; + + it("should replace 'htsVisitCount' with value path", () => { + // setup + const expression = "htsVisitCount && helpFn1(htsVisitCount) && helpFn2('htsVisitCount')"; + // replay + const result = linkReferencedFieldValues([field1], valuesMap, parseExpression(expression)); + // verify + expect(result).toEqual( + 'fieldValues.htsVisitCount && helpFn1(fieldValues.htsVisitCount) && helpFn2(fieldValues.htsVisitCount)', + ); + }); + + it('should support complex expressions', () => { + // setup + const expression = + 'htsVisitCount > 2 ? resolve(api.getByConcept(wasHivTested)) : resolve(api.call2ndApi(wasHivTested, htsVisitCount))'; + // replay + const result = linkReferencedFieldValues([field1, field2, field3], valuesMap, parseExpression(expression)); + // verify + expect(result).toEqual( + `fieldValues.htsVisitCount > 2 ? resolve(api.getByConcept('${ConceptFalse}')) : resolve(api.call2ndApi('${ConceptFalse}', fieldValues.htsVisitCount))`, + ); + }); + + it('should ignore ref to useFieldValue', () => { + // setup + const expression = + "htsVisitCount > 2 ? resolve(api.getByConcept(useFieldValue('wasHivTested'))) : resolve(api.call2ndApi(wasHivTested, useFieldValue('htsVisitCount')))"; + // replay + const result = linkReferencedFieldValues([field1, field2, field3], valuesMap, parseExpression(expression)); + // verify + expect(result).toEqual( + `fieldValues.htsVisitCount > 2 ? resolve(api.getByConcept(useFieldValue('wasHivTested'))) : resolve(api.call2ndApi('${ConceptFalse}', useFieldValue('htsVisitCount')))`, + ); + }); +}); + +describe('findAndRegisterReferencedFields', () => { + it('should register field dependants', () => { + // setup + const expression = "linkedToCare == 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3' && !isEmpty(htsProviderRemarks)"; + const patientIdentificationNumberField = testFields.find(f => f.id === 'patientIdentificationNumber'); + + // replay + findAndRegisterReferencedFields( + { value: patientIdentificationNumberField, type: 'field' }, + parseExpression(expression), + testFields, + ); + + // verify + const linkedToCare = testFields.find(f => f.id === 'linkedToCare'); + const htsProviderRemarks = testFields.find(f => f.id === 'htsProviderRemarks'); + expect(linkedToCare.fieldDependants).toStrictEqual(new Set(['patientIdentificationNumber'])); + expect(htsProviderRemarks.fieldDependants).toStrictEqual(new Set(['patientIdentificationNumber'])); + }); +}); diff --git a/src/utils/expression-parser.ts b/src/utils/expression-parser.ts index 95896844b..e14c50f74 100644 --- a/src/utils/expression-parser.ts +++ b/src/utils/expression-parser.ts @@ -1,3 +1,8 @@ +import { OHRIFormField } from '../api/types'; +import { ConceptFalse, ConceptTrue } from '../constants'; +import { registerDependency } from './common-expression-helpers'; +import { FormNode } from './expression-runner'; + /** * Parses a complex expression string into an array of tokens, ignoring operators found within quotes and within parentheses. * @@ -47,3 +52,110 @@ export function parseExpression(expression: string): string[] { } return tokens; } + +/** + * Links field references within expression fragments to the actual field values + * @returns The expression with linked field references + */ +export function linkReferencedFieldValues( + fields: OHRIFormField[], + fieldValues: Record, + tokens: string[], +): string { + const processedTokens = []; + tokens.forEach(token => { + if (hasParentheses(token)) { + let tokenWithUnresolvedArgs = token; + extractArgs(token).forEach(arg => { + const referencedField = findReferencedFieldIfExists(arg, fields); + if (referencedField) { + tokenWithUnresolvedArgs = replaceFieldRefWithValuePath( + referencedField, + fieldValues[referencedField.id], + tokenWithUnresolvedArgs, + ); + } + }); + processedTokens.push(tokenWithUnresolvedArgs); + } else { + const referencedField = findReferencedFieldIfExists(token, fields); + if (referencedField) { + processedTokens.push(replaceFieldRefWithValuePath(referencedField, fieldValues[referencedField.id], token)); + } else { + // push token as is + processedTokens.push(token); + } + } + }); + return processedTokens.join(' '); +} + +/** + * Extracts the arguments or parameters to a function within an arbitrary expression. + * + * @param {string} expression - The expression to extract arguments from. + * @returns {string[]} An array of the extracted arguments. + */ +export function extractArgs(expression: string): string[] { + const args = []; + const regx = /(?:\w+|'(?:\\'|[^'\n])*')(?=[,\)]|\s*(?=\)))/g; + let match; + while ((match = regx.exec(expression))) { + args.push(match[0].replace(/\\'/g, "'").replace(/(^'|'$)/g, '')); + } + return args; +} + +/** + * Checks if an expression contains opening and closing parentheses. + * + * @param {string} expression - The expression to check. + * @returns {boolean} `true` if the expression contains parentheses, otherwise `false`. + */ +export function hasParentheses(expression: string): boolean { + const re = /[()]/; + return re.test(expression); +} + +export function replaceFieldRefWithValuePath(field: OHRIFormField, value: any, token: string): string { + if (token.includes(`useFieldValue('${field.id}')`)) { + return token; + } + // strip quotes + token = token.replace(new RegExp(`['"]${field.id}['"]`, 'g'), field.id); + if (field.questionOptions.rendering == 'toggle' && typeof value == 'boolean') { + // TODO: reference ConceptTrue and ConceptFalse through config patterns + return token.replace(field.id, `${value ? `'${ConceptTrue}'` : `'${ConceptFalse}'`}`); + } + return token.replace(field.id, `fieldValues.${field.id}`); +} + +/** + * Finds and registers referenced fields in the expression + * @param fieldNode The field node + * @param tokens Expression tokens + * @param fields All fields + */ +export function findAndRegisterReferencedFields( + fieldNode: FormNode, + tokens: string[], + fields: Array, +): void { + tokens.forEach(token => { + if (hasParentheses(token)) { + extractArgs(token).forEach(arg => { + registerDependency(fieldNode, findReferencedFieldIfExists(arg, fields)); + }); + } else { + registerDependency(fieldNode, findReferencedFieldIfExists(token, fields)); + } + }); +} + +function findReferencedFieldIfExists(fieldId: string, fields: OHRIFormField[]): OHRIFormField | undefined { + // check if field id has trailing quotes + if (/^'+|'+$/.test(fieldId)) { + fieldId = fieldId.replace(/^'|'$/g, ''); + } + return fields.find(field => field.id === fieldId); +} diff --git a/src/utils/expression-runner.test.ts b/src/utils/expression-runner.test.ts index a0a0d7991..3e3816a1d 100644 --- a/src/utils/expression-runner.test.ts +++ b/src/utils/expression-runner.test.ts @@ -1,5 +1,7 @@ import { OHRIFormField } from '../api/types'; +import { ConceptFalse } from '../constants'; import { CommonExpressionHelpers } from './common-expression-helpers'; +import { parseExpression } from './expression-parser'; import { checkReferenceToResolvedFragment, evaluateExpression, ExpressionContext } from './expression-runner'; export const testFields: Array = [ @@ -172,7 +174,7 @@ describe('Common expression runner - evaluateExpression', () => { // replay and verify expect( evaluateExpression( - '!isEmpty(`linkedToCare`) && isEmpty(`htsProviderRemarks`)', + "!isEmpty('linkedToCare') && isEmpty('htsProviderRemarks')", { value: allFields[1], type: 'field' }, allFields, valuesMap, @@ -190,7 +192,7 @@ describe('Common expression runner - evaluateExpression', () => { // replay and verify expect( evaluateExpression( - 'includes(`referredToPreventionServices`, `88cdde2b-753b-48ac-a51a-ae5e1ab24846`) && !includes(`referredToPreventionServices`, `1691AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`)', + "includes('referredToPreventionServices', '88cdde2b-753b-48ac-a51a-ae5e1ab24846') && !includes('referredToPreventionServices', '1691AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA')", { value: allFields[1], type: 'field' }, allFields, valuesMap, @@ -202,7 +204,7 @@ describe('Common expression runner - evaluateExpression', () => { it('should support session mode as a runtime', () => { expect( evaluateExpression( - 'mode == `enter` && isEmpty(`htsProviderRemarks`)', + "mode == 'enter' && isEmpty('htsProviderRemarks')", { value: allFields[2], type: 'field' }, allFields, valuesMap, @@ -221,7 +223,7 @@ describe('Common expression runner - evaluateExpression', () => { // replay expect( evaluateExpression( - '!includes(`referredToPreventionServices`, `88cdde2b-753b-48ac-a51a-ae5e1ab24846`) && isEmpty(`htsProviderRemarks`)', + "!includes('referredToPreventionServices', '88cdde2b-753b-48ac-a51a-ae5e1ab24846') && isEmpty('htsProviderRemarks')", { value: allFields[4], type: 'field' }, allFields, valuesMap, diff --git a/src/utils/expression-runner.ts b/src/utils/expression-runner.ts index 7a305b57d..30c99d270 100644 --- a/src/utils/expression-runner.ts +++ b/src/utils/expression-runner.ts @@ -1,7 +1,6 @@ -import { ConceptFalse, ConceptTrue } from '../constants'; import { OHRIFormField, OHRIFormPage, OHRIFormSection } from '../api/types'; -import { CommonExpressionHelpers, registerDependency } from './common-expression-helpers'; -import { parseExpression } from './expression-parser'; +import { CommonExpressionHelpers } from './common-expression-helpers'; +import { findAndRegisterReferencedFields, linkReferencedFieldValues, parseExpression } from './expression-parser'; export interface FormNode { value: OHRIFormPage | OHRIFormSection | OHRIFormField; @@ -17,21 +16,22 @@ export interface ExpressionContext { export function evaluateExpression( expression: string, node: FormNode, - allFields: Array, - allFieldValues: Record, + fields: Array, + fieldValues: Record, context: ExpressionContext, ): any { if (!expression?.trim()) { return null; } - const allFieldsKeys = allFields.map(f => f.id); + const allFieldsKeys = fields.map(f => f.id); const parts = parseExpression(expression.trim()); + // register dependencies + findAndRegisterReferencedFields(node, parts, fields); // setup function scope let { mode, myValue, patient } = context; if (node.type === 'field' && myValue === undefined) { - myValue = allFieldValues[node.value['id']]; + myValue = fieldValues[node.value['id']]; } - const { isEmpty, today, @@ -56,13 +56,9 @@ export function evaluateExpression( calcGravida, calcDaysSinceCircumcisionProcedure, calcTimeDifference, - } = new CommonExpressionHelpers(node, patient, allFields, allFieldValues, allFieldsKeys); + } = new CommonExpressionHelpers(node, patient, fields, fieldValues, allFieldsKeys); - parts.forEach((part, index) => { - if (index % 2 == 0 && allFieldsKeys.includes(part)) { - expression = interpolateFieldValue(node, expression, allFields, allFieldValues, part); - } - }); + expression = linkReferencedFieldValues(fields, fieldValues, parts); try { return eval(expression); @@ -75,19 +71,21 @@ export function evaluateExpression( export async function evaluateAsyncExpression( expression: string, node: FormNode, - allFields: Array, - allFieldValues: Record, + fields: Array, + fieldValues: Record, context: ExpressionContext, ): Promise { if (!expression?.trim()) { return null; } - const allFieldsKeys = allFields.map(f => f.id); - const parts = parseExpression(expression.trim()); + const allFieldsKeys = fields.map(f => f.id); + let parts = parseExpression(expression.trim()); + // register dependencies + findAndRegisterReferencedFields(node, parts, fields); // setup function scope let { mode, myValue, patient } = context; if (node.type === 'field' && myValue === undefined) { - myValue = allFieldValues[node.value['id']]; + myValue = fieldValues[node.value['id']]; } const { api, @@ -114,14 +112,14 @@ export async function evaluateAsyncExpression( calcGravida, calcDaysSinceCircumcisionProcedure, calcTimeDifference, - } = new CommonExpressionHelpers(node, patient, allFields, allFieldValues, allFieldsKeys); + } = new CommonExpressionHelpers(node, patient, fields, fieldValues, allFieldsKeys); + expression = linkReferencedFieldValues(fields, fieldValues, parts); + // parts with resolve-able field references + parts = parseExpression(expression); const lazyFragments = []; parts.forEach((part, index) => { if (index % 2 == 0) { - if (allFieldsKeys.includes(part)) { - expression = interpolateFieldValue(node, expression, allFields, allFieldValues, part); - } if (part.startsWith('resolve(')) { const [refinedSubExpression] = checkReferenceToResolvedFragment(part); lazyFragments.push({ expression: refinedSubExpression, index }); @@ -160,46 +158,6 @@ export function resolve(lazy: Promise) { return Promise.resolve(lazy); } -/** - * Interpolates the field value into the expression; This is done by replacing the field id with the field value. - * @param fieldNode The field node - * @param expression The expression - * @param fields All fields - * @param fieldValues Field values - * @param token The field id to be replaced - * @returns Refined expression - */ -function interpolateFieldValue( - fieldNode: FormNode, - expression: string, - fields: Array, - fieldValues: Record, - token: string, -): string { - const determinant = fields.find(field => field.id === token); - registerDependency(fieldNode, determinant); - // prep eval variables - let determinantValue = fieldValues[token]; - if (determinant.questionOptions.rendering == 'toggle' && typeof determinantValue == 'boolean') { - determinantValue = determinantValue ? ConceptTrue : ConceptFalse; - } - if (typeof determinantValue == 'string') { - determinantValue = `'${determinantValue}'`; - } - const regx = new RegExp(token, 'g'); - expression = expression.replace(regx, determinantValue); - return expression; -} - -// For testing purposes only -function mockAsyncFunction(value: any, delay?: number) { - return new Promise(resolve => { - setTimeout(() => { - resolve(value); - }, delay || 1000); - }); -} - /** * Checks if the given token contains a reference to a resolved fragment * and returns the fragment and the remaining chained reference.