Skip to content

Commit e9aa02e

Browse files
committed
(feat) O3-3367 Add support for person attributes
1 parent 5401a64 commit e9aa02e

22 files changed

+309
-43
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?.attributeType,
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.attributeType,
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

+34-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,36 @@ 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+
}
201+
202+
export async function getPersonAttributeTypeFormat(personAttributeTypeUuid: string) {
203+
try {
204+
const response = await openmrsFetch(
205+
`${restBaseUrl}/personattributetype/${personAttributeTypeUuid}?v=custom:(format)`,
206+
);
207+
if (response) {
208+
const { data } = response;
209+
return data?.format;
210+
}
211+
return null;
212+
} catch (error) {
213+
return null;
214+
}
215+
}

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/inputs/ui-select-extended/ui-select-extended.test.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ jest.mock('../../../registry/registry', () => {
107107
};
108108
});
109109

110+
jest.mock('../../../hooks/usePersonAttributes', () => ({
111+
usePersonAttributes: jest.fn().mockReturnValue({
112+
personAttributes: [],
113+
error: null,
114+
isLoading: false,
115+
}),
116+
}));
117+
110118
const encounter = {
111119
uuid: 'encounter-uuid',
112120
obs: [

Diff for: src/components/inputs/unspecified/unspecified.test.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,14 @@ jest.mock('../../../hooks/useEncounter', () => ({
5858
}),
5959
}));
6060

61+
jest.mock('../../../hooks/usePersonAttributes', () => ({
62+
usePersonAttributes: jest.fn().mockReturnValue({
63+
personAttributes: [],
64+
error: null,
65+
isLoading: false,
66+
}),
67+
}));
68+
6169
const renderForm = async (mode: SessionMode = 'enter') => {
6270
await act(async () => {
6371
render(

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

+8
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,14 @@ jest.mock('./hooks/useConcepts', () => ({
118118
}),
119119
}));
120120

121+
jest.mock('./hooks/usePersonAttributes', () => ({
122+
usePersonAttributes: jest.fn().mockReturnValue({
123+
personAttributes: [],
124+
error: null,
125+
isLoading: false,
126+
}),
127+
}));
128+
121129
describe('Form engine component', () => {
122130
const user = userEvent.setup();
123131

Diff for: src/hooks/useFormJson.tsx

+8-5
Original file line numberDiff line numberDiff line change
@@ -108,14 +108,17 @@ function validateFormsArgs(formUuid: string, rawFormJson: any): Error {
108108
* @param {string} [formSessionIntent] - The optional form session intent.
109109
* @returns {FormSchema} - The refined form JSON object of type FormSchema.
110110
*/
111-
function refineFormJson(
111+
async function refineFormJson(
112112
formJson: any,
113113
schemaTransformers: FormSchemaTransformer[] = [],
114114
formSessionIntent?: string,
115-
): FormSchema {
115+
): Promise<FormSchema> {
116116
removeInlineSubForms(formJson, formSessionIntent);
117117
// apply form schema transformers
118-
schemaTransformers.reduce((draftForm, transformer) => transformer.transform(draftForm), formJson);
118+
for (let transformer of schemaTransformers) {
119+
const draftForm = await transformer.transform(formJson);
120+
formJson = draftForm;
121+
}
119122
setEncounterType(formJson);
120123
return applyFormIntent(formSessionIntent, formJson);
121124
}
@@ -134,7 +137,7 @@ function parseFormJson(formJson: any): FormSchema {
134137
* @param {FormSchema} formJson - The input form JSON object of type FormSchema.
135138
* @param {string} formSessionIntent - The form session intent.
136139
*/
137-
function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string): void {
140+
async function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string): Promise<void> {
138141
for (let i = formJson.pages.length - 1; i >= 0; i--) {
139142
const page = formJson.pages[i];
140143
if (
@@ -143,7 +146,7 @@ function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string):
143146
page.subform?.form?.encounterType === formJson.encounterType
144147
) {
145148
const nonSubformPages = page.subform.form.pages.filter((page) => !isTrue(page.isSubform));
146-
formJson.pages.splice(i, 1, ...refineFormJson(page.subform.form, [], formSessionIntent).pages);
149+
formJson.pages.splice(i, 1, ...(await refineFormJson(page.subform.form, [], formSessionIntent)).pages);
147150
}
148151
}
149152
}

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+
};

Diff for: src/processors/encounter/encounter-form-processor.ts

+30-3
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ import {
1919
prepareEncounter,
2020
preparePatientIdentifiers,
2121
preparePatientPrograms,
22+
preparePersonAttributes,
2223
saveAttachments,
2324
savePatientIdentifiers,
2425
savePatientPrograms,
26+
savePersonAttributes,
2527
} from './encounter-processor-helper';
2628
import { type OpenmrsResource, showSnackbar, translateFrom } from '@openmrs/esm-framework';
2729
import { moduleName } from '../../globals';
@@ -31,6 +33,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole';
3133
import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-runner';
3234
import { hasRendering } from '../../utils/common-utils';
3335
import { extractObsValueAndDisplay } from '../../utils/form-helper';
36+
import { usePersonAttributes } from '../../hooks/usePersonAttributes';
3437

3538
function useCustomHooks(context: Partial<FormProcessorContextProps>) {
3639
const [isLoading, setIsLoading] = useState(true);
@@ -40,13 +43,14 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
4043
context.patient?.id,
4144
context.formJson,
4245
);
46+
const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes(context.patient?.id);
4347

4448
useEffect(() => {
45-
setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole);
46-
}, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole]);
49+
setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole || isLoadingPersonAttributes);
50+
}, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole, isLoadingPersonAttributes]);
4751

