Skip to content

Commit 59be838

Browse files
(feat) O3-5420: Add PersonAttribute adapter support to Form Engine (#681)
1 parent 1c1c094 commit 59be838

File tree

8 files changed

+272
-1
lines changed

8 files changed

+272
-1
lines changed
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { PersonAttributeAdapter } from './person-attribute-adapter';
2+
import { type FormField, type FormProcessorContextProps } from '../types';
3+
import { type FormContextProps } from '../provider/form-provider';
4+
5+
describe('PersonAttributeAdapter', () => {
6+
const mockField = {
7+
id: 'test-person-attribute',
8+
type: 'personAttribute',
9+
questionOptions: {
10+
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
11+
rendering: 'text',
12+
},
13+
meta: {},
14+
} satisfies FormField;
15+
16+
const mockContext = {
17+
patient: {
18+
id: 'test-patient-uuid',
19+
} as fhir.Patient,
20+
} satisfies Partial<FormContextProps> as FormContextProps;
21+
22+
describe('transformFieldValue', () => {
23+
it('should return null for empty value', () => {
24+
const result = PersonAttributeAdapter.transformFieldValue(mockField, '', mockContext);
25+
expect(result).toBeNull();
26+
});
27+
28+
it('should return null when value equals initial value', () => {
29+
const field = {
30+
...mockField,
31+
meta: {
32+
submission: {},
33+
initialValue: {
34+
omrsObject: null,
35+
refinedValue: 'test-value',
36+
},
37+
},
38+
} satisfies FormField;
39+
const result = PersonAttributeAdapter.transformFieldValue(field, 'test-value', mockContext);
40+
expect(result).toBeNull();
41+
});
42+
43+
it('should transform field value correctly for new attribute', () => {
44+
const field = { ...mockField };
45+
const result = PersonAttributeAdapter.transformFieldValue(field, 'new-attribute-value', mockContext);
46+
47+
expect(result).toEqual({
48+
value: 'new-attribute-value',
49+
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
50+
uuid: undefined,
51+
});
52+
});
53+
54+
it('should include uuid when updating existing attribute', () => {
55+
const field = {
56+
...mockField,
57+
meta: {
58+
submission: {},
59+
initialValue: {
60+
omrsObject: { uuid: 'existing-attr-uuid' },
61+
refinedValue: 'old-value',
62+
},
63+
},
64+
} satisfies FormField;
65+
const result = PersonAttributeAdapter.transformFieldValue(field, 'updated-value', mockContext);
66+
67+
expect(result).toEqual({
68+
value: 'updated-value',
69+
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
70+
uuid: 'existing-attr-uuid',
71+
});
72+
});
73+
});
74+
75+
describe('getInitialValue', () => {
76+
it('should return undefined when no person attribute exists', () => {
77+
const mockProcessorContext = {
78+
patient: {
79+
id: 'test-patient',
80+
extension: [],
81+
} as fhir.Patient,
82+
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;
83+
84+
const result = PersonAttributeAdapter.getInitialValue(mockField, null, mockProcessorContext);
85+
expect(result).toBeUndefined();
86+
});
87+
88+
it('should return valueString when person attribute exists', () => {
89+
const mockProcessorContext = {
90+
patient: {
91+
id: 'test-patient',
92+
extension: [
93+
{
94+
url: 'http://fhir.openmrs.org/ext/person-attribute/7ef225db-94db-4e40-9dd8-fb121d9dc370',
95+
valueString: 'test-attribute-value',
96+
},
97+
],
98+
} as fhir.Patient,
99+
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;
100+
101+
const field = { ...mockField };
102+
const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext);
103+
104+
expect(result).toBe('test-attribute-value');
105+
expect((field.meta as any).initialValue.refinedValue).toBe('test-attribute-value');
106+
});
107+
108+
it('should return valueReference when person attribute has reference', () => {
109+
const mockProcessorContext = {
110+
patient: {
111+
id: 'test-patient',
112+
extension: [
113+
{
114+
url: 'http://fhir.openmrs.org/ext/person-attribute/7ef225db-94db-4e40-9dd8-fb121d9dc370',
115+
valueReference: {
116+
reference: 'Location/test-location-uuid',
117+
},
118+
},
119+
],
120+
} as fhir.Patient,
121+
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;
122+
123+
const field = { ...mockField };
124+
const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext);
125+
126+
expect(result).toBe('Location/test-location-uuid');
127+
expect((field.meta as any).initialValue.refinedValue).toBe('Location/test-location-uuid');
128+
});
129+
});
130+
131+
describe('getPreviousValue', () => {
132+
it('should return null', () => {
133+
const result = PersonAttributeAdapter.getPreviousValue(mockField, null, {} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps);
134+
expect(result).toBeNull();
135+
});
136+
});
137+
138+
describe('getDisplayValue', () => {
139+
it('should return display property if present', () => {
140+
const value = { display: 'Test Display', value: 'test-value' };
141+
const result = PersonAttributeAdapter.getDisplayValue(mockField, value);
142+
expect(result).toBe('Test Display');
143+
});
144+
145+
it('should return value as-is if no display property', () => {
146+
const value = 'simple-value';
147+
const result = PersonAttributeAdapter.getDisplayValue(mockField, value);
148+
expect(result).toBe('simple-value');
149+
});
150+
});
151+
152+
describe('tearDown', () => {
153+
it('should execute without errors', () => {
154+
expect(() => PersonAttributeAdapter.tearDown()).not.toThrow();
155+
});
156+
});
157+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { 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 PersonAttributeAdapter: FormFieldValueAdapter = {
8+
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
9+
clearSubmission(field);
10+
if (field.meta.initialValue?.refinedValue === value || isEmpty(value)) {
11+
return null;
12+
}
13+
field.meta.submission.newValue = {
14+
value: value,
15+
attributeType: field.questionOptions.attributeType,
16+
uuid: (field.meta.initialValue?.omrsObject as OpenmrsResource)?.uuid,
17+
};
18+
return field.meta.submission.newValue;
19+
},
20+
getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
21+
const latestAttribute = context.patient?.extension?.find(
22+
(ext) => ext.url === `http://fhir.openmrs.org/ext/person-attribute/${field.questionOptions.attributeType}`,
23+
);
24+
field.meta = {
25+
...field.meta,
26+
initialValue: {
27+
omrsObject: latestAttribute as unknown as OpenmrsResource,
28+
refinedValue: latestAttribute?.valueString || latestAttribute?.valueReference?.reference,
29+
},
30+
};
31+
return field.meta.initialValue.refinedValue;
32+
},
33+
getPreviousValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
34+
return null;
35+
},
36+
getDisplayValue: function (field: FormField, value: any) {
37+
if (value?.display) {
38+
return value.display;
39+
}
40+
return value;
41+
},
42+
tearDown: function (): void {
43+
return;
44+
},
45+
};

src/api/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
PatientDeathPayload,
99
PatientIdentifier,
1010
PatientProgramPayload,
11+
PersonAttribute,
1112
} from '../types';
1213
import { isUuid } from '../utils/boolean-utils';
1314

@@ -179,6 +180,24 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati
179180
});
180181
}
181182

