From 715582285ec72fa304b5778dc30a1a72fb1ab0e5 Mon Sep 17 00:00:00 2001 From: Raj-bytecommandor Date: Fri, 13 Feb 2026 17:01:20 +0530 Subject: [PATCH 1/2] Add PersonAttribute adapter support to Form Engine - Implement PersonAttributeAdapter following PatientIdentifierAdapter pattern - Add personAttribute type to inbuilt field value adapters registry - Add attributeType field to FormQuestionOptions interface - Add PersonAttribute interface to domain types - Add savePersonAttribute API method for backend persistence - Add personAttribute to contextInitializableTypes array - Add preparePersonAttributes and savePersonAttributes helper functions - Integrate person attribute processing in encounter form submission flow - Add comprehensive unit tests with full coverage (11 tests passing) This enables Form Builder to render and process person attribute fields. Previously, these fields were silently hidden in preview mode, causing form rendering issues and preventing data submission. Tested with all existing tests passing (334 tests) and no TypeScript errors. --- src/adapters/person-attribute-adapter.test.ts | 160 ++++++++++++++++++ src/adapters/person-attribute-adapter.ts | 45 +++++ src/api/index.ts | 19 +++ .../encounter/encounter-form-processor.ts | 25 +++ .../encounter/encounter-processor-helper.ts | 15 +- .../inbuiltFieldValueAdapters.ts | 5 + src/types/domain.ts | 6 + src/types/schema.ts | 1 + 8 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 src/adapters/person-attribute-adapter.test.ts create mode 100644 src/adapters/person-attribute-adapter.ts diff --git a/src/adapters/person-attribute-adapter.test.ts b/src/adapters/person-attribute-adapter.test.ts new file mode 100644 index 000000000..d610ee30e --- /dev/null +++ b/src/adapters/person-attribute-adapter.test.ts @@ -0,0 +1,160 @@ +import { PersonAttributeAdapter } from './person-attribute-adapter'; +import { type FormField, type FormProcessorContextProps } from '../types'; +import { type FormContextProps } from '../provider/form-provider'; + +describe('PersonAttributeAdapter', () => { + const mockField: FormField = { + id: 'test-person-attribute', + type: 'personAttribute', + questionOptions: { + attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370', + rendering: 'text', + }, + meta: { + submission: {}, + initialValue: {}, + }, + } as any; + + const mockContext: FormContextProps = { + patient: { + id: 'test-patient-uuid', + } as fhir.Patient, + } as any; + + 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', + }, + }, + } as any; + 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', + }, + }, + } as any; + 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: FormProcessorContextProps = { + patient: { + id: 'test-patient', + extension: [], + } as fhir.Patient, + } as any; + + const result = PersonAttributeAdapter.getInitialValue(mockField, null, mockProcessorContext); + expect(result).toBeUndefined(); + }); + + it('should return valueString when person attribute exists', () => { + const mockProcessorContext: FormProcessorContextProps = { + 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, + } as any; + + const field = { ...mockField }; + const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext); + + expect(result).toBe('test-attribute-value'); + expect(field.meta.initialValue.refinedValue).toBe('test-attribute-value'); + }); + + it('should return valueReference when person attribute has reference', () => { + const mockProcessorContext: FormProcessorContextProps = { + 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, + } as any; + + const field = { ...mockField }; + const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext); + + expect(result).toBe('Location/test-location-uuid'); + expect(field.meta.initialValue.refinedValue).toBe('Location/test-location-uuid'); + }); + }); + + describe('getPreviousValue', () => { + it('should return null', () => { + const result = PersonAttributeAdapter.getPreviousValue(mockField, null, {} as any); + 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(); + }); + }); +}); diff --git a/src/adapters/person-attribute-adapter.ts b/src/adapters/person-attribute-adapter.ts new file mode 100644 index 000000000..fa980bb48 --- /dev/null +++ b/src/adapters/person-attribute-adapter.ts @@ -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 any, + 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; + }, +}; diff --git a/src/api/index.ts b/src/api/index.ts index be0061480..071657717 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,6 +8,7 @@ import type { PatientDeathPayload, PatientIdentifier, PatientProgramPayload, + PersonAttribute, } from '../types'; import { isUuid } from '../utils/boolean-utils'; @@ -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: JSON.stringify(personAttribute), + }); +} + export function markPatientAsDeceased( t: TFunction, patientUUID: string, diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 1ba308993..a57f8252d 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -7,9 +7,11 @@ import { prepareEncounter, preparePatientIdentifiers, preparePatientPrograms, + preparePersonAttributes, saveAttachments, savePatientIdentifiers, savePatientPrograms, + savePersonAttributes, } from './encounter-processor-helper'; import { type FormField, @@ -33,6 +35,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole'; import { usePatientPrograms } from '../../hooks/usePatientPrograms'; import { type TOptions } from 'i18next'; + function useCustomHooks(context: Partial) { const [isLoading, setIsLoading] = useState(true); const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson); @@ -77,6 +80,7 @@ const contextInitializableTypes = [ 'patientIdentifier', 'encounterRole', 'programState', + 'personAttribute', ]; export class EncounterFormProcessor extends FormProcessor { @@ -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( diff --git a/src/processors/encounter/encounter-processor-helper.ts b/src/processors/encounter/encounter-processor-helper.ts index 7401e4d85..886d579cc 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -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'; @@ -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, diff --git a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts index 2a5632bf7..8d8a1c52f 100644 --- a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts +++ b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts @@ -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'; @@ -62,6 +63,10 @@ export const inbuiltFieldValueAdapters: RegistryItem[] = type: 'patientIdentifier', component: PatientIdentifierAdapter, }, + { + type: 'personAttribute', + component: PersonAttributeAdapter, + }, { type: 'diagnosis', component: EncounterDiagnosisAdapter, diff --git a/src/types/domain.ts b/src/types/domain.ts index a919b9664..a9c5eb05a 100644 --- a/src/types/domain.ts +++ b/src/types/domain.ts @@ -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; diff --git a/src/types/schema.ts b/src/types/schema.ts index 6305b686a..0be0b93a6 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -191,6 +191,7 @@ export interface FormQuestionOptions { workspaceProps?: Record; buttonLabel?: string; identifierType?: string; + attributeType?: string; orderSettingUuid?: string; orderType?: string; selectableOrders?: Array>; From 85d27bf87d854ad03b9385c01b3504783e558451 Mon Sep 17 00:00:00 2001 From: Raj-bytecommandor Date: Mon, 23 Feb 2026 23:16:20 +0530 Subject: [PATCH 2/2] some minor changes --- src/adapters/person-attribute-adapter.test.ts | 35 +++++++++---------- src/adapters/person-attribute-adapter.ts | 4 +-- src/api/index.ts | 2 +- 3 files changed, 19 insertions(+), 22 deletions(-) diff --git a/src/adapters/person-attribute-adapter.test.ts b/src/adapters/person-attribute-adapter.test.ts index d610ee30e..225787697 100644 --- a/src/adapters/person-attribute-adapter.test.ts +++ b/src/adapters/person-attribute-adapter.test.ts @@ -3,24 +3,21 @@ import { type FormField, type FormProcessorContextProps } from '../types'; import { type FormContextProps } from '../provider/form-provider'; describe('PersonAttributeAdapter', () => { - const mockField: FormField = { + const mockField = { id: 'test-person-attribute', type: 'personAttribute', questionOptions: { attributeType: '7ef225db-94db-4e40-9dd8-fb121d9dc370', rendering: 'text', }, - meta: { - submission: {}, - initialValue: {}, - }, - } as any; + meta: {}, + } satisfies FormField; - const mockContext: FormContextProps = { + const mockContext = { patient: { id: 'test-patient-uuid', } as fhir.Patient, - } as any; + } satisfies Partial as FormContextProps; describe('transformFieldValue', () => { it('should return null for empty value', () => { @@ -38,7 +35,7 @@ describe('PersonAttributeAdapter', () => { refinedValue: 'test-value', }, }, - } as any; + } satisfies FormField; const result = PersonAttributeAdapter.transformFieldValue(field, 'test-value', mockContext); expect(result).toBeNull(); }); @@ -64,7 +61,7 @@ describe('PersonAttributeAdapter', () => { refinedValue: 'old-value', }, }, - } as any; + } satisfies FormField; const result = PersonAttributeAdapter.transformFieldValue(field, 'updated-value', mockContext); expect(result).toEqual({ @@ -77,19 +74,19 @@ describe('PersonAttributeAdapter', () => { describe('getInitialValue', () => { it('should return undefined when no person attribute exists', () => { - const mockProcessorContext: FormProcessorContextProps = { + const mockProcessorContext = { patient: { id: 'test-patient', extension: [], } as fhir.Patient, - } as any; + } satisfies Partial as FormProcessorContextProps; const result = PersonAttributeAdapter.getInitialValue(mockField, null, mockProcessorContext); expect(result).toBeUndefined(); }); it('should return valueString when person attribute exists', () => { - const mockProcessorContext: FormProcessorContextProps = { + const mockProcessorContext = { patient: { id: 'test-patient', extension: [ @@ -99,17 +96,17 @@ describe('PersonAttributeAdapter', () => { }, ], } as fhir.Patient, - } as any; + } satisfies Partial as FormProcessorContextProps; const field = { ...mockField }; const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext); expect(result).toBe('test-attribute-value'); - expect(field.meta.initialValue.refinedValue).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: FormProcessorContextProps = { + const mockProcessorContext = { patient: { id: 'test-patient', extension: [ @@ -121,19 +118,19 @@ describe('PersonAttributeAdapter', () => { }, ], } as fhir.Patient, - } as any; + } satisfies Partial as FormProcessorContextProps; const field = { ...mockField }; const result = PersonAttributeAdapter.getInitialValue(field, null, mockProcessorContext); expect(result).toBe('Location/test-location-uuid'); - expect(field.meta.initialValue.refinedValue).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, {} as any); + const result = PersonAttributeAdapter.getPreviousValue(mockField, null, {} satisfies Partial as FormProcessorContextProps); expect(result).toBeNull(); }); }); diff --git a/src/adapters/person-attribute-adapter.ts b/src/adapters/person-attribute-adapter.ts index fa980bb48..5da670033 100644 --- a/src/adapters/person-attribute-adapter.ts +++ b/src/adapters/person-attribute-adapter.ts @@ -22,9 +22,9 @@ export const PersonAttributeAdapter: FormFieldValueAdapter = { (ext) => ext.url === `http://fhir.openmrs.org/ext/person-attribute/${field.questionOptions.attributeType}`, ); field.meta = { - ...(field.meta || {}), + ...field.meta, initialValue: { - omrsObject: latestAttribute as any, + omrsObject: latestAttribute as unknown as OpenmrsResource, refinedValue: latestAttribute?.valueString || latestAttribute?.valueReference?.reference, }, }; diff --git a/src/api/index.ts b/src/api/index.ts index 071657717..233aa221e 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -194,7 +194,7 @@ export function savePersonAttribute(personAttribute: PersonAttribute, patientUui 'Content-Type': 'application/json', }, method: 'POST', - body: JSON.stringify(personAttribute), + body: personAttribute, }); }