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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@
},
"dependencies": {
"@carbon/react": "^1.83.0",
"acorn": "^8.15.0",
"acorn-walk": "^8.3.4",
"classnames": "^2.5.1",
"lodash-es": "^4.17.21",
"react-error-boundary": "^4.0.13",
Expand Down
88 changes: 88 additions & 0 deletions src/components/renderer/field/fieldLogic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,94 @@ describe('handleFieldLogic', () => {

expect(mockContext.updateFormField).toHaveBeenCalled();
});

it('should evaluate hide expressions with calculated values', async () => {
// Mock async evaluation to return different values
const mockEvaluateAsyncExpression = require('../../../utils/expression-runner').evaluateAsyncExpression;
mockEvaluateAsyncExpression.mockImplementation((expression: string) => {
if (expression === 'choice1 === "selected"') {
return Promise.resolve(true); // calculate2
} else if (expression === 'calculate2') {
return Promise.resolve(true); // calculate3
}
return Promise.resolve(false);
});

// Mock evaluateExpression for hide
(evaluateExpression as jest.Mock).mockImplementation((expression: string, node, formFields, values) => {
if (expression === 'calculate3 === false') {
return values.calculate3 === false; // should hide when calculate3 is false
}
return false;
});

// Mock getValues to return updated values after calculation
(mockContext.methods.getValues as jest.Mock).mockReturnValue({
choice1: 'selected',
calculate2: true,
calculate3: true,
});

const choiceField = {
id: 'choice1',
type: 'obs',
questionOptions: {
rendering: 'select',
answers: [
{
label: 'Selected',
concept: 'selected',
disable: {
disableWhenExpression: '',
},
},
],
},
fieldDependents: new Set(['calculate2']),
} as FormField;

const calculateField2 = {
id: 'calculate2',
type: 'obs',
questionOptions: {
calculate: { calculateExpression: 'choice1 === "selected"' },
},
fieldDependents: new Set(['calculate3']),
validators: [],
meta: {},
} as FormField;

const calculateField3 = {
id: 'calculate3',
type: 'obs',
questionOptions: {
calculate: { calculateExpression: 'calculate2' },
},
fieldDependents: new Set(['message4']),
validators: [],
meta: {},
} as FormField;

const messageField4 = {
id: 'message4',
type: 'obs',
hide: { hideWhenExpression: 'calculate3 === false' },
isHidden: false,
validators: [],
meta: {},
} as FormField;

mockContext.formFields = [choiceField, calculateField2, calculateField3, messageField4];

// Trigger evaluation starting from choice1
handleFieldLogic(choiceField, mockContext);

// Wait for async operations to complete
await new Promise(resolve => setTimeout(resolve, 100));

// Since calculate3 is true, hide expression should return false (not hidden)
expect(messageField4.isHidden).toBe(false);
});
});

