Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
// Set timezone to UTC for consistent date handling in tests
process.env.TZ = 'UTC';

/** @type {import('jest').Config} */
module.exports = {
verbose: true,
Expand Down
128 changes: 94 additions & 34 deletions src/components/processor-factory/form-processor-factory.component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { type OpenmrsResource } from '@openmrs/esm-framework';
import useProcessorDependencies from '../../hooks/useProcessorDependencies';
import useInitialValues from '../../hooks/useInitialValues';
import { FormRenderer } from '../renderer/form/form-renderer.component';
Expand All @@ -22,12 +23,20 @@ interface FormProcessorFactoryProps {
setIsLoadingFormDependencies: (isLoading: boolean) => void;
}

// Mutable parts of the context that can be updated by processors/hooks
interface MutableContextState {
domainObjectValue?: OpenmrsResource;
previousDomainObjectValue?: OpenmrsResource;
customDependencies?: Record<string, any>;
}

const FormProcessorFactory = ({
formJson,
isSubForm = false,
setIsLoadingFormDependencies,
}: FormProcessorFactoryProps) => {
const { patient, sessionMode, formProcessors, layoutType, location, provider, sessionDate, visit } = useFormFactory();
const { t } = useTranslation();

const processor = useMemo(() => {
const ProcessorClass = formProcessors[formJson.processor];
Expand All @@ -38,58 +47,109 @@ const FormProcessorFactory = ({
return new EncounterFormProcessor(formJson);
}, [formProcessors, formJson.processor]);

const [processorContext, setProcessorContext] = useState<FormProcessorContextProps>({
patient,
formJson,
sessionMode,
layoutType,
location,
currentProvider: provider,
processor,
sessionDate,
visit,
formFields: [],
formFieldAdapters: {},
formFieldValidators: {},
});
const { t } = useTranslation();
// Derive form fields and related data
const { formFields: rawFormFields, conceptReferences } = useFormFields(formJson);
const { concepts: formFieldsConcepts, isLoading: isLoadingConcepts } = useConcepts(Array.from(conceptReferences));
const formFieldsWithMeta = useFormFieldsMeta(rawFormFields, formFieldsConcepts);
const formFieldAdapters = useFormFieldValueAdapters(rawFormFields);
const formFieldValidators = useFormFieldValidators(rawFormFields);

const formFields = useMemo(
() => (formFieldsWithMeta?.length ? formFieldsWithMeta : rawFormFields ?? []),
[formFieldsWithMeta, rawFormFields],
);

// We divide the context into the "mutable" parts, which can be changed by custom hooks
// and the "static" parts
const [mutableContext, setMutableContext] = useState<MutableContextState>({});

// Create the "static" part of the context
const baseContext = useMemo<Omit<FormProcessorContextProps, keyof MutableContextState>>(
() => ({
patient,
formJson,
sessionMode,
layoutType,
location,
currentProvider: provider,
processor,
sessionDate,
visit,
formFields,
formFieldAdapters: formFieldAdapters ?? {},
formFieldValidators: formFieldValidators ?? {},
}),
[
patient,
formJson,
sessionMode,
layoutType,
location,
provider,
processor,
sessionDate,
visit,
formFields,
formFieldAdapters,
formFieldValidators,
],
);

// re-create the full processor context
const processorContext = useMemo<FormProcessorContextProps>(
() => ({
...baseContext,
...mutableContext,
}),
[baseContext, mutableContext],
);

// callback to update the mutable part of the context
const setProcessorContext = useCallback(
(updater: FormProcessorContextProps | ((prev: FormProcessorContextProps) => FormProcessorContextProps)) => {
setMutableContext((prevMutable) => {
// Build the "previous" full context to pass to the updater
const prevFull: FormProcessorContextProps = {
...baseContext,
...prevMutable,
};

const newFull = typeof updater === 'function' ? updater(prevFull) : updater;

// Extract only the mutable parts from the result
return {
domainObjectValue: newFull.domainObjectValue,
previousDomainObjectValue: newFull.previousDomainObjectValue,
customDependencies: newFull.customDependencies,
};
});
},
[baseContext],
);

const { isLoading: isLoadingCustomDeps } = useProcessorDependencies(processor, processorContext, setProcessorContext);
const useCustomHooks = processor.getCustomHooks().useCustomHooks;
const [isLoadingCustomHooks, setIsLoadingCustomHooks] = useState(!!useCustomHooks);
const [isLoadingProcessorDependencies, setIsLoadingProcessorDependencies] = useState(true);
const {
isLoadingInitialValues,
initialValues,
error: initialValuesError,
} = useInitialValues(processor, isLoadingCustomDeps || isLoadingCustomHooks || isLoadingConcepts, processorContext);

useEffect(() => {
const isLoading = isLoadingCustomDeps || isLoadingCustomHooks || isLoadingConcepts || isLoadingInitialValues;
setIsLoadingFormDependencies(isLoading);
setIsLoadingProcessorDependencies(isLoading);
}, [isLoadingCustomDeps, isLoadingCustomHooks, isLoadingConcepts, isLoadingInitialValues]);
// Derive loading state with useMemo to avoid effect-based state updates
const isLoadingProcessorDependencies = useMemo(
() => isLoadingCustomDeps || isLoadingCustomHooks || isLoadingConcepts || isLoadingInitialValues,
[isLoadingCustomDeps, isLoadingCustomHooks, isLoadingConcepts, isLoadingInitialValues],
);

// Notify parent of loading state changes
useEffect(() => {
setProcessorContext((prev) => ({
...prev,
...(formFieldAdapters && { formFieldAdapters }),
...(formFieldValidators && { formFieldValidators }),
...(formFieldsWithMeta?.length
? { formFields: formFieldsWithMeta }
: rawFormFields?.length
? { formFields: rawFormFields }
: {}),
}));
}, [formFieldAdapters, formFieldValidators, rawFormFields, formFieldsWithMeta]);
setIsLoadingFormDependencies(isLoadingProcessorDependencies);
}, [isLoadingProcessorDependencies, setIsLoadingFormDependencies]);

useEffect(() => {
reportError(initialValuesError, t('errorLoadingInitialValues', 'Error loading initial values'));
}, [initialValuesError]);
}, [initialValuesError, t]);