4852
return {
49-
data: { encounter, patientPrograms, encounterRole },
53+
data: { encounter, patientPrograms, encounterRole, personAttributes },
5054
isLoading,
5155
error: null,
5256
updateContext: (setContext: React.Dispatch<React.SetStateAction<FormProcessorContextProps>>) => {
@@ -59,6 +63,7 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
5963
...context.customDependencies,
6064
patientPrograms: patientPrograms,
6165
defaultEncounterRole: encounterRole,
66+
personAttributes: personAttributes,
6267
},
6368
};
6469
});
@@ -79,6 +84,7 @@ const contextInitializableTypes = [
7984
'patientIdentifier',
8085
'encounterRole',
8186
'programState',
87+
'personAttributes',
8288
];
8389

8490
export class EncounterFormProcessor extends FormProcessor {
@@ -162,6 +168,27 @@ export class EncounterFormProcessor extends FormProcessor {
162168
});
163169
}
164170

171+
// save person attributes
172+
try {
173+
const personattributes = preparePersonAttributes(context.formFields, context.location?.uuid);
174+
const savedPrograms = await savePersonAttributes(context.patient, personattributes);
175+
if (savedPrograms?.length) {
176+
showSnackbar({
177+
title: translateFn('personAttributesSaved', 'Person attribute(s) saved successfully'),
178+
kind: 'success',
179+
isLowContrast: true,
180+
});
181+
}
182+
} catch (error) {
183+
const errorMessages = extractErrorMessagesFromResponse(error);
184+
return Promise.reject({
185+
title: translateFn('errorSavingPersonAttributes', 'Error saving person attributes'),
186+
description: errorMessages.join(', '),
187+
kind: 'error',
188+
critical: true,
189+
});
190+
}
191+
165192
// save encounter
166193
try {
167194
const { data: savedEncounter } = await saveEncounter(abortController, encounter, encounter.uuid);

Diff for: src/processors/encounter/encounter-processor-helper.ts

+14-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
type PatientProgram,
88
type PatientProgramPayload,
99
} from '../../types';
10-
import { saveAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api';
10+
import { saveAttachment, savePatientIdentifier, savePersonAttribute, saveProgramEnrollment } from '../../api';
1111
import { hasRendering, hasSubmission } from '../../utils/common-utils';
1212
import dayjs from 'dayjs';
1313
import { assignedObsIds, constructObs, voidObs } from '../../adapters/obs-adapter';
@@ -16,6 +16,7 @@ import { ConceptTrue } from '../../constants';
1616
import { DefaultValueValidator } from '../../validators/default-value-validator';
1717
import { cloneRepeatField } from '../../components/repeat/helpers';
1818
import { assignedOrderIds } from '../../adapters/orders-adapter';
19+
import { type PersonAttribute } from '@openmrs/esm-framework';
1920

2021
export function prepareEncounter(
2122
context: FormContextProps,
@@ -152,6 +153,12 @@ export function saveAttachments(fields: FormField[], encounter: OpenmrsEncounter
152153
});
153154
}
154155

156+
export function savePersonAttributes(patient: fhir.Patient, attributes: PersonAttribute[]) {
157+
return attributes.map((personAttribute) => {
158+
return savePersonAttribute(personAttribute, patient.id);
159+
});
160+
}
161+
155162
export function getMutableSessionProps(context: FormContextProps) {
156163
const {
157164
formFields,
@@ -328,3 +335,9 @@ export async function hydrateRepeatField(
328335
}),
329336
).then((results) => results.flat());
330337
}
338+
339+
export function preparePersonAttributes(fields: FormField[], encounterLocation: string): PersonAttribute[] {
340+
return fields
341+
.filter((field) => field.type === 'personAttribute' && hasSubmission(field))
342+
.map((field) => field.meta.submission.newValue);
343+
}

Diff for: src/registry/inbuilt-components/control-templates.ts

+6
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ export const controlTemplates: Array<ControlTemplate> = [
5050
},
5151
},
5252
},
53+
{
54+
name: 'person-attribute-location',
55+
datasource: {
56+
name: 'person_attribute_location_datasource',
57+
},
58+
},
5359
];
5460

5561
export const getControlTemplate = (name: string) => {

Diff for: src/registry/inbuilt-components/inbuiltControls.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,6 @@ export const inbuiltControls: Array<RegistryItem<React.ComponentType<FormFieldIn
9494
},
9595
...controlTemplates.map((template) => ({
9696
name: template.name,
97-
component: templateToComponentMap.find((component) => component.name === template.name).baseControlComponent,
97+
component: templateToComponentMap.find((component) => component.name === template.name)?.baseControlComponent,
9898
})),
9999
];

0 commit comments

Comments
 (0)