Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
16 changes: 15 additions & 1 deletion src/components/renderer/field/fieldLogic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ 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';
Expand Down Expand Up @@ -83,6 +83,8 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context);
}
updateFormField(dependent);
// Recursively evaluate dependents of calculate fields
evaluateFieldDependents(dependent, values, context);
})
.catch((error) => {
reportError(error, 'Error evaluating calculate expression');
Expand Down Expand Up @@ -171,6 +173,12 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
patient,
},
);
// Track dependencies for answer hide expressions
trackFieldDependenciesFromString(
answer.hide?.hideWhenExpression,
{ value: dependent, type: 'field' },
formFields,
);
});
// evaluate disabled
dependent?.questionOptions.answers
Expand All @@ -186,6 +194,12 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
patient,
},
);
// Track dependencies for answer disable expressions
trackFieldDependenciesFromString(
answer.disable?.disableWhenExpression,
{ value: dependent, type: 'field' },
formFields,
);
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unsure this is still needed (was added along investigation but might have been a wrong approach)

});
// evaluate readonly
if (!dependent.isHidden && dependent.meta.readonlyExpression) {
Expand Down
230 changes: 230 additions & 0 deletions src/hooks/useEvaluateFormFieldExpressions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { renderHook } from '@testing-library/react';
import { useEvaluateFormFieldExpressions } from './useEvaluateFormFieldExpressions';
import { type FormProcessorContextProps, type FormField, type RenderType } from '../types';

// Mock form JSON
const mockFormJson = {
name: 'test-form',
uuid: 'test-uuid',
pages: [],
referencedForms: [],
processor: 'EncounterFormProcessor',
encounterType: 'test-encounter-type-uuid',
};

// Mock the form processor context
const createMockContext = (formFields: FormField[] = []): FormProcessorContextProps => ({
formJson: mockFormJson,
formFields,
patient: {
id: 'test-patient-id',
resourceType: 'Patient',
} as any,
sessionMode: 'enter',
visit: {} as any,
sessionDate: new Date(),
location: {} as any,
currentProvider: {} as any,
layoutType: 'desktop' as any,
processor: {
getHistoricalValue: jest.fn(),
} as any,
formFieldAdapters: {},
formFieldValidators: {},
});

describe('useEvaluateFormFieldExpressions', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should evaluate field expressions and track dependencies', () => {
const formValues = { field1: 'value1', field2: 'value2' };

// Mock form fields with expressions that reference other fields
const mockFields: FormField[] = [
{
id: 'field1',
type: 'obs',
questionOptions: {
rendering: 'text' as any,
answers: [],
},
hide: {
hideWhenExpression: 'field2 === "hide"',
},
disabled: {
disableWhenExpression: 'field1 === "disabled"',
},
readonly: 'field2 === "readonly"',
isHidden: false,
isDisabled: false,
meta: {},
},
{
id: 'field2',
type: 'obs',
questionOptions: {
rendering: 'text' as any,
answers: [
{
label: 'Answer 1',
concept: 'answer1',
hide: {
hideWhenExpression: 'field1 === "hide_answer"',
},
},
{
label: 'Answer 2',
concept: 'answer2',
disable: {
disableWhenExpression: 'field1 === "disable_answer"',
},
},
],
},
isHidden: false,
isDisabled: false,
meta: {},
},
];

const contextWithFields = createMockContext(mockFields);

const { result } = renderHook(() =>
useEvaluateFormFieldExpressions(formValues, contextWithFields)
);

// Check that fields are evaluated
expect(result.current.evaluatedFields).toBeDefined();
expect(result.current.evaluatedFields.length).toBe(2);

// Check that expressions are evaluated
const field1 = result.current.evaluatedFields.find(f => f.id === 'field1');
const field2 = result.current.evaluatedFields.find(f => f.id === 'field2');

expect(field1).toBeDefined();
expect(field2).toBeDefined();

// Field1 should not be hidden (field2 !== "hide")
expect(field1.isHidden).toBe(false);

// Field2 answers should be evaluated
expect(field2.questionOptions.answers[0].isHidden).toBe(false); // field1 !== "hide_answer"
expect(field2.questionOptions.answers[1].disable.isDisabled).toBe(false); // field1 !== "disable_answer"
});

it('should handle empty expressions gracefully', () => {
const formValues = {};

const mockFields: FormField[] = [
{
id: 'field1',
type: 'obs',
questionOptions: {
rendering: 'text' as any,
answers: [],
},
isHidden: false,
isDisabled: false,
meta: {},
},
];

const contextWithFields = createMockContext(mockFields);

const { result } = renderHook(() =>
useEvaluateFormFieldExpressions(formValues, contextWithFields)
);

expect(result.current.evaluatedFields).toBeDefined();
expect(result.current.evaluatedFields.length).toBe(1);
});