183+
export function savePersonAttribute(personAttribute: PersonAttribute, patientUuid: string) {
184+
let url: string;
185+
186+
if (personAttribute.uuid) {
187+
url = `${restBaseUrl}/person/${patientUuid}/attribute/${personAttribute.uuid}`;
188+
} else {
189+
url = `${restBaseUrl}/person/${patientUuid}/attribute`;
190+
}
191+
192+
return openmrsFetch(url, {
193+
headers: {
194+
'Content-Type': 'application/json',
195+
},
196+
method: 'POST',
197+
body: personAttribute,
198+
});
199+
}
200+
182201
export function markPatientAsDeceased(
183202
t: TFunction,
184203
patientUUID: string,

src/processors/encounter/encounter-form-processor.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import {
77
prepareEncounter,
88
preparePatientIdentifiers,
99
preparePatientPrograms,
10+
preparePersonAttributes,
1011
saveAttachments,
1112
savePatientIdentifiers,
1213
savePatientPrograms,
14+
savePersonAttributes,
1315
} from './encounter-processor-helper';
1416
import {
1517
type FormField,
@@ -33,6 +35,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole';
3335
import { usePatientPrograms } from '../../hooks/usePatientPrograms';
3436
import { type TOptions } from 'i18next';
3537

38+
3639
function useCustomHooks(context: Partial<FormProcessorContextProps>) {
3740
const [isLoading, setIsLoading] = useState(true);
3841
const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson);
@@ -77,6 +80,7 @@ const contextInitializableTypes = [
7780
'patientIdentifier',
7881
'encounterRole',
7982
'programState',
83+
'personAttribute',
8084
];
8185

8286
export class EncounterFormProcessor extends FormProcessor {
@@ -146,6 +150,27 @@ export class EncounterFormProcessor extends FormProcessor {
146150
});
147151
}
148152

153+
// save person attributes
154+
try {
155+
const personAttributes = preparePersonAttributes(context.formFields);
156+
await Promise.all(savePersonAttributes(context.patient, personAttributes));
157+
if (personAttributes?.length) {
158+
showSnackbar({
159+
title: t('personAttributesSaved', 'Person attribute(s) saved successfully'),
160+
kind: 'success',
161+
isLowContrast: true,
162+
});
163+
}
164+
} catch (error) {
165+
const errorMessages = extractErrorMessagesFromResponse(error);
166+
return Promise.reject({
167+
title: t('errorSavingPersonAttributes', 'Error saving person attributes'),
168+
description: errorMessages.join(', '),
169+
kind: 'error',
170+
critical: true,
171+
});
172+
}
173+
149174
// save patient programs
150175
try {
151176
const programs = preparePatientPrograms(

src/processors/encounter/encounter-processor-helper.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ import {
77
type PatientIdentifier,
88
type PatientProgram,
99
type PatientProgramPayload,
10+
type PersonAttribute,
1011
} from '../../types';
11-
import { createAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api';
12+
import { createAttachment, savePatientIdentifier, savePersonAttribute, saveProgramEnrollment } from '../../api';
1213
import { hasRendering, hasSubmission } from '../../utils/common-utils';
1314
import dayjs from 'dayjs';
1415
import { assignedObsIds, constructObs, voidObs } from '../../adapters/obs-adapter';
@@ -100,6 +101,18 @@ export function savePatientIdentifiers(patient: fhir.Patient, identifiers: Patie
100101
});
101102
}
102103

104+
export function preparePersonAttributes(fields: FormField[]): PersonAttribute[] {
105+
return fields
106+
.filter((field) => field.type === 'personAttribute' && hasSubmission(field))
107+
.map((field) => field.meta.submission.newValue);
108+
}
109+
110+
export function savePersonAttributes(patient: fhir.Patient, attributes: PersonAttribute[]) {
111+
return attributes.map((personAttribute) => {
112+
return savePersonAttribute(personAttribute, patient.id);
113+
});
114+
}
115+
103116
export function preparePatientPrograms(
104117
fields: FormField[],
105118
patient: fhir.Patient,

src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { ObsAdapter } from '../../adapters/obs-adapter';
99
import { ObsCommentAdapter } from '../../adapters/obs-comment-adapter';
1010
import { OrdersAdapter } from '../../adapters/orders-adapter';
1111
import { PatientIdentifierAdapter } from '../../adapters/patient-identifier-adapter';
12+
import { PersonAttributeAdapter } from '../../adapters/person-attribute-adapter';
1213
import { ProgramStateAdapter } from '../../adapters/program-state-adapter';
1314
import { EncounterDiagnosisAdapter } from '../../adapters/encounter-diagnosis-adapter';
1415
import { type FormFieldValueAdapter } from '../../types';
@@ -62,6 +63,10 @@ export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] =
6263
type: 'patientIdentifier',
6364
component: PatientIdentifierAdapter,
6465
},
66+
{
67+
type: 'personAttribute',
68+
component: PersonAttributeAdapter,
69+
},
6570
{
6671
type: 'diagnosis',
6772
component: EncounterDiagnosisAdapter,

src/types/domain.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,12 @@ export interface PatientIdentifier {
199199
preferred?: boolean;
200200
}
201201

202+
export interface PersonAttribute {
203+
uuid?: string;
204+
value: string;
205+
attributeType: string;
206+
}
207+
202208
export interface DiagnosisPayload {
203209
patient: string;
204210
condition: null;

src/types/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ export interface FormQuestionOptions {
191191
workspaceProps?: Record<string, any>;
192192
buttonLabel?: string;
193193
identifierType?: string;
194+
attributeType?: string;
194195
orderSettingUuid?: string;
195196
orderType?: string;
196197
selectableOrders?: Array<Record<any, any>>;

0 commit comments

Comments
 (0)