From 1c3206066b44e1dce63ee9969c0b6ddc3159f401 Mon Sep 17 00:00:00 2001 From: CynthiaKamau Date: Tue, 19 Nov 2024 20:51:58 +0300 Subject: [PATCH 1/3] (feat) O3-3367 Add support for person attributes --- src/adapters/person-attributes-adapter.ts | 54 ++++++++++++++++++ src/api/index.ts | 35 +++++++++++- .../ui-select-extended.component.tsx | 10 ++++ .../ui-select-extended.test.tsx | 8 +++ .../inputs/unspecified/unspecified.test.tsx | 8 +++ .../person-attribute-datasource.ts | 16 ++++++ .../select-concept-answers-datasource.ts | 2 +- src/hooks/useFormJson.ts | 13 +++-- src/hooks/usePersonAttributes.tsx | 30 ++++++++++ .../encounter/encounter-form-processor.ts | 38 +++++++++++-- .../encounter/encounter-processor-helper.ts | 16 +++++- .../inbuilt-components/control-templates.ts | 6 ++ .../inbuilt-components/inbuiltControls.ts | 2 +- .../inbuilt-components/inbuiltDataSources.ts | 5 ++ .../inbuiltFieldValueAdapters.ts | 5 ++ .../template-component-map.ts | 4 ++ src/registry/registry.ts | 19 +++++-- .../default-schema-transformer.test.ts | 8 +-- .../default-schema-transformer.ts | 56 ++++++++++++------- src/types/index.ts | 2 +- 20 files changed, 293 insertions(+), 44 deletions(-) create mode 100644 src/adapters/person-attributes-adapter.ts create mode 100644 src/datasources/person-attribute-datasource.ts create mode 100644 src/hooks/usePersonAttributes.tsx diff --git a/src/adapters/person-attributes-adapter.ts b/src/adapters/person-attributes-adapter.ts new file mode 100644 index 000000000..0ff59046f --- /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?.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; + }, +}; diff --git a/src/api/index.ts b/src/api/index.ts index 72fe0f44c..cba2df90b 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 { FHIRObsResource, OpenmrsForm, PatientIdentifier, PatientProgramPayload } from '../types'; import { isUuid } from '../utils/boolean-utils'; @@ -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; + } +} 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 2673e0600..a7ef88943 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 @@ -169,6 +169,16 @@ const UiSelectExtended: React.FC = ({ field, errors, warnin itemToString={(item) => item?.display} selectedItem={selectedItem} placeholder={isSearchable ? t('search', 'Search') + '...' : null} +<<<<<<< HEAD +======= + shouldFilterItem={({ item, inputValue }) => { + if (!inputValue || items.find((item) => item.uuid == field.value)) { + // Carbon's initial call at component mount + return true; + } + return item.display?.toLowerCase().includes(inputValue.toLowerCase()); + }} +>>>>>>> 5510d0b ((feat) O3-3367 Add support for person attributes) onChange={({ selectedItem }) => { isProcessingSelection.current = true; setFieldValue(selectedItem?.uuid); diff --git a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx index 026fa911a..e3be16dc7 100644 --- a/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx +++ b/src/components/inputs/ui-select-extended/ui-select-extended.test.tsx @@ -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: [ diff --git a/src/components/inputs/unspecified/unspecified.test.tsx b/src/components/inputs/unspecified/unspecified.test.tsx index d7635e558..daf37242d 100644 --- a/src/components/inputs/unspecified/unspecified.test.tsx +++ b/src/components/inputs/unspecified/unspecified.test.tsx @@ -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( 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/useFormJson.ts b/src/hooks/useFormJson.ts index e52b1134b..274a71919 100644 --- a/src/hooks/useFormJson.ts +++ b/src/hooks/useFormJson.ts @@ -118,14 +118,17 @@ 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 { +): Promise { removeInlineSubForms(formJson, formSessionIntent); // apply form schema transformers - schemaTransformers.reduce((draftForm, transformer) => transformer.transform(draftForm), formJson); + for (let transformer of schemaTransformers) { + const draftForm = await transformer.transform(formJson); + formJson = draftForm; + } setEncounterType(formJson); return applyFormIntent(formSessionIntent, formJson); } @@ -144,7 +147,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 { for (let i = formJson.pages.length - 1; i >= 0; i--) { const page = formJson.pages[i]; if ( @@ -153,7 +156,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); } } } 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 75c351673..45adc0f01 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -7,9 +7,11 @@ import { prepareEncounter, preparePatientIdentifiers, preparePatientPrograms, + preparePersonAttributes, saveAttachments, savePatientIdentifiers, savePatientPrograms, + savePersonAttributes, } from './encounter-processor-helper'; import { type FormField, @@ -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) { 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: isLoadingPatientPrograms, patientPrograms } = usePatientPrograms( + 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>) => { @@ -56,6 +63,7 @@ function useCustomHooks(context: Partial) { ...context.customDependencies, patientPrograms: patientPrograms, defaultEncounterRole: encounterRole, + personAttributes: personAttributes, }, }; }); @@ -76,6 +84,7 @@ const contextInitializableTypes = [ 'patientIdentifier', 'encounterRole', 'programState', + 'personAttributes', ]; export class EncounterFormProcessor extends FormProcessor { @@ -159,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 7ccd0323c..9233fe824 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -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'; @@ -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( @@ -159,6 +159,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, @@ -373,3 +379,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); +} 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 2a5632bf7..8f3f330d5 100644 --- a/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts +++ b/src/registry/inbuilt-components/inbuiltFieldValueAdapters.ts @@ -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[] = [ { @@ -66,4 +67,8 @@ export const inbuiltFieldValueAdapters: RegistryItem[] = type: 'diagnosis', component: EncounterDiagnosisAdapter, }, + { + 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/registry/registry.ts b/src/registry/registry.ts index 1d96bb8b8..4b1f7290d 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -171,7 +171,7 @@ export async function getRegisteredFieldValueAdapter(type: string): Promise
{ - const transformers = []; + const transformers: FormSchemaTransformer[] = []; const cachedTransformers = registryCache.formSchemaTransformers; if (Object.keys(cachedTransformers).length) { @@ -186,14 +186,21 @@ export async function getRegisteredFormSchemaTransformers(): Promise transformer !== undefined)); - - transformers.push(...inbuiltFormTransformers.map((inbuiltTransformer) => inbuiltTransformer.component)); - + const inbuiltTransformersPromises = inbuiltFormTransformers.map(async (inbuiltTransformer) => { + const transformer = inbuiltTransformer.component; + if (transformer instanceof Promise) { + return await transformer; + } + return transformer; + }); + const resolvedInbuiltTransformers = await Promise.all(inbuiltTransformersPromises); + transformers.push(...resolvedInbuiltTransformers); transformers.forEach((transformer) => { const inbuiltTransformer = inbuiltFormTransformers.find((t) => t.component === transformer); - registryCache.formSchemaTransformers[inbuiltTransformer.name] = transformer; + if (inbuiltTransformer) { + registryCache.formSchemaTransformers[inbuiltTransformer.name] = transformer; + } }); - return transformers; } diff --git a/src/transformers/default-schema-transformer.test.ts b/src/transformers/default-schema-transformer.test.ts index 0a5ab3eca..045b15695 100644 --- a/src/transformers/default-schema-transformer.test.ts +++ b/src/transformers/default-schema-transformer.test.ts @@ -182,11 +182,11 @@ const expectedTransformedSchema = { }; describe('Default form schema transformer', () => { - it('should transform AFE schema to be compatible with RFE', () => { - expect(DefaultFormSchemaTransformer.transform(testForm as any)).toEqual(expectedTransformedSchema); + it('should transform AFE schema to be compatible with RFE', async () => { + expect(await DefaultFormSchemaTransformer.transform(testForm as any)).toEqual(expectedTransformedSchema); }); - it('should handle checkbox-searchable rendering', () => { + it('should handle checkbox-searchable rendering', async () => { // setup const form = { pages: [ @@ -209,7 +209,7 @@ describe('Default form schema transformer', () => { ], }; // exercise - const transformedForm = DefaultFormSchemaTransformer.transform(form as FormSchema); + const transformedForm = await DefaultFormSchemaTransformer.transform(form as FormSchema); const transformedQuestion = transformedForm.pages[0].sections[0].questions[0]; // verify expect(transformedQuestion.questionOptions.rendering).toEqual('checkbox'); diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index db5fd80ad..d84925afd 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -2,26 +2,36 @@ import { type OpenmrsResource } from '@openmrs/esm-framework'; import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType, type FormPage } from '../types'; import { isTrue } from '../utils/boolean-utils'; import { hasRendering } from '../utils/common-utils'; +import { getPersonAttributeTypeFormat } from '../api/'; export type RenderTypeExtended = 'multiCheckbox' | 'numeric' | RenderType; export const DefaultFormSchemaTransformer: FormSchemaTransformer = { - transform: (form: FormSchema) => { - parseBooleanTokenIfPresent(form, 'readonly'); - form.pages.forEach((page, index) => { - const label = page.label ?? ''; - page.id = `page-${label.replace(/\s/g, '')}-${index}`; - parseBooleanTokenIfPresent(page, 'readonly'); - if (page.sections) { - page.sections.forEach((section) => { - section.questions = handleQuestionsWithDateOptions(section.questions); - section.questions = handleQuestionsWithObsComments(section.questions); - parseBooleanTokenIfPresent(section, 'readonly'); - parseBooleanTokenIfPresent(section, 'isExpanded'); - section?.questions?.forEach((question, index) => handleQuestion(question, page, form)); - }); + transform: async (form: FormSchema): Promise => { + try { + parseBooleanTokenIfPresent(form, 'readonly'); + for (const [index, page] of form.pages.entries()) { + const label = page.label ?? ''; + page.id = `page-${label.replace(/\s/g, '')}-${index}`; + parseBooleanTokenIfPresent(page, 'readonly'); + if (page.sections) { + for (const section of page.sections) { + section.questions = handleQuestionsWithDateOptions(section.questions); + section.questions = handleQuestionsWithObsComments(section.questions); + parseBooleanTokenIfPresent(section, 'readonly'); + parseBooleanTokenIfPresent(section, 'isExpanded'); + if (section.questions) { + section.questions = await Promise.all( + section.questions.map((question) => handleQuestion(question, page, form)), + ); + } + } + } } - }); + } catch (error) { + console.error('Error in form transformation:', error); + throw error; + } if (form.meta?.programs) { handleProgramMetaTags(form); } @@ -29,7 +39,7 @@ export const DefaultFormSchemaTransformer: FormSchemaTransformer = { }, }; -function handleQuestion(question: FormField, page: FormPage, form: FormSchema) { +async function handleQuestion(question: FormField, page: FormPage, form: FormSchema): Promise { if (question.type === 'programState') { const formMeta = form.meta ?? {}; formMeta.programs = formMeta.programs @@ -40,17 +50,20 @@ function handleQuestion(question: FormField, page: FormPage, form: FormSchema) { try { sanitizeQuestion(question); setFieldValidators(question); - transformByType(question); + await transformByType(question); transformByRendering(question); if (question.questions?.length) { if (question.type === 'obsGroup' && question.questions.length) { question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, page, form)); } else { - question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, page, form)); + question.questions = await Promise.all( + question.questions.map((nestedQuestion) => handleQuestion(nestedQuestion, page, form)), + ); } } question.meta.pageId = page.id; + return question; } catch (error) { console.error(error); } @@ -132,7 +145,7 @@ function setFieldValidators(question: FormField) { } } -function transformByType(question: FormField) { +async function transformByType(question: FormField) { switch (question.type) { case 'encounterProvider': question.questionOptions.rendering = 'encounter-provider'; @@ -148,8 +161,13 @@ function transformByType(question: FormField) { ? 'date' : question.questionOptions.rendering; break; +<<<<<<< HEAD case 'diagnosis': handleDiagnosis(question); +======= + case 'personAttribute': + await handlePersonAttributeType(question); +>>>>>>> 777dea6 ((feat) O3-3367 Add support for person attributes) break; } } diff --git a/src/types/index.ts b/src/types/index.ts index 39434ae29..2f58a5104 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -102,7 +102,7 @@ export interface FormSchemaTransformer { /** * Transforms the raw schema to be compatible with the React Form Engine. */ - transform: (form: FormSchema) => FormSchema; + transform: (form: FormSchema) => Promise | FormSchema; } export interface PostSubmissionAction { From 7f6067bf60e7a040164469a87bb62aea02ae78bd Mon Sep 17 00:00:00 2001 From: CynthiaKamau Date: Fri, 13 Dec 2024 17:01:55 +0300 Subject: [PATCH 2/3] code review --- .../ui-select-extended.component.tsx | 10 --- src/hooks/useFormJson.ts | 23 ++++-- src/hooks/usePersonAttributes.tsx | 7 +- .../encounter/encounter-form-processor.ts | 14 ++-- .../encounter/encounter-processor-helper.ts | 4 +- .../inbuilt-components/control-templates.ts | 6 -- .../inbuilt-components/inbuiltDataSources.ts | 5 +- .../inbuilt-components/inbuiltTransformers.ts | 5 ++ .../template-component-map.ts | 4 - src/registry/registry.ts | 3 +- .../default-schema-transformer.ts | 58 +++++-------- .../person-attributes-transformer.ts | 81 +++++++++++++++++++ src/types/schema.ts | 4 + 13 files changed, 141 insertions(+), 83 deletions(-) create mode 100644 src/transformers/person-attributes-transformer.ts 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 a7ef88943..2673e0600 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 @@ -169,16 +169,6 @@ const UiSelectExtended: React.FC = ({ field, errors, warnin itemToString={(item) => item?.display} selectedItem={selectedItem} placeholder={isSearchable ? t('search', 'Search') + '...' : null} -<<<<<<< HEAD -======= - shouldFilterItem={({ item, inputValue }) => { - if (!inputValue || items.find((item) => item.uuid == field.value)) { - // Carbon's initial call at component mount - return true; - } - return item.display?.toLowerCase().includes(inputValue.toLowerCase()); - }} ->>>>>>> 5510d0b ((feat) O3-3367 Add support for person attributes) onChange={({ selectedItem }) => { isProcessingSelection.current = true; setFieldValue(selectedItem?.uuid); diff --git a/src/hooks/useFormJson.ts b/src/hooks/useFormJson.ts index 274a71919..e5b4643b5 100644 --- a/src/hooks/useFormJson.ts +++ b/src/hooks/useFormJson.ts @@ -123,14 +123,21 @@ async function refineFormJson( schemaTransformers: FormSchemaTransformer[] = [], formSessionIntent?: string, ): Promise { - removeInlineSubForms(formJson, formSessionIntent); - // apply form schema transformers - for (let transformer of schemaTransformers) { - const draftForm = await transformer.transform(formJson); - formJson = draftForm; - } - setEncounterType(formJson); - return applyFormIntent(formSessionIntent, formJson); + 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 { + return value && typeof value.then === 'function'; } /** diff --git a/src/hooks/usePersonAttributes.tsx b/src/hooks/usePersonAttributes.tsx index b65fae98d..4e00dc11f 100644 --- a/src/hooks/usePersonAttributes.tsx +++ b/src/hooks/usePersonAttributes.tsx @@ -1,16 +1,17 @@ import { openmrsFetch, type PersonAttribute, restBaseUrl } from '@openmrs/esm-framework'; import { useEffect, useState } from 'react'; +import { type FormSchema } from '../types'; -export const usePersonAttributes = (patientUuid: string) => { +export const usePersonAttributes = (patientUuid: string, formJson: FormSchema) => { const [personAttributes, setPersonAttributes] = useState>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (patientUuid) { + if (formJson.meta?.personAttributes?.hasPersonAttributeFields && patientUuid) { openmrsFetch(`${restBaseUrl}/patient/${patientUuid}?v=custom:(attributes)`) .then((response) => { - setPersonAttributes(response?.data?.attributes); + setPersonAttributes(response.data?.attributes); setIsLoading(false); }) .catch((error) => { diff --git a/src/processors/encounter/encounter-form-processor.ts b/src/processors/encounter/encounter-form-processor.ts index 45adc0f01..3776dab59 100644 --- a/src/processors/encounter/encounter-form-processor.ts +++ b/src/processors/encounter/encounter-form-processor.ts @@ -39,11 +39,11 @@ function useCustomHooks(context: Partial) { const [isLoading, setIsLoading] = useState(true); const { encounter, isLoading: isLoadingEncounter } = useEncounter(context.formJson); const { encounterRole, isLoading: isLoadingEncounterRole } = useEncounterRole(); - const { isLoading: isLoadingPatientPrograms, patientPrograms } = usePatientPrograms( + const { isLoadingPatientPrograms, patientPrograms } = usePatientPrograms(context.patient?.id, context.formJson); + const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes( context.patient?.id, context.formJson, ); - const { isLoading: isLoadingPersonAttributes, personAttributes } = usePersonAttributes(context.patient?.id); useEffect(() => { setIsLoading(isLoadingPatientPrograms || isLoadingEncounter || isLoadingEncounterRole || isLoadingPersonAttributes); @@ -170,9 +170,9 @@ 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) { + 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', @@ -181,12 +181,12 @@ export class EncounterFormProcessor extends FormProcessor { } } catch (error) { const errorMessages = extractErrorMessagesFromResponse(error); - return Promise.reject({ + throw { title: translateFn('errorSavingPersonAttributes', 'Error saving person attributes'), description: errorMessages.join(', '), kind: 'error', critical: true, - }); + }; } // save encounter diff --git a/src/processors/encounter/encounter-processor-helper.ts b/src/processors/encounter/encounter-processor-helper.ts index 9233fe824..4851a64b3 100644 --- a/src/processors/encounter/encounter-processor-helper.ts +++ b/src/processors/encounter/encounter-processor-helper.ts @@ -160,9 +160,7 @@ export function saveAttachments(fields: FormField[], encounter: OpenmrsEncounter } export function savePersonAttributes(patient: fhir.Patient, attributes: PersonAttribute[]) { - return attributes.map((personAttribute) => { - return savePersonAttribute(personAttribute, patient.id); - }); + return attributes.map((personAttribute) => savePersonAttribute(personAttribute, patient.id)); } export function getMutableSessionProps(context: FormContextProps) { diff --git a/src/registry/inbuilt-components/control-templates.ts b/src/registry/inbuilt-components/control-templates.ts index 55b1f34c5..0ea36c84d 100644 --- a/src/registry/inbuilt-components/control-templates.ts +++ b/src/registry/inbuilt-components/control-templates.ts @@ -50,12 +50,6 @@ 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/inbuiltDataSources.ts b/src/registry/inbuilt-components/inbuiltDataSources.ts index 188107ee0..c437dc423 100644 --- a/src/registry/inbuilt-components/inbuiltDataSources.ts +++ b/src/registry/inbuilt-components/inbuiltDataSources.ts @@ -5,7 +5,6 @@ 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 @@ -36,8 +35,8 @@ export const inbuiltDataSources: Array>> = [ component: new EncounterRoleDataSource(), }, { - name: 'person_attribute_location_datasource', - component: new PersonAttributeLocationDataSource(), + name: 'person-attribute-location', + component: new LocationDataSource(), }, ]; diff --git a/src/registry/inbuilt-components/inbuiltTransformers.ts b/src/registry/inbuilt-components/inbuiltTransformers.ts index a35c79cc8..2f67bd743 100644 --- a/src/registry/inbuilt-components/inbuiltTransformers.ts +++ b/src/registry/inbuilt-components/inbuiltTransformers.ts @@ -1,3 +1,4 @@ +import { PersonAttributesTransformer } from '../../transformers/person-attributes-transformer'; import { DefaultFormSchemaTransformer } from '../../transformers/default-schema-transformer'; import { type FormSchemaTransformer } from '../../types'; import { type RegistryItem } from '../registry'; @@ -7,4 +8,8 @@ export const inbuiltFormTransformers: Array> name: 'DefaultFormSchemaTransformer', component: DefaultFormSchemaTransformer, }, + { + name: 'PersonAttributesTransformer', + component: PersonAttributesTransformer, + }, ]; diff --git a/src/registry/inbuilt-components/template-component-map.ts b/src/registry/inbuilt-components/template-component-map.ts index 8e6b76766..ad0c9064d 100644 --- a/src/registry/inbuilt-components/template-component-map.ts +++ b/src/registry/inbuilt-components/template-component-map.ts @@ -25,8 +25,4 @@ export const templateToComponentMap = [ name: 'encounter-role', baseControlComponent: UiSelectExtended, }, - { - name: 'person_attribute_location_datasource', - baseControlComponent: UiSelectExtended, - }, ]; diff --git a/src/registry/registry.ts b/src/registry/registry.ts index 4b1f7290d..09bcde4da 100644 --- a/src/registry/registry.ts +++ b/src/registry/registry.ts @@ -171,9 +171,10 @@ export async function getRegisteredFieldValueAdapter(type: string): Promise { - const transformers: FormSchemaTransformer[] = []; + const transformers: Array = []; const cachedTransformers = registryCache.formSchemaTransformers; + if (Object.keys(cachedTransformers).length) { return Object.values(cachedTransformers); } diff --git a/src/transformers/default-schema-transformer.ts b/src/transformers/default-schema-transformer.ts index d84925afd..d1c0a8874 100644 --- a/src/transformers/default-schema-transformer.ts +++ b/src/transformers/default-schema-transformer.ts @@ -2,36 +2,26 @@ import { type OpenmrsResource } from '@openmrs/esm-framework'; import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType, type FormPage } from '../types'; import { isTrue } from '../utils/boolean-utils'; import { hasRendering } from '../utils/common-utils'; -import { getPersonAttributeTypeFormat } from '../api/'; export type RenderTypeExtended = 'multiCheckbox' | 'numeric' | RenderType; export const DefaultFormSchemaTransformer: FormSchemaTransformer = { - transform: async (form: FormSchema): Promise => { - try { - parseBooleanTokenIfPresent(form, 'readonly'); - for (const [index, page] of form.pages.entries()) { - const label = page.label ?? ''; - page.id = `page-${label.replace(/\s/g, '')}-${index}`; - parseBooleanTokenIfPresent(page, 'readonly'); - if (page.sections) { - for (const section of page.sections) { - section.questions = handleQuestionsWithDateOptions(section.questions); - section.questions = handleQuestionsWithObsComments(section.questions); - parseBooleanTokenIfPresent(section, 'readonly'); - parseBooleanTokenIfPresent(section, 'isExpanded'); - if (section.questions) { - section.questions = await Promise.all( - section.questions.map((question) => handleQuestion(question, page, form)), - ); - } - } - } + transform: (form: FormSchema) => { + parseBooleanTokenIfPresent(form, 'readonly'); + form.pages.forEach((page, index) => { + const label = page.label ?? ''; + page.id = `page-${label.replace(/\s/g, '')}-${index}`; + parseBooleanTokenIfPresent(page, 'readonly'); + if (page.sections) { + page.sections.forEach((section) => { + section.questions = handleQuestionsWithDateOptions(section.questions); + section.questions = handleQuestionsWithObsComments(section.questions); + parseBooleanTokenIfPresent(section, 'readonly'); + parseBooleanTokenIfPresent(section, 'isExpanded'); + section?.questions?.forEach((question, index) => handleQuestion(question, page, form)); + }); } - } catch (error) { - console.error('Error in form transformation:', error); - throw error; - } + }); if (form.meta?.programs) { handleProgramMetaTags(form); } @@ -39,7 +29,7 @@ export const DefaultFormSchemaTransformer: FormSchemaTransformer = { }, }; -async function handleQuestion(question: FormField, page: FormPage, form: FormSchema): Promise { +function handleQuestion(question: FormField, page: FormPage, form: FormSchema) { if (question.type === 'programState') { const formMeta = form.meta ?? {}; formMeta.programs = formMeta.programs @@ -50,20 +40,17 @@ async function handleQuestion(question: FormField, page: FormPage, form: FormSch try { sanitizeQuestion(question); setFieldValidators(question); - await transformByType(question); + transformByType(question); transformByRendering(question); if (question.questions?.length) { if (question.type === 'obsGroup' && question.questions.length) { question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, page, form)); } else { - question.questions = await Promise.all( - question.questions.map((nestedQuestion) => handleQuestion(nestedQuestion, page, form)), - ); + question.questions.forEach((nestedQuestion) => handleQuestion(nestedQuestion, page, form)); } } question.meta.pageId = page.id; - return question; } catch (error) { console.error(error); } @@ -121,7 +108,7 @@ function sanitizeQuestion(question: FormField) { } } -function parseBooleanTokenIfPresent(node: any, token: any) { +export function parseBooleanTokenIfPresent(node: any, token: any) { if (node && typeof node[token] === 'string') { const trimmed = node[token].trim().toLowerCase(); if (trimmed === 'true' || trimmed === 'false') { @@ -145,7 +132,7 @@ function setFieldValidators(question: FormField) { } } -async function transformByType(question: FormField) { +function transformByType(question: FormField) { switch (question.type) { case 'encounterProvider': question.questionOptions.rendering = 'encounter-provider'; @@ -161,13 +148,8 @@ async function transformByType(question: FormField) { ? 'date' : question.questionOptions.rendering; break; -<<<<<<< HEAD case 'diagnosis': handleDiagnosis(question); -======= - case 'personAttribute': - await handlePersonAttributeType(question); ->>>>>>> 777dea6 ((feat) O3-3367 Add support for person attributes) break; } } diff --git a/src/transformers/person-attributes-transformer.ts b/src/transformers/person-attributes-transformer.ts new file mode 100644 index 000000000..e9545d6a6 --- /dev/null +++ b/src/transformers/person-attributes-transformer.ts @@ -0,0 +1,81 @@ +import { type FormField, type FormSchema, type FormSchemaTransformer, type RenderType, type FormPage } from '../types'; +import { getPersonAttributeTypeFormat } from '../api/'; +import { parseBooleanTokenIfPresent } from './default-schema-transformer'; + +export type RenderTypeExtended = 'multiCheckbox' | 'numeric' | RenderType; + +export const PersonAttributesTransformer: FormSchemaTransformer = { + transform: async (form: FormSchema): Promise => { + try { + parseBooleanTokenIfPresent(form, 'readonly'); + for (const [index, page] of form.pages.entries()) { + const label = page.label ?? ''; + page.id = `page-${label.replace(/\s/g, '')}-${index}`; + parseBooleanTokenIfPresent(page, 'readonly'); + if (page.sections) { + for (const section of page.sections) { + if (section.questions) { + const formMeta = form.meta ?? {}; + if (checkQuestions(section.questions)) { + formMeta.personAttributes = { hasPersonAttributeFields: true }; + } + form.meta = formMeta; + section.questions = await Promise.all( + section.questions.map((question) => handleQuestion(question, page, form)), + ); + } + } + } + } + } catch (error) { + console.error('Error in form transformation:', error); + throw error; + } + return form; + }, +}; + +async function handleQuestion(question: FormField, page: FormPage, form: FormSchema): Promise { + try { + await transformByType(question); + if (question.questions?.length) { + question.questions = await Promise.all( + question.questions.map((nestedQuestion) => handleQuestion(nestedQuestion, page, form)), + ); + } + question.meta.pageId = page.id; + return question; + } catch (error) { + console.error(error); + } +} + +async function transformByType(question: FormField) { + switch (question.type) { + case 'personAttribute': + await handlePersonAttributeType(question); + break; + } +} + +async function handlePersonAttributeType(question: FormField) { + const attributeTypeFormat = await getPersonAttributeTypeFormat(question.questionOptions.attributeType); + if (attributeTypeFormat === 'org.openmrs.Location') { + question.questionOptions.datasource = { + name: 'location_datasource', + }; + } else if (attributeTypeFormat === 'org.openmrs.Concept') { + question.questionOptions.datasource = { + name: 'select_concept_answers_datasource', + config: { + concept: question.questionOptions?.concept, + }, + }; + } else if (attributeTypeFormat === 'java.lang.String') { + question.questionOptions.rendering = 'text'; + } +} + +function checkQuestions(questions) { + return questions.some((question) => question.type === 'personAttribute'); +} diff --git a/src/types/schema.ts b/src/types/schema.ts index be00e3154..68c4752a2 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -25,6 +25,10 @@ export interface FormSchema { hasProgramFields?: boolean; [anythingElse: string]: any; }; + personAttributes?: { + hasPersonAttributeFields?: boolean; + [anythingElse: string]: any; + }; }; } From d951e444c22fec981fadc1e91eb62ab2bc9f61ad Mon Sep 17 00:00:00 2001 From: CynthiaKamau Date: Wed, 22 Jan 2025 11:18:40 +0300 Subject: [PATCH 3/3] code review --- src/types/schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/types/schema.ts b/src/types/schema.ts index 68c4752a2..db465f015 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -202,6 +202,7 @@ export interface FormQuestionOptions { conceptClasses?: Array; conceptSet?: string; }; + attributeType?: string; } export interface QuestionAnswerOption {