Skip to content

Commit e0576dd

Browse files
(feat) O3-5408: Add support for creating PersonAttribute type questions (#1082)
* fix: resolve PersonAttributeTypeQuestion state management issues * fix: resolve form-validator person attribute typing * adding tests * Update src/components/interactive-builder/modals/add-form-reference/add-form-reference.modal.tsx Co-authored-by: Nethmi Rodrigo <nethmi@openmrs.org> * fix * fix * Improvement1 * Improvement2 * Improvement3 * Improvement4 * Improvement5 * Correct mocking * Correct mocking pattern for form-field-context * Warn when stored person attribute type UUID is not found * Cleanup mock --------- Co-authored-by: Nethmi Rodrigo <nethmi@openmrs.org>
1 parent c77c41b commit e0576dd

File tree

11 files changed

+348
-24
lines changed

11 files changed

+348
-24
lines changed

src/components/action-buttons/action-buttons.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ function ActionButtons({
7979

8080
async function handleValidateAndPublish() {
8181
setStatus('validateBeforePublishing');
82-
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap);
82+
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap, t);
8383
setValidationResponse(errorsArray);
8484
if (errorsArray.length) {
8585
setStatus('validated');

src/components/form-editor/form-editor.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ const FormEditorContent: React.FC<TranslationFnProps> = ({ t }) => {
146146
const onValidateForm = async () => {
147147
setIsValidating(true);
148148
try {
149-
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap);
149+
const [errorsArray] = await handleFormValidation(schema, dataTypeToRenderingMap, t);
150150
setValidationResponse(errorsArray);
151151
setValidationComplete(true);
152152
} catch (error) {

src/components/interactive-builder/modals/add-form-reference/add-form-reference.modal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { showSnackbar } from '@openmrs/esm-framework';
2020
import { useForms } from '@hooks/useForms';
2121
import { useClobdata } from '@hooks/useClobdata';
22-
import type { FormPage, FormSection, FormField } from '@openmrs/esm-form-engine-lib';
22+
import type { FormField, FormPage, FormSection } from '@openmrs/esm-form-engine-lib';
2323
import type { Form as FormType, Schema } from '@types';
2424
import styles from './add-form-reference.scss';
2525

src/components/interactive-builder/modals/question/question-form/question-types/inputs/index.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export { default as ObsTypeQuestion } from './obs/obs-type-question.component';
22
export { default as ProgramStateTypeQuestion } from './program-state/program-state-type-question.component';
33
export { default as PatientIdentifierTypeQuestion } from './patient-identifier/patient-identifier-type-question.component';
44
export { default as TestOrderTypeQuestion } from './test-order/test-order-type-question.component';
5+
export { default as PersonAttributeTypeQuestion } from './person-attribute/person-attribute-type-question.component';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import React, { useState, useCallback, useEffect } from 'react';
2+
import { useTranslation } from 'react-i18next';
3+
import { FormLabel, InlineNotification, ComboBox, InlineLoading } from '@carbon/react';
4+
import { usePersonAttributeTypes } from '@hooks/usePersonAttributeTypes';
5+
import { useFormField } from '../../../../form-field-context';
6+
import type { PersonAttributeType } from '@types';
7+
import styles from './person-attribute-type-question.scss';
8+
9+
const PersonAttributeTypeQuestion: React.FC = () => {
10+
const { t } = useTranslation();
11+
const { formField, setFormField } = useFormField();
12+
const { personAttributeTypes, personAttributeTypeLookupError, isLoadingPersonAttributeTypes } =
13+
usePersonAttributeTypes();
14+
15+
const attributeTypeUuid = (formField.questionOptions as { attributeType?: string })?.attributeType;
16+
const [selectedPersonAttributeType, setSelectedPersonAttributeType] = useState<PersonAttributeType | null>(null);
17+
const [isInvalidAttributeType, setIsInvalidAttributeType] = useState(false);
18+
19+
// Sync selected person attribute type when personAttributeTypes loads or attributeTypeUuid changes
20+
useEffect(() => {
21+
if (attributeTypeUuid && personAttributeTypes.length > 0) {
22+
const matchingType = personAttributeTypes.find(
23+
(personAttributeType) => personAttributeType.uuid === attributeTypeUuid,
24+
);
25+
if (matchingType) {
26+
setSelectedPersonAttributeType(matchingType);
27+
setIsInvalidAttributeType(false);
28+
} else {
29+
setSelectedPersonAttributeType(null);
30+
setIsInvalidAttributeType(true);
31+
}
32+
} else if (!attributeTypeUuid) {
33+
setSelectedPersonAttributeType(null);
34+
setIsInvalidAttributeType(false);
35+
}
36+
}, [attributeTypeUuid, personAttributeTypes]);
37+
38+
const handlePersonAttributeTypeChange = ({ selectedItem }: { selectedItem: PersonAttributeType }) => {
39+
setSelectedPersonAttributeType(selectedItem);
40+
setFormField({
41+
...formField,
42+
questionOptions: {
43+
...formField.questionOptions,
44+
attributeType: selectedItem?.uuid,
45+
} as typeof formField.questionOptions & { attributeType?: string },
46+
});
47+
};
48+
49+
const convertItemsToString = useCallback((item: PersonAttributeType) => item?.display ?? '', []);
50+
51+
return (
52+
<div>
53+
<FormLabel className={styles.label}>
54+
{t('searchForBackingPersonAttributeType', 'Search for a backing person attribute type')}
55+
</FormLabel>
56+
{isInvalidAttributeType && (
57+
<InlineNotification
58+
kind="warning"
59+
lowContrast
60+
className={styles.error}
61+
title={t('invalidPersonAttributeType', 'Invalid person attribute type')}
62+
subtitle={t(
63+
'personAttributeTypeNotFound',
64+
'The configured person attribute type ({{uuid}}) was not found. It may have been deleted or retired.',
65+
{ uuid: attributeTypeUuid },
66+
)}
67+
/>
68+
)}
69+
{personAttributeTypeLookupError && (
70+
<InlineNotification
71+
kind="error"
72+
lowContrast
73+
className={styles.error}
74+
title={t('errorFetchingPersonAttributeTypes', 'Error fetching person attribute types')}
75+
subtitle={t('pleaseTryAgain', 'Please try again.')}
76+
/>
77+
)}
78+
{isLoadingPersonAttributeTypes ? (
79+
<InlineLoading className={styles.loader} description={t('loading', 'Loading') + '...'} />
80+
) : (
81+
<ComboBox
82+
disabled={!!personAttributeTypeLookupError}
83+
helperText={t(
84+
'personAttributeTypeHelperText',
85+
'Person attribute type fields must be linked to a person attribute type',
86+
)}
87+
id="personAttributeTypeLookup"
88+
items={personAttributeTypes}
89+
itemToString={convertItemsToString}
90+
onChange={handlePersonAttributeTypeChange}
91+
placeholder={t('choosePersonAttributeType', 'Choose a person attribute type')}
92+
selectedItem={selectedPersonAttributeType}
93+
/>
94+
)}
95+
</div>
96+
);
97+
};
98+
99+
export default PersonAttributeTypeQuestion;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
@use '@carbon/styles/scss/spacing';
2+
3+
.label {
4+
display: block;
5+
margin-bottom: spacing.$spacing-03;
6+
}
7+
8+
.error {
9+
margin-bottom: spacing.$spacing-05;
10+
}
11+
12+
.loader {
13+
margin-top: spacing.$spacing-05;
14+
}
15+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import React from 'react';
2+
import userEvent from '@testing-library/user-event';
3+
import { render, screen } from '@testing-library/react';
4+
import type { FormField } from '@openmrs/esm-form-engine-lib';
5+
import type { PersonAttributeType } from '@types';
6+
import { useFormField } from '../../../../form-field-context';
7+
import { usePersonAttributeTypes } from '@hooks/usePersonAttributeTypes';
8+
import PersonAttributeTypeQuestion from './person-attribute-type-question.component';
9+
10+
const mockSetFormField = jest.fn();
11+
const formField: FormField = {
12+
id: '1',
13+
type: 'personAttribute',
14+
questionOptions: {
15+
rendering: 'text',
16+
},
17+
};
18+
19+
jest.mock('../../../../form-field-context');
20+
const mockUseFormField = jest.mocked(useFormField);
21+
22+
jest.mock('@hooks/usePersonAttributeTypes');
23+
const mockUsePersonAttributeTypes = jest.mocked(usePersonAttributeTypes);
24+
25+
const personAttributeTypes: Array<PersonAttributeType> = [
26+
{ uuid: '1', display: 'Email', format: 'java.lang.String', concept: null },
27+
{ uuid: '2', display: 'Phone Number', format: 'java.lang.String', concept: null },
28+
];
29+
30+
describe('PersonAttributeTypeQuestion', () => {
31+
beforeEach(() => {
32+
mockUseFormField.mockReturnValue({
33+
formField,
34+
setFormField: mockSetFormField,
35+
concept: null,
36+
setConcept: jest.fn(),
37+
});
38+
});
39+
40+
it('renders without crashing and displays the person attribute types', async () => {
41+
mockUsePersonAttributeTypes.mockReturnValue({
42+
personAttributeTypes: personAttributeTypes,
43+
personAttributeTypeLookupError: null,
44+
isLoadingPersonAttributeTypes: false,
45+
});
46+
const user = userEvent.setup();
47+
renderComponent();
48+
49+
expect(screen.getByText(/search for a backing person attribute type/i)).toBeInTheDocument();
50+
expect(
51+
screen.getByText(/person attribute type fields must be linked to a person attribute type/i),
52+
).toBeInTheDocument();
53+
const comboBox = screen.getByRole('combobox');
54+
expect(comboBox).toBeInTheDocument();
55+
expect(screen.getByRole('button', { name: /open/i })).toBeInTheDocument();
56+
57+
await user.click(comboBox);
58+
expect(screen.getByText(/email/i)).toBeInTheDocument();
59+
expect(screen.getByText(/phone number/i)).toBeInTheDocument();
60+
});
61+
62+
it('shows spinner when loading the person attribute types', () => {
63+
mockUsePersonAttributeTypes.mockReturnValue({
64+
personAttributeTypes: [],
65+
personAttributeTypeLookupError: null,
66+
isLoadingPersonAttributeTypes: true,
67+
});
68+
renderComponent();
69+
70+
expect(screen.getByText(/loading\.\.\./i)).toBeInTheDocument();
71+
expect(screen.queryByRole('combobox')).not.toBeInTheDocument();
72+
expect(screen.queryByRole('button', { name: /open/i })).not.toBeInTheDocument();
73+
expect(
74+
screen.queryByText(/person attribute type fields must be linked to a person attribute type/i),
75+
).not.toBeInTheDocument();
76+
});
77+
78+
it('displays an error if person attribute types cannot be loaded', () => {
79+
mockUsePersonAttributeTypes.mockReturnValue({
80+
personAttributeTypes: [],
81+
personAttributeTypeLookupError: Error(),
82+
isLoadingPersonAttributeTypes: false,
83+
});
84+
renderComponent();
85+
86+
expect(screen.getByText(/error fetching person attribute types/i)).toBeInTheDocument();
87+
expect(screen.getByText(/please try again\./i)).toBeInTheDocument();
88+
});
89+
90+
it('shows the selected person attribute type', () => {
91+
mockUsePersonAttributeTypes.mockReturnValue({
92+
personAttributeTypes: personAttributeTypes,
93+
personAttributeTypeLookupError: null,
94+
isLoadingPersonAttributeTypes: false,
95+
});
96+
formField.questionOptions = {
97+
rendering: 'text',
98+
attributeType: personAttributeTypes[0].uuid,
99+
};
100+
renderComponent();
101+
102+
expect(screen.getByRole('button', { name: /clear selected item/i })).toBeInTheDocument();
103+
expect(screen.getByRole('combobox')).toHaveDisplayValue(/email/i);
104+
});
105+
106+
it('calls setFormField with the correct attributeType when a person attribute type is selected', async () => {
107+
mockUsePersonAttributeTypes.mockReturnValue({
108+
personAttributeTypes: personAttributeTypes,
109+
personAttributeTypeLookupError: null,
110+
isLoadingPersonAttributeTypes: false,
111+
});
112+
formField.questionOptions = { rendering: 'text' };
113+
const user = userEvent.setup();
114+
renderComponent();
115+
116+
await user.click(screen.getByRole('button', { name: /open/i }));
117+
await user.click(screen.getByText(/email/i));
118+
119+
expect(mockSetFormField).toHaveBeenCalledWith(
120+
expect.objectContaining({
121+
questionOptions: expect.objectContaining({
122+
attributeType: '1',
123+
}),
124+
}),
125+
);
126+
});
127+
});
128+
129+
function renderComponent() {
130+
render(<PersonAttributeTypeQuestion />);
131+
}

src/components/interactive-builder/modals/question/question-form/question-types/question-type.component.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
ProgramStateTypeQuestion,
55
PatientIdentifierTypeQuestion,
66
TestOrderTypeQuestion,
7+
PersonAttributeTypeQuestion,
78
} from './inputs';
89
import { useFormField } from '../../form-field-context';
910
import type { QuestionType } from '@types';
@@ -14,10 +15,12 @@ const componentMap: Partial<Record<QuestionType, React.FC>> = {
1415
patientIdentifier: PatientIdentifierTypeQuestion,
1516
obsGroup: ObsTypeQuestion,
1617
testOrder: TestOrderTypeQuestion,
18+
personAttribute: PersonAttributeTypeQuestion,
1719
};
1820

1921
const QuestionTypeComponent: React.FC = () => {
2022
const { formField } = useFormField();
23+
2124
const Component = componentMap[formField.type as QuestionType];
2225
if (!Component) {
2326
console.error(`No component found for questiontype: ${formField.type}`);

src/components/interactive-builder/modals/question/question-form/question/question.component.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const Question: React.FC<QuestionProps> = ({ checkIfQuestionIdExists }) => {
5656
if (!isQuestionTypeObs) {
5757
const isRenderingTypeValidForQuestionType =
5858
questionTypes.includes(newQuestionType as keyof typeof renderTypeOptions) &&
59-
renderTypeOptions[newQuestionType].includes(prevFormField.questionOptions.rendering as RenderType);
59+
renderTypeOptions[newQuestionType]?.includes(prevFormField.questionOptions.rendering as RenderType);
6060
if (!isRenderingTypeValidForQuestionType) {
6161
return {
6262
...prevFormField,

src/constants.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,13 +44,15 @@ export const renderingTypes: Array<RenderType> = [
4444
'select-concept-answers',
4545
];
4646

47-
export const renderTypeOptions: Record<Exclude<QuestionType, 'obs' | 'personAttribute'>, Array<RenderType>> = {
47+
export const renderTypeOptions: Record<QuestionType, Array<RenderType>> = {
4848
control: ['text', 'markdown'],
4949
encounterDatetime: ['date', 'datetime'],
5050
encounterLocation: ['ui-select-extended'],
5151
encounterProvider: ['ui-select-extended'],
5252
encounterRole: ['ui-select-extended'],
53+
obs: renderingTypes,
5354
obsGroup: ['group', 'repeating'],
55+
personAttribute: ['text', 'select', 'date', 'radio', 'checkbox', 'textarea', 'toggle', 'ui-select-extended'],
5456
testOrder: ['group', 'repeating'],
5557
patientIdentifier: ['text'],
5658
programState: ['select'],

0 commit comments

Comments
 (0)