Skip to content

Commit 7b25040

Browse files
samuelmaleCynthiaKamau
authored andcommitted
(feat) (feat) O3-3367 Add support for person attributes
1 parent e0b0512 commit 7b25040

18 files changed

+292
-55
lines changed

Diff for: src/adapters/person-attributes-adapter.ts

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { type PersonAttribute, type OpenmrsResource } from '@openmrs/esm-framework';
2+
import { type FormContextProps } from '../provider/form-provider';
3+
import { type FormField, type FormFieldValueAdapter, type FormProcessorContextProps } from '../types';
4+
import { clearSubmission } from '../utils/common-utils';
5+
import { isEmpty } from '../validators/form-validator';
6+
7+
export const PersonAttributesAdapter: FormFieldValueAdapter = {
8+
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
9+
clearSubmission(field);
10+
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
11+
return null;
12+
}
13+
field.meta.submission.newValue = {
14+
value: value,
15+
attributeType: field.questionOptions?.attribute?.type,
16+
};
17+
return field.meta.submission.newValue;
18+
},
19+
getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
20+
const rendering = field.questionOptions.rendering;
21+
22+
const personAttributeValue = context?.customDependencies.personAttributes.find(
23+
(attribute: PersonAttribute) => attribute.attributeType.uuid === field.questionOptions.attribute?.type,
24+
)?.value;
25+
if (rendering === 'text') {
26+
if (typeof personAttributeValue === 'string') {
27+
return personAttributeValue;
28+
} else if (
29+
personAttributeValue &&
30+
typeof personAttributeValue === 'object' &&
31+
'display' in personAttributeValue
32+
) {
33+
return personAttributeValue?.display;
34+
}
35+
} else if (rendering === 'ui-select-extended') {
36+
if (personAttributeValue && typeof personAttributeValue === 'object' && 'uuid' in personAttributeValue) {
37+
return personAttributeValue?.uuid;
38+
}
39+
}
40+
return null;
41+
},
42+
getPreviousValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
43+
return null;
44+
},
45+
getDisplayValue: function (field: FormField, value: any) {
46+
if (value?.display) {
47+
return value.display;
48+
}
49+
return value;
50+
},
51+
tearDown: function (): void {
52+
return;
53+
},
54+
};

Diff for: src/api/index.ts

+19-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
1+
import { fhirBaseUrl, openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
22
import { encounterRepresentation } from '../constants';
33
import { type OpenmrsForm, type PatientIdentifier, type PatientProgramPayload } from '../types';
44
import { isUuid } from '../utils/boolean-utils';
@@ -180,3 +180,21 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati
180180
body: JSON.stringify(patientIdentifier),
181181
});
182182
}
183+
184+
export function savePersonAttribute(personAttribute: PersonAttribute, personUuid: string) {
185+
let url: string;
186+
187+
if (personAttribute.uuid) {
188+
url = `${restBaseUrl}/person/${personUuid}/attribute/${personAttribute.uuid}`;
189+
} else {
190+
url = `${restBaseUrl}/person/${personUuid}/attribute`;
191+
}
192+
193+
return openmrsFetch(url, {
194+
headers: {
195+
'Content-Type': 'application/json',
196+
},
197+
method: 'POST',
198+
body: JSON.stringify(personAttribute),
199+
});
200+
}

