Skip to content

Commit

Permalink
(feat) O3-3061: Support for conditional answered validation (#297)
Browse files Browse the repository at this point in the history
* MVP

* validate the dependent field

* overwrite errors on the field validations hook

* move dependant registration to helper

* cleanup

* more cleanup

* PR resolutions and tests

* fix failing tests

* more cleanup
  • Loading branch information
arodidev committed Jun 4, 2024
1 parent c40ab1d commit f09bef5
Show file tree
Hide file tree
Showing 8 changed files with 201 additions and 5 deletions.
97 changes: 97 additions & 0 deletions __mocks__/forms/rfe-forms/conditional-answered-form.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"name": "ampath_poc_adult_return_visit_form_v1.9",
"uuid": "xxxx",
"EncounterType": "xxxx",
"referencedForms": [],
"processor": "EncounterFormProcessor",
"pages": [
{
"label": "Encounter Details",
"sections": [
{
"label": "Hospitalization History",
"questions": [
{
"type": "obsGroup",
"label": "Recent hospitalizations",
"questionOptions": {
"rendering": "group",
"concept": "a8a003a6-1350-11df-a1f1-0026b9348838"
},
"questions": [
{
"label": "Was the patient hospitalized since last visit?",
"id": "wasHospitalized",
"questionOptions": {
"concept": "a898c56e-1350-11df-a1f1-0026b9348838",
"answers": [
{
"concept": "a899b35c-1350-11df-a1f1-0026b9348838",
"label": "Yes"
},
{
"concept": "a899b42e-1350-11df-a1f1-0026b9348838",
"label": "No"
}
],
"rendering": "select"
},
"type": "obs",
"validators": []
}
],
"id": "__qLKuwyDMu"
},
{
"type": "obsGroup",
"label": "If yes reason for hospitalization:",
"questionOptions": {
"concept": "a8a003a6-1350-11df-a1f1-0026b9348838",
"rendering": "repeating"
},
"questions": [
{
"label": "Reason for hospitalization:",
"id": "hospReason",
"questionOptions": {
"concept": "a8a07a48-1350-11df-a1f1-0026b9348838",
"rendering": "select",
"answers": [
{
"concept": "a89b6440-1350-11df-a1f1-0026b9348838",
"label": "Maternal visit"
},
{
"concept": "a89ff816-1350-11df-a1f1-0026b9348838",
"label": "Emergency visit"
},
{
"concept": "a89ff8de-1350-11df-a1f1-0026b9348838",
"label": "Unscheduled visit late"
}
]
},
"type": "obs",
"validators": [
{
"type": "conditionalAnswered",
"message": "Providing diagnosis but didn't answer that patient was hospitalized in question",
"referenceQuestionId": "wasHospitalized",
"referenceQuestionAnswers": ["a899b35c-1350-11df-a1f1-0026b9348838"]
},
{
"type": "js_expression",
"failsWhenExpression": "isEmpty(myValue) && !isEmpty(wasHospitalized) && wasHospitalized === 'a899b35c-1350-11df-a1f1-0026b9348838'",
"message": "Patient previously marked as hospitalized. Please provide hospitalization reason."
}
]
}
],
"id": "__wIytMGq5E"
}
]
}
]
}
]
}
22 changes: 21 additions & 1 deletion src/components/encounter/encounter-form.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import FormPage from '../page/form-page.component';
import { FormContext } from '../../form-context';
import {
evalConditionalRequired,
evaluateConditionalAnswered,
evaluateDisabled,
evaluateFieldReadonlyProp,
evaluateHide,
Expand Down Expand Up @@ -262,6 +263,10 @@ const EncounterForm: React.FC<EncounterFormProps> = ({
: isTrue(field.disabled);
}

if (field.validators?.some((validator) => validator.type === 'conditionalAnswered')) {
evaluateConditionalAnswered(field, flattenedFields);
}

field.questionOptions.answers
?.filter((answer) => !isEmpty(answer.hide?.hideWhenExpression))
.forEach((answer) => {
Expand Down Expand Up @@ -549,7 +554,6 @@ const EncounterForm: React.FC<EncounterFormProps> = ({
}
return savedEncounter;
} catch (error) {
console.error(error.responseBody);
const errorMessages = extractErrorMessagesFromResponse(error);
return Promise.reject({
title: t('errorSavingEncounter', 'Error saving encounter'),
Expand Down Expand Up @@ -647,6 +651,22 @@ const EncounterForm: React.FC<EncounterFormProps> = ({
dependant.isRequired = evalConditionalRequired(dependant, fields, { ...values, [fieldName]: value });
}

if (dependant.validators?.some((validator) => validator.type === 'conditionalAnswered')) {
const fieldValidatorConfig = dependant.validators?.find(
(validator) => validator.type === 'conditionalAnswered',
);

const validationResults = formFieldValidators['conditionalAnswered'].validate(
dependant,
dependant.meta.submission?.newValue,
{
...baseValidatorConfig,
...fieldValidatorConfig,
},
);
dependant.meta.submission = { ...dependant.meta.submission, errors: validationResults };
}

dependant?.questionOptions.answers
?.filter((answer) => !isEmpty(answer.hide?.hideWhenExpression))
.forEach((answer) => {
Expand Down
45 changes: 45 additions & 0 deletions src/form-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import historicalExpressionsForm from '__mocks__/forms/rfe-forms/historical-expr
import mockHxpEncounter from '__mocks__/forms/rfe-forms/mockHistoricalvisitsEncounter.json';
import requiredTestForm from '__mocks__/forms/rfe-forms/required-form.json';
import conditionalRequiredTestForm from '__mocks__/forms/rfe-forms/conditional-required-form.json';
import conditionalAnsweredForm from '__mocks__/forms/rfe-forms/conditional-answered-form.json';
import FormEngine from './form-engine.component';

const mockShowToast = showToast as jest.Mock;
Expand Down Expand Up @@ -156,6 +157,50 @@ describe('Form engine component', () => {
});
});

describe('conditional answered validation', () => {
it('should fail if the referenced field has a value that does not exist on the referenced answers array', async () => {
await act(async () => {
renderForm(null, conditionalAnsweredForm);
});

const hospitalizationHistoryDropdown = screen.getByRole('combobox', {
name: /was the patient hospitalized since last visit\?/i,
});
const hospitalizationReasonDropdown = screen.getByRole('combobox', {
name: /reason for hospitalization:/i,
});

expect(hospitalizationHistoryDropdown);
expect(hospitalizationReasonDropdown);

fireEvent.click(hospitalizationHistoryDropdown);

expect(screen.getByText(/yes/i)).toBeInTheDocument();
expect(screen.getByText(/no/i)).toBeInTheDocument();

fireEvent.click(screen.getByText(/no/i));

fireEvent.click(hospitalizationReasonDropdown);

expect(screen.getByText(/Maternal Visit/i)).toBeInTheDocument();
expect(screen.getByText(/Emergency Visit/i)).toBeInTheDocument();
expect(screen.getByText(/Unscheduled visit late/i)).toBeInTheDocument();

fireEvent.click(screen.getByText(/Maternal Visit/i));

const errorMessage = screen.getByText(
/Providing diagnosis but didn't answer that patient was hospitalized in question/i,
);

expect(errorMessage).toBeInTheDocument();

fireEvent.click(hospitalizationHistoryDropdown);
fireEvent.click(screen.getByText(/yes/i));

expect(errorMessage).not.toBeInTheDocument();
});
});

describe('historical expressions', () => {
it('should ascertain getPreviousEncounter() returns an encounter and the historical expression displays on the UI', async () => {
const user = userEvent.setup();
Expand Down
8 changes: 5 additions & 3 deletions src/hooks/useFieldValidationResults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ export function useFieldValidationResults(field: FormField) {
const [warnings, setWarnings] = useState([]);

useEffect(() => {
if (field.meta?.submission) {
setErrors((prevErrors) => [...prevErrors, ...(field.meta.submission.errors || [])]);
setWarnings((prevWarnings) => [...prevWarnings, ...(field.meta.submission.warnings || [])]);
if (field.meta?.submission?.errors) {
setErrors(field.meta.submission.errors);
}
if (field.meta?.submission?.warnings) {
setWarnings(field.meta.submission.warnings);
}
}, [field.meta?.submission]);

Expand Down
5 changes: 5 additions & 0 deletions src/registry/inbuilt-components/inbuiltValidators.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { conditionalAnsweredValidator } from '../../validators/conditional-answered-validator';
import { type FormFieldValidator } from '../../types';
import { DateValidator } from '../../validators/date-validator';
import { DefaultValueValidator } from '../../validators/default-value-validator';
Expand Down Expand Up @@ -25,4 +26,8 @@ export const inbuiltValidators: Array<RegistryItem<FormFieldValidator>> = [
name: 'js_expression',
component: ExpressionValidator,
},
{
name: 'conditionalAnswered',
component: conditionalAnsweredValidator,
},
];
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export interface FormFieldValidator {
/**
* Validates a field and returns validation errors
*/
validate(field: FormField, value: any, config?: any): Array<ValidationResult>;
validate(field: FormField, value?: any, config?: any): Array<ValidationResult>;
}

export interface ValidationResult {
Expand Down
10 changes: 10 additions & 0 deletions src/utils/form-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export function isInlineView(
return renderingType == 'single-line';
}

export function evaluateConditionalAnswered(field: FormField, allFields: FormField[]) {
const referencedFieldId = field.validators.find(
(validator) => validator.type === 'conditionalAnswered',
).referenceQuestionId;
const referencedField = allFields.find((field) => field.id == referencedFieldId);
if (referencedField) {
(referencedField.fieldDependants || (referencedField.fieldDependants = new Set())).add(field.id);
}
}

export function evaluateFieldReadonlyProp(
field: FormField,
sectionReadonly: string | boolean,
Expand Down
17 changes: 17 additions & 0 deletions src/validators/conditional-answered-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { isEmpty } from '../validators/form-validator';
import { type FormFieldValidator, type FormField } from '../types';

export const conditionalAnsweredValidator: FormFieldValidator = {
validate: function (field: FormField, value: unknown, config: Record<string, any>) {
const { referenceQuestionId, referenceQuestionAnswers, values, fields, message } = config;

const referencedField = fields.find((field) => field.id === referenceQuestionId);
const referencedFieldValue = values[referencedField.id];

if (!isEmpty(value) && !referenceQuestionAnswers.includes(referencedFieldValue)) {
return [{ resultType: 'error', errCode: 'invalid.valueSelected', message: message }];
}

return [];
},
};

0 comments on commit f09bef5

Please sign in to comment.