Skip to content

Commit

Permalink
(feat) O3-3367 Add support for person attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
CynthiaKamau committed Nov 29, 2024
1 parent 5401a64 commit e9aa02e
Show file tree
Hide file tree
Showing 22 changed files with 309 additions and 43 deletions.
54 changes: 54 additions & 0 deletions src/adapters/person-attributes-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { type PersonAttribute, 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 PersonAttributesAdapter: FormFieldValueAdapter = {
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
return null;
}
field.meta.submission.newValue = {
value: value,
attributeType: field.questionOptions?.attributeType,
};
return field.meta.submission.newValue;
},
getInitialValue: function (field: FormField, sourceObject: OpenmrsResource, context: FormProcessorContextProps) {
const rendering = field.questionOptions.rendering;

const personAttributeValue = context?.customDependencies.personAttributes.find(
(attribute: PersonAttribute) => attribute.attributeType.uuid === field.questionOptions.attributeType,
)?.value;
if (rendering === 'text') {
if (typeof personAttributeValue === 'string') {
return personAttributeValue;
} else if (
personAttributeValue &&
typeof personAttributeValue === 'object' &&
'display' in personAttributeValue
) {
return personAttributeValue?.display;
}
} else if (rendering === 'ui-select-extended') {
if (personAttributeValue && typeof personAttributeValue === 'object' && 'uuid' in personAttributeValue) {
return personAttributeValue?.uuid;
}
}
return null;
},
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;
},
};
35 changes: 34 additions & 1 deletion src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { fhirBaseUrl, openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { fhirBaseUrl, openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
import { encounterRepresentation } from '../constants';
import { type OpenmrsForm, type PatientIdentifier, type PatientProgramPayload } from '../types';
import { isUuid } from '../utils/boolean-utils';
Expand Down Expand Up @@ -180,3 +180,36 @@ export function savePatientIdentifier(patientIdentifier: PatientIdentifier, pati
body: JSON.stringify(patientIdentifier),
});
}

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

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

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

