import { useEffect, useState } from 'react'; import { type OpenmrsResource, showSnackbar, translateFrom } from '@openmrs/esm-framework'; import { getMutableSessionProps, hydrateRepeatField, inferInitialValueFromDefaultFieldValue, prepareEncounter, preparePatientIdentifiers, preparePatientPrograms, preparePersonAttributes, saveAttachments, savePatientIdentifiers, savePatientPrograms, savePersonAttributes, } from './encounter-processor-helper'; import { type FormField, type FormPage, type FormProcessorContextProps, type FormSchema, type FormSection, type ValueAndDisplay, } from '../../types'; import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-runner'; import { extractErrorMessagesFromResponse } from '../../utils/error-utils'; import { extractObsValueAndDisplay } from '../../utils/form-helper'; import { FormProcessor } from '../form-processor'; import { getPreviousEncounter, saveEncounter } from '../../api'; import { hasRendering } from '../../utils/common-utils'; import { isEmpty } from '../../validators/form-validator'; import { formEngineAppName } from '../../globals'; import { type FormContextProps } from '../../provider/form-provider'; import { useEncounter } from '../../hooks/useEncounter'; import { useEncounterRole } from '../../hooks/useEncounterRole'; import { usePatientPrograms } from '../../hooks/usePatientPrograms'; import { type TOptions } from 'i18next'; function useCustomHooks(context: Partial) { const [isLoading, setIsLoading] = useState(true); const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson); const { encounterRole, isLoading: isLoadingEncounterRole } = useEncounterRole(); const { isLoadingPatientPrograms, patientPrograms } = usePatientPrograms(context.patient?.id, context.formJson); useEffect(() => { setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole); }, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole]); return { data: { encounter, patientPrograms, encounterRole }, isLoading, error: null, updateContext: (setContext: React.Dispatch>) => { setContext((context) => { context.processor.domainObjectValue = encounter as OpenmrsResource; return { ...context, domainObjectValue: encounter as OpenmrsResource, customDependencies: { ...context.customDependencies, patientPrograms: patientPrograms, defaultEncounterRole: encounterRole, }, }; }); }, }; } const emptyValues = { checkbox: [], toggle: false, text: '', }; const contextInitializableTypes = [ 'encounterProvider', 'encounterDatetime', 'encounterLocation', 'patientIdentifier', 'encounterRole', 'programState', 'personAttribute', ]; export class EncounterFormProcessor extends FormProcessor { prepareFormSchema(schema: FormSchema) { const allFieldIds = new Set(); schema.pages.forEach((page) => { page.sections.forEach((section) => { section.questions.forEach((question) => { prepareFormField(question, section, page, schema); }); }); }); function prepareFormField(field: FormField, section: FormSection, page: FormPage, schema: FormSchema) { // Collect field ID if (field.id) { allFieldIds.add(field.id); } // inherit inlineRendering and readonly from parent section and page if not set field.inlineRendering = field.inlineRendering ?? section.inlineRendering ?? page.inlineRendering ?? schema.inlineRendering; field.readonly = field.readonly ?? section.readonly ?? page.readonly ?? schema.readonly; if (field.questionOptions?.rendering == 'fixed-value' && !field.meta.fixedValue) { field.meta.fixedValue = field.value; delete field.value; } if (field.questionOptions?.rendering == 'group' || field.type === 'obsGroup') { field.questions?.forEach((child) => { child.readonly = child.readonly ?? field.readonly; return prepareFormField(child, section, page, schema); }); } } // Validate calculate expressions for common mistakes validateCalculateExpressions(schema, allFieldIds); return schema; } async processSubmission(context: FormContextProps, abortController: AbortController) { const { encounterRole, encounterProvider, encounterDate, encounterLocation } = getMutableSessionProps(context); const t = (key: string, defaultValue: string, options?: Omit) => translateFrom(formEngineAppName, key, defaultValue, options); const patientIdentifiers = preparePatientIdentifiers(context.formFields, encounterLocation); const encounter = prepareEncounter(context, encounterDate, encounterRole, encounterProvider, encounterLocation); // save patient identifiers try { await Promise.all(savePatientIdentifiers(context.patient, patientIdentifiers)); if (patientIdentifiers?.length) { showSnackbar({ title: t('patientIdentifiersSaved', 'Patient identifier(s) saved successfully'), kind: 'success', isLowContrast: true, }); } } catch (error) { const errorMessages = extractErrorMessagesFromResponse(error); return Promise.reject({ title: t('errorSavingPatientIdentifiers', 'Error saving patient identifiers'), subtitle: errorMessages.join(', '), kind: 'error', isLowContrast: false, }); } // save person attributes try { const personAttributes = preparePersonAttributes(context.formFields); await Promise.all(savePersonAttributes(context.patient, personAttributes)); if (personAttributes?.length) { showSnackbar({ title: t('personAttributesSaved', 'Person attribute(s) saved successfully'), kind: 'success', isLowContrast: true, }); } } catch (error) { const errorMessages = extractErrorMessagesFromResponse(error); return Promise.reject({ title: t('errorSavingPersonAttributes', 'Error saving person attributes'), description: errorMessages.join(', '), kind: 'error', critical: true, }); } // save patient programs try { const programs = preparePatientPrograms( context.formFields, context.patient, context.customDependencies.patientPrograms, ); const savedPrograms = await await savePatientPrograms(programs); if (savedPrograms?.length) { showSnackbar({ title: t('patientProgramsSaved', 'Patient program(s) saved successfully'), kind: 'success', isLowContrast: true, }); } } catch (error) { const errorMessages = extractErrorMessagesFromResponse(error); return Promise.reject({ title: t('errorSavingPatientPrograms', 'Error saving patient program(s)'), subtitle: errorMessages.join(', '), kind: 'error', isLowContrast: false, }); } // save encounter try { const { data: savedEncounter } = await saveEncounter(abortController, encounter, encounter.uuid); const savedOrders = savedEncounter.orders.map((order) => order.orderNumber); const savedDiagnoses = savedEncounter.diagnoses.map((diagnosis) => diagnosis.display); if (savedOrders.length) { showSnackbar({ title: t('ordersSaved', 'Order(s) saved successfully'), subtitle: savedOrders.join(', '), kind: 'success', isLowContrast: true, }); } // handle diagnoses if (savedDiagnoses.length) { showSnackbar({ title: t('diagnosisSaved', 'Diagnosis(es) saved successfully'), subtitle: savedDiagnoses.join(', '), kind: 'success', isLowContrast: true, }); } // handle attachments try { const attachmentsResponse = await saveAttachments(context.formFields, savedEncounter, abortController); if (attachmentsResponse?.length) { showSnackbar({ title: t('attachmentsSaved', 'Attachment(s) saved successfully'), kind: 'success', isLowContrast: true, }); } } catch (error) { console.error('Error saving attachments', error); const errorMessages = extractErrorMessagesFromResponse(error); return Promise.reject({ title: t('errorSavingAttachments', 'Error saving attachment(s)'), subtitle: errorMessages.join(', '), kind: 'error', isLowContrast: false, }); } return savedEncounter; } catch (error) { console.error('Error saving encounter', error); const errorMessages = extractErrorMessagesFromResponse(error); return Promise.reject({ title: t('errorSavingEncounter', 'Error saving encounter'), subtitle: errorMessages.join(', '), kind: 'error', isLowContrast: false, }); } } getCustomHooks() { return { useCustomHooks }; } async getInitialValues(context: FormProcessorContextProps) { const { domainObjectValue: encounter, formFields, formFieldAdapters } = context; const initialValues = {}; const repeatableFields = []; if (encounter) { await Promise.all( formFields.map(async (field) => { const adapter = formFieldAdapters[field.type]; if (field.meta.initialValue?.omrsObject && !isEmpty(field.meta.initialValue.refinedValue)) { initialValues[field.id] = field.meta.initialValue.refinedValue; return; } if (adapter) { if (hasRendering(field, 'repeating') && !field.meta?.repeat?.isClone) { repeatableFields.push(field); } let value = null; try { value = await adapter.getInitialValue(field, encounter, context); field.meta.initialValue.refinedValue = value; } catch (error) { console.error(error); } if (field.type === 'obsGroup') { return; } if (!isEmpty(value)) { initialValues[field.id] = value; } else if (!isEmpty(field.questionOptions.defaultValue)) { initialValues[field.id] = inferInitialValueFromDefaultFieldValue(field); } else { initialValues[field.id] = emptyValues[field.questionOptions.rendering] ?? ''; } if (field.questionOptions.calculate?.calculateExpression) { try { await evaluateCalculateExpression(field, initialValues, context); } catch (error) { console.error(error); } } } else { console.warn(`No adapter found for field type ${field.type}`); } }), ); const flattenedRepeatableFields = await Promise.all( repeatableFields.flatMap((field) => hydrateRepeatField(field, encounter, initialValues, context)), ).then((results) => results.flat()); formFields.push(...flattenedRepeatableFields); } else { const filteredFields = formFields.filter( (field) => field.questionOptions.rendering !== 'group' && field.type !== 'obsGroup', ); const fieldsWithCalculateExpressions = []; await Promise.all( filteredFields.map(async (field) => { const adapter = formFieldAdapters[field.type]; initialValues[field.id] = emptyValues[field.questionOptions.rendering] ?? null; if (isEmpty(initialValues[field.id]) && contextInitializableTypes.includes(field.type)) { try { initialValues[field.id] = await adapter.getInitialValue(field, null, context); } catch (error) { console.error(error); } } if (field.questionOptions.defaultValue) { initialValues[field.id] = inferInitialValueFromDefaultFieldValue(field); } if (field.questionOptions.calculate?.calculateExpression) { fieldsWithCalculateExpressions.push(field); } }), ); await Promise.all( fieldsWithCalculateExpressions.map(async (field) => { try { await evaluateCalculateExpression(field, initialValues, context); } catch (error) { console.error(error); } }), ); } return initialValues; } async loadDependencies( context: FormContextProps, setContext: React.Dispatch>, ) { const { patient, formJson } = context; const encounter = await getPreviousEncounter(patient?.id, formJson.encounterType); setContext((context) => { return { ...context, previousDomainObjectValue: encounter, }; }); return context; } async getHistoricalValue(field: FormField, context: FormContextProps): Promise { const { formFields, sessionMode, patient, methods: { getValues }, formFieldAdapters, previousDomainObjectValue, visit, } = context; const node: FormNode = { value: field, type: 'field' }; const adapter = formFieldAdapters[field.type]; if (field.historicalExpression) { const value = await evaluateAsyncExpression(field.historicalExpression, node, formFields, getValues(), { mode: sessionMode, patient: patient, previousEncounter: previousDomainObjectValue, visit, }); return value ? extractObsValueAndDisplay(field, value) : null; } if (previousDomainObjectValue && field.questionOptions.enablePreviousValue) { return await adapter.getPreviousValue(field, previousDomainObjectValue, context); } return null; } } async function evaluateCalculateExpression( field: FormField, values: Record, formContext: FormProcessorContextProps, ) { const { formFields, sessionMode, patient, visit } = formContext; const expression = field.questionOptions.calculate.calculateExpression; const node: FormNode = { value: field, type: 'field' }; const context = { mode: sessionMode, patient: patient, visit, }; const value = await evaluateAsyncExpression(expression, node, formFields, values, context); if (!isEmpty(value)) { values[field.id] = value; } } /** * Validates calculate expressions to warn about common mistakes. * Specifically, checks if string literals in expressions match field IDs, * which usually indicates the user should use bare variable references instead. * * For example: calcEDD('lmp') should be calcEDD(lmp) */ function validateCalculateExpressions(schema: FormSchema, allFieldIds: Set) { const stringLiteralPattern = /(['"])([a-zA-Z_][a-zA-Z0-9_]*)\1/g; function checkExpression(expression: string, fieldId: string) { for (const match of expression.matchAll(stringLiteralPattern)) { const quotedValue = match[2]; if (allFieldIds.has(quotedValue)) { console.error( `The calculateExpression for the field '${fieldId}' incorrectly quotes the field ID '${quotedValue}' as a string. ` + `Field IDs must be referenced as variables without quotes to access their values. ` + `Remove the quotes: use ${quotedValue} instead of '${quotedValue}'.`, ); } } } function processField(field: FormField) { if (field.questionOptions?.calculate?.calculateExpression) { checkExpression(field.questionOptions.calculate.calculateExpression, field.id); } // Process nested questions (for obsGroups) if (field.questions) { field.questions.forEach(processField); } } schema.pages.forEach((page) => { page.sections.forEach((section) => { section.questions.forEach(processField); }); }); }