Skip to content
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 = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you planning on adding some test coverage for this adapter?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes i will

transformFieldValue: function (field: FormField, value: any, context: FormContextProps) {
clearSubmission(field);
if (field.meta?.previousValue?.value === value || isEmpty(value)) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if the user tries to delete an attribute in edit mode?

 if (field.meta?.previousValue && isEmpty(value)) {
   // should we void the attribute?
}

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 {
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;
}
Comment on lines +9 to +15
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This data source seems to just load locations (not tied down to the "attribute" model). Anything stopping you from reusing the existing location DS`?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

^^^^ This

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

}
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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the inversion here?

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,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't really understand why we need the ? here?

})),
];
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