Diff for: src/components/inputs/ui-select-extended/ui-select-extended.component.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
149149
selectedItem={selectedItem}
150150
placeholder={isSearchable ? t('search', 'Search') + '...' : null}
151151
shouldFilterItem={({ item, inputValue }) => {
152-
if (!inputValue) {
152+
if (!inputValue || items.find((item) => item.uuid == field.value)) {
153153
// Carbon's initial call at component mount
154154
return true;
155155
}

Diff for: src/components/renderer/field/fieldLogic.ts

+61-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { codedTypes } from '../../../constants';
22
import { type FormContextProps } from '../../../provider/form-provider';
3-
import { type FormField } from '../../../types';
3+
import { type FormFieldValidator, type SessionMode, type ValidationResult, type FormField } from '../../../types';
44
import { isTrue } from '../../../utils/boolean-utils';
55
import { hasRendering } from '../../../utils/common-utils';
66
import { evaluateAsyncExpression, evaluateExpression } from '../../../utils/expression-runner';
@@ -65,6 +65,21 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
6565
},
6666
).then((result) => {
6767
setValue(dependent.id, result);
68+
// validate calculated value
69+
const { errors, warnings } = validateFieldValue(dependent, result, context.formFieldValidators, {
70+
formFields,
71+
values,
72+
expressionContext: { patient, mode: sessionMode },
73+
});
74+
if (!dependent.meta.submission) {
75+
dependent.meta.submission = {};
76+
}
77+
dependent.meta.submission.errors = errors;
78+
dependent.meta.submission.warnings = warnings;
79+
if (!errors.length) {
80+
context.formFieldAdapters[dependent.type].transformFieldValue(dependent, result, context);
81+
}
82+
updateFormField(dependent);
6883
});
6984
}
7085
// evaluate hide
@@ -212,3 +227,48 @@ function evaluateFieldDependents(field: FormField, values: any, context: FormCon
212227
setForm(formJson);
213228
}
214229
}
230+
231+
export interface ValidatorConfig {
232+
formFields: FormField[];
233+
values: Record<string, any>;
234+
expressionContext: {
235+
patient: fhir.Patient;
236+
mode: SessionMode;
237+
};
238+
}
239+
240+
export function validateFieldValue(
241+
field: FormField,
242+
value: any,
243+
validators: Record<string, FormFieldValidator>,
244+
context: ValidatorConfig,
245+
): { errors: ValidationResult[]; warnings: ValidationResult[] } {
246+
const errors: ValidationResult[] = [];
247+
const warnings: ValidationResult[] = [];
248+
249+
if (field.meta.submission?.unspecified) {
250+
return { errors: [], warnings: [] };
251+
}
252+
253+
try {
254+
field.validators.forEach((validatorConfig) => {
255+
const results = validators[validatorConfig.type]?.validate?.(field, value, {
256+
...validatorConfig,
257+
...context,
258+
});
259+
if (results) {
260+
results.forEach((result) => {
261+
if (result.resultType === 'error') {
262+
errors.push(result);
263+
} else if (result.resultType === 'warning') {
264+
warnings.push(result);
265+
}
266+
});
267+
}
268+
});
269+
} catch (error) {
270+
console.error(error);
271+
}
272+
273+
return { errors, warnings };
274+
}

Diff for: src/components/renderer/field/form-field-renderer.component.tsx

+1-46
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { getFieldControlWithFallback, getRegisteredControl } from '../../../regi
2121
import styles from './form-field-renderer.scss';
2222
import { isTrue } from '../../../utils/boolean-utils';
2323
import UnspecifiedField from '../../inputs/unspecified/unspecified.component';
24-
import { handleFieldLogic } from './fieldLogic';
24+
import { handleFieldLogic, validateFieldValue } from './fieldLogic';
2525

2626
export interface FormFieldRendererProps {
2727
fieldId: string;
@@ -221,51 +221,6 @@ function ErrorFallback({ error }) {
221221
);
222222
}
223223

224-
export interface ValidatorConfig {
225-
formFields: FormField[];
226-
values: Record<string, any>;
227-
expressionContext: {
228-
patient: fhir.Patient;
229-
mode: SessionMode;
230-
};
231-
}
232-
233-
function validateFieldValue(
234-
field: FormField,
235-
value: any,
236-
validators: Record<string, FormFieldValidator>,
237-
context: ValidatorConfig,
238-
): { errors: ValidationResult[]; warnings: ValidationResult[] } {
239-
const errors: ValidationResult[] = [];
240-
const warnings: ValidationResult[] = [];
241-
242-
if (field.meta.submission?.unspecified) {
243-
return { errors: [], warnings: [] };
244-
}
245-
246-
try {
247-
field.validators.forEach((validatorConfig) => {
248-
const results = validators[validatorConfig.type]?.validate?.(field, value, {
249-
...validatorConfig,
250-
...context,
251-
});
252-
if (results) {
253-
results.forEach((result) => {
254-
if (result.resultType === 'error') {
255-
errors.push(result);
256-
} else if (result.resultType === 'warning') {
257-
warnings.push(result);
258-
}
259-
});
260-
}
261-
});
262-
} catch (error) {
263-
console.error(error);
264-
}
265-
266-
return { errors, warnings };
267-
}
268-
269224
/**
270225
* Determines whether a field can be unspecified
271226
*/

