From 30cf1b178868d57f41d6674eaabe140afbd2b764 Mon Sep 17 00:00:00 2001 From: Jamie Arodi <51090527+arodidev@users.noreply.github.com> Date: Thu, 9 May 2024 22:38:34 +0300 Subject: [PATCH] (feat) O3-3049: Add support for historical expression (#226) * Initial commit(will remove the date bug fix) * cleanup * Getting the HD object to evaluate tt t * aligning Hxp with previous value * more cleanup * resolving logic * revamped transformer, migrated historical data source class * combine hxp and previous value logic * Added tests * PR resolutions * Fix CI build failure --- .../historical-expressions-form.json | 170 ++++++++++++++++++ .../mockHistoricalvisitsEncounter.json | 89 +++++++++ src/components/inputs/date/date.component.tsx | 2 +- .../multi-select/multi-select.component.tsx | 12 +- .../section/form-section.component.tsx | 32 +++- src/components/section/helpers.ts | 31 +++- src/datasources/historical-data-source.ts | 11 ++ src/form-engine.test.tsx | 25 ++- src/types.ts | 3 +- src/utils/common-expression-helpers.ts | 9 +- src/utils/expression-runner.ts | 21 ++- 11 files changed, 383 insertions(+), 22 deletions(-) create mode 100644 __mocks__/forms/rfe-forms/historical-expressions-form.json create mode 100644 __mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json create mode 100644 src/datasources/historical-data-source.ts diff --git a/__mocks__/forms/rfe-forms/historical-expressions-form.json b/__mocks__/forms/rfe-forms/historical-expressions-form.json new file mode 100644 index 000000000..dd78c3d92 --- /dev/null +++ b/__mocks__/forms/rfe-forms/historical-expressions-form.json @@ -0,0 +1,170 @@ +{ + "name": "COVID Assessment Form", + "version": "1", + "published": true, + "retired": false, + "pages": [ + { + "label": "COVID Assessment", + "sections": [ + { + "label": "Assessment Details", + "isExpanded": "true", + "questions": [ + { + "label": "Reasons for assessment", + "type": "obs", + "historicalExpression": "HD.getObject('prevEnc').getValue('ae46f4b1-c15d-4bba-ab41-b9157b82b0ce')", + "questionOptions": { + "rendering": "checkbox", + "concept": "ae46f4b1-c15d-4bba-ab41-b9157b82b0ce", + "answers": [ + { + "concept": "1068AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Symptomatic", + "conceptMappings": [ + { + "type": "AMPATH", + "value": "1068" + }, + { + "type": "SNOMED-CT", + "value": "264931009" + } + ] + }, + { + "concept": "0ed2e3ca-b9a6-4ff6-ac74-4d4cd9520acc", + "label": "RDT confirmatory", + "conceptMappings": [] + }, + { + "concept": "f974e267-feeb-42be-9d37-61554dad16b1", + "label": "Voluntary", + "conceptMappings": [] + }, + { + "concept": "1cee0ab3-bf06-49e9-a49c-baf261620c67", + "label": "Post-mortem", + "conceptMappings": [] + }, + { + "concept": "e0f1584a-cc8b-48e9-980f-64d9f724caf8", + "label": "Quarantine", + "conceptMappings": [] + }, + { + "concept": "ad8be6bf-ced7-4390-a6af-c5acebeac216", + "label": "Follow-up", + "conceptMappings": [] + }, + { + "concept": "30320fb8-b29b-443f-98cf-f3ef491f8947", + "label": "Health worker", + "conceptMappings": [] + }, + { + "concept": "38769c82-a3d3-4714-97b7-015726cdb209", + "label": "Other frontline worker", + "conceptMappings": [] + }, + { + "concept": "f8c9c2cc-3070-444e-8818-26fb8100bb78", + "label": "Travel out of country", + "conceptMappings": [] + }, + { + "concept": "677f4d21-7293-4810-abe6-57a192acde57", + "label": "Entry into a country", + "conceptMappings": [] + }, + { + "concept": "8a6ab892-1b1d-4ad9-82da-c6c38ee8fcfb", + "label": "Surveillance", + "conceptMappings": [] + }, + { + "concept": "5340f478-ec5d-41e6-bc62-961c52014d4d", + "label": "Contact of a case", + "conceptMappings": [] + }, + { + "concept": "5622AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Other", + "conceptMappings": [ + { + "type": "PIH-Malawi", + "value": "6408" + }, + { + "type": "org.openmrs.module.mdrtb", + "value": "OTHER" + }, + { + "type": "CIEL", + "value": "5622" + }, + { + "type": "SNOMED-MVP", + "value": "56221000105001" + }, + { + "type": "PIH", + "value": "5622" + }, + { + "type": "AMPATH", + "value": "5622" + }, + { + "type": "SNOMED-CT", + "value": "74964007" + } + ] + } + ] + }, + "id": "reasonsForTesting", + "behaviours": [ + { + "intent": "*", + "required": "true", + "unspecified": "true", + "hide": { + "hideWhenExpression": "false" + }, + "validators": [] + }, + { + "intent": "COVID_LAB_ASSESSMENT_EMBED", + "required": "true", + "unspecified": "true", + "hide": { + "hideWhenExpression": "false" + }, + "validators": [] + } + ] + } + ] + } + ] + } + ], + "availableIntents": [ + { + "intent": "*", + "display": "COVID Assessment" + }, + { + "intent": "COVID_LAB_ASSESSMENT_EMBED", + "display": "Covid Case Form" + } + ], + "processor": "EncounterFormProcessor", + "uuid": "f5fb6bc4-6fc3-3462-a191-2fff0896bab3", + "referencedForms": [], + "encounterType": "253a43d3-c99e-415c-8b78-ee7d4d3c1d54", + "encounter": "COVID Case Assessment", + "allowUnspecifiedAll": true +} diff --git a/__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json b/__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json new file mode 100644 index 000000000..9294d935a --- /dev/null +++ b/__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json @@ -0,0 +1,89 @@ +{ + "uuid": "82649039-540d-45e8-b935-2edf3ac68449", + "encounterDatetime": "2024-05-01T09:34:18.000+0000", + "encounterType": { + "uuid": "253a43d3-c99e-415c-8b78-ee7d4d3c1d54", + "display": "COVID Case Assessment", + "name": "COVID Case Assessment", + "description": "This encounter type is shared across the COVID Assessment Form/COVID Outcome Form and COVID Lab Result Form", + "retired": false, + "links": [ + { + "rel": "self", + "uri": "https://ohri-dev.globalhealthapp.net/openmrs/ws/rest/v1/encountertype/253a43d3-c99e-415c-8b78-ee7d4d3c1d54", + "resourceAlias": "encountertype" + }, + { + "rel": "full", + "uri": "https://ohri-dev.globalhealthapp.net/openmrs/ws/rest/v1/encountertype/253a43d3-c99e-415c-8b78-ee7d4d3c1d54?v=full", + "resourceAlias": "encountertype" + } + ], + "resourceVersion": "1.8" + }, + "location": { + "uuid": "28066518-a0e5-4331-b23e-7b40f96d733a", + "name": "MCH Clinic" + }, + "patient": { + "uuid": "b280078a-c0ce-443b-9997-3c66c63ec2f8", + "display": "100000Y - John Doe" + }, + "encounterProviders": [ + { + "uuid": "72d1879e-f949-48ba-bdbe-9ea157ab5c20", + "provider": { + "uuid": "bc450226-4138-40b7-ad88-9c98df687738", + "name": "Super User" + } + } + ], + "orders": [], + "obs": [ + { + "uuid": "810d08df-9e14-494c-9c02-eada18af90ed", + "obsDatetime": "2024-05-01T09:34:18.000+0000", + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-dateOfAssessment", + "concept": { + "uuid": "160753AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "name": { + "uuid": "f415bf25-2008-3ac2-8f0e-46e75648e30d", + "name": "Date of event" + } + }, + "value": "2024-01-08T00:00:00.000+0000" + }, + { + "uuid": "f03bc18e-b4bc-432c-bffc-47daff58892b", + "obsDatetime": "2024-05-01T09:34:18.000+0000", + "voided": false, + "groupMembers": null, + "formFieldNamespace": "rfe-forms", + "formFieldPath": "rfe-forms-reasonsForTesting", + "concept": { + "uuid": "ae46f4b1-c15d-4bba-ab41-b9157b82b0ce", + "name": { + "uuid": "8b31f49b-04cf-3c31-b9da-149f262aa7b6", + "name": "What is the reason for COVID-19 testing?" + } + }, + "value": { + "uuid": "677f4d21-7293-4810-abe6-57a192acde57", + "name": { + "uuid": "3f3b2df5-fe22-3833-b2e9-22368c88f6de", + "name": "Entry into a country" + }, + "names": [ + { + "uuid": "3f3b2df5-fe22-3833-b2e9-22368c88f6de", + "conceptNameType": "FULLY_SPECIFIED", + "name": "Entry into a country" + } + ] + } + } + ] +} diff --git a/src/components/inputs/date/date.component.tsx b/src/components/inputs/date/date.component.tsx index 642075910..b692760ed 100644 --- a/src/components/inputs/date/date.component.tsx +++ b/src/components/inputs/date/date.component.tsx @@ -111,7 +111,7 @@ const DateField: React.FC = ({ question, onChange, handler, prev }, []); useEffect(() => { - if (encounterContext?.previousEncounter && !isTrue(question.questionOptions.usePreviousValueDisabled)) { + if (encounterContext?.previousEncounter && isTrue(question.questionOptions.enablePreviousValue)) { let prevValue = handler?.getPreviousValue(question, encounterContext?.previousEncounter, fields); if (!isEmpty(prevValue?.value)) { diff --git a/src/components/inputs/multi-select/multi-select.component.tsx b/src/components/inputs/multi-select/multi-select.component.tsx index ba3be01a3..74c7fbdfa 100644 --- a/src/components/inputs/multi-select/multi-select.component.tsx +++ b/src/components/inputs/multi-select/multi-select.component.tsx @@ -58,11 +58,13 @@ const MultiSelect: React.FC = ({ question, onChange, handler, pr }; useEffect(() => { - if (!isEmpty(previousValue) && Array.isArray(previousValue)) { - const valuesToSet = previousValue.map((eachItem) => eachItem.value); - setFieldValue(question.id, valuesToSet); - onChange(question.id, valuesToSet, setErrors, setWarnings); - question.value = handler?.handleFieldSubmission(question, valuesToSet, encounterContext); + if (!isEmpty(previousValue)) { + const previousValues = Array.isArray(previousValue) + ? previousValue.map((item) => item.value) + : [previousValue.value]; + setFieldValue(question.id, previousValues); + onChange(question.id, previousValues, setErrors, setWarnings); + question.value = handler?.handleFieldSubmission(question, previousValues, encounterContext); } }, [previousValue]); diff --git a/src/components/section/form-section.component.tsx b/src/components/section/form-section.component.tsx index fe7d76be1..ac2bf5af7 100644 --- a/src/components/section/form-section.component.tsx +++ b/src/components/section/form-section.component.tsx @@ -5,7 +5,12 @@ import { useField } from 'formik'; import type { FormField, FormFieldProps, previousValue, SubmissionHandler } from '../../types'; import { ErrorBoundary } from 'react-error-boundary'; import { ToastNotification } from '@carbon/react'; -import { formatPreviousValueDisplayText, getFieldControlWithFallback, isUnspecifiedSupported } from './helpers'; +import { + formatPreviousValueDisplayText, + getFieldControlWithFallback, + isUnspecifiedSupported, + extractObsValueAndDisplay, +} from './helpers'; import { getRegisteredFieldSubmissionHandler } from '../../registry/registry'; import { isTrue } from '../../utils/boolean-utils'; import { FormContext } from '../../form-context'; @@ -13,6 +18,7 @@ import PreviousValueReview from '../previous-value-review/previous-value-review. import Tooltip from '../inputs/tooltip/tooltip.component'; import UnspecifiedField from '../inputs/unspecified/unspecified.component'; import styles from './form-section.scss'; +import { evaluateExpression } from '../../utils/expression-runner'; interface FieldComponentMap { fieldComponent: React.ComponentType; @@ -47,7 +53,24 @@ const FormSection = ({ fields, onFieldChange }) => { .map((entry, index) => { const { fieldComponent: FieldComponent, fieldDescriptor, handler } = entry; const rendering = fieldDescriptor.questionOptions.rendering; - const previousFieldValue = encounterContext.previousEncounter + + const historicalValue = fieldDescriptor.historicalExpression + ? evaluateExpression( + fieldDescriptor.historicalExpression, + { value: fieldDescriptor, type: 'field' }, + fieldsFromEncounter, + encounterContext.initValues, + { + mode: encounterContext.sessionMode, + patient: encounterContext.patient, + previousEncounter: encounterContext.previousEncounter, + }, + ) + : null; + + const previousFieldValue = historicalValue + ? extractObsValueAndDisplay(fieldDescriptor, historicalValue) + : encounterContext.previousEncounter ? handler?.getPreviousValue(fieldDescriptor, encounterContext.previousEncounter, fieldsFromEncounter) : null; @@ -96,8 +119,9 @@ const FormSection = ({ fields, onFieldChange }) => { )} {encounterContext?.previousEncounter && - previousFieldValue && - !isTrue(fieldDescriptor.questionOptions.usePreviousValueDisabled) && ( + (previousFieldValue || historicalValue) && + (isTrue(fieldDescriptor.questionOptions.enablePreviousValue) || + fieldDescriptor.historicalExpression) && (
{ switch (question.questionOptions.rendering) { case 'date': + if (value instanceof Date) { + return formatDate(value); + } return formatDate(new Date(value?.display)) || formatDate(value?.value); case 'checkbox': - return Array.isArray(value) ? previousValueDisplayForCheckbox(value) : null; + return Array.isArray(value) ? previousValueDisplayForCheckbox(value) : value.display; default: return value?.display; } }; + +export const extractObsValueAndDisplay = (field: FormField, obs: OpenmrsObs) => { + const rendering = field.questionOptions.rendering; + + if (typeof obs.value === 'string' || typeof obs.value === 'number') { + if (rendering === 'date' || rendering === 'datetime') { + const dateObj = parseToLocalDateTime(`${obs.value}`); + return { value: dateObj, display: dayjs(dateObj).format('YYYY-MM-DD HH:mm') }; + } + return { value: obs.value, display: obs.value }; + } else if (['toggle', 'checkbox'].includes(rendering)) { + return { + value: obs.value?.uuid, + display: obs.value?.name?.name, + }; + } else { + return { + value: obs.value?.uuid, + display: field.questionOptions.answers?.find((option) => option.concept === obs.value?.uuid)?.label, + }; + } +}; diff --git a/src/datasources/historical-data-source.ts b/src/datasources/historical-data-source.ts new file mode 100644 index 000000000..e0fd1d424 --- /dev/null +++ b/src/datasources/historical-data-source.ts @@ -0,0 +1,11 @@ +export class HistoricalDataSourceService { + dataSourceMap: Record = {}; + + putObject(key: string, value: any) { + this.dataSourceMap[key] = value; + } + + getObject(key: string) { + return this.dataSourceMap[key]; + } +} diff --git a/src/form-engine.test.tsx b/src/form-engine.test.tsx index 2e172f522..326616722 100644 --- a/src/form-engine.test.tsx +++ b/src/form-engine.test.tsx @@ -29,6 +29,8 @@ import referenceByMappingForm from '__mocks__/forms/rfe-forms/reference-by-mappi import sampleFieldsForm from '__mocks__/forms/rfe-forms/sample_fields.json'; import testEnrolmentForm from '__mocks__/forms/rfe-forms/test-enrolment-form.json'; import viralLoadStatusForm from '__mocks__/forms/rfe-forms/viral-load-status-form.json'; +import historicalExpressionsForm from '__mocks__/forms/rfe-forms/historical-expressions-form.json'; +import mockHxpEncounter from '__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json'; import FormEngine from './form-engine.component'; const mockShowToast = showToast as jest.Mock; @@ -65,7 +67,7 @@ jest.mock('../src/api/api', () => { return { ...originalModule, - getPreviousEncounter: jest.fn().mockImplementation(() => Promise.resolve(null)), + getPreviousEncounter: jest.fn().mockImplementation(() => Promise.resolve(mockHxpEncounter)), getConcept: jest.fn().mockImplementation(() => Promise.resolve(null)), getLatestObs: jest.fn().mockImplementation(() => Promise.resolve({ valueNumeric: 60 })), saveEncounter: jest.fn(), @@ -152,6 +154,27 @@ describe('Form engine component', () => { }); }); + describe('historical expressions', () => { + it('should ascertain getPreviousEncounter() returns an encounter and the historical expression displays on the UI', async () => { + const user = userEvent.setup(); + + renderForm(null, historicalExpressionsForm, 'COVID Assessment'); + + //ascertain form has rendered + await screen.findByRole('combobox', { name: /Reasons for assessment/i }); + + //ascertain function fetching the encounter has been called + expect(api.getPreviousEncounter).toHaveBeenCalled(); + expect(api.getPreviousEncounter).toHaveReturnedWith(Promise.resolve(mockHxpEncounter)); + + const reuseValueButton = screen.getByRole('button', { name: /reuse value/i }); + const evaluatedHistoricalValue = screen.getByText(/Entry into a country/i); + + expect(reuseValueButton).toBeInTheDocument; + expect(evaluatedHistoricalValue).toBeInTheDocument; + }); + }); + describe('Form submission', () => { it('should validate form submission', async () => { const saveEncounterMock = jest.spyOn(api, 'saveEncounter'); diff --git a/src/types.ts b/src/types.ts index df1e31938..634f661e4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -124,6 +124,7 @@ export interface FormField { validators?: Array>; behaviours?: Array>; questionInfo?: string; + historicalExpression?: string; constrainMaxWidth?: boolean; meta?: QuestionMetaProps; } @@ -216,7 +217,7 @@ export interface FormQuestionOptions { calculateExpression: string; }; isDateTime?: { labelTrue: boolean; labelFalse: boolean }; - usePreviousValueDisabled?: boolean; + enablePreviousValue?: boolean; allowedFileTypes?: Array; allowMultiple?: boolean; datasource?: { name: string; config?: Record }; diff --git a/src/utils/common-expression-helpers.ts b/src/utils/common-expression-helpers.ts index cef2aed6a..b6809d0f2 100644 --- a/src/utils/common-expression-helpers.ts +++ b/src/utils/common-expression-helpers.ts @@ -94,16 +94,13 @@ export class CommonExpressionHelpers { return null; }; - doesNotMatchExpression = ( - regexString: string, - val: string | null | undefined - ): boolean => { + doesNotMatchExpression = (regexString: string, val: string | null | undefined): boolean => { if (!val || ['undefined', 'null', ''].includes(val.toString())) { return true; } const pattern = new RegExp(regexString); - - return !pattern.test(val) + + return !pattern.test(val); }; calcBMI = (height: number, weight: number) => { diff --git a/src/utils/expression-runner.ts b/src/utils/expression-runner.ts index 8fbc0c372..80a27cfdd 100644 --- a/src/utils/expression-runner.ts +++ b/src/utils/expression-runner.ts @@ -1,7 +1,8 @@ import { getRegisteredExpressionHelpers } from '../registry/registry'; -import { type FormField, type FormPage, type FormSection } from '../types'; +import { type OpenmrsEncounter, type FormField, type FormPage, type FormSection } from '../types'; import { CommonExpressionHelpers } from './common-expression-helpers'; import { findAndRegisterReferencedFields, linkReferencedFieldValues, parseExpression } from './expression-parser'; +import { HistoricalDataSourceService } from '../datasources/historical-data-source'; export interface FormNode { value: FormPage | FormSection | FormField; @@ -12,8 +13,11 @@ export interface ExpressionContext { mode: 'enter' | 'edit' | 'view' | 'embedded-view'; myValue?: any; patient: any; + previousEncounter?: OpenmrsEncounter; } +export const HD = new HistoricalDataSourceService(); + export function evaluateExpression( expression: string, node: FormNode, @@ -24,6 +28,7 @@ export function evaluateExpression( if (!expression?.trim()) { return null; } + const allFieldsKeys = fields.map((f) => f.id); const parts = parseExpression(expression.trim()); // register dependencies @@ -32,10 +37,19 @@ export function evaluateExpression( let { myValue, patient } = context; const { sex, age } = patient && 'sex' in patient && 'age' in patient ? patient : { sex: undefined, age: undefined }; - if (node.type === 'field' && myValue === undefined) { + if (node.type === 'field' && myValue === undefined && node.value) { myValue = fieldValues[node.value['id']]; } + const HD = new HistoricalDataSourceService(); + + HD.putObject('prevEnc', { + value: context.previousEncounter, + getValue(concept) { + return this.value.obs.find((obs) => obs.concept.uuid == concept); + }, + }); + const expressionContext = { ...new CommonExpressionHelpers(node, patient, fields, fieldValues, allFieldsKeys), ...getRegisteredExpressionHelpers(), @@ -45,6 +59,7 @@ export function evaluateExpression( myValue, sex, age, + HD, }; expression = linkReferencedFieldValues(fields, fieldValues, parts); @@ -67,8 +82,10 @@ export async function evaluateAsyncExpression( if (!expression?.trim()) { return null; } + const allFieldsKeys = fields.map((f) => f.id); let parts = parseExpression(expression.trim()); + // register dependencies findAndRegisterReferencedFields(node, parts, fields);