Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-3061: Support for conditional answered validation #297

Merged
merged 9 commits into from
Jun 4, 2024
Merged
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
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 @@ -260,6 +261,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 @@ -545,7 +550,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 @@ -643,6 +647,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) {
Copy link
Member

Choose a reason for hiding this comment

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

The meta object is initialised at a earlier stage so it's not necessary to optionally chain it.

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 [];
},
};
Loading