export async function getPersonAttributeTypeFormat(personAttributeTypeUuid: string) {
try {
const response = await openmrsFetch(
`${restBaseUrl}/personattributetype/${personAttributeTypeUuid}?v=custom:(format)`,
);
if (response) {
const { data } = response;
return data?.format;
}
return null;
} catch (error) {
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ const UiSelectExtended: React.FC<FormFieldInputProps> = ({ field, errors, warnin
selectedItem={selectedItem}
placeholder={isSearchable ? t('search', 'Search') + '...' : null}
shouldFilterItem={({ item, inputValue }) => {
if (!inputValue) {
if (!inputValue || items.find((item) => item.uuid == field.value)) {
// Carbon's initial call at component mount
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ jest.mock('../../../registry/registry', () => {
};
});

jest.mock('../../../hooks/usePersonAttributes', () => ({
usePersonAttributes: jest.fn().mockReturnValue({
personAttributes: [],
error: null,
isLoading: false,
}),
}));

const encounter = {
uuid: 'encounter-uuid',
obs: [
Expand Down
8 changes: 8 additions & 0 deletions src/components/inputs/unspecified/unspecified.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,14 @@ jest.mock('../../../hooks/useEncounter', () => ({
}),
}));

jest.mock('../../../hooks/usePersonAttributes', () => ({
usePersonAttributes: jest.fn().mockReturnValue({
personAttributes: [],
error: null,
isLoading: false,
}),
}));

const renderForm = async (mode: SessionMode = 'enter') => {
await act(async () => {
render(
Expand Down
16 changes: 16 additions & 0 deletions src/datasources/person-attribute-datasource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { BaseOpenMRSDataSource } from './data-source';

export class PersonAttributeLocationDataSource extends BaseOpenMRSDataSource {
constructor() {
super(null);
}

async fetchData(searchTerm: string, config?: Record<string, any>, uuid?: string): Promise<any[]> {
const rep = 'v=custom:(uuid,display)';
const url = `${restBaseUrl}/location?${rep}`;
const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url);

return data?.results;
}
}
2 changes: 1 addition & 1 deletion src/datasources/select-concept-answers-datasource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource {
}

fetchData(searchTerm: string, config?: Record<string, any>): Promise<any[]> {
const apiUrl = this.url.replace('conceptUuid', config.referencedValue || config.concept);
const apiUrl = this.url.replace('conceptUuid', config.concept || config.referencedValue);
return openmrsFetch(apiUrl).then(({ data }) => {
return data['setMembers'].length ? data['setMembers'] : data['answers'];
});
Expand Down
8 changes: 8 additions & 0 deletions src/form-engine.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ jest.mock('./hooks/useConcepts', () => ({
}),
}));

jest.mock('./hooks/usePersonAttributes', () => ({
usePersonAttributes: jest.fn().mockReturnValue({
personAttributes: [],
error: null,
isLoading: false,
}),
}));

describe('Form engine component', () => {
const user = userEvent.setup();

Expand Down
13 changes: 8 additions & 5 deletions src/hooks/useFormJson.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,17 @@ function validateFormsArgs(formUuid: string, rawFormJson: any): Error {
* @param {string} [formSessionIntent] - The optional form session intent.
* @returns {FormSchema} - The refined form JSON object of type FormSchema.
*/
function refineFormJson(
async function refineFormJson(
formJson: any,
schemaTransformers: FormSchemaTransformer[] = [],
formSessionIntent?: string,
): FormSchema {
): Promise<FormSchema> {
removeInlineSubForms(formJson, formSessionIntent);
// apply form schema transformers
schemaTransformers.reduce((draftForm, transformer) => transformer.transform(draftForm), formJson);
for (let transformer of schemaTransformers) {
const draftForm = await transformer.transform(formJson);
formJson = draftForm;
}
setEncounterType(formJson);
return applyFormIntent(formSessionIntent, formJson);
}
Expand All @@ -134,7 +137,7 @@ function parseFormJson(formJson: any): FormSchema {
* @param {FormSchema} formJson - The input form JSON object of type FormSchema.
* @param {string} formSessionIntent - The form session intent.
*/
function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string): void {
async function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string): Promise<void> {
for (let i = formJson.pages.length - 1; i >= 0; i--) {
const page = formJson.pages[i];
if (
Expand All @@ -143,7 +146,7 @@ function removeInlineSubForms(formJson: FormSchema, formSessionIntent: string):
page.subform?.form?.encounterType === formJson.encounterType
) {
const nonSubformPages = page.subform.form.pages.filter((page) => !isTrue(page.isSubform));
formJson.pages.splice(i, 1, ...refineFormJson(page.subform.form, [], formSessionIntent).pages);
formJson.pages.splice(i, 1, ...(await refineFormJson(page.subform.form, [], formSessionIntent)).pages);
}
}
}
Expand Down
30 changes: 30 additions & 0 deletions src/hooks/usePersonAttributes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
import { useEffect, useState } from 'react';

export const usePersonAttributes = (patientUuid: string) => {
const [personAttributes, setPersonAttributes] = useState<Array<PersonAttribute>>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
if (patientUuid) {
openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`)
.then((response) => {
setPersonAttributes(response?.data?.attributes);
setIsLoading(false);
})
.catch((error) => {
setError(error);
setIsLoading(false);
});
} else {
setIsLoading(false);
}
}, [patientUuid]);

return {
personAttributes,
error,
isLoading: isLoading,
};
};
33 changes: 30 additions & 3 deletions src/processors/encounter/encounter-form-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ import {
prepareEncounter,
preparePatientIdentifiers,
preparePatientPrograms,
preparePersonAttributes,
saveAttachments,
savePatientIdentifiers,
savePatientPrograms,
savePersonAttributes,
} from './encounter-processor-helper';
import { type OpenmrsResource, showSnackbar, translateFrom } from '@openmrs/esm-framework';
import { moduleName } from '../../globals';
Expand All @@ -31,6 +33,7 @@ import { useEncounterRole } from '../../hooks/useEncounterRole';
import { evaluateAsyncExpression, type FormNode } from '../../utils/expression-runner';
import { hasRendering } from '../../utils/common-utils';
import { extractObsValueAndDisplay } from '../../utils/form-helper';
import { usePersonAttributes } from '../../hooks/usePersonAttributes';

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

useEffect(() => {
setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole);
}, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole]);
setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole || isLoadingPersonAttributes);
}, [isLoadingPatientPrograms, isLoadingEncounter, isLoadingEncounterRole, isLoadingPersonAttributes]);

return {
data: { encounter, patientPrograms, encounterRole },
data: { encounter, patientPrograms, encounterRole, personAttributes },
isLoading,
error: null,
updateContext: (setContext: React.Dispatch<React.SetStateAction<FormProcessorContextProps>>) => {
Expand All @@ -59,6 +63,7 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
...context.customDependencies,
patientPrograms: patientPrograms,
defaultEncounterRole: encounterRole,
personAttributes: personAttributes,
},
};
});
Expand All @@ -79,6 +84,7 @@ const contextInitializableTypes = [
'patientIdentifier',
'encounterRole',
'programState',
'personAttributes',
];

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

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

// save encounter
try {
const { data: savedEncounter } = await saveEncounter(abortController, encounter, encounter.uuid);
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,7 +7,7 @@ import {
type PatientProgram,
type PatientProgramPayload,
} from '../../types';
import { saveAttachment, savePatientIdentifier, saveProgramEnrollment } from '../../api';
import { saveAttachment, 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 All @@ -16,6 +16,7 @@ import { ConceptTrue } from '../../constants';
import { DefaultValueValidator } from '../../validators/default-value-validator';
import { cloneRepeatField } from '../../components/repeat/helpers';
import { assignedOrderIds } from '../../adapters/orders-adapter';
import { type PersonAttribute } from '@openmrs/esm-framework';

export function prepareEncounter(
context: FormContextProps,
Expand Down Expand Up @@ -152,6 +153,12 @@ export function saveAttachments(fields: FormField[], encounter: OpenmrsEncounter
});
}

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

export function getMutableSessionProps(context: FormContextProps) {
const {
formFields,
Expand Down Expand Up @@ -328,3 +335,9 @@ export async function hydrateRepeatField(
}),
).then((results) => results.flat());
}

export function preparePersonAttributes(fields: FormField[], encounterLocation: string): PersonAttribute[] {
return fields
.filter((field) => field.type === 'personAttribute' && hasSubmission(field))
.map((field) => field.meta.submission.newValue);
}
6 changes: 6 additions & 0 deletions src/registry/inbuilt-components/control-templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ export const controlTemplates: Array<ControlTemplate> = [
},
},
},
{
name: 'person-attribute-location',
datasource: {
name: 'person_attribute_location_datasource',
},
},
];

export const getControlTemplate = (name: string) => {
Expand Down
2 changes: 1 addition & 1 deletion src/registry/inbuilt-components/inbuiltControls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,6 @@ export const inbuiltControls: Array<RegistryItem<React.ComponentType<FormFieldIn
},
...controlTemplates.map((template) => ({
name: template.name,
component: templateToComponentMap.find((component) => component.name === template.name).baseControlComponent,
component: templateToComponentMap.find((component) => component.name === template.name)?.baseControlComponent,
})),
];
Loading

0 comments on commit e9aa02e

Please sign in to comment.