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
13 changes: 13 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ export interface OHRIFormQuestionOptions {
/**
* maxLength and maxLength are used to validate text field length
*/
conceptMappings?: Array<Record<string, string>>;
maxLength?: string;
minLength?: string;
showDate?: string;
Expand All @@ -169,6 +170,8 @@ export interface OHRIFormQuestionOptions {

export type SessionMode = 'edit' | 'enter' | 'view';

export type conceptUUID = string;

export type RenderType =
| 'select'
| 'text'
Expand Down Expand Up @@ -259,3 +262,13 @@ export interface DataSource<T> {
*/
toUuidAndDisplay(item: T): OpenmrsResource;
}

export interface datatypeToRenderingMapInterface {
Numeric: Array<string>;
Coded: Array<string>;
Text: Array<string>;
Date: Array<string>;
Datetime: Array<string>;
Boolean: Array<string>;
Rule: Array<string>;
}
31 changes: 31 additions & 0 deletions src/config-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Type } from '@openmrs/esm-framework';

export const configSchema = {
dataTypeToRenderingMap: {
_description: 'A map used to match concept datatypes to rendering types',
_type: Type.Object,
_default: {
Numeric: ['number', 'fixed-value'],
Coded: ['select', 'checkbox', 'radio', 'toggle', 'content-switcher', 'fixed-value'],
Text: ['text', 'textarea', 'fixed-value'],
Date: ['date', 'fixed-value'],
Datetime: ['datetime', 'fixed-value'],
Boolean: ['toggle', 'select', 'radio', 'content-switcher', 'fixed-value'],
Rule: ['repeating', 'group'],
},
},

conceptDataTypes: {
_description: 'An object containing availabe datatypes',
_type: Type.Object,
_default: {
Numeric: 'Numeric',
Coded: 'Coded',
Text: 'Text',
Date: 'Date',
Datetime: 'Datetime',
Boolean: 'Boolean',
Rule: 'Rule',
},
},
};
19 changes: 19 additions & 0 deletions src/hooks/useDatatype.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import useSWRImmutable from 'swr/immutable';
import { openmrsFetch, OpenmrsResource } from '@openmrs/esm-framework';

const conceptRepresentation =
'custom:(uuid,display,datatype,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))';

export function useDatatype(references: Set<string>) {
Copy link
Member

Choose a reason for hiding this comment

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

Assuming this returns concepts, what stops you from reusing the useConcepts() hook?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we had this conversation a while back to create a new custom hook that also returns a filtered set with unresolved concepts, but if need be I can revert back to using the useConcept hook and modifying it.

// TODO: handle paging (ie when number of concepts greater than default limit per page)
const { data, error, isLoading } = useSWRImmutable<{ data: { results: Array<OpenmrsResource> } }, Error>(
`/ws/rest/v1/concept?references=${Array.from(references).join(',')}&v=${conceptRepresentation}`,
openmrsFetch,
);

const filteredSet = Array.from(references).filter(
eachItem => !data?.data.results.some(item => eachItem === item.uuid),
);

return { concepts: data?.data.results, error, isLoading, filteredSet };
}
175 changes: 175 additions & 0 deletions src/hooks/useSchemaValidator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { useDatatype } from './useDatatype';
import { useEffect, useMemo, useState } from 'react';
import { useConfig } from '@openmrs/esm-framework';
import { ConceptTrue, ConceptFalse } from '../constants';
import { OHRIFormField, OHRIFormSchema } from '../api/types';

export function useSchemaValidator(schema: OHRIFormSchema, doValidate: Boolean) {
const [errors, setErrors] = useState([]);
const [warnings, setWarnings] = useState([]);
const [questionFields, setQuestionFields] = useState<Array<OHRIFormField>>([]);
const [answerFields, setAnswerFields] = useState([]);
const [isValidating, setIsValidating] = useState(true);
const [conceptSet, setConceptSet] = useState<Set<string>>();
const [answerConceptSet, setAnswerConceptSet] = useState<Set<string>>();
const { dataTypeToRenderingMap, conceptDataTypes } = useConfig({
// externalModuleName: '@openmrs/openmrs-form-engine-lib',
});

if (!schema) {
throw new Error('Form schema is not provided');
}

useMemo(() => {
let questionSearchReferences = [];
let nullSearchReferenceErrors = [];

schema.pages?.forEach((page) =>
page.sections?.forEach((section) =>
section.questions?.forEach((question) => {
if (question.type === 'obsGroup') {
question.questions.forEach((obsGrpQuestion) => {
const fieldConceptReference = extractQuestionSearchReferenceFromField(obsGrpQuestion);
if (fieldConceptReference) questionSearchReferences.push(fieldConceptReference);
else {
nullSearchReferenceErrors.push[`${obsGrpQuestion.id} has no search reference`];
}
});
} else {
const fieldConceptReference = extractQuestionSearchReferenceFromField(question);
if (fieldConceptReference) {
questionSearchReferences.push(fieldConceptReference);
}
}
}),
),
);
setConceptSet(new Set(questionSearchReferences));
setErrors((previousErrors) => ({
...previousErrors,
nullSearchReferenceErrors: nullSearchReferenceErrors,
}));
}, [schema]);

const findUnresolvedConcepts = (questionFields, filteredSetArray) => {
const unresolvedConcepts = questionFields
?.filter((questionFieldsItem) => {
return filteredSetArray?.includes(questionFieldsItem.questionOptions.concept);
})
?.map((item) => {
return {
errorMessage: `❓ Field Concept "${item.questionOptions.concept}" not found`,
field: item,
};
});

setErrors((prevState) => [...prevState, ...unresolvedConcepts]);
};

const unresolvedAnswers = (questionFields, filteredSetArray) => {
const unresolvedConcepts = questionFields
?.filter((questionFieldsItem) => {
return filteredSetArray?.includes(questionFieldsItem.concept);
})
?.map((item) => {
return {
errorMessage: `Answer Concept "${item.concept}" not found`,
field: item,
};
});

setErrors((prevState) => [...prevState, ...unresolvedConcepts]);
};

const dataTypeChecker = (responseObject, questionFields) => {
questionFields
?.filter((item) => item.questionOptions.concept === responseObject.uuid)
.map((item) => {
responseObject.datatype.name === conceptDataTypes.Boolean &&
item.questionOptions.answers.forEach((answer) => {
if (![ConceptTrue, ConceptFalse].includes(answer.concept)) {
setErrors((prevErrors) => [
...prevErrors,
{
errorMessage: `❌ concept "${item.questionOptions.concept}" of type "boolean" has a non-boolean answer "${answer.label}"`,
field: item,
},
]);
}
});

responseObject.datatype.name === conceptDataTypes.Coded &&
item.questionOptions.answers.forEach((answer) => {
if (!responseObject.answers?.some((answerObject) => answerObject.uuid === answer.concept)) {
setWarnings((prevWarnings) => [
...prevWarnings,
{
warningMessage: `⚠️ answer: "${answer.label}" - "${answer.concept}" does not exist in the response answers but exists in the form`,
field: item,
},
]);
}
});

dataTypeToRenderingMap.hasOwnProperty(responseObject?.datatype?.name) &&
!dataTypeToRenderingMap[responseObject.datatype.name].includes(item.questionOptions.rendering) &&
setErrors((prevErrors) => [
...prevErrors,
{
errorMessage: `❌ ${item.questionOptions.concept}: datatype "${responseObject.datatype.display}" doesn't match control type "${item.questionOptions.rendering}"`,
field: item,
},
]);

!dataTypeToRenderingMap.hasOwnProperty(responseObject.datatype.name) &&
setErrors((prevErrors) => [
...prevErrors,
{ errorMessage: `Untracked datatype "${responseObject.datatype.display}"`, field: item },
]);
});
};

function extractQuestionSearchReferenceFromField(formField) {
setQuestionFields((prevArray) => [...prevArray, formField]);
const searchRef = formField.questionOptions.concept
? formField.questionOptions.concept
: formField.questionOptions.conceptMappings?.length
? formField.questionOptions.conceptMappings
?.map((mapping) => {
return `${mapping.type}:${mapping.value}`;
})
.join(',')
: '';

return searchRef;
}

const { concepts, filteredSet, isLoading } = useDatatype(conceptSet);
const {
concepts: answerConcepts,
filteredSet: filteredSetAnswers,
isLoading: isLoadingAnswers,
} = useDatatype(answerConceptSet);

useEffect(() => {
if (concepts?.length) {
findUnresolvedConcepts(questionFields, filteredSet);
concepts?.forEach((concept) => {
dataTypeChecker(concept, questionFields);
});
setIsValidating((prevValue) => !prevValue);
}
}, [concepts]);

useEffect(() => {
if (answerConcepts?.length) {
unresolvedAnswers(answerFields, filteredSetAnswers);
}
}, [answerConcepts]);

if (!doValidate) {
return { errors: [], warnings: [], isValidating };
}

return { errors, warnings, isValidating };
}
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ 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 * from './hooks/useSchemaValidator';

export { default as OHRIForm } from './ohri-form.component';