Diff for: src/datasources/person-attribute-datasource.ts

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
2+
import { BaseOpenMRSDataSource } from './data-source';
3+
4+
export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource {
5+
constructor() {
6+
super(null);
7+
}
8+
9+
async fetchData(searchTerm: string, config?: Record<string, any>, uuid?: string): Promise<any[]> {
10+
const rep = 'v=custom:(uuid,display)';
11+
const url = `${restBaseUrl}/location?${rep}`;
12+
const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url);
13+
14+
return data?.results;
15+
}
16+
}

Diff for: src/datasources/select-concept-answers-datasource.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource {
77
}
88

99
fetchData(searchTerm: string, config?: Record<string, any>): Promise<any[]> {
10-
const apiUrl = this.url.replace('conceptUuid', config.referencedValue || config.concept);
10+
const apiUrl = this.url.replace('conceptUuid', config.concept || config.referencedValue);
1111
return openmrsFetch(apiUrl).then(({ data }) => {
1212
return data['setMembers'].length ? data['setMembers'] : data['answers'];
1313
});

Diff for: src/form-engine.test.tsx

+16
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,8 @@ describe('Form engine component', () => {
681681

682682
describe('Calculated values', () => {
683683
it('should evaluate BMI', async () => {
684+
const saveEncounterMock = jest.spyOn(api, 'saveEncounter');
685+
684686
await act(async () => renderForm(null, bmiForm));
685687

686688
const bmiField = screen.getByRole('textbox', { name: /bmi/i });
@@ -694,9 +696,17 @@ describe('Form engine component', () => {
694696
expect(heightField).toHaveValue(150);
695697
expect(weightField).toHaveValue(50);
696698
expect(bmiField).toHaveValue('22.2');
699+
700+
await user.click(screen.getByRole('button', { name: /save/i }));
701+
702+
const encounter = saveEncounterMock.mock.calls[0][1];
703+
expect(encounter.obs.length).toEqual(3);
704+
expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bmi').value).toBe(22.2);
697705
});
698706

699707
it('should evaluate BSA', async () => {
708+
const saveEncounterMock = jest.spyOn(api, 'saveEncounter');
709+
700710
await act(async () => renderForm(null, bsaForm));
701711

702712
const bsaField = screen.getByRole('textbox', { name: /bsa/i });
@@ -710,6 +720,12 @@ describe('Form engine component', () => {
710720
expect(heightField).toHaveValue(190.5);
711721
expect(weightField).toHaveValue(95);
712722
expect(bsaField).toHaveValue('2.24');
723+
724+
await user.click(screen.getByRole('button', { name: /save/i }));
725+
726+
const encounter = saveEncounterMock.mock.calls[0][1];
727+
expect(encounter.obs.length).toEqual(3);
728+
expect(encounter.obs.find((obs) => obs.formFieldPath === 'rfe-forms-bsa').value).toBe(2.24);
713729
});
714730

715731
it('should evaluate EDD', async () => {

Diff for: src/hooks/usePersonAttributes.tsx

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
2+
import { useEffect, useState } from 'react';
3+
4+
export const usePersonAttributes = (patientUuid: string) => {
5+
const [personAttributes, setPersonAttributes] = useState<Array<PersonAttribute>>([]);
6+
const [isLoading, setIsLoading] = useState(true);
7+
const [error, setError] = useState(null);
8+
9+
useEffect(() => {
10+
if (patientUuid) {
11+
openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`)
12+
.then((response) => {
13+
setPersonAttributes(response?.data?.attributes);
14+
setIsLoading(false);
15+
})
16+
.catch((error) => {
17+
setError(error);
18+
setIsLoading(false);
19+
});
20+
} else {
21+
setIsLoading(false);
22+
}
23+
}, [patientUuid]);
24+
25+
return {
26+
personAttributes,
27+
error,
28+
isLoading: isLoading,
29+
};
30+
};

0 commit comments

Comments
 (0)