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 19, 2024
1 parent b651a2e commit 00fa67f
Show file tree
Hide file tree
Showing 14 changed files with 222 additions and 6 deletions.
52 changes: 52 additions & 0 deletions src/adapters/person-attributes-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
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 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?.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;
},
};
31 changes: 30 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 @@ -148,6 +148,17 @@ export function getPatientEnrolledPrograms(patientUuid: string) {
});
}

export function getPersonAttributeTypeFormat(personAttributeTypeUuid: string) {
return openmrsFetch(`${restBaseUrl}/personattributetype/${personAttributeTypeUuid}?v=custom:(format)`).then(
({ data }) => {
if (data) {
return data;
}
return null;
},
);
}

export function saveProgramEnrollment(payload: PatientProgramPayload, abortController: AbortController) {
if (!payload) {
throw new Error('Program enrollment cannot be created because no payload is supplied');
Expand Down Expand Up @@ -180,3 +191,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;
}
}
27 changes: 27 additions & 0 deletions src/hooks/usePersonAttributes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import useSWRImmutable from 'swr/immutable';
import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework';
import { type FormSchema } from '../types';

const fetcher = (url: string) => openmrsFetch(url).then((response) => response.data);

export const usePersonAttributes = (patientUuid: string, formJson: FormSchema) => {
const { data, error } = useSWRImmutable(
formJson ? `${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)` : null,
fetcher,
);

const personAttributes =
data?.attributes?.length > 0
? {
uuid: data.attributes[0]?.uuid,
value: data.attributes[0]?.value,
attributeType: data.attributes[0]?.attributeType?.display,
}
: null;

return {
personAttributes,
error,
isLoading: !error && !data,
};
};
32 changes: 31 additions & 1 deletion 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,10 +43,14 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
context.patient?.id,
context.formJson,
);
const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes(
context.patient?.id,
context.formJson,
);

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

return {
data: { encounter, patientPrograms, encounterRole },
Expand All @@ -59,6 +66,7 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
...context.customDependencies,
patientPrograms: patientPrograms,
defaultEncounterRole: encounterRole,
personAttributes: personAttributes,
},
};
});
Expand All @@ -79,6 +87,7 @@ const contextInitializableTypes = [
'patientIdentifier',
'encounterRole',
'programState',
'personAttributes',
];

export class EncounterFormProcessor extends FormProcessor {
Expand Down Expand Up @@ -162,6 +171,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,
},
];
30 changes: 29 additions & 1 deletion src/transformers/default-schema-transformer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getPersonAttributeTypeFormat } from '../api/index';
import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType } from '../types';
import { isTrue } from '../utils/boolean-utils';
import { hasRendering } from '../utils/common-utils';
Expand Down Expand Up @@ -122,7 +123,7 @@ function setFieldValidators(question: FormField) {
}
}

function transformByType(question: FormField) {
async function transformByType(question: FormField) {
switch (question.type) {
case 'encounterProvider':
question.questionOptions.rendering = 'encounter-provider';
Expand All @@ -138,6 +139,9 @@ function transformByType(question: FormField) {
? 'date'
: question.questionOptions.rendering;
break;
case 'personAttribute':
await handlePersonAttributeType(question);
break;
}
}

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

return augmentedQuestions;
}

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

const attributeTypeFormat = await getPersonAttributeTypeFormat(question.questionOptions?.attributeType);
if (attributeTypeFormat?.format === 'org.openmrs.Location') {
question.questionOptions.datasource = {
name: 'person_attribute_location_datasource',
};
} else if (attributeTypeFormat?.format === 'Concept') {
question.questionOptions.datasource = {
name: 'select_concept_answers_datasource',
config: {
concept: question.questionOptions?.concept,
},
};
} else if (attributeTypeFormat?.format === 'java.lang.String') {
question.questionOptions.rendering = 'text';
} else {
console.error(`Unsupported format: ${attributeTypeFormat?.format}`);
}
}
1 change: 1 addition & 0 deletions src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export interface FormQuestionOptions {
comment?: string;
orientation?: 'vertical' | 'horizontal';
shownCommentOptions?: { validators?: Array<Record<string, any>>; hide?: { hideWhenExpression: string } };
attributeType?: string;
}

export interface QuestionAnswerOption {
Expand Down

0 comments on commit 00fa67f

Please sign in to comment.