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 @@ -254,3 +254,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.

4 changes: 3 additions & 1 deletion src/components/inputs/date/ohri-date.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ const OHRIDate: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler })
}
}, [field.value, time]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<OHRIFieldValueView
label={question.label}
value={field.value instanceof Date ? getDisplay(field.value, question.questionOptions.rendering) : field.value}
Expand Down Expand Up @@ -169,6 +169,7 @@ const OHRIDate: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler })
invalid={!isFieldRequiredError && errors.length > 0}
invalidText={errors[0]?.message}
warn={warnings.length > 0}
readOnly={question.readonly}
arodidev marked this conversation as resolved.
Show resolved Hide resolved
warnText={warnings[0]?.message}
/>
</DatePicker>
Expand All @@ -180,6 +181,7 @@ const OHRIDate: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler })
id={question.id}
labelText="Time:"
placeholder="HH:MM"
readOnly={question.readonly}
arodidev marked this conversation as resolved.
Show resolved Hide resolved
pattern="(1[012]|[1-9]):[0-5][0-9])$"
type="time"
disabled={!field.value ? true : false}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const OHRIEncounterLocationPicker: React.FC<{ question: OHRIFormField; on
});
}, [conceptName]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
Expand All @@ -60,6 +60,7 @@ export const OHRIEncounterLocationPicker: React.FC<{ question: OHRIFormField; on
setEncounterLocation(selectedItem);
}}
disabled={question.disabled}
readOnly={question.readonly}
/>
</div>
)
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/number/ohri-number.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ const OHRINumber: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
Expand Down Expand Up @@ -103,6 +103,7 @@ const OHRINumber: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler
warn={warnings.length > 0}
warnText={warnings[0]?.message}
step="0.01"
readOnly={question.readonly}
/>
</div>
{previousValueForReview && (
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/radio/ohri-radio.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const OHRIRadio: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler }
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
Expand All @@ -78,6 +78,7 @@ const OHRIRadio: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler }
name={question.id}
valueSelected={field.value}
onChange={handleChange}
readOnly={isTrue(question.readonly)}
orientation="vertical">
{question.questionOptions.answers
.filter(answer => !answer.isHidden)
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/select/ohri-dropdown.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const OHRIDropdown: React.FC<OHRIFormFieldProps> = ({ question, onChange, handle
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<OHRIFieldValueView
label={question.label}
value={field.value ? handler?.getDisplayValue(question, field.value) : field.value}
Expand All @@ -91,6 +91,7 @@ const OHRIDropdown: React.FC<OHRIFormFieldProps> = ({ question, onChange, handle
invalidText={errors.length && errors[0].message}
warn={warnings.length > 0}
warnText={warnings.length && warnings[0].message}
readOnly={question.readonly}
/>
</div>
{previousValueForReview && (
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/text-area/ohri-text-area.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const OHRITextArea: React.FC<OHRIFormFieldProps> = ({ question, onChange, handle
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView label={question.label} value={field.value} conceptName={conceptName} isInline={isInline} />
</div>
Expand All @@ -72,6 +72,7 @@ const OHRITextArea: React.FC<OHRIFormFieldProps> = ({ question, onChange, handle
invalidText={errors.length && errors[0].message}
warn={warnings.length > 0}
warnText={warnings.length && warnings[0].message}
readOnly={question.readonly}
/>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/text/ohri-text.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const OHRIText: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler })
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView label={question.label} value={field.value} conceptName={conceptName} isInline={isInline} />
</div>
Expand All @@ -86,6 +86,7 @@ const OHRIText: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler })
value={field.value || ''}
onFocus={() => setPreviousValue(field.value)}
disabled={question.disabled}
readOnly={isTrue(question.readonly)}
invalid={!isFieldRequiredError && errors.length > 0}
invalidText={errors.length && errors[0].message}
warn={warnings.length > 0}
Expand Down
3 changes: 2 additions & 1 deletion src/components/inputs/toggle/ohri-toggle.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ const OHRIToggle: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler
return false;
}, [encounterContext.sessionMode, question.readonly, question.inlineRendering, layoutType, workspaceLayout]);

return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
Expand All @@ -60,6 +60,7 @@ const OHRIToggle: React.FC<OHRIFormFieldProps> = ({ question, onChange, handler
onToggle={handleChange}
toggled={!!field.value}
disabled={question.disabled}
readOnly={question.readonly}
/>
</div>
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ export const UISelectExtended: React.FC<OHRIFormFieldProps> = ({ question, handl
}
return changes;
};
return encounterContext.sessionMode == 'view' || isTrue(question.readonly) ? (
return encounterContext.sessionMode == 'view' ? (
<div className={styles.formField}>
<OHRIFieldValueView
label={question.label}
Expand Down Expand Up @@ -130,6 +130,7 @@ export const UISelectExtended: React.FC<OHRIFormFieldProps> = ({ question, handl
}}
onChange={({ selectedItem }) => handleChange(selectedItem?.uuid)}
disabled={question.disabled}
readOnly={question.readonly}
onInputChange={value => {
inputValue.current = value;
if (question.questionOptions['isSearchable']) {
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ export * from './utils/ohri-form-helper';
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/useFormJson';
export * from './schema-validator';
export { default as OHRIForm } from './ohri-form.component';
139 changes: 139 additions & 0 deletions src/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 errorsArray = [];
const warningsArray = [];

if (schema) {
const asyncTasks = [];

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

return [errorsArray, warningsArray];
}
};

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}`);
Copy link
Member

Choose a reason for hiding this comment

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

I'm concerned about the fact that we have fused the validation logic with API-specific logic. I advise reusing the existing useConcepts() hook. This means the validator becomes a React hook:

// the validator runs if doValidate is set to true
// isValidating shows the validation status
const { errors, warnings, isValidating } = useSchemaValidationResults(doValidate, schema);

Alternatively, you can define a FormSchemaValidator interface but ensure you fetch the concepts in one API hit with proper separation of concerns.

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 I had a previous implementation of how I understood your statement here, kindly advise if this logic suffices.

if (data.results.length) {
const [resObject] = data.results;

resObject.datatype.name === 'Boolean' &&
Copy link
Member

Choose a reason for hiding this comment

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

Should be driven by a config

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@samuelmale kindly expound on this

Copy link
Member

Choose a reason for hiding this comment

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

My bad @arodidev my comment regarding the config, see.

As a good practice, I would create an enum holding all the know datatypes to prevent hard-coding these.

const ConceptDataTypes {
  Boolean: 'Boolean',
  Coded: 'Coded',
  Numeric: 'Numeric',
  Text: 'Text'
  // more...
};


// validator.js


if (concept.datatype.name === ConceptDataTypes.Boolean) {}

conceptObject.questionOptions.answers.forEach(answer => {
if (
answer.concept !== 'cf82933b-3f3f-45e7-a5ab-5d31aaee3da3' &&
answer.concept !== '488b58ff-64f5-4f8a-8979-fa79940b1594'
) {
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,
});
}
});
});
};