Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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 "mutatable" 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