Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

(feat) O3-3367 Add support for person attributes #423

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
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
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 = {
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
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 { FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types';
import { isUuid } from '../utils/boolean-utils';
Expand Down Expand Up @@ -183,3 +183,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 @@ -106,6 +106,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 {
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
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;
}
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
}
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);
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
return openmrsFetch(apiUrl).then(({ data }) => {
return data['setMembers'].length ? data['setMembers'] : data['answers'];
});
Expand Down
28 changes: 19 additions & 9 deletions src/hooks/useFormJson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,16 +118,26 @@ 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 {
removeInlineSubForms(formJson, formSessionIntent);
// apply form schema transformers
schemaTransformers.reduce((draftForm, transformer) => transformer.transform(draftForm), formJson);
setEncounterType(formJson);
return applyFormIntent(formSessionIntent, formJson);
): Promise<FormSchema> {
await removeInlineSubForms(formJson, formSessionIntent);
const transformedFormJson = await schemaTransformers.reduce(async (form, transformer) => {
const currentForm = await form;
if (isPromise(transformer.transform(currentForm))) {
return transformer.transform(currentForm);
} else {
return transformer.transform(currentForm);
}
}, Promise.resolve(formJson));
setEncounterType(transformedFormJson);
return applyFormIntent(formSessionIntent, transformedFormJson);
}

function isPromise(value: any): value is Promise<any> {
return value && typeof value.then === 'function';
}

/**
Expand All @@ -144,7 +154,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 @@ -153,7 +163,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
31 changes: 31 additions & 0 deletions src/hooks/usePersonAttributes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework';
import { useEffect, useState } from 'react';
import { type FormSchema } from '../types';

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

useEffect(() => {
if (formJson.meta?.personAttributes?.hasPersonAttributeFields && 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,
};
};
36 changes: 33 additions & 3 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 @@ -31,19 +33,24 @@ import { type FormContextProps } from '../../provider/form-provider';
import { useEncounter } from '../../hooks/useEncounter';
import { useEncounterRole } from '../../hooks/useEncounterRole';
import { usePatientPrograms } from '../../hooks/usePatientPrograms';
import { usePersonAttributes } from '../../hooks/usePersonAttributes';

function useCustomHooks(context: Partial<FormProcessorContextProps>) {
const [isLoading, setIsLoading] = useState(true);
const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson);
const { encounterRole, isLoading: isLoadingEncounterRole } = useEncounterRole();
const { isLoadingPatientPrograms, patientPrograms } = usePatientPrograms(context.patient?.id, context.formJson);
const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes(
context.patient?.id,
context.formJson,
);

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 @@ -56,6 +63,7 @@ function useCustomHooks(context: Partial<FormProcessorContextProps>) {
...context.customDependencies,
patientPrograms: patientPrograms,
defaultEncounterRole: encounterRole,
personAttributes: personAttributes,
},
};
});
Expand All @@ -76,6 +84,7 @@ const contextInitializableTypes = [
'patientIdentifier',
'encounterRole',
'programState',
'personAttributes',
];

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

// save person attributes
try {
const personAttributes = preparePersonAttributes(context.formFields, context.location?.uuid);
const savedAttributes = await savePersonAttributes(context.patient, personAttributes);
if (savedAttributes?.length) {
showSnackbar({
title: translateFn('personAttributesSaved', 'Person attribute(s) saved successfully'),
kind: 'success',
isLowContrast: true,
});
}
} catch (error) {
const errorMessages = extractErrorMessagesFromResponse(error);
throw {
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
14 changes: 12 additions & 2 deletions 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,7 +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 OpenmrsResource } from '@openmrs/esm-framework';
import { type OpenmrsResource, type PersonAttribute } from '@openmrs/esm-framework';
import { assignedDiagnosesIds } from '../../adapters/encounter-diagnosis-adapter';

export function prepareEncounter(
Expand Down Expand Up @@ -159,6 +159,10 @@ export function saveAttachments(fields: FormField[], encounter: OpenmrsEncounter
});
}

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

export function getMutableSessionProps(context: FormContextProps) {
const {
formFields,
Expand Down Expand Up @@ -373,3 +377,9 @@ function prepareDiagnosis(fields: FormField[]) {

return diagnoses;
}

export function preparePersonAttributes(fields: FormField[], encounterLocation: string): PersonAttribute[] {
return fields
.filter((field) => field.type === 'personAttribute' && hasSubmission(field))
.map((field) => field.meta.submission.newValue);
}
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,
CynthiaKamau marked this conversation as resolved.
Show resolved Hide resolved
})),
];
4 changes: 4 additions & 0 deletions src/registry/inbuilt-components/inbuiltDataSources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export const inbuiltDataSources: Array<RegistryItem<DataSource<any>>> = [
name: 'encounter_role_datasource',
component: new EncounterRoleDataSource(),
},
{
name: 'person-attribute-location',
component: new LocationDataSource(),
},
];

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 @@ -12,6 +12,7 @@ import { PatientIdentifierAdapter } from '../../adapters/patient-identifier-adap
import { ProgramStateAdapter } from '../../adapters/program-state-adapter';
import { EncounterDiagnosisAdapter } from '../../adapters/encounter-diagnosis-adapter';
import { type FormFieldValueAdapter } from '../../types';
import { PersonAttributesAdapter } from '../../adapters/person-attributes-adapter';

export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] = [
{
Expand Down Expand Up @@ -66,4 +67,8 @@ export const inbuiltFieldValueAdapters: RegistryItem<FormFieldValueAdapter>[] =
type: 'diagnosis',
component: EncounterDiagnosisAdapter,
},
{
type: 'personAttribute',
component: PersonAttributesAdapter,
},
];
Loading
Loading