it('should evaluate page and section visibility', () => {
const formValues = { field1: 'value1' };

const mockFields: FormField[] = [
{
id: 'field1',
type: 'obs',
questionOptions: {
rendering: 'text' as any,
answers: [],
},
isHidden: false,
isDisabled: false,
meta: {},
},
];

const formJsonWithPages = {
...mockFormJson,
pages: [
{
label: 'page1',
sections: [
{
label: 'section1',
questions: [mockFields[0]],
isHidden: false,
isExpanded: 'true',
},
],
isHidden: false,
},
],
};

const contextWithFields = createMockContext(mockFields);
contextWithFields.formJson = formJsonWithPages;

const { result } = renderHook(() =>
useEvaluateFormFieldExpressions(formValues, contextWithFields)
);

expect(result.current.evaluatedFormJson).toBeDefined();
expect(result.current.evaluatedPagesVisibility).toBe(true);
});

it('should handle fields with calculate expressions', () => {
const formValues = { field1: 5, field2: 10 };

const mockFields: FormField[] = [
{
id: 'field1',
type: 'obs',
questionOptions: {
rendering: 'number' as any,
answers: [],
calculate: {
calculateExpression: 'field2 * 2',
},
},
isHidden: false,
isDisabled: false,
meta: {},
},
{
id: 'field2',
type: 'obs',
questionOptions: {
rendering: 'number' as any,
answers: [],
},
isHidden: false,
isDisabled: false,
meta: {},
},
];

const contextWithFields = createMockContext(mockFields);

const { result } = renderHook(() =>
useEvaluateFormFieldExpressions(formValues, contextWithFields)
);

expect(result.current.evaluatedFields).toBeDefined();
expect(result.current.evaluatedFields.length).toBe(2);
});
});
22 changes: 21 additions & 1 deletion src/hooks/useEvaluateFormFieldExpressions.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { type FormProcessorContextProps } from '../types';
import { type FormNode, evaluateExpression } from '../utils/expression-runner';
import { type FormNode, evaluateExpression, trackFieldDependenciesFromString } from '../utils/expression-runner';
import { evalConditionalRequired, evaluateConditionalAnswered, evaluateHide } from '../utils/form-helper';
import { isTrue } from '../utils/boolean-utils';
import { isEmpty } from '../validators/form-validator';
Expand Down Expand Up @@ -32,6 +32,10 @@ export const useEvaluateFormFieldExpressions = (
runnerContext,
);
field.isHidden = isHidden;
// Track dependencies for field hide expressions
if (typeof field.hide.hideWhenExpression === 'string') {
trackFieldDependenciesFromString(field.hide.hideWhenExpression, fieldNode, formFields);
}
if (Array.isArray(field.questions)) {
field.questions.forEach((question) => {
question.isHidden = isHidden;
Expand All @@ -55,6 +59,10 @@ export const useEvaluateFormFieldExpressions = (
formValues,
runnerContext,
);
// Track dependencies for field disable expressions
if (typeof field.disabled.disableWhenExpression === 'string') {
trackFieldDependenciesFromString(field.disabled.disableWhenExpression, fieldNode, formFields);
}
} else {
field.isDisabled = isTrue(field.disabled as string);
}
Expand All @@ -73,6 +81,10 @@ export const useEvaluateFormFieldExpressions = (
formValues,
runnerContext,
);
// Track dependencies for answer hide expressions
if (typeof answer.hide.hideWhenExpression === 'string') {
trackFieldDependenciesFromString(answer.hide.hideWhenExpression, fieldNode, formFields);
}
});
// evaluate conditional disable for answers
field.questionOptions.answers
Expand All @@ -85,11 +97,19 @@ export const useEvaluateFormFieldExpressions = (
formValues,
runnerContext,
);
// Track dependencies for answer disable expressions
if (typeof answer.disable.disableWhenExpression === 'string') {
trackFieldDependenciesFromString(answer.disable.disableWhenExpression, fieldNode, formFields);
}
});
// evaluate readonly
if (typeof field.readonly == 'string' && isNotBooleanString(field.readonly)) {
field.meta.readonlyExpression = field.readonly;
field.readonly = evaluateExpression(field.readonly, fieldNode, formFields, formValues, runnerContext);
// Track dependencies for readonly expressions
if (typeof field.readonly === 'string') {
trackFieldDependenciesFromString(field.readonly, fieldNode, formFields);
}
}
// evaluate repeat limit
const limitExpression = field.questionOptions.repeatOptions?.limitExpression;
Expand Down
Loading