From d289092df7cca862f45e2af52e35992204c6ef1e Mon Sep 17 00:00:00 2001 From: Jovan Ssebaggala Date: Wed, 20 Dec 2023 14:41:43 +0300 Subject: [PATCH] (feat) Introduce generic program enrollment (#153) * Introduce generic program enrollment * fix failing tests * address PR review comments and code clean up * fix obs count failing test * remove commented out code --- .../labour_and_delivery_test_form.json | 4 - .../ohri-forms/post-submission-test-form.json | 116 ++++++++++++++++++ src/api/api.ts | 50 +++++++- src/api/types.ts | 8 ++ .../ui-select-extended.test.tsx | 8 +- src/hooks/usePostSubmissionAction.tsx | 12 +- src/ohri-form.component.test.tsx | 97 ++++++++++++++- src/ohri-form.component.tsx | 22 ++-- .../program-enrollment-action.ts | 97 +++++++++++++++ .../InbuiltPostSubmissionActions.ts | 9 ++ src/registry/registry.ts | 38 +++--- src/utils/expression-parser.ts | 10 +- src/utils/post-submission-action-helper.ts | 71 +++++++++++ 13 files changed, 494 insertions(+), 48 deletions(-) create mode 100644 __mocks__/forms/ohri-forms/post-submission-test-form.json create mode 100644 src/post-submission-actions/program-enrollment-action.ts create mode 100644 src/registry/inbuilt-components/InbuiltPostSubmissionActions.ts create mode 100644 src/utils/post-submission-action-helper.ts diff --git a/__mocks__/forms/ohri-forms/labour_and_delivery_test_form.json b/__mocks__/forms/ohri-forms/labour_and_delivery_test_form.json index 387d48a71..099deec39 100644 --- a/__mocks__/forms/ohri-forms/labour_and_delivery_test_form.json +++ b/__mocks__/forms/ohri-forms/labour_and_delivery_test_form.json @@ -367,10 +367,6 @@ "uuid": "1e5614d6-5306-11e6-beb8-9e71128cae77", "referencedForms": [], "encounterType": "6dc5308d-27c9-4d49-b16f-2c5e3c759757", - "postSubmissionActions": [ - "MotherToChildLinkageSubmissionAction", - "ArtSubmissionAction" - ], "allowUnspecifiedAll": true, "formOptions": { "usePreviousValueDisabled": "true" diff --git a/__mocks__/forms/ohri-forms/post-submission-test-form.json b/__mocks__/forms/ohri-forms/post-submission-test-form.json new file mode 100644 index 000000000..026dc4b13 --- /dev/null +++ b/__mocks__/forms/ohri-forms/post-submission-test-form.json @@ -0,0 +1,116 @@ +{ + "name": "TB Case Enrollment Form", + "published": true, + "retired": false, + "pages": [ + { + "label": "TB Enrollment", + "sections": [ + { + "label": "TB Program", + "isExpanded": "true", + "questions": [ + { + "label": "TB Program to enrol", + "type": "obs", + "required": false, + "id": "tbProgramType", + "questionOptions": { + "rendering": "radio", + "concept": "163775AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "conceptMappings": [], + "answers": [ + { + "concept": "160541AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Drug-susceptible (DS) TB Program" + }, + { + "concept": "160052AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "label": "Drug Resistant (DR) TB program" + } + ] + }, + "validators": [] + }, + { + "label": "Date enrolled in tuberculosis (TB) care", + "type": "obs", + "required": true, + "id": "tbRegDate", + "questionOptions": { + "rendering": "date", + "concept": "161552AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "conceptMappings": [ + { + "relationship": "SAME-AS", + "type": "CIEL", + "value": "161552" + }, + { + "relationship": "NARROWER-THAN", + "type": "SNOMED CT", + "value": "413946009" + } + ], + "answers": [] + }, + "validators": [] + }, + { + "label": "DS TB Treatment Number", + "type": "obs", + "required": false, + "id": "dsServiceID", + "questionOptions": { + "rendering": "number", + "concept": "161654AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "conceptMappings": [ + { + "relationship": "SAME-AS", + "type": "CIEL", + "value": "161654" + } + ], + "answers": [] + }, + "validators": [] + } + ] + } + ] + } + ], + "availableIntents": [ + { + "intent": "*", + "display": "TB Case Enrollment Form" + } + ], + "processor": "EncounterFormProcessor", + "encounterType": "9a199b59-b185-485b-b9b3-a9754e65ae57", + "postSubmissionActions": [ + { + "actionId": "ProgramEnrollmentSubmissionAction", + "enabled":"tbProgramType === '160541AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'", + "config": { + "enrollmentDate": "tbRegDate", + "programUuid": "58005eb2-4560-4ada-b7bb-67a5cffa0a27", + "completionDate": "outcomeTBRx" + } + }, + { + "actionId": "ProgramEnrollmentSubmissionAction", + "enabled":"tbProgramType === '160052AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'", + "config": { + "enrollmentDate": "utbRegDate", + "programUuid": "00f37871-0578-4ebc-af1d-e4b3ce75310d", + "completionDate": "outcomeTBRx" + } + } + ], + "encounter": "TB Program Enrolment", + "referencedForms": [], + "uuid": "9ad909d2-64a9-437d-9a40-301d50cae1f6", + "description": "This form enrols a client to the respective TB Program", + "version": "1.0" +} \ No newline at end of file diff --git a/src/api/api.ts b/src/api/api.ts index 7aca9b3b0..ffd9a5447 100644 --- a/src/api/api.ts +++ b/src/api/api.ts @@ -2,9 +2,11 @@ import { openmrsFetch, openmrsObservableFetch } from '@openmrs/esm-framework'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { encounterRepresentation } from '../constants'; -import { OpenmrsForm } from './types'; +import { OpenmrsForm, ProgramEnrollmentPayload } from './types'; import { isUuid } from '../utils/boolean-utils'; +const BASE_WS_API_URL = '/ws/rest/v1/'; + export function saveEncounter(abortController: AbortController, payload, encounterUuid?: string) { const url = !!encounterUuid ? `/ws/rest/v1/encounter/${encounterUuid}?v=full` : `/ws/rest/v1/encounter?v=full`; @@ -152,3 +154,49 @@ function dataURItoFile(dataURI: string) { const blob = new Blob([buffer], { type: mimeString }); return blob; } + +//Program Enrollment +export function getPatientEnrolledPrograms(patientUuid: string) { + return openmrsFetch( + `${BASE_WS_API_URL}programenrollment?patient=${patientUuid}&v=custom:(uuid,display,program,dateEnrolled,dateCompleted,location:(uuid,display))`, + ).then(({ data }) => { + if (data) { + return data; + } + return null; + }); +} + +export function createProgramEnrollment(payload: ProgramEnrollmentPayload, abortController: AbortController) { + if (!payload) { + throw new Error('Program enrollment cannot be created because no payload is supplied'); + } + const { program, patient, dateEnrolled, dateCompleted, location } = payload; + return openmrsObservableFetch(`${BASE_WS_API_URL}programenrollment`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: { program, patient, dateEnrolled, dateCompleted, location }, + signal: abortController.signal, + }); +} + +export function updateProgramEnrollment( + programEnrollmentUuid: string, + payload: ProgramEnrollmentPayload, + abortController: AbortController, +) { + if (!payload || !programEnrollmentUuid) { + throw new Error('Program enrollment cannot be edited without a payload or a program Uuid'); + } + const { dateEnrolled, dateCompleted, location } = payload; + return openmrsObservableFetch(`${BASE_WS_API_URL}programenrollment/${programEnrollmentUuid}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: { dateEnrolled, dateCompleted, location }, + signal: abortController.signal, + }); +} diff --git a/src/api/types.ts b/src/api/types.ts index c6a04ab54..79512af26 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -207,6 +207,7 @@ export interface PostSubmissionAction { sessionMode: SessionMode; }, config?: Record, + enabled?: string, ): void; } @@ -318,3 +319,10 @@ export type RepeatObsGroupCounter = { obsGroupCount: number; limit?: number; }; +export interface ProgramEnrollmentPayload { + patient: string; + program: string; + dateEnrolled: string; + dateCompleted?: string; + location: string; +} diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx index c44f1e07c..9374b4c18 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx @@ -33,13 +33,13 @@ const encounterContext: EncounterContext = { }, sessionMode: 'enter', encounterDate: new Date(2023, 8, 29), - setEncounterDate: value => {}, + setEncounterDate: (value) => {}, }; -const renderForm = intialValues => { +const renderForm = (intialValues) => { render( - {props => ( + {(props) => (
({ display: 'Muyenga', }, ]), - toUuidAndDisplay: data => data, + toUuidAndDisplay: (data) => data, }), })); diff --git a/src/hooks/usePostSubmissionAction.tsx b/src/hooks/usePostSubmissionAction.tsx index 9a50ad4de..dc9de9d95 100644 --- a/src/hooks/usePostSubmissionAction.tsx +++ b/src/hooks/usePostSubmissionAction.tsx @@ -2,17 +2,19 @@ import { useEffect, useState } from 'react'; import { PostSubmissionAction } from '../api/types'; import { getRegisteredPostSubmissionAction } from '../registry/registry'; -export function usePostSubmissionAction(actionRefs: Array<{ actionId: string; config?: Record }>) { +export function usePostSubmissionAction( + actionRefs: Array<{ actionId: string; enabled?: string; config?: Record }>, +) { const [actions, setActions] = useState< - Array<{ postAction: PostSubmissionAction; config: Record; actionId: string }> + Array<{ postAction: PostSubmissionAction; config: Record; actionId: string; enabled?: string }> >([]); useEffect(() => { const actionArray = []; if (actionRefs?.length) { - actionRefs.map(ref => { + actionRefs.map((ref) => { const actionId = typeof ref === 'string' ? ref : ref.actionId; - getRegisteredPostSubmissionAction(actionId)?.then(action => - actionArray.push({ postAction: action, config: ref.config, actionId: actionId }), + getRegisteredPostSubmissionAction(actionId)?.then((action) => + actionArray.push({ postAction: action, config: ref.config, actionId: actionId, enabled: ref.enabled }), ); }); } diff --git a/src/ohri-form.component.test.tsx b/src/ohri-form.component.test.tsx index e7538b154..232f24c37 100644 --- a/src/ohri-form.component.test.tsx +++ b/src/ohri-form.component.test.tsx @@ -37,6 +37,11 @@ import demoHtsOhriForm from '../__mocks__/forms/ohri-forms/demo_hts-form.json'; import obsGroup_test_form from '../__mocks__/forms/ohri-forms/obs-group-test_form.json'; import labour_and_delivery_test_form from '../__mocks__/forms/ohri-forms/labour_and_delivery_test_form.json'; import sample_fields_form from '../__mocks__/forms/ohri-forms/sample_fields.json'; +import postSubmission_test_form from '../__mocks__/forms/ohri-forms/post-submission-test-form.json'; +import * as registry from '../src/registry/registry'; +import { evaluatePostSubmissionExpression } from './utils/post-submission-action-helper'; +import * as formContext from './ohri-form-context'; +import * as usePostSubmission from './hooks/usePostSubmissionAction'; import { assertFormHasAllFields, @@ -52,6 +57,7 @@ import { } from './utils/test-utils'; import { mockVisit } from '../__mocks__/visit.mock'; import { showToast } from '@openmrs/esm-framework'; +import { PostSubmissionAction } from './api/types'; ////////////////////////////////////////// ////// Base setup @@ -98,6 +104,7 @@ jest.mock('../src/api/api', () => { getConcept: jest.fn().mockImplementation(() => Promise.resolve(null)), getLatestObs: jest.fn().mockImplementation(() => Promise.resolve({ valueNumeric: 60 })), saveEncounter: jest.fn(), + createProgramEnrollment: jest.fn(), }; }); @@ -156,7 +163,7 @@ describe('OHRI Forms:', () => { // Form submission describe('Question Info', () => { - fit('Should ascertain that each field with questionInfo passed will display a tooltip', async () => { + it('Should ascertain that each field with questionInfo passed will display a tooltip', async () => { //render the test form await act(async () => renderForm(null, sample_fields_form)); @@ -234,10 +241,94 @@ describe('OHRI Forms:', () => { expect(encounter.obs.length).toEqual(3); expect(encounter.obs.find((obs) => obs.formFieldPath === 'ohri-forms-hivEnrolmentDate')).toBeUndefined(); }); + it('should evaluate post submission enabled flag expression', () => { + const encounters = [ + { + uuid: '47cfe95b-357a-48f8-aa70-63eb5ae51916', + obs: [ + { + formFieldPath: 'ohri-forms-tbProgramType', + value: { + display: 'Tuberculosis treatment program', + uuid: '160541AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }, + { + formFieldPath: 'ohri-forms-tbRegDate', + value: '2023-12-05T00:00:00.000+0000', + }, + ], + }, + ]; + + const expression1 = "tbProgramType === '160541AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'"; + const expression2 = "tbProgramType === '160052AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'"; + let enabled = evaluatePostSubmissionExpression(expression1, encounters); + expect(enabled).toEqual(true); + enabled = evaluatePostSubmissionExpression(expression2, encounters); + expect(enabled).toEqual(false); + }); + it('Should test post submission actions', async () => { + const saveEncounterMock = jest.spyOn(api, 'saveEncounter'); + saveEncounterMock.mockResolvedValue({ + headers: null, + ok: true, + redirected: false, + status: 200, + statusText: 'ok', + type: 'default', + url: '', + clone: null, + body: null, + bodyUsed: null, + arrayBuffer: null, + blob: null, + formData: null, + json: null, + text: jest.fn(), + data: [ + { + uuid: '47cfe95b-357a-48f8-aa70-63eb5ae51916', + obs: [ + { + formFieldPath: 'ohri-forms-tbProgramType', + value: { + display: 'Tuberculosis treatment program', + uuid: '160541AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + }, + { + formFieldPath: 'ohri-forms-tbRegDate', + value: '2023-12-05T00:00:00.000+0000', + }, + ], + }, + ], + }); + + // Render the form + await act(async () => renderForm(null, postSubmission_test_form)); + const drugSensitiveProgramField = await findRadioGroupMember(screen, 'Drug-susceptible (DS) TB Program'); + const enrolmentDateField = await findTextOrDateInput(screen, 'Date enrolled in tuberculosis (TB) care'); + const treatmentNumber = await findNumberInput(screen, 'DS TB Treatment Number'); + + // Simulate user interaction + fireEvent.click(drugSensitiveProgramField); + fireEvent.blur(enrolmentDateField, { target: { value: '2023-12-12T00:00:00.000Z' } }); + fireEvent.blur(treatmentNumber, { target: { value: '11200' } }); + + // Simulate a successful form submission + await act(async () => { + fireEvent.submit(screen.getByText(/save/i)); + }); + + expect(saveEncounterMock).toHaveBeenCalled(); + await act(async () => expect(saveEncounterMock).toReturn()); + }); }); describe('obs group count validation', () => { - fit('should show error toast when the obs group count does not match the number count specified', async () => { + it('should show error toast when the obs group count does not match the number count specified', async () => { await act(async () => renderForm(null, labour_and_delivery_test_form)); //Number of babies born from this pregnancy @@ -555,7 +646,7 @@ describe('OHRI Forms:', () => { await act(async () => expect(birthDateFields.length).toBe(2)); }); - fit('Should test deletion of a group', async () => { + it('Should test deletion of a group', async () => { //Setup await act(async () => renderForm(null, obsGroup_test_form)); let femaleRadios = await findAllRadioGroupMembers(screen, 'Female'); diff --git a/src/ohri-form.component.tsx b/src/ohri-form.component.tsx index 4077e20cf..852ddb502 100644 --- a/src/ohri-form.component.tsx +++ b/src/ohri-form.component.tsx @@ -28,6 +28,7 @@ import LoadingIcon from './components/loaders/loading.component'; import OHRIFormSidebar from './components/sidebar/ohri-form-sidebar.component'; import WarningModal from './components/warning-modal.component'; import styles from './ohri-form.component.scss'; +import { evaluatePostSubmissionExpression } from './utils/post-submission-action-helper'; interface OHRIFormProps { patientUUID: string; @@ -200,7 +201,7 @@ const OHRIForm: React.FC = ({ // Post Submission Actions if (postSubmissionHandlers) { await Promise.all( - postSubmissionHandlers.map(async ({ postAction, config, actionId }) => { + postSubmissionHandlers.map(async ({ postAction, config, actionId, enabled }) => { try { const encounterData = []; if (results) { @@ -210,14 +211,17 @@ const OHRIForm: React.FC = ({ } }); if (encounterData.length) { - await postAction.applyAction( - { - patient, - sessionMode, - encounters: encounterData, - }, - config, - ); + const isActionEnabled = enabled ? evaluatePostSubmissionExpression(enabled, encounterData) : true; + if (isActionEnabled) { + await postAction.applyAction( + { + patient, + sessionMode, + encounters: encounterData, + }, + config, + ); + } } else { throw new Error('No encounter data to process post submission action'); } diff --git a/src/post-submission-actions/program-enrollment-action.ts b/src/post-submission-actions/program-enrollment-action.ts new file mode 100644 index 000000000..8f0b70016 --- /dev/null +++ b/src/post-submission-actions/program-enrollment-action.ts @@ -0,0 +1,97 @@ +import { showToast } from '@openmrs/esm-framework'; +import { createProgramEnrollment, getPatientEnrolledPrograms, updateProgramEnrollment } from '../api/api'; +import { PostSubmissionAction, ProgramEnrollmentPayload } from '../api/types'; +import dayjs from 'dayjs'; + +export const ProgramEnrollmentSubmissionAction: PostSubmissionAction = { + applyAction: async function ({ patient, encounters, sessionMode }, config) { + const encounter = encounters[0]; + const encounterLocation = encounter.location['uuid']; + // only do this in enter or edit mode. + if (sessionMode === 'view') { + return; + } + + const enrollmentDate = encounter.obs?.find((item) => item.formFieldPath.includes(config.enrollmentDate))?.value; + const completionDate = encounter.obs?.find((item) => item.formFieldPath.includes(config.completionDate))?.value; + const programUuid = config.programUuid; + + if (programUuid) { + const abortController = new AbortController(); + const payload: ProgramEnrollmentPayload = { + patient: patient.id, + program: programUuid, + dateEnrolled: enrollmentDate ? dayjs(enrollmentDate).format() : null, + dateCompleted: completionDate ? dayjs(completionDate).format() : null, + location: encounterLocation, + }; + + if (sessionMode === 'enter') { + const patientEnrolledPrograms = await getPatientEnrolledPrograms(patient.id); + if (patientEnrolledPrograms) { + const hasActiveEnrollment = patientEnrolledPrograms.results.some( + (enrollment) => enrollment.program.uuid === programUuid && enrollment.dateCompleted === null, + ); + if (hasActiveEnrollment) { + throw new Error('Cannot enroll patient to program. Patient already has an active enrollment'); + } + } + createProgramEnrollment(payload, abortController).subscribe( + (response) => { + if (response.status === 201) { + showToast({ + critical: true, + kind: 'success', + description: 'It is now visible in the Programs table', + title: 'Program enrollment saved', + }); + } + }, + (err) => { + showToast({ + title: 'Error saving program enrollment', + kind: 'error', + critical: false, + description: err?.message, + }); + }, + ); + } else { + const patientEnrolledPrograms = await getPatientEnrolledPrograms(patient.id); + let patientProgramEnrollment = null; + if (patientEnrolledPrograms) { + patientProgramEnrollment = patientEnrolledPrograms.results.find( + (enrollment) => enrollment.program.uuid === programUuid && enrollment.dateCompleted === null, + ); + } + + if (patientProgramEnrollment) { + updateProgramEnrollment(patientProgramEnrollment.uuid, payload, abortController).subscribe( + (response) => { + if (response.status === 200) { + showToast({ + critical: true, + kind: 'success', + description: 'Changes to the program are now visible in the Programs table', + title: 'Program enrollment updated', + }); + } + }, + (err) => { + showToast({ + title: 'Error saving enrollment', + kind: 'error', + critical: false, + description: err?.message, + }); + }, + ); + } + } + } else { + throw new Error('Please provide Program Uuid to enroll patient to.'); + } + }, +}; + +export default ProgramEnrollmentSubmissionAction; diff --git a/src/registry/inbuilt-components/InbuiltPostSubmissionActions.ts b/src/registry/inbuilt-components/InbuiltPostSubmissionActions.ts new file mode 100644 index 000000000..7ac6e627f --- /dev/null +++ b/src/registry/inbuilt-components/InbuiltPostSubmissionActions.ts @@ -0,0 +1,9 @@ +import { PostSubmissionAction } from '../../api/types'; +import { ComponentRegistration } from '../registry'; + +export const inbuiltPostSubmissionActions: Array> = [ + { + name: 'ProgramEnrollmentSubmissionAction', + load: () => import('../../post-submission-actions/program-enrollment-action'), + }, +]; diff --git a/src/registry/registry.ts b/src/registry/registry.ts index a3c56d619..5c4879944 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -6,6 +6,7 @@ import { inbuiltFieldSubmissionHandlers } from './inbuilt-components/inbuiltFiel import { inbuiltValidators } from './inbuilt-components/inbuiltValidators'; import { inbuiltDataSources } from './inbuilt-components/inbuiltDataSources'; import { getControlTemplate } from './inbuilt-components/control-templates'; +import { inbuiltPostSubmissionActions } from './inbuilt-components/InbuiltPostSubmissionActions'; /** * @internal @@ -91,11 +92,11 @@ export async function getRegisteredControl(renderType: string) { if (registryCache.controls[renderType]) { return registryCache.controls[renderType]; } - let component = inbuiltControls.find(item => item.type === renderType || item?.alias === renderType)?.component; + let component = inbuiltControls.find((item) => item.type === renderType || item?.alias === renderType)?.component; // if undefined, try serching through the registered custom controls if (!component) { const importedControl = await getFormsStore() - .controls.find(item => item.type === renderType || item?.alias === renderType) + .controls.find((item) => item.type === renderType || item?.alias === renderType) ?.load?.(); component = importedControl?.default; } @@ -110,11 +111,11 @@ export async function getRegisteredFieldSubmissionHandler(type: string): Promise if (registryCache.fieldSubmissionHandlers[type]) { return registryCache.fieldSubmissionHandlers[type]; } - let handler = inbuiltFieldSubmissionHandlers.find(handler => handler.type === type)?.component; + let handler = inbuiltFieldSubmissionHandlers.find((handler) => handler.type === type)?.component; // if undefined, try serching through the registered custom handlers if (!handler) { const handlerImport = await getFormsStore() - .fieldSubmissionHandlers.find(handler => handler.type === type) + .fieldSubmissionHandlers.find((handler) => handler.type === type) ?.load?.(); handler = handlerImport?.default; } @@ -126,25 +127,28 @@ export async function getRegisteredPostSubmissionAction(actionId: string) { if (registryCache.postSubmissionActions[actionId]) { return registryCache.postSubmissionActions[actionId]; } - const lazy = getFormsStore().postSubmissionActions.find(registration => registration.name === actionId)?.load; - if (lazy) { - const actionImport = await lazy(); - registryCache.postSubmissionActions[actionId] = actionImport.default; - return actionImport.default; - } else { - console.error(`No loader found for PostSubmissionAction registration of id: ${actionId}`); + let lazy = await inbuiltPostSubmissionActions.find((registration) => registration.name === actionId)?.load; + let actionImport = (await lazy()) ?? null; + if (!actionImport) { + lazy = getFormsStore().postSubmissionActions.find((registration) => registration.name === actionId)?.load; + if (lazy) { + actionImport = await lazy(); + registryCache.postSubmissionActions[actionId] = actionImport.default; + } else { + console.error(`No loader found for PostSubmissionAction registration of id: ${actionId}`); + } } - return null; + return actionImport.default ?? null; } export async function getRegisteredValidator(name: string): Promise { if (registryCache.validators[name]) { return registryCache.validators[name]; } - let validator = inbuiltValidators.find(validator => validator.name === name)?.component; + let validator = inbuiltValidators.find((validator) => validator.name === name)?.component; if (!validator) { const validatorImport = await getFormsStore() - .fieldValidators.find(validator => validator.name === name) + .fieldValidators.find((validator) => validator.name === name) ?.load?.(); validator = validatorImport?.default; } @@ -156,14 +160,14 @@ export async function getRegisteredDataSource(name: string): Promise dataSource.name === name)?.component; + let ds = inbuiltDataSources.find((dataSource) => dataSource.name === name)?.component; if (!ds) { const template = getControlTemplate(name); if (template) { - ds = inbuiltDataSources.find(dataSource => dataSource.name === template.datasource.name)?.component; + ds = inbuiltDataSources.find((dataSource) => dataSource.name === template.datasource.name)?.component; } else { const dataSourceImport = await getFormsStore() - .dataSources.find(ds => ds.name === name) + .dataSources.find((ds) => ds.name === name) ?.load?.(); if (!dataSourceImport) { throw new Error('Datasource not found'); diff --git a/src/utils/expression-parser.ts b/src/utils/expression-parser.ts index e14c50f74..cc3f77cbd 100644 --- a/src/utils/expression-parser.ts +++ b/src/utils/expression-parser.ts @@ -63,10 +63,10 @@ export function linkReferencedFieldValues( tokens: string[], ): string { const processedTokens = []; - tokens.forEach(token => { + tokens.forEach((token) => { if (hasParentheses(token)) { let tokenWithUnresolvedArgs = token; - extractArgs(token).forEach(arg => { + extractArgs(token).forEach((arg) => { const referencedField = findReferencedFieldIfExists(arg, fields); if (referencedField) { tokenWithUnresolvedArgs = replaceFieldRefWithValuePath( @@ -141,9 +141,9 @@ export function findAndRegisterReferencedFields( tokens: string[], fields: Array, ): void { - tokens.forEach(token => { + tokens.forEach((token) => { if (hasParentheses(token)) { - extractArgs(token).forEach(arg => { + extractArgs(token).forEach((arg) => { registerDependency(fieldNode, findReferencedFieldIfExists(arg, fields)); }); } else { @@ -157,5 +157,5 @@ function findReferencedFieldIfExists(fieldId: string, fields: OHRIFormField[]): if (/^'+|'+$/.test(fieldId)) { fieldId = fieldId.replace(/^'|'$/g, ''); } - return fields.find(field => field.id === fieldId); + return fields.find((field) => field.id === fieldId); } diff --git a/src/utils/post-submission-action-helper.ts b/src/utils/post-submission-action-helper.ts new file mode 100644 index 000000000..cb66ea740 --- /dev/null +++ b/src/utils/post-submission-action-helper.ts @@ -0,0 +1,71 @@ +export function evaluatePostSubmissionExpression(expression: string, encounters: any[]): boolean { + const encounter = encounters[0]; + const regx = /(?:\w+|'(?:\\'|[^'\n])*')/g; + let match; + const fieldIds = new Set(); + try { + while ((match = regx.exec(expression))) { + const value = match[0].replace(/\\'/g, "'"); // Replace escaped single quotes + + const isBoolean = /^(true|false)$/i.test(value); + const isNumber = /^-?\d+$/.test(value); + const isFloat = /^-?\d+\.\d+$/.test(value); + + if ( + !(value.startsWith("'") && value.endsWith("'")) && + typeof value === 'string' && + !isBoolean && + !isNumber && + !isFloat + ) { + fieldIds.add(value); + } + } + + let fieldToValueMap = {}; + let replacedExpression; + if (fieldIds.size) { + fieldToValueMap = getFieldValues(fieldIds, encounter); + } + + if (Object.keys(fieldToValueMap).length) { + replacedExpression = expression.replace(/(\w+)/g, (match) => { + return fieldToValueMap.hasOwnProperty(match) ? fieldToValueMap[match] : match; + }); + } else { + replacedExpression = expression; + } + + return eval(replacedExpression); + } catch (error) { + throw new Error('Error evaluating expression'); + } +} + +function getFieldValues(fieldIds: Set, encounter: any): Record { + const result: Record = {}; + fieldIds.forEach((fieldId) => { + let value = encounter.obs?.find((item) => item.formFieldPath.includes(fieldId))?.value; + if (typeof value === 'object') { + value = value.uuid; + } + if (value) { + value = formatValue(value); + } + result[fieldId] = value; + }); + + return result; +} + +//This function wraps string values in single quotes which Javascript will evaluate +function formatValue(value: any): any { + if (typeof value === 'string') { + if (value.length >= 2 && value[0] === "'" && value[value.length - 1] === "'") { + return value; + } else { + return `'${value}'`; + } + } + return value; +}