Skip to content
Open
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
2 changes: 1 addition & 1 deletion src/components/action-buttons/action-buttons.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ function ActionButtons({

async function handleValidateAndPublish() {
setStatus('validateBeforePublishing');
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap);
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap, t);
setValidationResponse(errorsArray);
if (errorsArray.length) {
setStatus('validated');
Expand Down
2 changes: 1 addition & 1 deletion src/components/form-editor/form-editor.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {
const onValidateForm = async () => {
setIsValidating(true);
try {
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap);
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap, t);
setValidationResponse(errorsArray);
setValidationComplete(true);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export { default as ObsTypeQuestion } from './obs/obs-type-question.component';
export { default as ProgramStateTypeQuestion } from './program-state/program-state-type-question.component';
export { default as PatientIdentifierTypeQuestion } from './patient-identifier/patient-identifier-type-question.component';
export { default as TestOrderTypeQuestion } from './test-order/test-order-type-question.component';
export { default as PersonAttributeTypeQuestion } from './person-attribute/person-attribute-type-question.component';
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { FormLabel, InlineNotification, ComboBox, InlineLoading } from '@carbon/react';
import { usePersonAttributeTypes } from '@hooks/usePersonAttributeTypes';
import { useFormField } from '../../../../form-field-context';
import type { PersonAttributeType } from '@types';
import styles from './person-attribute-type-question.scss';

const PersonAttributeTypeQuestion: React.FC = () => {
const { t } = useTranslation();
const { formField, setFormField } = useFormField();
const { personAttributeTypes, personAttributeTypeLookupError, isLoadingPersonAttributeTypes } =
usePersonAttributeTypes();

const attributeTypeUuid = (formField.questionOptions as any)?.attributeType;
const [selectedPersonAttributeType, setSelectedPersonAttributeType] = useState<PersonAttributeType | null>(null);

// Sync selected person attribute type when personAttributeTypes loads or attributeTypeUuid changes
useEffect(() => {
if (attributeTypeUuid && personAttributeTypes.length > 0) {
const matchingType = personAttributeTypes.find(
(personAttributeType) => personAttributeType.uuid === attributeTypeUuid,
);
if (matchingType) {
setSelectedPersonAttributeType(matchingType);
}
} else if (!attributeTypeUuid) {
setSelectedPersonAttributeType(null);
}
}, [attributeTypeUuid, personAttributeTypes]);

const handlePersonAttributeTypeChange = ({ selectedItem }: { selectedItem: PersonAttributeType }) => {
setSelectedPersonAttributeType(selectedItem);
setFormField({
...formField,
questionOptions: {
...formField.questionOptions,
attributeType: selectedItem?.uuid,
} as any,
});
};

const convertItemsToString = useCallback((item: PersonAttributeType) => item?.display ?? '', []);

return (
<div>
<FormLabel className={styles.label}>
{t('searchForBackingPersonAttributeType', 'Search for a backing person attribute type')}
</FormLabel>
{personAttributeTypeLookupError && (
<InlineNotification
kind="error"
lowContrast
className={styles.error}
title={t('errorFetchingPersonAttributeTypes', 'Error fetching person attribute types')}
subtitle={t('pleaseTryAgain', 'Please try again.')}
/>
)}
{isLoadingPersonAttributeTypes ? (
<InlineLoading className={styles.loader} description={t('loading', 'Loading') + '...'} />
) : (
<ComboBox
helperText={t(
'personAttributeTypeHelperText',
'Person attribute type fields must be linked to a person attribute type',
)}
id="personAttributeTypeLookup"
items={personAttributeTypes}
itemToString={convertItemsToString}
onChange={handlePersonAttributeTypeChange}
placeholder={t('choosePersonAttributeType', 'Choose a person attribute type')}
selectedItem={selectedPersonAttributeType}
/>
)}
</div>
);
};

export default PersonAttributeTypeQuestion;
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
@use '@carbon/styles/scss/spacing';

.label {
display: block;
margin-bottom: spacing.$spacing-03;
}

.error {
margin-bottom: spacing.$spacing-05;
}

.loader {
margin-top: spacing.$spacing-05;
}

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ProgramStateTypeQuestion,
PatientIdentifierTypeQuestion,
TestOrderTypeQuestion,
PersonAttributeTypeQuestion,
} from './inputs';
import { useFormField } from '../../form-field-context';
import type { QuestionType } from '@types';
Expand All @@ -14,10 +15,12 @@ const componentMap: Partial<Record<QuestionType, React.FC>> = {
patientIdentifier: PatientIdentifierTypeQuestion,
obsGroup: ObsTypeQuestion,
testOrder: TestOrderTypeQuestion,
personAttribute: PersonAttributeTypeQuestion,
};

const QuestionTypeComponent: React.FC = () => {
const { formField } = useFormField();

const Component = componentMap[formField.type as QuestionType];
if (!Component) {
console.error(`No component found for questiontype: ${formField.type}`);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
if (!isQuestionTypeObs) {
const isRenderingTypeValidForQuestionType =
questionTypes.includes(newQuestionType as keyof typeof renderTypeOptions) &&
renderTypeOptions[newQuestionType].includes(prevFormField.questionOptions.rendering as RenderType);
renderTypeOptions[newQuestionType]?.includes(prevFormField.questionOptions.rendering as RenderType);
if (!isRenderingTypeValidForQuestionType) {
return {
...prevFormField,
Expand Down
4 changes: 3 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,15 @@ export const renderingTypes: Array<RenderType> = [
'select-concept-answers',
];

export const renderTypeOptions: Record<Exclude<QuestionType, 'obs' | 'personAttribute'>, Array<RenderType>> = {
export const renderTypeOptions: Record<QuestionType, Array<RenderType>> = {
control: ['text', 'markdown'],
encounterDatetime: ['date', 'datetime'],
encounterLocation: ['ui-select-extended'],
encounterProvider: ['ui-select-extended'],
encounterRole: ['ui-select-extended'],
obs: renderingTypes,
obsGroup: ['group', 'repeating'],
personAttribute: ['text', 'select', 'date', 'radio', 'checkbox', 'textarea', 'toggle'],
testOrder: ['group', 'repeating'],
patientIdentifier: ['text'],
programState: ['select'],
Expand Down
97 changes: 77 additions & 20 deletions src/resources/form-validator.resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import type { FormField } from '@openmrs/esm-form-engine-lib';
import type { Schema } from '@types';
import type { ConfigObject } from '../config-schema';
import type { TFunction } from 'i18next';

interface Field {
label: string;
Expand All @@ -23,6 +24,7 @@ interface WarningMessageResponse {
export const handleFormValidation = async (
schema: string | Schema,
configObject: ConfigObject['dataTypeToRenderingMap'],
t: TFunction,
): Promise<[Array<ErrorMessageResponse>, Array<WarningMessageResponse>]> => {
const errors: Array<ErrorMessageResponse> = [];
const warnings: Array<WarningMessageResponse> = [];
Expand All @@ -36,15 +38,16 @@ export const handleFormValidation = async (
page.sections?.forEach((section: { questions: Array<FormField> }) =>
section.questions?.forEach((question) => {
asyncTasks.push(
handleQuestionValidation(question, errors, configObject, warnings),
handleAnswerValidation(question, errors),
handlePatientIdentifierValidation(question, errors),
handleQuestionValidation(question, errors, configObject, warnings, t),
handleAnswerValidation(question, errors, t),
handlePatientIdentifierValidation(question, errors, t),
handlePersonAttributeValidation(question, errors, t),
);
if (question.type === 'obsGroup') {
question?.questions?.forEach((obsGrpQuestion) =>
asyncTasks.push(
handleQuestionValidation(obsGrpQuestion, errors, configObject, warnings),
handleAnswerValidation(obsGrpQuestion, errors),
handleQuestionValidation(obsGrpQuestion, errors, configObject, warnings, t),
handleAnswerValidation(obsGrpQuestion, errors, t),
),
);
}
Expand All @@ -58,7 +61,7 @@ export const handleFormValidation = async (
return [errors, warnings]; // Return empty arrays if schema is falsy
};

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

Expand All @@ -84,7 +87,14 @@ const handleQuestionValidation = async (conceptObject, errorsArray, configObject
answer.concept !== '488b58ff-64f5-4f8a-8979-fa79940b1594'
) {
errorsArray.push({
errorMessage: `❌ concept "${conceptObject.questionOptions.concept}" of type "boolean" has a non-boolean answer "${answer.label}"`,
errorMessage: t(
'booleanConceptNonBooleanAnswer',
'Concept "{{concept}}" of type "boolean" has a non-boolean answer "{{answer}}"',
{
concept: conceptObject.questionOptions.concept,
answer: answer.label,
},
),
field: conceptObject,
});
}
Expand All @@ -94,16 +104,25 @@ const handleQuestionValidation = async (conceptObject, errorsArray, configObject
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`,
warningMessage: t(
'answerNotInResponseAnswers',
'Answer: "{{label}}" - "{{concept}}" does not exist in the response answers but exists in the form',
{
label: answer.label,
concept: answer.concept,
},
),
field: conceptObject,
});
}
});

dataTypeChecker(conceptObject, resObject, errorsArray, configObject);
dataTypeChecker(conceptObject, resObject, errorsArray, configObject, t);
} else {
errorsArray.push({
errorMessage: `❓ Concept "${conceptObject.questionOptions.concept}" not found`,
errorMessage: t('conceptNotFound', 'Concept "{{concept}}" not found', {
concept: conceptObject.questionOptions.concept,
}),
field: conceptObject,
});
}
Expand All @@ -112,16 +131,16 @@ const handleQuestionValidation = async (conceptObject, errorsArray, configObject
}
} else if (conceptObject.questionOptions.rendering !== 'workspace-launcher') {
errorsArray.push({
errorMessage: `❓ No UUID`,
errorMessage: t('noUuid', 'No UUID'),
field: conceptObject,
});
}
};

const handlePatientIdentifierValidation = async (question, errors) => {
const handlePatientIdentifierValidation = async (question, errors, t: TFunction) => {
if (question.type === 'patientIdentifier' && !question.questionOptions.identifierType) {
errors.push({
errorMessage: `❓ Patient identifier type missing in schema`,
errorMessage: t('patientIdentifierTypeMissing', 'Patient identifier type missing in schema'),
field: question,
});
}
Expand All @@ -134,36 +153,74 @@ const handlePatientIdentifierValidation = async (question, errors) => {
);
if (!data) {
errors.push({
errorMessage: `❓ The identifier type does not exist`,
errorMessage: t('identifierTypeDoesNotExist', 'The identifier type does not exist'),
field: question,
});
}
} catch (error) {
console.error('Error fetching patient identifier:', error);
errors.push({
errorMessage: `❓ The identifier type does not exist`,
errorMessage: t('identifierTypeDoesNotExist', 'The identifier type does not exist'),
field: question,
});
}
}
};

const dataTypeChecker = (conceptObject, responseObject, array, dataTypeToRenderingMap) => {
const handlePersonAttributeValidation = async (question, errors, t: TFunction) => {
if (question.type === 'personAttribute' && !question.questionOptions.attributeType) {
errors.push({
errorMessage: t('personAttributeTypeMissing', 'Person attribute type missing in schema'),
field: question,
});
}
const personAttribute = question.questionOptions.attributeType;

if (personAttribute) {
try {
const { data } = await openmrsFetch(`${restBaseUrl}/personattributetype/${personAttribute}`);
if (!data) {
errors.push({
errorMessage: t('personAttributeTypeDoesNotExist', 'The person attribute type does not exist'),
field: question,
});
}
} catch (error) {
console.error('Error fetching person attribute:', error);
errors.push({
errorMessage: t('personAttributeTypeDoesNotExist', 'The person attribute type does not exist'),
field: question,
});
}
}
};

const dataTypeChecker = (conceptObject, responseObject, array, dataTypeToRenderingMap, t: TFunction) => {
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}"`,
errorMessage: t(
'datatypeMismatch',
'{{concept}}: datatype "{{datatype}}" doesn\'t match control type "{{rendering}}"',
{
concept: conceptObject.questionOptions.concept,
datatype: responseObject.datatype.name,
rendering: conceptObject.questionOptions.rendering,
},
),
field: conceptObject,
});

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

const handleAnswerValidation = async (questionObject, array) => {
const handleAnswerValidation = async (questionObject, array, t: TFunction) => {
const answerArray = questionObject.questionOptions.answers;
const conceptRepresentation =
'custom:(uuid,display,datatype,conceptMappings:(conceptReferenceTerm:(conceptSource:(name),code)))';
Expand All @@ -186,7 +243,7 @@ const handleAnswerValidation = async (questionObject, array) => {
);
if (!response.data.results.length) {
array.push({
errorMessage: `❌ concept "${answer.concept}" not found`,
errorMessage: t('answerConceptNotFound', 'Concept "{{concept}}" not found', { concept: answer.concept }),
field: answer,
});
}
Expand Down
Loading