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
157 changes: 157 additions & 0 deletions src/adapters/person-attribute-adapter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { PersonAttributeAdapter } from './person-attribute-adapter';
import { type FormField, type FormProcessorContextProps } from '../types';
import { type FormContextProps } from '../provider/form-provider';

describe('PersonAttributeAdapter', () => {
const mockField = {
id: 'test-person-attribute',
type: 'personAttribute',
questionOptions: {
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
rendering: 'text',
},
meta: {},
} satisfies FormField;

const mockContext = {
patient: {
id: 'test-patient-uuid',
} as fhir.Patient,
} satisfies Partial<FormContextProps> as FormContextProps;

describe('transformFieldValue', () => {
it('should return null for empty value', () => {
const result = PersonAttributeAdapter.transformFieldValue(mockField, '', mockContext);
expect(result).toBeNull();
});

it('should return null when value equals initial value', () => {
const field = {
...mockField,
meta: {
submission: {},
initialValue: {
omrsObject: null,
refinedValue: 'test-value',
},
},
} satisfies FormField;
const result = PersonAttributeAdapter.transformFieldValue(field, 'test-value', mockContext);
expect(result).toBeNull();
});

it('should transform field value correctly for new attribute', () => {
const field = { ...mockField };
const result = PersonAttributeAdapter.transformFieldValue(field, 'new-attribute-value', mockContext);

expect(result).toEqual({
value: 'new-attribute-value',
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
uuid: undefined,
});
});

it('should include uuid when updating existing attribute', () => {
const field = {
...mockField,
meta: {
submission: {},
initialValue: {
omrsObject: { uuid: 'existing-attr-uuid' },
refinedValue: 'old-value',
},
},
} satisfies FormField;
const result = PersonAttributeAdapter.transformFieldValue(field, 'updated-value', mockContext);

expect(result).toEqual({
value: 'updated-value',
attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370',
uuid: 'existing-attr-uuid',
});
});
});

describe('getInitialValue', () => {
it('should return undefined when no person attribute exists', () => {
const mockProcessorContext = {
patient: {
id: 'test-patient',
extension: [],
} as fhir.Patient,
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;

const result = PersonAttributeAdapter.getInitialValue(mockField, null, mockProcessorContext);
expect(result).toBeUndefined();
});

it('should return valueString when person attribute exists', () => {
const mockProcessorContext = {
patient: {
id: 'test-patient',
extension: [
{
url: 'http://fhir.openmrs.org/ext/person-attribute/7ef225db-94db-4e40-9dd8-fb121d9dc370',
valueString: 'test-attribute-value',
},
],
} as fhir.Patient,
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;

const field = { ...mockField };
const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext);

expect(result).toBe('test-attribute-value');
expect((field.meta as any).initialValue.refinedValue).toBe('test-attribute-value');
});

it('should return valueReference when person attribute has reference', () => {
const mockProcessorContext = {
patient: {
id: 'test-patient',
extension: [
{
url: 'http://fhir.openmrs.org/ext/person-attribute/7ef225db-94db-4e40-9dd8-fb121d9dc370',
valueReference: {
reference: 'Location/test-location-uuid',
},
},
],
} as fhir.Patient,
} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps;

const field = { ...mockField };
const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext);

expect(result).toBe('Location/test-location-uuid');
expect((field.meta as any).initialValue.refinedValue).toBe('Location/test-location-uuid');
});
});

describe('getPreviousValue', () => {
it('should return null', () => {
const result = PersonAttributeAdapter.getPreviousValue(mockField, null, {} satisfies Partial<FormProcessorContextProps> as FormProcessorContextProps);
expect(result).toBeNull();
});
});

describe('getDisplayValue', () => {
it('should return display property if present', () => {
const value = { display: 'Test Display', value: 'test-value' };
const result = PersonAttributeAdapter.getDisplayValue(mockField, value);
expect(result).toBe('Test Display');
});

it('should return value as-is if no display property', () => {
const value = 'simple-value';
const result = PersonAttributeAdapter.getDisplayValue(mockField, value);
expect(result).toBe('simple-value');
});
});

describe('tearDown', () => {
it('should execute without errors', () => {
expect(() => PersonAttributeAdapter.tearDown()).not.toThrow();
});
});
});
45 changes: 45 additions & 0 deletions src/adapters/person-attribute-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { type OpenmrsResource } from '@openmrs/esm-framework';
import { type FormContextProps } from '../provider/form-provider';
import { type FormField, type FormFieldValueAdapter, type FormProcessorContextProps } from '../types';
import { clearSubmission } from '../utils/common-utils';
import { isEmpty } from '../validators/form-validator';

export const PersonAttributeAdapter: FormFieldValueAdapter = {
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta.initialValue?.refinedValue === value || isEmpty(value)) {
return null;
}
field.meta.submission.newValue = {
value: value,
attributeType: field.questionOptions.attributeType,
uuid: (field.meta.initialValue?.omrsObject as OpenmrsResource)?.uuid,
};
return field.meta.submission.newValue;
},
getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
const latestAttribute = context.patient?.extension?.find(
(ext) => ext.url === `http://fhir.openmrs.org/ext/person-attribute/${field.questionOptions.attributeType}`,
);
field.meta = {
...field.meta,
initialValue: {
omrsObject: latestAttribute as unknown as OpenmrsResource,
refinedValue: latestAttribute?.valueString || latestAttribute?.valueReference?.reference,
},
};
return field.meta.initialValue.refinedValue;
},
getPreviousValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
return null;
},
getDisplayValue: function (field: FormField, value: any) {
if (value?.display) {
return value.display;
}
return value;
},
tearDown: function (): void {
return;
},
};
19 changes: 19 additions & 0 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
PatientDeathPayload,
PatientIdentifier,
PatientProgramPayload,
PersonAttribute,
} from '../types';
import { isUuid } from '../utils/boolean-utils';

