Skip to content

Commit

Permalink
(feat) (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 21, 2024
1 parent b651a2e commit be9d967
Show file tree
Hide file tree
Showing 15 changed files with 214 additions and 8 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?.attribute?.type,
};
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.attribute?.type,
)?.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;
},
};
20 changes: 19 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,21 @@ 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),
});
}
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
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
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 @@ -339,3 +346,9 @@ function transformNestedObsGroups(field: FormField, value: any): any {
}
return constructObs(field, value);
}

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,
})),
];
5 changes: 5 additions & 0 deletions src/registry/inbuilt-components/inbuiltDataSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { LocationDataSource } from '../../datasources/location-data-source';
import { ProviderDataSource } from '../../datasources/provider-datasource';
import { SelectConceptAnswersDatasource } from '../../datasources/select-concept-answers-datasource';
import { EncounterRoleDataSource } from '../../datasources/encounter-role-datasource';
import { PersonAttributeLocationDataSource } from '../../datasources/person-attribute-datasource';

/**
* @internal
Expand Down Expand Up @@ -34,6 +35,10 @@ export const inbuiltDataSources: Array<RegistryItem<DataSource<any>>> = [
name: 'encounter_role_datasource',
component: new EncounterRoleDataSource(),
},
{
name: 'person_attribute_location_datasource',
component: new PersonAttributeLocationDataSource(),
},
];

export const validateInbuiltDatasource = (name: string) => {
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 @@ -11,6 +11,7 @@ import { OrdersAdapter } from '../../adapters/orders-adapter';
import { PatientIdentifierAdapter } from '../../adapters/patient-identifier-adapter';
import { ProgramStateAdapter } from '../../adapters/program-state-adapter';
import { type FormFieldValueAdapter } from '../../types';
import { PersonAttributesAdapter } from '../../adapters/person-attributes-adapter';

export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] = [
{
Expand Down Expand Up @@ -61,4 +62,8 @@ export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] =
type: 'patientIdentifier',
component: PatientIdentifierAdapter,
},
{
type: 'personAttribute',
component: PersonAttributesAdapter,
},
];
4 changes: 4 additions & 0 deletions src/registry/inbuilt-components/template-component-map.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,8 @@ export const templateToComponentMap = [
name: 'encounter-role',
baseControlComponent: UiSelectExtended,
},
{
name: 'person_attribute_location_datasource',
baseControlComponent: UiSelectExtended,
},
];
24 changes: 24 additions & 0 deletions src/transformers/default-schema-transformer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,9 @@ function transformByType(question: FormField) {
? 'date'
: question.questionOptions.rendering;
break;
case 'personAttribute':
handlePersonAttributeType(question);
break;
}
}

Expand Down Expand Up @@ -264,3 +267,24 @@ function handleQuestionsWithObsComments(sectionQuestions: Array<FormField>): Arr

return augmentedQuestions;
}

function handlePersonAttributeType(question: FormField) {
if (question.questionOptions.rendering !== 'text') {
question.questionOptions.rendering === 'ui-select-extended';
}

if (question.questionOptions?.attribute?.format === 'location') {
question.questionOptions.datasource = {
name: 'person_attribute_location_datasource',
};
} else if (question.questionOptions?.attribute?.format === 'concept') {
question.questionOptions.datasource = {
name: 'select_concept_answers_datasource',
config: {
concept: question.questionOptions?.concept,
},
};
} else if (question.questionOptions?.attribute?.format === 'string') {
question.questionOptions.rendering = 'text';
}
}
4 changes: 4 additions & 0 deletions src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,10 @@ export interface FormQuestionOptions {
comment?: string;
orientation?: 'vertical' | 'horizontal';
shownCommentOptions?: { validators?: Array<Record<string, any>>; hide?: { hideWhenExpression: string } };
attribute?: {
type?: string;
format?: string;
};
}

export interface QuestionAnswerOption {
Expand Down

0 comments on commit be9d967

Please sign in to comment.