diff --git a/src/adapters/person-attributes-adapter.ts b/src/adapters/person-attributes-adapter.ts new file mode 100644 index 000000000..2331fd7bd --- /dev/null +++ b/src/adapters/person-attributes-adapter.ts @@ -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; + }, +}; diff --git a/src/api/index.ts b/src/api/index.ts index 8124c4df5..3716730cc 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -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'; @@ -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), + }); +} diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx index bd8df9e65..0af52c578 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.component.tsx @@ -149,7 +149,7 @@ const UiSelectExtended: React.FC = ({ 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; } diff --git a/src/datasources/person-attribute-datasource.ts b/src/datasources/person-attribute-datasource.ts new file mode 100644 index 000000000..a122b76f7 --- /dev/null +++ b/src/datasources/person-attribute-datasource.ts @@ -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, uuid?: string): Promise { + const rep = 'v=custom:(uuid,display)'; + const url = `${restBaseUrl}/location?${rep}`; + const { data } = await openmrsFetch(searchTerm ? `${url}&q=${searchTerm}` : url); + + return data?.results; + } +} diff --git a/src/datasources/select-concept-answers-datasource.ts b/src/datasources/select-concept-answers-datasource.ts index 03504ac48..208946550 100644 --- a/src/datasources/select-concept-answers-datasource.ts +++ b/src/datasources/select-concept-answers-datasource.ts @@ -7,7 +7,7 @@ export class SelectConceptAnswersDatasource extends BaseOpenMRSDataSource { } fetchData(searchTerm: string, config?: Record): Promise { - 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']; }); diff --git a/src/hooks/usePersonAttributes.tsx b/src/hooks/usePersonAttributes.tsx new file mode 100644 index 000000000..b65fae98d --- /dev/null +++ b/src/hooks/usePersonAttributes.tsx @@ -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>([]); + 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, + }; +}; diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 2ff47387b..e4bc56b92 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -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'; @@ -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) { const [isLoading, setIsLoading] = useState(true); @@ -40,13 +43,14 @@ function useCustomHooks(context: Partial) { 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>) => { @@ -59,6 +63,7 @@ function useCustomHooks(context: Partial) { ...context.customDependencies, patientPrograms: patientPrograms, defaultEncounterRole: encounterRole, + personAttributes: personAttributes, }, }; }); @@ -79,6 +84,7 @@ const contextInitializableTypes = [ 'patientIdentifier', 'encounterRole', 'programState', + 'personAttributes', ]; export class EncounterFormProcessor extends FormProcessor { @@ -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); diff --git a/src/processors/encounter/encounter-processor-helper.ts b/src/processors/encounter/encounter-processor-helper.ts index 852187109..89e01f905 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -7,7 +7,7 @@ import { type PatientProgramPayload, type FormProcessorContextProps, } 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 { voidObs, constructObs, assignedObsIds } from '../../adapters/obs-adapter'; @@ -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, @@ -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, @@ -318,3 +325,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); +} diff --git a/src/registry/inbuilt-components/control-templates.ts b/src/registry/inbuilt-components/control-templates.ts index 0ea36c84d..55b1f34c5 100644 --- a/src/registry/inbuilt-components/control-templates.ts +++ b/src/registry/inbuilt-components/control-templates.ts @@ -50,6 +50,12 @@ export const controlTemplates: Array = [ }, }, }, + { + name: 'person-attribute-location', + datasource: { + name: 'person_attribute_location_datasource', + }, + }, ]; export const getControlTemplate = (name: string) => { diff --git a/src/registry/inbuilt-components/inbuiltControls.ts b/src/registry/inbuilt-components/inbuiltControls.ts index 4e4a80938..fa005a6ad 100644 --- a/src/registry/inbuilt-components/inbuiltControls.ts +++ b/src/registry/inbuilt-components/inbuiltControls.ts @@ -94,6 +94,6 @@ export const inbuiltControls: Array ({ name: template.name, - component: templateToComponentMap.find((component) => component.name === template.name).baseControlComponent, + component: templateToComponentMap.find((component) => component.name === template.name)?.baseControlComponent, })), ]; diff --git a/src/registry/inbuilt-components/inbuiltDataSources.ts b/src/registry/inbuilt-components/inbuiltDataSources.ts index 0a1d92492..188107ee0 100644 --- a/src/registry/inbuilt-components/inbuiltDataSources.ts +++ b/src/registry/inbuilt-components/inbuiltDataSources.ts @@ -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 @@ -34,6 +35,10 @@ export const inbuiltDataSources: Array>> = [ name: 'encounter_role_datasource', component: new EncounterRoleDataSource(), }, + { + name: 'person_attribute_location_datasource', + component: new PersonAttributeLocationDataSource(), + }, ]; export const validateInbuiltDatasource = (name: string) => { diff --git a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts index 9a4c3d850..c4d62bd37 100644 --- a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts +++ b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts @@ -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[] = [ { @@ -61,4 +62,8 @@ export const inbuiltFieldValueAdapters: RegistryItem[] = type: 'patientIdentifier', component: PatientIdentifierAdapter, }, + { + type: 'personAttribute', + component: PersonAttributesAdapter, + }, ]; diff --git a/src/registry/inbuilt-components/template-component-map.ts b/src/registry/inbuilt-components/template-component-map.ts index ad0c9064d..8e6b76766 100644 --- a/src/registry/inbuilt-components/template-component-map.ts +++ b/src/registry/inbuilt-components/template-component-map.ts @@ -25,4 +25,8 @@ export const templateToComponentMap = [ name: 'encounter-role', baseControlComponent: UiSelectExtended, }, + { + name: 'person_attribute_location_datasource', + baseControlComponent: UiSelectExtended, + }, ]; diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index 0433ae339..8332338ed 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -133,6 +133,9 @@ function transformByType(question: FormField) { ? 'date' : question.questionOptions.rendering; break; + case 'personAttribute': + handlePersonAttributeType(question); + break; } } @@ -259,3 +262,24 @@ function handleQuestionsWithObsComments(sectionQuestions: Array): 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'; + } +} diff --git a/src/types/schema.ts b/src/types/schema.ts index 424f15bad..ea849d6b4 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -182,6 +182,10 @@ export interface FormQuestionOptions { comment?: string; orientation?: 'vertical' | 'horizontal'; shownCommentOptions?: { validators?: Array>; hide?: { hideWhenExpression: string } }; + attribute?: { + type?: string; + format?: string; + }; } export interface QuestionAnswerOption {