Expand Down Expand Up @@ -179,6 +180,24 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati
});
}

export function savePersonAttribute(personAttribute: PersonAttribute, patientUuid: string) {
let url: string;

if (personAttribute.uuid) {
url = `${restBaseUrl}/person/${patientUuid}/attribute/${personAttribute.uuid}`;
} else {
url = `${restBaseUrl}/person/${patientUuid}/attribute`;
}

return openmrsFetch(url, {
headers: {
'Content-Type': 'application/json',
},
method: 'POST',
body: personAttribute,
});
}

export function markPatientAsDeceased(
t: TFunction,
patientUUID: string,
Expand Down
25 changes: 25 additions & 0 deletions src/processors/encounter/encounter-form-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
prepareEncounter,
preparePatientIdentifiers,
preparePatientPrograms,
preparePersonAttributes,
saveAttachments,
savePatientIdentifiers,
savePatientPrograms,
savePersonAttributes,
} from './encounter-processor-helper';
import {
type FormField,
Expand All @@ -33,6 +35,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole';
import { usePatientPrograms } from '../../hooks/usePatientPrograms';
import { type TOptions } from 'i18next';


function useCustomHooks(context: Partial<FormProcessorContextProps>) {
const [isLoading, setIsLoading] = useState(true);
const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson);
Expand Down Expand Up @@ -77,6 +80,7 @@ const contextInitializableTypes = [
'patientIdentifier',
'encounterRole',
'programState',
'personAttribute',
];

export class EncounterFormProcessor extends FormProcessor {
Expand Down Expand Up @@ -146,6 +150,27 @@ export class EncounterFormProcessor extends FormProcessor {
});
}

// save person attributes
try {
const personAttributes = preparePersonAttributes(context.formFields);
await Promise.all(savePersonAttributes(context.patient, personAttributes));
if (personAttributes?.length) {
showSnackbar({
title: t('personAttributesSaved', 'Person attribute(s) saved successfully'),
kind: 'success',
isLowContrast: true,
});
}
} catch (error) {
const errorMessages = extractErrorMessagesFromResponse(error);
return Promise.reject({
title: t('errorSavingPersonAttributes', 'Error saving person attributes'),
description: errorMessages.join(', '),
kind: 'error',
critical: true,
});
}

// save patient programs
try {
const programs = preparePatientPrograms(
Expand Down
15 changes: 14 additions & 1 deletion src/processors/encounter/encounter-processor-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ import {
type PatientIdentifier,
type PatientProgram,
type PatientProgramPayload,
type PersonAttribute,
} from '../../types';
import { createAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api';
import { createAttachment, savePatientIdentifier, savePersonAttribute, saveProgramEnrollment } from '../../api';
import { hasRendering, hasSubmission } from '../../utils/common-utils';
import dayjs from 'dayjs';
import { assignedObsIds, constructObs, voidObs } from '../../adapters/obs-adapter';
Expand Down Expand Up @@ -100,6 +101,18 @@ export function savePatientIdentifiers(patient: fhir.Patient, identifiers: Patie
});
}

export function preparePersonAttributes(fields: FormField[]): PersonAttribute[] {
return fields
.filter((field) => field.type === 'personAttribute' && hasSubmission(field))
.map((field) => field.meta.submission.newValue);
}

export function savePersonAttributes(patient: fhir.Patient, attributes: PersonAttribute[]) {
return attributes.map((personAttribute) => {
return savePersonAttribute(personAttribute, patient.id);
});
}

export function preparePatientPrograms(
fields: FormField[],
patient: fhir.Patient,
Expand Down
5 changes: 5 additions & 0 deletions src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { ObsAdapter } from '../../adapters/obs-adapter';
import { ObsCommentAdapter } from '../../adapters/obs-comment-adapter';
import { OrdersAdapter } from '../../adapters/orders-adapter';
import { PatientIdentifierAdapter } from '../../adapters/patient-identifier-adapter';
import { PersonAttributeAdapter } from '../../adapters/person-attribute-adapter';
import { ProgramStateAdapter } from '../../adapters/program-state-adapter';
import { EncounterDiagnosisAdapter } from '../../adapters/encounter-diagnosis-adapter';
import { type FormFieldValueAdapter } from '../../types';
Expand Down Expand Up @@ -62,6 +63,10 @@ export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] =
type: 'patientIdentifier',
component: PatientIdentifierAdapter,
},
{
type: 'personAttribute',
component: PersonAttributeAdapter,
},
{
type: 'diagnosis',
component: EncounterDiagnosisAdapter,
Expand Down
6 changes: 6 additions & 0 deletions src/types/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ export interface PatientIdentifier {
preferred?: boolean;
}

export interface PersonAttribute {
uuid?: string;
value: string;
attributeType: string;
}

export interface DiagnosisPayload {
patient: string;
condition: null;
Expand Down
1 change: 1 addition & 0 deletions src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ export interface FormQuestionOptions {
workspaceProps?: Record<string, any>;
buttonLabel?: string;
identifierType?: string;
attributeType?: string;
orderSettingUuid?: string;
orderType?: string;
selectableOrders?: Array<Record<any, any>>;
Expand Down