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

Moved schema-validator logic to form engine #107

Closed
wants to merge 15 commits into from
10 changes: 10 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,13 @@ export interface DataSource<T> {
*/
toUuidAndDisplay(item: T): OpenmrsResource;
}

export interface ConfigObject {
Numeric: Array<string>;
Coded: Array<string>;
Text: Array<string>;
Date: Array<string>;
Datetime: Array<string>;
Boolean: Array<string>;
Rule: Array<string>;
}
Copy link
Member

Choose a reason for hiding this comment

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

This is possessing a generic name; one may assume it's the general config interface for the form-engine configuration yet I think it's a form schema validator configuration?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Noted.

3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ export * from './ohri-form-context';
export * from './components/value/view/ohri-field-value-view.component';
export * from './components/previous-value-review/previous-value-review.component';
export * from './hooks/useFormJson';
export { default as OHRIForm } from './ohri-form.component';
export * from './validators/schema-validator';
export { default as OHRIForm } from './ohri-form.component';
139 changes: 139 additions & 0 deletions src/validators/schema-validator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import { openmrsFetch } from '@openmrs/esm-framework';
import { OHRIFormSchema, ConfigObject } from '../api/types';

export const handleFormValidation = async (
schema: OHRIFormSchema,
configObject: ConfigObject,
): Promise<Array<Array<Record<string, any>>>> => {
const errors = [];
const warnings = [];

if (schema) {
const asyncTasks = [];

schema.pages?.forEach(page =>
page.sections?.forEach(section =>
section.questions?.forEach(question => {
asyncTasks.push(
handleQuestionValidation(question, errors, warnings, configObject),
handleAnswerValidation(question, errors),
);
question.type === 'obsGroup' &&
question.questions?.forEach(obsGrpQuestion =>
asyncTasks.push(
handleQuestionValidation(obsGrpQuestion, errors, configObject, warnings),
handleAnswerValidation(question, errors),
),
);
}),
),
);
await Promise.all(asyncTasks);

return [errors, warnings];
}
};

const handleQuestionValidation = async (conceptObject, errorsArray, warningsArray, configObject) => {
const conceptRepresentation =
'custom:(uuid,display,datatype,answers,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))';

const searchRef = conceptObject.questionOptions.concept
? conceptObject.questionOptions.concept
: conceptObject.questionOptions.conceptMappings?.length
? conceptObject.questionOptions.conceptMappings
?.map(mapping => {
return `${mapping.type}:${mapping.value}`;
})
.join(',')
: '';

if (searchRef) {
try {
const { data } = await openmrsFetch(`/ws/rest/v1/concept?references=${searchRef}&v=${conceptRepresentation}`);
if (data.results.length) {
const [resObject] = data.results;

resObject.datatype.name === 'Boolean' &&
conceptObject.questionOptions.answers.forEach(answer => {
if (
answer.concept !== 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3' &&
answer.concept !== '488b58ff-64f5-4f8a-8979-fa79940b1594'
Copy link
Member

Choose a reason for hiding this comment

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

I would do something like:

if ( ![ConceptTrue, ConceptFalse].includes(answer.concept) ) {
   // handle error
}

The ConceptTrue and ConceptFalse are driven by a System configuration: concept.true and concept.false. We need to be picking these values from the backend. On the contrary, these are currently defined as constants within the library which isn't the conventional practice. I've always wanted to address this technical debt but never got the latitude.

Ideally, we should introduce a new hook that will read this system setting. It would be great if you offered to address this otherwise you can use the already defined constants and we address this later on.

Copy link
Contributor Author

@arodidev arodidev Aug 27, 2023

Choose a reason for hiding this comment

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

@samuelmale this is well noted. I would also like to take on the issue of the hook reading the values from the backend if possible.

) {
errorsArray.push({
errorMessage: `❌ concept "${conceptObject.questionOptions.concept}" of type "boolean" has a non-boolean answer "${answer.label}"`,
field: conceptObject,
});
}
});

resObject.datatype.name === 'Coded' &&
conceptObject.questionOptions.answers.forEach(answer => {
if (!resObject.answers.some(answerObject => answerObject.uuid === answer.concept)) {
warningsArray.push({
warningMessage: `⚠️ answer: "${answer.label}" - "${answer.concept}" does not exist in the response answers but exists in the form`,
field: conceptObject,
});
}
});

dataTypeChecker(conceptObject, resObject, errorsArray, configObject);
} else {
errorsArray.push({
errorMessage: `❓ Concept "${conceptObject.questionOptions.concept}" not found`,
field: conceptObject,
});
}
} catch (error) {
console.error(error);
}
} else {
errorsArray.push({
errorMessage: `❓ Question with no concept UUID / mappings: ${conceptObject.id}`,
field: conceptObject,
});
}
};

const dataTypeChecker = (conceptObject, responseObject, array, dataTypeToRenderingMap) => {
Object.prototype.hasOwnProperty.call(dataTypeToRenderingMap, responseObject.datatype.name) &&
!dataTypeToRenderingMap[responseObject.datatype.name].includes(conceptObject.questionOptions.rendering) &&
array.push({
errorMessage: `❓ ${conceptObject.questionOptions.concept}: datatype "${responseObject.datatype.name}" doesn't match control type "${conceptObject.questionOptions.rendering}"`,
field: conceptObject,
});

!Object.prototype.hasOwnProperty.call(dataTypeToRenderingMap, responseObject.datatype.name) &&
array.push({
errorMessage: `❓ Untracked datatype "${responseObject.datatype.name}"`,
field: conceptObject,
});
};

const handleAnswerValidation = (questionObject, array) => {
const answerArray = questionObject.questionOptions.answers;
const conceptRepresentation =
'custom:(uuid,display,datatype,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))';

answerArray?.length &&
answerArray.forEach(answer => {
const searchRef = answer.concept
? answer.concept
: answer.conceptMappings?.length
? answer.conceptMappings
.map(eachMapping => {
return `${eachMapping.type}:${eachMapping.value}`;
})
.join(',')
: '';

openmrsFetch(`/ws/rest/v1/concept?references=${searchRef}&v=${conceptRepresentation}`).then(response => {
if (!response.data.results.length) {
array.push({
errorMessage: `❌ answer "${answer.label}" backed by concept "${answer.concept}" not found`,
field: questionObject,
});
}
});
});
};