describe('validateFieldValue', () => {
Expand Down
139 changes: 105 additions & 34 deletions src/components/renderer/field/fieldLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@ import { codedTypes } from '../../../constants';
import { type FormContextProps } from '../../../provider/form-provider';
import { type FormFieldValidator, type SessionMode, type ValidationResult, type FormField } from '../../../types';
import { hasRendering } from '../../../utils/common-utils';
import { evaluateAsyncExpression, evaluateExpression } from '../../../utils/expression-runner';
import { evaluateAsyncExpression, evaluateExpression, trackFieldDependenciesFromString } from '../../../utils/expression-runner';
import { evalConditionalRequired, evaluateDisabled, evaluateHide, findFieldSection } from '../../../utils/form-helper';
import { isEmpty } from '../../../validators/form-validator';
import { reportError } from '../../../utils/error-utils';
import { extractVariableNamesFromExpression } from '../../../utils/variable-extractor';

export function handleFieldLogic(field: FormField, context: FormContextProps) {
const {
Expand Down Expand Up @@ -38,7 +39,7 @@ function evaluateFieldAnswerDisabled(field: FormField, values: Record<string, an
});
}

function evaluateFieldDependents(field: FormField, values: any, context: FormContextProps) {
function evaluateFieldDependents(field: FormField, values: any, context: FormContextProps, evaluatedFields = new Set<string>()) {
const {
sessionMode,
formFields,
Expand All @@ -54,8 +55,16 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
let shouldUpdateForm = false;
// handle fields
if (field.fieldDependents) {
field.fieldDependents.forEach((dep) => {
// Sort dependents by dependency order to ensure calculate fields are evaluated correctly
const sortedDependents = sortDependentsByDependencyOrder(Array.from(field.fieldDependents), formFields);
sortedDependents.forEach((dep) => {
const dependent = formFields.find((f) => f.id == dep);
// Skip if this field has already been evaluated in this cycle to prevent infinite loops
if (evaluatedFields.has(dependent.id)) {
return;
}
evaluatedFields.add(dependent.id);

// evaluate calculated value
if (dependent.questionOptions.calculate?.calculateExpression) {
evaluateAsyncExpression(
Expand Down Expand Up @@ -86,44 +95,48 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context);
}
updateFormField(dependent);
// Recursively evaluate dependents of calculate fields with updated values
const updatedValues = { ...values, [dependent.id]: result };
evaluateFieldDependents(dependent, updatedValues, context, evaluatedFields);
})
.catch((error) => {
reportError(error, 'Error evaluating calculate expression');
});
}
// evaluate hide
if (dependent.hide) {
const targetSection = findFieldSection(formJson, dependent);
const isSectionVisible = targetSection?.questions.some((question) => !question.isHidden);

evaluateHide(
{ value: dependent, type: 'field' },
formFields,
values,
sessionMode,
patient,
evaluateExpression,
updateFormField,
);
} else {
// For non-calculate fields, evaluate hide synchronously
if (dependent.hide) {
const targetSection = findFieldSection(formJson, dependent);
const isSectionVisible = targetSection?.questions.some((question) => !question.isHidden);

if (targetSection) {
targetSection.questions = targetSection?.questions.map((question) => {
if (question.id === dependent.id) {
return dependent;
}
return question;
});
const isDependentFieldHidden = dependent.isHidden;
const sectionHasVisibleFieldAfterEvaluation = [...targetSection.questions, dependent].some(
(field) => !field.isHidden,
evaluateHide(
{ value: dependent, type: 'field' },
formFields,
values,
sessionMode,
patient,
evaluateExpression,
updateFormField,
);

if (!isSectionVisible && !isDependentFieldHidden) {
targetSection.isHidden = false;
shouldUpdateForm = true;
} else if (isSectionVisible && !sectionHasVisibleFieldAfterEvaluation) {
targetSection.isHidden = true;
shouldUpdateForm = true;
if (targetSection) {
targetSection.questions = targetSection?.questions.map((question) => {
if (question.id === dependent.id) {
return dependent;
}
return question;
});
const isDependentFieldHidden = dependent.isHidden;
const sectionHasVisibleFieldAfterEvaluation = [...targetSection.questions, dependent].some(
(field) => !field.isHidden,
);

if (!isSectionVisible && !isDependentFieldHidden) {
targetSection.isHidden = false;
shouldUpdateForm = true;
} else if (isSectionVisible && !sectionHasVisibleFieldAfterEvaluation) {
targetSection.isHidden = true;
shouldUpdateForm = true;
}
}
}
}
Expand Down Expand Up @@ -268,6 +281,64 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
}
}

/**
* Sorts dependents by dependency order to ensure calculate fields are evaluated correctly.
* Fields with no dependencies come first, followed by fields that depend on them.
*/
function sortDependentsByDependencyOrder(dependentIds: string[], allFields: FormField[]): string[] {
// Get the actual field objects for the dependents
const dependentFields = dependentIds
.map(id => allFields.find(f => f.id === id))
.filter(f => f && f.questionOptions?.calculate?.calculateExpression);

if (dependentFields.length <= 1) {
return dependentIds; // No sorting needed for 0 or 1 fields
}

// Build dependency graph for these fields
const graph = new Map<string, string[]>();
for (const field of dependentFields) {
const dependencies = extractVariableNamesFromExpression(field.questionOptions.calculate.calculateExpression);
// Filter to only include dependencies that are also in our dependent fields
const fieldDependencies = dependencies.filter(dep => dependentIds.includes(dep));
graph.set(field.id, fieldDependencies);
}

// Perform topological sort
const visited = new Set<string>();
const visiting = new Set<string>();
const result: string[] = [];

function visit(node: string): void {
if (visiting.has(node)) {
// Cycle detected - just add it and continue
return;
}
if (visited.has(node)) {
return;
}

visiting.add(node);

const dependencies = graph.get(node) || [];
for (const dep of dependencies) {
visit(dep);
}

visiting.delete(node);
visited.add(node);
result.push(node);
}

for (const node of dependentIds) {
if (!visited.has(node)) {
visit(node);
}
}

return result;
}

export interface ValidatorConfig {
formFields: FormField[];
values: Record<string, any>;
Expand Down
46 changes: 45 additions & 1 deletion src/components/renderer/form/form-renderer.component.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useReducer } from 'react';
import React, { useEffect, useMemo, useReducer, useRef } from 'react';
import { useForm } from 'react-hook-form';
import PageRenderer from '../page/page.renderer.component';
import FormProcessorFactory from '../../processor-factory/form-processor-factory.component';
Expand All @@ -10,6 +10,9 @@ import { type FormProcessorContextProps } from '../../../types';
import { useFormStateHelpers } from '../../../hooks/useFormStateHelpers';
import { pageObserver } from '../../sidebar/page-observer';
import { isPageContentVisible } from '../../../utils/form-helper';
import { validateFieldValue } from '../field/fieldLogic';
import { evaluateAsyncExpression } from '../../../utils/expression-runner';
import { reportError } from '../../../utils/error-utils';

export type FormRendererProps = {
processorContext: FormProcessorContextProps;
Expand Down Expand Up @@ -40,6 +43,8 @@ export const FormRenderer = ({
formState: { isDirty },
} = methods;

const calculationsEvaluatedRef = useRef(false);

const [{ formFields, invalidFields, formJson, deletedFields }, dispatch] = useReducer(formStateReducer, {
...initialState,
formFields: evaluatedFields,
Expand Down Expand Up @@ -100,6 +105,45 @@ export const FormRenderer = ({
setIsFormDirty(isDirty);
}, [isDirty]);

useEffect(() => {
if (!calculationsEvaluatedRef.current) {
calculationsEvaluatedRef.current = true;
const calculatedFields = formFields.filter((f) => f.questionOptions.calculate?.calculateExpression);
calculatedFields.forEach((field) => {
evaluateAsyncExpression(
field.questionOptions.calculate.calculateExpression,
{ value: field, type: 'field' },
formFields,
methods.getValues(),
{
mode: processorContext.sessionMode,
patient: processorContext.patient,
},
)
.then((result) => {
methods.setValue(field.id, result);
const { errors, warnings } = validateFieldValue(field, result, processorContext.formFieldValidators, {
formFields,
values: methods.getValues(),
expressionContext: { patient: processorContext.patient, mode: processorContext.sessionMode },
});
if (!field.meta.submission) {
field.meta.submission = {};
}
field.meta.submission.errors = errors;
field.meta.submission.warnings = warnings;
if (!errors.length) {
processorContext.formFieldAdapters[field.type].transformFieldValue(field, result, context);
}
updateFormField(field);
})
.catch((error) => {
reportError(error, 'Error evaluating calculate expression');
});
});
}
}, []);

return (
<FormProvider {...context}>
{formJson.pages.map((page) => {
Expand Down
Loading