diff --git a/packages/esm-patient-registration-app/src/config-schema.ts b/packages/esm-patient-registration-app/src/config-schema.ts index 1e2cadd78..ae4b4d3fd 100644 --- a/packages/esm-patient-registration-app/src/config-schema.ts +++ b/packages/esm-patient-registration-app/src/config-schema.ts @@ -65,6 +65,7 @@ export interface RegistrationConfig { month: number; }; }; + identifier: [{ identifierTypeSystem: string; identifierTypeUuid: string }]; phone: { personAttributeUuid: string; validation?: { @@ -332,6 +333,21 @@ export const esmPatientRegistrationSchema = { }, }, }, + identifier: { + _type: Type.Array, + _elements: { + identifierTypeSystem: { + _type: Type.String, + _description: 'Identifier system from the fhir server', + }, + identifierTypeUuid: { + _type: Type.String, + _default: null, + _description: 'Identifier type uuid of OpenMRS to map the identifier system', + }, + }, + _default: [], + }, phone: { personAttributeUuid: { _type: Type.UUID, diff --git a/packages/esm-patient-registration-app/src/patient-registration/mpi/mpi-patient.resource.ts b/packages/esm-patient-registration-app/src/patient-registration/mpi/mpi-patient.resource.ts new file mode 100644 index 000000000..a79cb3fe9 --- /dev/null +++ b/packages/esm-patient-registration-app/src/patient-registration/mpi/mpi-patient.resource.ts @@ -0,0 +1,18 @@ +import { fhirBaseUrl, openmrsFetch } from '@openmrs/esm-framework'; +import useSWR from 'swr'; + +export function useMpiPatient(patientId: string) { + const url = `${fhirBaseUrl}/Patient/${patientId}/$cr`; + + const { + data: patient, + error: error, + isLoading: isLoading, + } = useSWR<{ data: fhir.Patient }, Error>(url, openmrsFetch); + + return { + isLoading, + patient, + error: error, + }; +} diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts index ff50d4b20..0a9ec8176 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts +++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-hooks.ts @@ -24,20 +24,27 @@ import { import { getAddressFieldValuesFromFhirPatient, getFormValuesFromFhirPatient, + getIdentifierFieldValuesFromFhirPatient, getPatientUuidMapFromFhirPatient, getPhonePersonAttributeValueFromFhirPatient, latestFirstEncounter, } from './patient-registration-utils'; import { useInitialPatientRelationships } from './section/patient-relationships/relationships.resource'; import dayjs from 'dayjs'; - -export function useInitialFormValues(patientUuid: string): [FormValues, Dispatch] { - const { freeTextFieldConceptUuid } = useConfig(); - const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(patientUuid); - const { data: deathInfo, isLoading: isLoadingDeathInfo } = useInitialPersonDeathInfo(patientUuid); - const { data: attributes, isLoading: isLoadingAttributes } = useInitialPersonAttributes(patientUuid); - const { data: identifiers, isLoading: isLoadingIdentifiers } = useInitialPatientIdentifiers(patientUuid); - const { data: relationships, isLoading: isLoadingRelationships } = useInitialPatientRelationships(patientUuid); +import { useMpiPatient } from './mpi/mpi-patient.resource'; + +export function useInitialFormValues(patientUuid: string, isLocal: boolean): [FormValues, Dispatch] { + const { freeTextFieldConceptUuid, fieldConfigurations } = useConfig(); + const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(isLocal ? patientUuid : null); + const { isLoading: isLoadingMpiPatient, patient: mpiPatient } = useMpiPatient(!isLocal ? patientUuid : null); + const { data: deathInfo, isLoading: isLoadingDeathInfo } = useInitialPersonDeathInfo(isLocal ? patientUuid : null); + const { data: attributes, isLoading: isLoadingAttributes } = useInitialPersonAttributes(isLocal ? patientUuid : null); + const { data: identifiers, isLoading: isLoadingIdentifiers } = useInitialPatientIdentifiers( + isLocal ? patientUuid : null, + ); + const { data: relationships, isLoading: isLoadingRelationships } = useInitialPatientRelationships( + isLocal ? patientUuid : null, + ); const { data: encounters } = useInitialEncounters(patientUuid, patientToEdit); const [initialFormValues, setInitialFormValues] = useState({ @@ -81,12 +88,12 @@ export function useInitialFormValues(patientUuid: string): [FormValues, Dispatch ...initialFormValues, ...getFormValuesFromFhirPatient(patientToEdit), address: getAddressFieldValuesFromFhirPatient(patientToEdit), - ...getPhonePersonAttributeValueFromFhirPatient(patientToEdit), + ...getPhonePersonAttributeValueFromFhirPatient(patientToEdit, fieldConfigurations.phone.personAttributeUuid), birthdateEstimated: !/^\d{4}-\d{2}-\d{2}$/.test(patientToEdit.birthDate), yearsEstimated, monthsEstimated, }); - } else if (!isLoadingPatientToEdit && patientUuid) { + } else if (!isLoadingPatientToEdit && patientUuid && isLocal) { const registration = await getPatientRegistration(patientUuid); if (!registration._patientRegistrationData.formValues) { @@ -101,6 +108,32 @@ export function useInitialFormValues(patientUuid: string): [FormValues, Dispatch })(); }, [isLoadingPatientToEdit, patientToEdit, patientUuid]); + useEffect(() => { + const fetchValues = async () => { + if (mpiPatient?.data?.identifier) { + const identifiers = await getIdentifierFieldValuesFromFhirPatient( + mpiPatient.data, + fieldConfigurations.identifier, + ); + + const values = { + ...initialFormValues, + ...getFormValuesFromFhirPatient(mpiPatient.data), + address: getAddressFieldValuesFromFhirPatient(mpiPatient.data), + identifiers, + attributes: getPhonePersonAttributeValueFromFhirPatient( + mpiPatient.data, + fieldConfigurations.phone.personAttributeUuid, + ), + }; + + setInitialFormValues(values); + } + }; + + fetchValues(); + }, [mpiPatient, isLoadingMpiPatient]); + // Set initial patient death info useEffect(() => { if (!isLoadingDeathInfo && deathInfo?.dead) { diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts index 3aa777a2c..b4eecdd18 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts +++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration-utils.ts @@ -9,6 +9,7 @@ import { type PatientIdentifierValue, type PatientUuidMapType, } from './patient-registration.types'; +import { openmrsFetch, restBaseUrl } from '@openmrs/esm-framework'; export function parseAddressTemplateXml(addressTemplate: string) { const templateXmlDoc = new DOMParser().parseFromString(addressTemplate, 'text/xml'); @@ -192,10 +193,48 @@ export function getPatientIdentifiersFromFhirPatient(patient: fhir.Patient): Arr }); } -export function getPhonePersonAttributeValueFromFhirPatient(patient: fhir.Patient) { +export async function getIdentifierFieldValuesFromFhirPatient( + patient: fhir.Patient, + identifierConfig, +): Promise<{ [identifierFieldName: string]: PatientIdentifierValue }> { + const identifiers: FormValues['identifiers'] = {}; + + for (const identifier of patient.identifier) { + for (const config of identifierConfig) { + const identifierConfig = config.identifierTypeSystem === identifier.system ? config : null; + + if (identifierConfig) { + let identifierTypeName; + + const url = `${restBaseUrl}/patientidentifiertype/${identifierConfig.identifierTypeUuid}`; + await openmrsFetch(url).then((response) => { + if (response.status == 200 && response.data) { + identifierTypeName = response.data.name; + } + }); + + identifiers[identifierTypeName] = { + identifierUuid: null, + preferred: false, // consider identifier.use === 'official' ?? by default autogen is preferred + initialValue: identifier.value, + identifierValue: identifier.value, + identifierTypeUuid: identifierConfig.identifierTypeUuid, + identifierName: identifierTypeName, + required: false, + selectedSource: null, + autoGeneration: false, + }; + } + } + } + + return identifiers; +} + +export function getPhonePersonAttributeValueFromFhirPatient(patient: fhir.Patient, phoneUuid) { const result = {}; if (patient.telecom) { - result['phone'] = patient.telecom[0].value; + result[phoneUuid] = patient.telecom[0].value; } return result; } diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx index 55b250bda..d2926852e 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.component.tsx @@ -39,10 +39,14 @@ export const PatientRegistration: React.FC = ({ savePa const config = useConfig() as RegistrationConfig; const [target, setTarget] = useState(); const { patientUuid: uuidOfPatientToEdit } = useParams(); + const sourcePatientId = new URLSearchParams(search).get('sourceRecord'); const { isLoading: isLoadingPatientToEdit, patient: patientToEdit } = usePatient(uuidOfPatientToEdit); const { t } = useTranslation(); const [capturePhotoProps, setCapturePhotoProps] = useState(null); - const [initialFormValues, setInitialFormValues] = useInitialFormValues(uuidOfPatientToEdit); + const [initialFormValues, setInitialFormValues] = useInitialFormValues( + uuidOfPatientToEdit || sourcePatientId, + !!uuidOfPatientToEdit, + ); const [initialAddressFieldValues] = useInitialAddressFieldValues(uuidOfPatientToEdit); const [patientUuidMap] = usePatientUuidMap(uuidOfPatientToEdit); const location = currentSession?.sessionLocation?.uuid; diff --git a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx index f2c93fd80..532f2bebf 100644 --- a/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx +++ b/packages/esm-patient-registration-app/src/patient-registration/patient-registration.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; import dayjs from 'dayjs'; import userEvent from '@testing-library/user-event'; -import { BrowserRouter as Router, useParams } from 'react-router-dom'; -import { render, screen, within } from '@testing-library/react'; +import { BrowserRouter as Router, useParams, useLocation } from 'react-router-dom'; +import { act, render, screen, within } from '@testing-library/react'; import { type FetchResponse, getDefaultsFromConfigSchema, @@ -10,15 +10,17 @@ import { showSnackbar, useConfig, usePatient, + openmrsFetch, } from '@openmrs/esm-framework'; import { mockedAddressTemplate } from '__mocks__'; -import { mockPatient } from 'tools'; +import { mockPatient, mockOpenMRSIdentificationNumberIdType } from 'tools'; import { saveEncounter, savePatient } from './patient-registration.resource'; import { esmPatientRegistrationSchema, type RegistrationConfig } from '../config-schema'; import type { AddressTemplate, Encounter } from './patient-registration.types'; import { ResourcesContext } from '../offline.resources'; import { FormManager } from './form-manager'; import { PatientRegistration } from './patient-registration.component'; +import { useMpiPatient } from './mpi/mpi-patient.resource'; const mockSaveEncounter = jest.mocked(saveEncounter); const mockSavePatient = savePatient as jest.Mock; @@ -26,6 +28,11 @@ const mockShowSnackbar = jest.mocked(showSnackbar); const mockUseConfig = jest.mocked(useConfig); const mockUsePatient = jest.mocked(usePatient); const mockOpenmrsDatePicker = jest.mocked(OpenmrsDatePicker); +const mockUseMpiPatient = useMpiPatient as jest.Mock; + +jest.mock('./mpi/mpi-patient.resource', () => ({ + useMpiPatient: jest.fn(), +})); jest.mock('./field/field.resource', () => ({ useConcept: jest.fn().mockImplementation((uuid: string) => { @@ -86,7 +93,7 @@ jest.mock('./field/field.resource', () => ({ jest.mock('react-router-dom', () => ({ ...(jest.requireActual('react-router-dom') as any), - useLocation: () => ({ + useLocation: jest.fn().mockReturnValue({ pathname: 'openmrs/spa/patient-registration', }), useHistory: () => [], @@ -129,7 +136,7 @@ const mockResourcesContextValue = { let mockOpenmrsConfig: RegistrationConfig = { sections: ['demographics', 'contact'], sectionDefinitions: [ - { id: 'demographics', name: 'Demographics', fields: ['name', 'gender', 'dob'] }, + { id: 'demographics', name: 'Demographics', fields: ['name', 'gender', 'dob', 'id'] }, { id: 'contact', name: 'Contact Info', fields: ['address'] }, { id: 'relationships', name: 'Relationships', fields: ['relationship'] }, ], @@ -160,6 +167,9 @@ let mockOpenmrsConfig: RegistrationConfig = { label: 'Male', }, ], + identifier: [ + { identifierTypeSystem: 'MPI OpenMRS ID', identifierTypeUuid: '8d793bee-c2cc-11de-8d13-0010c6dffd0f' }, + ], address: { useAddressHierarchy: { enabled: true, @@ -174,7 +184,7 @@ let mockOpenmrsConfig: RegistrationConfig = { links: { submitButton: '#', }, - defaultPatientIdentifierTypes: [], + defaultPatientIdentifierTypes: ['8d793bee-c2cc-11de-8d13-0010c6dffd0f'], registrationObs: { encounterTypeUuid: null, encounterProviderRoleUuid: 'asdf', @@ -256,6 +266,24 @@ describe('Registering a new patient', () => { ...mockOpenmrsConfig, }); mockSavePatient.mockReturnValue({ data: { uuid: 'new-pt-uuid' }, ok: true }); + mockUseMpiPatient.mockReturnValue({ + isLoading: false, + patient: { data: null }, + error: undefined, + }); + mockUsePatient.mockReturnValue({ + isLoading: false, + patient: null, + patientUuid: null, + error: null, + }); + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'openmrs/spa/patient-registration', + state: undefined, + key: '', + search: '', + hash: '', + }); }); it('renders without crashing', () => { @@ -397,7 +425,11 @@ describe('Updating an existing patient record', () => { const mockUseParams = useParams as jest.Mock; mockUseParams.mockReturnValue({ patientUuid: mockPatient.id }); - + mockUseMpiPatient.mockReturnValue({ + isLoading: false, + patient: { data: null }, + error: undefined, + }); mockUsePatient.mockReturnValue({ isLoading: false, patient: mockPatient, @@ -438,6 +470,9 @@ describe('Updating an existing patient record', () => { '1': { openMrsId: '100GEJ', }, + '2': { + mpiOpenMrsId: '100GEG', + }, addNameInLocalLanguage: undefined, additionalFamilyName: '', additionalGivenName: '', @@ -475,3 +510,131 @@ describe('Updating an existing patient record', () => { ); }); }); + +describe('Import an MPI patient record', () => { + beforeEach(() => { + mockUseConfig.mockReturnValue(mockOpenmrsConfig); + mockSavePatient.mockReturnValue({ data: { uuid: 'new-pt-uuid' }, ok: true }); + (useParams as jest.Mock).mockReturnValue({ patientUuid: undefined }), + (useLocation as jest.Mock).mockReturnValue({ + pathname: 'openmrs/spa/patient-registration', + state: undefined, + key: '', + search: '?sourceRecord=55', + hash: '', + }); + }); + + it('fills patient demographics from MPI patient', async () => { + const user = userEvent.setup(); + mockSavePatient.mockResolvedValue({} as FetchResponse); + + mockUsePatient.mockReturnValue({ + isLoading: false, + patient: null, + patientUuid: null, + error: null, + }); + + mockUseMpiPatient.mockReturnValue({ + isLoading: false, + patient: { data: mockPatient }, + error: undefined, + }); + + const mockOpenmrsFetch = openmrsFetch as jest.Mock; + const mockResponse = { status: 200, data: mockOpenMRSIdentificationNumberIdType }; + mockOpenmrsFetch.mockResolvedValue(mockResponse); + + // eslint-disable-next-line testing-library/no-unnecessary-act + await act(async () => { + render(, { wrapper: Wrapper }); + }); + expect(mockOpenmrsFetch.mock.calls[0][0]).toEqual( + `/ws/rest/v1/patientidentifiertype/8d793bee-c2cc-11de-8d13-0010c6dffd0f`, + ); + + const givenNameInput: HTMLInputElement = screen.getByLabelText(/First Name/); + const familyNameInput: HTMLInputElement = screen.getByLabelText(/Family Name/); + const middleNameInput: HTMLInputElement = screen.getByLabelText(/Middle Name/); + const dateOfBirthInput: HTMLInputElement = screen.getByLabelText(/Date of Birth/i); + const genderInput: HTMLInputElement = screen.getByLabelText(/Male/); + + // assert initial values + expect(givenNameInput.value).toBe('John'); + expect(familyNameInput.value).toBe('Wilson'); + expect(middleNameInput.value).toBeFalsy(); + expect(dateOfBirthInput.value).toBe('04/04/1972'); + expect(genderInput.value).toBe('male'); + + // do some edits + await user.clear(givenNameInput); + await user.clear(middleNameInput); + await user.clear(familyNameInput); + await user.type(givenNameInput, 'Eric'); + await user.type(middleNameInput, 'Johnson'); + await user.type(familyNameInput, 'Smith'); + await user.click(screen.getByText(/Register patient/i)); + + expect(mockSavePatient).toHaveBeenCalledWith( + true, + { + '0': { + oldIdentificationNumber: '100732HE', + }, + '1': { + openMrsId: '100GEJ', + }, + '2': { + mpiOpenMrsId: '100GEG', + }, + addNameInLocalLanguage: undefined, + additionalFamilyName: '', + additionalGivenName: '', + additionalMiddleName: '', + address: {}, + attributes: {}, + birthdate: new Date('1972-04-04T00:00:00.000Z'), + birthdateEstimated: false, + deathCause: '', + nonCodedCauseOfDeath: '', + deathDate: undefined, + deathTime: undefined, + deathTimeFormat: 'AM', + familyName: 'Smith', + gender: expect.stringMatching(/male/i), + givenName: 'Eric', + identifiers: { + 'OpenMRS Identification Number': { + identifierUuid: null, + preferred: false, + initialValue: '100GEG', + identifierValue: '100GEG', + identifierTypeUuid: '8d793bee-c2cc-11de-8d13-0010c6dffd0f', + identifierName: 'OpenMRS Identification Number', + required: false, + selectedSource: null, + autoGeneration: false, + }, + }, + isDead: false, + middleName: 'Johnson', + monthsEstimated: 0, + patientUuid: '8673ee4f-e2ab-4077-ba55-4980f408773e', + relationships: [], + telephoneNumber: '', + unidentifiedPatient: undefined, + yearsEstimated: 0, + }, + expect.anything(), + expect.anything(), + null, + undefined, + expect.anything(), + expect.anything(), + expect.anything(), + { patientSaved: false }, + expect.anything(), + ); + }); +}); diff --git a/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.component.tsx b/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.component.tsx index 96093f1f9..1e66b063a 100644 --- a/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.component.tsx +++ b/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.component.tsx @@ -8,6 +8,8 @@ import PatientSearch from './patient-search.component'; import PatientSearchBar from '../patient-search-bar/patient-search-bar.component'; import RecentlySearchedPatients from './recently-searched-patients.component'; import styles from './compact-patient-search.scss'; +import { useSearchParams } from 'react-router-dom'; +import { inferModeFromSearchParams } from '../mpi/utils'; interface CompactPatientSearchProps { isSearchPage: boolean; @@ -29,7 +31,12 @@ const CompactPatientSearchComponent: React.FC = ({ const searchInputRef = useRef(null); const config = useConfig(); const { showRecentlySearchedPatients } = config.search; - const patientSearchResponse = useInfinitePatientSearch(debouncedSearchTerm, config.includeDead); + const [searchParams] = useSearchParams(); + const patientSearchResponse = useInfinitePatientSearch( + debouncedSearchTerm, + inferModeFromSearchParams(searchParams), + config.includeDead, + ); const { data: searchedPatients } = patientSearchResponse; const { recentlyViewedPatients, addViewedPatient, mutateUserProperties } = useRecentlyViewedPatients(showRecentlySearchedPatients); diff --git a/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.test.tsx b/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.test.tsx index f6aa25949..a0e7cf029 100644 --- a/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.test.tsx +++ b/packages/esm-patient-search-app/src/compact-patient-search/compact-patient-search.test.tsx @@ -5,6 +5,7 @@ import { getDefaultsFromConfigSchema, navigate, useConfig, useSession } from '@o import { mockSession } from '__mocks__'; import { configSchema, type PatientSearchConfig } from '../config-schema'; import CompactPatientSearchComponent from './compact-patient-search.component'; +import { BrowserRouter, Route, Routes } from 'react-router-dom'; const mockUseConfig = jest.mocked(useConfig); const mockUseSession = jest.mocked(useSession); @@ -17,13 +18,25 @@ describe('CompactPatientSearchComponent', () => { }); it('renders a compact search bar', () => { - render(); + render( + + + } /> + + , + ); expect(screen.getByPlaceholderText(/Search for a patient by name or identifier number/i)).toBeInTheDocument(); }); it('renders search results when search term is not empty', async () => { const user = userEvent.setup(); - render(); + render( + + + } /> + + , + ); const searchbox = screen.getByPlaceholderText(/Search for a patient by name or identifier number/i); await user.type(searchbox, 'John'); const searchResultsContainer = screen.getByTestId('floatingSearchResultsContainer'); @@ -39,7 +52,13 @@ describe('CompactPatientSearchComponent', () => { patientResultUrl: configSchema.search.patientResultUrl._default, }, }); - render(); + render( + + + } /> + + , + ); const searchResultsContainer = screen.getByTestId('floatingSearchResultsContainer'); expect(searchResultsContainer).toBeInTheDocument(); }); @@ -47,7 +66,20 @@ describe('CompactPatientSearchComponent', () => { it('navigates to the advanced search page with the correct query string when the Search button is clicked', async () => { const user = userEvent.setup(); render( - , + + + + } + /> + + , ); const searchbox = screen.getByRole('searchbox'); await user.type(searchbox, 'John'); diff --git a/packages/esm-patient-search-app/src/index.ts b/packages/esm-patient-search-app/src/index.ts index efeb91047..2b6733698 100644 --- a/packages/esm-patient-search-app/src/index.ts +++ b/packages/esm-patient-search-app/src/index.ts @@ -5,6 +5,7 @@ import { getSyncLifecycle, makeUrl, messageOmrsServiceWorker, + registerFeatureFlag, setupDynamicOfflineDataHandler, } from '@openmrs/esm-framework'; import { configSchema } from './config-schema'; @@ -35,6 +36,8 @@ export const patientSearchBar = getSyncLifecycle(patientSearchBarComponent, opti export function startupApp() { defineConfigSchema(moduleName, configSchema); + registerFeatureFlag('mpiFlag', 'MPI Service', 'Enables the Master Patient Index workflows'); + setupDynamicOfflineDataHandler({ id: 'esm-patient-search-app:patient', type: 'patient', diff --git a/packages/esm-patient-search-app/src/mpi/utils.ts b/packages/esm-patient-search-app/src/mpi/utils.ts new file mode 100644 index 000000000..446caae67 --- /dev/null +++ b/packages/esm-patient-search-app/src/mpi/utils.ts @@ -0,0 +1,49 @@ +import { capitalize } from 'lodash-es'; +import { type SearchedPatient } from '../types'; +import { getCoreTranslation } from '@openmrs/esm-framework'; +export function inferModeFromSearchParams(searchParams: URLSearchParams) { + return searchParams.get('mode')?.toLowerCase() === 'external' ? 'external' : 'internal'; +} + +export function mapToOpenMRSPatient(fhirPatients: Array): Array { + if (fhirPatients[0].total < 1) { + return []; + } + //Consider patient // https://github.com/openmrs/openmrs-esm-core/blob/main/packages/framework/esm-api/src/types/patient-resource.ts + const pts: Array = []; + + fhirPatients[0].entry.forEach((pt, index) => { + let fhirPatient = pt.resource; + pts.push({ + externalId: fhirPatient.id, + uuid: null, + identifiers: null, + person: { + addresses: fhirPatient?.address?.map((address) => ({ + cityVillage: address.city, + stateProvince: address.state, + country: address.country, + postalCode: address.postalCode, + preferred: false, + address1: '', + })), + age: null, + birthdate: fhirPatient.birthDate, + gender: getCoreTranslation(fhirPatient.gender), + dead: fhirPatient.deceasedBoolean, + deathDate: fhirPatient.deceasedDateTime, + personName: { + display: fhirPatient.name[0].text + ? fhirPatient.name[0].text + : `${fhirPatient.name[0].family} ${fhirPatient.name[0].given[0]}`, + givenName: fhirPatient.name[0].given[0], + familyName: fhirPatient.name[0].family, + middleName: fhirPatient.name[0].given[1], + }, + }, + attributes: [], + }); + }); + + return pts; +} diff --git a/packages/esm-patient-search-app/src/patient-search-button/patient-search-button.test.tsx b/packages/esm-patient-search-app/src/patient-search-button/patient-search-button.test.tsx index a7af49c4a..5b9132b8f 100644 --- a/packages/esm-patient-search-app/src/patient-search-button/patient-search-button.test.tsx +++ b/packages/esm-patient-search-app/src/patient-search-button/patient-search-button.test.tsx @@ -4,6 +4,7 @@ import { render, screen } from '@testing-library/react'; import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework'; import { type PatientSearchConfig, configSchema } from '../config-schema'; import PatientSearchButton from './patient-search-button.component'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; const mockUseConfig = jest.mocked(useConfig); @@ -37,8 +38,13 @@ describe('PatientSearchButton', () => { it('displays overlay when button is clicked', async () => { const user = userEvent.setup(); - render(); - + render( + + + } /> + + , + ); const searchButton = screen.getByLabelText('Search Patient Button'); await user.click(searchButton); diff --git a/packages/esm-patient-search-app/src/patient-search-overlay/patient-search-overlay.component.tsx b/packages/esm-patient-search-app/src/patient-search-overlay/patient-search-overlay.component.tsx index 6d8a92727..637133463 100644 --- a/packages/esm-patient-search-app/src/patient-search-overlay/patient-search-overlay.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-overlay/patient-search-overlay.component.tsx @@ -1,10 +1,12 @@ import React, { useCallback, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useConfig, useDebounce } from '@openmrs/esm-framework'; +import { useSearchParams } from 'react-router-dom'; import AdvancedPatientSearchComponent from '../patient-search-page/advanced-patient-search.component'; import Overlay from '../ui-components/overlay'; import PatientSearchBar from '../patient-search-bar/patient-search-bar.component'; import { type PatientSearchConfig } from '../config-schema'; +import { inferModeFromSearchParams } from '../mpi/utils'; interface PatientSearchOverlayProps { onClose: () => void; @@ -26,7 +28,7 @@ const PatientSearchOverlay: React.FC = ({ const [searchTerm, setSearchTerm] = useState(query); const showSearchResults = Boolean(searchTerm?.trim()); const debouncedSearchTerm = useDebounce(searchTerm); - + const [searchParams] = useSearchParams(); const handleClearSearchTerm = useCallback(() => setSearchTerm(''), [setSearchTerm]); const onSearchTermChange = useCallback((value: string) => { @@ -42,7 +44,13 @@ const PatientSearchOverlay: React.FC = ({ onClear={handleClearSearchTerm} onSubmit={onSearchTermChange} /> - {showSearchResults && } + {showSearchResults && ( + + )} ); }; diff --git a/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.component.tsx b/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.component.tsx index 2c45f992e..9f3eb5a4c 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.component.tsx @@ -11,12 +11,14 @@ interface AdvancedPatientSearchProps { query: string; inTabletOrOverlay?: boolean; stickyPagination?: boolean; + searchMode: string; } const AdvancedPatientSearchComponent: React.FC = ({ query, stickyPagination, inTabletOrOverlay, + searchMode, }) => { const [filters, setFilters] = useState(initialState); const filtersApplied = useMemo(() => { @@ -36,7 +38,7 @@ const AdvancedPatientSearchComponent: React.FC = ({ hasMore, isLoading, fetchError, - } = useInfinitePatientSearch(query, false, !!query, 50); + } = useInfinitePatientSearch(query, searchMode, false, !!query, 50); useEffect(() => { if (searchResults?.length === currentPage * 50 && hasMore) { diff --git a/packages/esm-patient-search-app/src/patient-search-page/patient-banner/banner/patient-banner.component.tsx b/packages/esm-patient-search-app/src/patient-search-page/patient-banner/banner/patient-banner.component.tsx index 22dbce44b..82af3ad85 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/patient-banner/banner/patient-banner.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-page/patient-banner/banner/patient-banner.component.tsx @@ -15,6 +15,8 @@ import { useConfig, usePatient, useVisit, + navigate, + UserFollowIcon, } from '@openmrs/esm-framework'; import { type SearchedPatient } from '../../../types'; import { PatientSearchContext } from '../../../patient-search-context'; @@ -24,14 +26,17 @@ interface ClickablePatientContainerProps { patientUuid: string; children: React.ReactNode; } +import { Tag } from '@carbon/react'; +import { Button } from '@carbon/react'; interface PatientBannerProps { patient: SearchedPatient; patientUuid: string; hideActionsOverflow?: boolean; + isMPIPatient: boolean; } -const PatientBanner: React.FC = ({ patient, patientUuid, hideActionsOverflow }) => { +const PatientBanner: React.FC = ({ patient, patientUuid, hideActionsOverflow, isMPIPatient }) => { const { t } = useTranslation(); const { currentVisit } = useVisit(patientUuid); const { patient: fhirPatient, isLoading } = usePatient(patientUuid); @@ -79,6 +84,13 @@ const PatientBanner: React.FC = ({ patient, patientUuid, hid
{patientName} + {isMPIPatient && ( +
+ + 🌐 {'MPI'} + +
+ )} = ({ patient, patientUuid, hid patientUuid={patientUuid} /> ) : null} - {!isDeceased && !currentVisit && ( + {isMPIPatient && ( +
+ +
+ )} + {!isDeceased && !currentVisit && !isMPIPatient && ( = ({ const { t } = useTranslation(); const resultsToShow = inTabletOrOverlay ? 15 : 20; const totalResults = searchResults.length; + const [searchParams] = useSearchParams(); + const searchMode = inferModeFromSearchParams(searchParams); const { results, goTo, totalPages, currentPage, showNextButton, paginated } = usePagination( searchResults, @@ -45,23 +48,25 @@ const PatientSearchComponent: React.FC = ({ const searchResultsView = useMemo(() => { if (!query) { - return ; + return ; } if (isLoading) { - return ; + return ; } if (fetchError) { - return ; + return ; } if (results?.length === 0) { - return ; + return ( + + ); } - return ; - }, [query, isLoading, inTabletOrOverlay, results, fetchError]); + return ; + }, [query, isLoading, inTabletOrOverlay, results, fetchError, searchMode]); return (
= () query={searchParams?.get('query') ?? ''} inTabletOrOverlay={!isDesktop(layout)} stickyPagination + searchMode={inferModeFromSearchParams(searchParams)} />
diff --git a/packages/esm-patient-search-app/src/patient-search-page/patient-search-views.component.tsx b/packages/esm-patient-search-app/src/patient-search-page/patient-search-views.component.tsx index 118cec871..3bd1199bd 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/patient-search-views.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-page/patient-search-views.component.tsx @@ -6,13 +6,19 @@ import EmptyDataIllustration from '../ui-components/empty-data-illustration.comp import PatientBanner, { PatientBannerSkeleton } from './patient-banner/banner/patient-banner.component'; import { type SearchedPatient } from '../types'; import styles from './patient-search-lg.scss'; +import { Button } from '@carbon/react'; +import { navigate, useFeatureFlag } from '@openmrs/esm-framework'; interface CommonProps { inTabletOrOverlay: boolean; + searchMode: string; + searchTerm: string; } interface PatientSearchResultsProps { searchResults: SearchedPatient[]; + searchTerm: string; + searchMode: string; } export const EmptyState: React.FC = ({ inTabletOrOverlay }) => { @@ -73,8 +79,9 @@ export const ErrorState: React.FC = ({ inTabletOrOverlay }) => { ); }; -export const SearchResultsEmptyState: React.FC = ({ inTabletOrOverlay }) => { +export const SearchResultsEmptyState: React.FC = ({ inTabletOrOverlay, searchMode, searchTerm }) => { const { t } = useTranslation(); + const isMPIEnabled = useFeatureFlag('mpiFlag'); return ( = ({ inTabletOrOverl

{t('noPatientChartsFoundMessage', 'Sorry, no patient charts were found')}

-

- {t('trySearchWithPatientUniqueID', "Try to search again using the patient's unique ID number")} -

+ {isMPIEnabled ? ( + <> +
+
+
+ {searchMode == 'internal' && ( + <> +
+

+ {t( + 'trySearchFromClientRegistry', + "Try searching using the patient's unique ID number or search the external registry", + )} +

+
+ + + )} + + ) : ( +

+ {t('trySearchWithPatientUniqueID', "Try to search again using the patient's unique ID number")} +

+ )}
); }; -export const PatientSearchResults: React.FC = ({ searchResults }) => { +export const PatientSearchResults: React.FC = ({ searchResults, searchMode }) => { + const { t } = useTranslation(); return (
{searchResults.map((patient, indx) => ( - + ))}
); }; + +function doMPISearch(searchTerm: string) { + navigate({ + to: '${openmrsSpaBase}/search?query=${searchTerm}&mode=external', + templateParams: { searchTerm: searchTerm }, + }); +} diff --git a/packages/esm-patient-search-app/src/patient-search.resource.tsx b/packages/esm-patient-search-app/src/patient-search.resource.tsx index 541b3155c..891c5a374 100644 --- a/packages/esm-patient-search-app/src/patient-search.resource.tsx +++ b/packages/esm-patient-search-app/src/patient-search.resource.tsx @@ -2,8 +2,16 @@ import { useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import useSWR from 'swr'; import useSWRInfinite from 'swr/infinite'; -import { openmrsFetch, showNotification, useSession, type FetchResponse, restBaseUrl } from '@openmrs/esm-framework'; +import { + openmrsFetch, + showNotification, + useSession, + type FetchResponse, + restBaseUrl, + fhirBaseUrl, +} from '@openmrs/esm-framework'; import type { PatientSearchResponse, SearchedPatient, User } from './types'; +import { mapToOpenMRSPatient } from './mpi/utils'; const v = 'custom:(patientId,uuid,identifiers,display,' + @@ -13,6 +21,7 @@ const v = export function useInfinitePatientSearch( searchTerm: string, + searchMode: string, includeDead: boolean, searching: boolean = true, resultsToFetch: number = 10, @@ -35,14 +44,32 @@ export function useInfinitePatientSearch( [searchTerm, customRepresentation, includeDead, resultsToFetch], ); + const getExtUrl = useCallback( + ( + page, + prevPageData: FetchResponse<{ results: Array; links: Array<{ rel: 'prev' | 'next' }> }>, + ) => { + if (prevPageData && !prevPageData?.data?.links.some((link) => link.rel === 'next')) { + return null; + } + let url = `${fhirBaseUrl}/Patient/$cr-search?name=${searchTerm}`; + + return url; + }, + [searchTerm, customRepresentation, includeDead, resultsToFetch], + ); + const { data, isLoading, isValidating, setSize, error, size } = useSWRInfinite< FetchResponse<{ results: Array; links: Array<{ rel: 'prev' | 'next' }>; totalCount: number }>, Error - >(searching ? getUrl : null, openmrsFetch); - + >(searching ? (searchMode == 'external' ? getExtUrl : getUrl) : null, openmrsFetch); const results = useMemo( () => ({ - data: data ? [].concat(...data?.map((resp) => resp?.data?.results)) : null, + data: data + ? searchMode == 'internal' + ? [].concat(...data?.map((resp) => resp?.data?.results)) + : [mapToOpenMRSPatient(data?.map((resp) => resp?.data))][0] + : null, isLoading: isLoading, fetchError: error, hasMore: data?.length ? !!data[data.length - 1].data?.links?.some((link) => link.rel === 'next') : false, diff --git a/packages/esm-patient-search-app/src/types/index.ts b/packages/esm-patient-search-app/src/types/index.ts index fe96e211f..63610442a 100644 --- a/packages/esm-patient-search-app/src/types/index.ts +++ b/packages/esm-patient-search-app/src/types/index.ts @@ -1,6 +1,7 @@ import { type FetchResponse, type OpenmrsResource } from '@openmrs/esm-framework'; export interface SearchedPatient { + externalId?: string; uuid: string; identifiers: Array; person: { diff --git a/packages/esm-patient-search-app/translations/en.json b/packages/esm-patient-search-app/translations/en.json index dadd39861..c47bdf19c 100644 --- a/packages/esm-patient-search-app/translations/en.json +++ b/packages/esm-patient-search-app/translations/en.json @@ -6,6 +6,7 @@ "clearSearch": "Clear", "closeSearch": "Close Search Panel", "countOfFiltersApplied": "filters applied", + "createPatientRecord": "Create Patient Record", "dayOfBirth": "Day of Birth", "error": "Error", "errorCopy": "Sorry, there was a an error. You can try to reload this page, or contact the site administrator and quote the error code above.", @@ -33,6 +34,7 @@ "searchResultsCount_one": "{{count}} search result", "searchResultsCount_other": "{{count}} search results", "sex": "Sex", + "trySearchFromClientRegistry": "Try searching using the patient's unique ID number or search the external registry", "trySearchWithPatientUniqueID": "Try to search again using the patient's unique ID number", "unknown": "Unknown", "yearOfBirth": "Year of Birth" diff --git a/tools/index.ts b/tools/index.ts index e3600e40a..76f531745 100644 --- a/tools/index.ts +++ b/tools/index.ts @@ -1,6 +1,7 @@ export { getByTextWithMarkup, mockPatient, + mockOpenMRSIdentificationNumberIdType, mockPatientWithLongName, mockPatientWithoutFormattedName, patientChartBasePath, diff --git a/tools/test-utils.tsx b/tools/test-utils.tsx index cf0d50e4f..b72733ac8 100644 --- a/tools/test-utils.tsx +++ b/tools/test-utils.tsx @@ -28,6 +28,7 @@ function getByTextWithMarkup(text: RegExp | string) { try { return screen.getByText((content, node) => { const hasText = (node: Element) => node.textContent === text || node.textContent.match(text); + // eslint-disable-next-line testing-library/no-node-access const childrenDontHaveText = Array.from(node.children).every((child) => !hasText(child as HTMLElement)); return hasText(node) && childrenDontHaveText; }); @@ -36,6 +37,33 @@ function getByTextWithMarkup(text: RegExp | string) { } } +const mockOpenMRSIdentificationNumberIdType = { + uuid: '8d793bee-c2cc-11de-8d13-0010c6dffd0f', + display: 'OpenMRS Identification Number', + name: 'OpenMRS Identification Number', + description: 'Unique number used in OpenMRS', + format: '', + formatDescription: null, + required: false, + validator: 'org.openmrs.patient.impl.LuhnIdentifierValidator', + locationBehavior: null, + uniquenessBehavior: null, + retired: false, + links: [ + { + rel: 'self', + uri: 'http://localhost/openmrs/ws/rest/v1/patientidentifiertype/8d793bee-c2cc-11de-8d13-0010c6dffd0f', + resourceAlias: 'patientidentifiertype', + }, + { + rel: 'full', + uri: 'http://localhost/openmrs/ws/rest/v1/patientidentifiertype/8d793bee-c2cc-11de-8d13-0010c6dffd0f?v=full', + resourceAlias: 'patientidentifiertype', + }, + ], + resourceVersion: '2.0', +}; + const mockPatient = { resourceType: 'Patient', id: '8673ee4f-e2ab-4077-ba55-4980f408773e', @@ -62,6 +90,12 @@ const mockPatient = { system: 'OpenMRS ID', value: '100GEJ', }, + { + id: '2f0ad7a1-430f-4397-b571-59ea654a52db', + use: 'official', + system: 'MPI OpenMRS ID', + value: '100GEG', + }, ], active: true, name: [ @@ -111,6 +145,7 @@ export { waitForLoadingToFinish, getByTextWithMarkup, mockPatient, + mockOpenMRSIdentificationNumberIdType, mockPatientWithLongName, mockPatientWithoutFormattedName, patientChartBasePath,