useEffect(() => {
if (formFieldAdapters) {
Expand Down
8 changes: 7 additions & 1 deletion src/form-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,13 @@ jest.mock('./hooks/useEncounterRole', () => ({

jest.mock('./hooks/useConcepts', () => ({
useConcepts: jest.fn().mockImplementation((references: Set<string>) => {
if ([...references].join(',').includes('PIH:Occurrence of trauma,PIH:Yes,PIH:No,PIH:COUGH')) {
const refArray = [...references];
const hasAllRefs =
refArray.includes('PIH:Occurrence of trauma') &&
refArray.includes('PIH:Yes') &&
refArray.includes('PIH:No') &&
refArray.includes('PIH:COUGH');
if (hasAllRefs) {
return {
isLoading: false,
concepts: mockConcepts.results,
Expand Down
39 changes: 26 additions & 13 deletions src/hooks/useFormFieldValidators.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,35 @@
import { useEffect, useState } from 'react';
import { useMemo, useRef } from 'react';
import useSWRImmutable from 'swr/immutable';
import { getRegisteredValidator } from '../registry/registry';
import { type FormField, type FormFieldValidator } from '../types';

export function useFormFieldValidators(fields: FormField[]) {
const [validators, setValidators] = useState<Record<string, FormFieldValidator>>();

useEffect(() => {
const supportedTypes = new Set<string>();
const validatorTypesKey = useMemo(() => {
const uniqueTypes = new Set<string>();
fields.forEach((field) => {
field.validators?.forEach((validator) => supportedTypes.add(validator.type));
});
const supportedTypesArray = Array.from(supportedTypes);
Promise.all(supportedTypesArray.map((type) => getRegisteredValidator(type))).then((validators) => {
setValidators(
Object.assign({}, ...validators.map((validator, index) => ({ [supportedTypesArray[index]]: validator }))),
);
field.validators?.forEach((validator) => uniqueTypes.add(validator.type));
});
return Array.from(uniqueTypes).sort().join(',');
}, [fields]);

return validators;
const validatorsRef = useRef<Record<string, FormFieldValidator>>({});

const { data: validators } = useSWRImmutable(
validatorTypesKey ? ['formFieldValidators', validatorTypesKey] : null,
async ([, key]) => {
const types = key.split(',');
const loadedValidators = await Promise.all(types.map((type) => getRegisteredValidator(type)));
const validatorsByType: Record<string, FormFieldValidator> = {};
types.forEach((type, index) => {
validatorsByType[type] = loadedValidators[index];
});
return validatorsByType;
},
);

if (validators && validators !== validatorsRef.current) {
validatorsRef.current = validators;
}

return validatorsRef.current;
}
42 changes: 26 additions & 16 deletions src/hooks/useFormFieldValueAdapters.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,33 @@
import { useState, useEffect } from 'react';
import { useMemo, useRef } from 'react';
import useSWRImmutable from 'swr/immutable';
import { type FormField, type FormFieldValueAdapter } from '../types';
import { getRegisteredFieldValueAdapter } from '../registry/registry';

export const useFormFieldValueAdapters = (fields: FormField[]) => {
const [adapters, setAdapters] = useState<Record<string, FormFieldValueAdapter>>({});

useEffect(() => {
const supportedTypes = new Set<string>();
fields.forEach((field) => {
supportedTypes.add(field.type);
});
const supportedTypesArray = Array.from(supportedTypes);
Promise.all(supportedTypesArray.map((type) => getRegisteredFieldValueAdapter(type))).then((adapters) => {
const adaptersByType = supportedTypesArray.map((type, index) => ({
[type]: adapters[index],
}));
setAdapters(Object.assign({}, ...adaptersByType));
});
const typesKey = useMemo(() => {
const uniqueTypes = new Set<string>();
fields.forEach((field) => uniqueTypes.add(field.type));
return Array.from(uniqueTypes).sort().join(',');
}, [fields]);

return adapters;
const adaptersRef = useRef<Record<string, FormFieldValueAdapter>>({});

const { data: adapters } = useSWRImmutable(
typesKey ? ['formFieldValueAdapters', typesKey] : null,
async ([, key]) => {
const types = key.split(',');
const loadedAdapters = await Promise.all(types.map((type) => getRegisteredFieldValueAdapter(type)));
const adaptersByType: Record<string, FormFieldValueAdapter> = {};
types.forEach((type, index) => {
adaptersByType[type] = loadedAdapters[index];
});
return adaptersByType;
},
);

if (adapters && adapters !== adaptersRef.current) {
adaptersRef.current = adapters;
}

return adaptersRef.current;
};
76 changes: 33 additions & 43 deletions src/hooks/useFormFields.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,55 @@
import { useMemo } from 'react';
import { useMemo, useRef } from 'react';
import { type FormField, type FormSchema } from '../types';

export function useFormFields(form: FormSchema): { formFields: FormField[]; conceptReferences: Set<string> } {
const [flattenedFields, conceptReferences] = useMemo(() => {
const conceptReferencesRef = useRef<Set<string>>(new Set());

const [flattenedFields, conceptReferencesKey] = useMemo(() => {
const flattenedFieldsTemp: FormField[] = [];
const conceptReferencesTemp = new Set<string>();

const processFlattenedFields = (
fields: FormField[],
): {
flattenedFields: FormField[];
conceptReferences: Set<string>;
} => {
const flattenedFields: FormField[] = [];
const conceptReferences = new Set<string>();
const processField = (field: FormField, parentGroupId?: string) => {
// Add group ID to nested fields if applicable
const processedField = parentGroupId ? { ...field, meta: { ...field.meta, groupId: parentGroupId } } : field;

const processField = (field: FormField, parentGroupId?: string) => {
// Add group ID to nested fields if applicable
const processedField = parentGroupId ? { ...field, meta: { ...field.meta, groupId: parentGroupId } } : field;
// Add field to flattened list
flattenedFieldsTemp.push(processedField);

// Add field to flattened list
flattenedFields.push(processedField);
// Collect concept references
if (processedField.questionOptions?.concept) {
conceptReferencesTemp.add(processedField.questionOptions.concept);
}

// Collect concept references
if (processedField.questionOptions?.concept) {
conceptReferences.add(processedField.questionOptions.concept);
// Collect concept references from answers
processedField.questionOptions?.answers?.forEach((answer) => {
if (answer.concept) {
conceptReferencesTemp.add(answer.concept);
}
});

// Collect concept references from answers
processedField.questionOptions?.answers?.forEach((answer) => {
if (answer.concept) {
conceptReferences.add(answer.concept);
}
// Recursively process nested questions for obsGroup
if (processedField.type === 'obsGroup' && processedField.questions) {
processedField.questions.forEach((nestedField) => {
processField(nestedField, processedField.id);
});

// Recursively process nested questions for obsGroup
if (processedField.type === 'obsGroup' && processedField.questions) {
processedField.questions.forEach((nestedField) => {
processField(nestedField, processedField.id);
});
}
};

// Process all input fields
fields.forEach((field) => processField(field));

return { flattenedFields, conceptReferences };
}
};

// Process all input fields
form.pages?.forEach((page) =>
page.sections?.forEach((section) => {
if (section.questions) {
const { flattenedFields, conceptReferences } = processFlattenedFields(section.questions);
flattenedFieldsTemp.push(...flattenedFields);
conceptReferences.forEach((conceptReference) => conceptReferencesTemp.add(conceptReference));
}
section.questions?.forEach((field) => processField(field));
}),
);

return [flattenedFieldsTemp, conceptReferencesTemp];
const sortedRefs = Array.from(conceptReferencesTemp).sort();
return [flattenedFieldsTemp, sortedRefs.join(',')];
}, [form]);

return { formFields: flattenedFields, conceptReferences };
const currentKey = Array.from(conceptReferencesRef.current).sort().join(',');
if (conceptReferencesKey !== currentKey) {
conceptReferencesRef.current = new Set(conceptReferencesKey.split(',').filter(Boolean));
}

return { formFields: flattenedFields, conceptReferences: conceptReferencesRef.current };
}
Loading