diff --git a/__mocks__/search.mock.ts b/__mocks__/search.mock.ts index 783ca54f7..fdaf71236 100644 --- a/__mocks__/search.mock.ts +++ b/__mocks__/search.mock.ts @@ -33,3 +33,116 @@ export const mockSearchResults = { ], }, }; + +export const mockAdvancedSearchResults = [ + { + patientId: 14, + uuid: 'e46dfea6-f32d-4b61-bc7d-e79fd35332a4', + identifiers: [ + { + identifier: '100008E', + identifierType: { + uuid: '05a29f94-c0ed-11e2-94be-8c13b969e334', + display: 'OpenMRS ID', + }, + }, + ], + display: '100008E - Joshua Johnson', + patientIdentifier: { + uuid: '1e6c2da6-f63f-4ea5-a595-ded69df9f882', + identifier: '100008E', + }, + person: { + gender: 'M', + age: 5, + birthdate: '2019-09-25T00:00:00.000+0000', + birthdateEstimated: false, + personName: { + display: 'Joshua Johnson', + givenName: 'Joshua', + familyName: 'Johnson', + }, + addresses: [ + { + address1: 'Address16442', + cityVillage: 'City6442', + stateProvince: 'State6442', + country: 'Country6442', + postalCode: '20839', + preferred: true, + }, + ], + dead: false, + deathDate: null, + }, + attributes: [ + { + value: '0785434125', + attributeType: { + uuid: '14d4f066-15f5-102d-96e4-000c29c2a5d7', + display: 'Telephone Number', + }, + }, + ], + }, + { + patientId: 42, + uuid: 'a83747aa-3041-489a-a112-1c024582c83d', + identifiers: [ + { + identifier: '100016H', + identifierType: { + uuid: '05a29f94-c0ed-11e2-94be-8c13b969e334', + display: 'OpenMRS ID', + }, + }, + ], + display: '100016H - Joseph Davis', + patientIdentifier: { + uuid: '0ac0a9a0-b040-4c0a-9c35-c4e0bb52a570', + identifier: '100016H', + }, + person: { + gender: 'M', + age: 30, + birthdate: '1994-10-13T00:00:00.000+0000', + birthdateEstimated: false, + personName: { + display: 'Joseph Davis', + givenName: 'Joseph', + familyName: 'Davis', + }, + addresses: [ + { + address1: 'Address19050', + cityVillage: 'City9050', + stateProvince: 'State9050', + country: 'Country9050', + postalCode: '46548', + preferred: true, + }, + ], + dead: false, + deathDate: null, + }, + attributes: [ + { + value: { + uuid: '1ce1b7d4-c865-4178-82b0-5932e51503d6', + display: 'Community Outreach', + links: [ + { + rel: 'self', + uri: 'http://dev3.openmrs.org/openmrs/ws/rest/v1/location/1ce1b7d4-c865-4178-82b0-5932e51503d6', + resourceAlias: 'location', + }, + ], + }, + attributeType: { + uuid: '8d87236c-c2cc-11de-8d13-0010c6dffd0f', + display: 'Health Center', + }, + }, + ], + }, +]; 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 58246e687..3a0bbc645 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 @@ -94,7 +94,7 @@ const AdvancedPatientSearchComponent: React.FC = ({ // Age filter if (filters.age) { - if (patient.person.age !== filters.age) { + if (Number(patient.person.age) !== Number(filters.age)) { return false; } } diff --git a/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.test.tsx b/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.test.tsx new file mode 100644 index 000000000..0a452fa4d --- /dev/null +++ b/packages/esm-patient-search-app/src/patient-search-page/advanced-patient-search.test.tsx @@ -0,0 +1,227 @@ +import React from 'react'; +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useInfinitePatientSearch } from '../patient-search.resource'; +import AdvancedPatientSearchComponent from './advanced-patient-search.component'; +import { getDefaultsFromConfigSchema, useConfig } from '@openmrs/esm-framework'; +import { configSchema, type PatientSearchConfig } from '../config-schema'; +import { type PatientSearchResponse } from '../types'; +import { mockAdvancedSearchResults } from '__mocks__'; +import { PatientSearchContext } from '../patient-search-context'; +import { usePersonAttributeType } from './refine-search/person-attributes.resource'; + +const mockUseConfig = jest.mocked(useConfig); +const mockUseInfinitePatientSearch = jest.mocked(useInfinitePatientSearch); +const mockUsePersonAttributeType = jest.mocked(usePersonAttributeType); + +jest.mock('../patient-search.resource', () => ({ + useInfinitePatientSearch: jest.fn(), +})); + +jest.mock('./refine-search/person-attributes.resource', () => ({ + usePersonAttributeType: jest.fn(), +})); + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useParams: jest.fn(() => ({ + page: 1, + })), + useLocation: jest.fn(), + useSearchParams: jest.fn(() => [ + { + get: jest.fn(() => 'Jos'), + }, + ]), +})); + +const mockPatientActionContextValue = { + nonNavigationSelectPatientAction: jest.fn(), + selectPatientAction: jest.fn(), +}; + +const mockSearchResults: PatientSearchResponse = { + isValidating: false, + totalResults: 2, + data: mockAdvancedSearchResults as unknown as PatientSearchResponse['data'], + currentPage: 1, + setPage: jest.fn(), + hasMore: false, + isLoading: false, + fetchError: null, +}; + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +describe('AdvancedPatientSearchComponent', () => { + const user = userEvent.setup(); + + beforeEach(() => { + mockUseInfinitePatientSearch.mockReturnValue(mockSearchResults); + mockUseConfig.mockReturnValue({ + ...getDefaultsFromConfigSchema(configSchema), + search: { + disableTabletSearchOnKeyUp: false, + showRecentlySearchedPatients: false, + searchFields: { + fields: { + gender: { + enabled: true, + label: 'Sex', + }, + dateOfBirth: { + enabled: true, + label: 'Date of Birth', + }, + age: { + enabled: true, + label: 'Age', + min: 0, + }, + postcode: { + enabled: true, + label: 'Postcode', + }, + }, + personAttributes: [ + { + label: 'Phone Number', + attributeTypeUuid: '14d4f066-15f5-102d-96e4-000c29c2a5d7', + }, + ], + }, + } as PatientSearchConfig['search'], + }); + mockUsePersonAttributeType.mockReturnValue({ + isLoading: false, + error: null, + data: { + format: 'java.lang.String', + uuid: '14d4f066-15f5-102d-96e4-000c29c2a5d7', + display: 'Telephone Number', + }, + }); + }); + + const renderComponent = (props = {}) => { + return render( + + + , + ); + }; + + it('renders without crashing', () => { + renderComponent(); + expect(screen.getByText('Refine search')).toBeInTheDocument(); + }); + + it('displays search results correctly', () => { + renderComponent(); + expect(screen.getByText(/2 search result/)).toBeInTheDocument(); + }); + + describe('Filtering', () => { + it('filters by gender correctly', async () => { + renderComponent(); + + await user.click(screen.getByRole('tab', { name: /female/i })); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // expect to have 0 search result + expect(screen.getByText(/0 search result/)).toBeInTheDocument(); + }); + + it('filters by age correctly', async () => { + renderComponent(); + + // Set age filter + const ageInput = screen.getByRole('spinbutton', { name: /age/i }); + await user.type(ageInput, '30'); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // expect one patient Joseph Davis + const patientBanners = screen.getAllByRole('banner'); + expect(patientBanners).toHaveLength(1); + expect(within(patientBanners[0]).getByText(/Joseph Davis/i)).toBeInTheDocument(); + expect(within(patientBanners[0]).getByText(/30/)).toBeInTheDocument(); + }); + + it('filters by postcode correctly', async () => { + renderComponent(); + + // Set postcode filter + const postcodeInput = screen.getByRole('textbox', { name: /postcode/i }); + await user.type(postcodeInput, '46548'); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // expect one patient Joseph Davis + + const patientBanners = screen.getAllByRole('banner'); + expect(patientBanners).toHaveLength(1); + expect(within(patientBanners[0]).getByText(/Joseph Davis/i)).toBeInTheDocument(); + }); + + it('filters by person attribute correctly', async () => { + renderComponent(); + + // Set phone number attribute filter + const phoneInput = screen.getByLabelText(/phone number/i); + await user.type(phoneInput, '0785434125'); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // expect one patient Joshua Johnson + + const patientBanners = screen.getAllByRole('banner'); + expect(patientBanners).toHaveLength(1); + expect(within(patientBanners[0]).getByText(/Joshua Johnson/)).toBeInTheDocument(); + }); + + it('combines multiple filters correctly', async () => { + renderComponent(); + + // Set multiple filters + await user.click(screen.getByRole('tab', { name: /any/i })); + const ageInput = screen.getByRole('spinbutton', { name: /age/i }); + await user.type(ageInput, '5'); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // expect one patient Joshua Johnson + + const patientBanners = screen.getAllByRole('banner'); + expect(patientBanners).toHaveLength(1); + expect(within(patientBanners[0]).getByText(/Joshua Johnson/)).toBeInTheDocument(); + }); + + it('resets filters correctly', async () => { + renderComponent(); + + // Set a filter + await user.click(screen.getByRole('tab', { name: /female/i })); + await user.click(screen.getByRole('button', { name: /apply/i })); + + // Reset filters + await user.click(screen.getByRole('button', { name: /reset fields/i })); + + // expects all search results 2 patients + const patientBanners = screen.getAllByRole('banner'); + expect(patientBanners).toHaveLength(2); + }); + }); + + describe('Layout', () => { + it('renders in desktop layout by default', () => { + renderComponent(); + const container = screen.getByText(/Refine search/i); + expect(container).toBeInTheDocument(); + }); + + it('renders in tablet layout when specified', () => { + renderComponent({ inTabletOrOverlay: true }); + const container = screen.getByText(/Refine search/i); + expect(container).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-search-field.component.tsx b/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-field.component.tsx similarity index 86% rename from packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-search-field.component.tsx rename to packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-field.component.tsx index 491396264..c646f48d6 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-search-field.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attribute-field.component.tsx @@ -5,7 +5,7 @@ import { useAttributeConceptAnswers, useLocations, usePersonAttributeType } from import { type AdvancedPatientSearchState, type SearchFieldConfig } from '../../types'; import styles from './search-field.scss'; -interface PersonAttributeSearchFieldProps { +export interface PersonAttributeFieldProps { field: SearchFieldConfig; formState: AdvancedPatientSearchState; inTabletOrOverlay: boolean; @@ -13,12 +13,7 @@ interface PersonAttributeSearchFieldProps { onInputChange: (fieldName: string) => (evt: { target: { value: string } } | { name: string }) => void; } -export function PersonAttributeSearchField({ - field, - formState, - isTablet, - onInputChange, -}: PersonAttributeSearchFieldProps) { +export function PersonAttributeField({ field, formState, isTablet, onInputChange }: PersonAttributeFieldProps) { const { t } = useTranslation(); const { data: personAttributeType, isLoading, error } = usePersonAttributeType(field.attributeTypeUuid); @@ -100,14 +95,16 @@ const ConceptAttributeField = ({ attributeDisplay: string; }) => { const { t } = useTranslation(); - const { data: conceptAnswers, isLoading } = useAttributeConceptAnswers( - field.customConceptAnswers.length ? '' : field.answerConceptSetUuid, - ); + const { + data: conceptAnswers, + isLoading, + error, + } = useAttributeConceptAnswers(field.customConceptAnswers?.length ? '' : field.answerConceptSetUuid); const handleChange = onInputChange(field.name); const items = useMemo(() => { - if (field.customConceptAnswers.length) return field.customConceptAnswers; - if (!conceptAnswers || !isLoading) return []; + if (field.customConceptAnswers?.length) return field.customConceptAnswers; + if (!conceptAnswers || isLoading) return []; return conceptAnswers .map((answer) => ({ uuid: answer.uuid, @@ -120,6 +117,14 @@ const ConceptAttributeField = ({ return ; } + if (error) { + return ( + + {t('errorLoadingConceptAttribute', 'Error loading concept attribute')} + + ); + } + return ( { const { t } = useTranslation(); const [searchQuery, setSearchQuery] = useState(''); - const { locations, isLoading, loadingNewData } = useLocations(field.locationTag || null, searchQuery); + const { locations, isLoading, loadingNewData, error } = useLocations(field.locationTag || null, searchQuery); const prevLocationOptions = useRef([]); const handleChange = onInputChange(field.name); @@ -196,6 +201,14 @@ const LocationAttributeField = ({ [handleChange], ); + if (error) { + return ( + + {t('errorLoadingLocationAttribute', 'Error loading location attribute')} + + ); + } + return (
({ + usePersonAttributeType: jest.fn(), + useAttributeConceptAnswers: jest.fn(), + useLocations: jest.fn(), +})); + +describe('PersonAttributeField', () => { + const user = userEvent.setup(); + + const mockOnInputChange = jest.fn(() => jest.fn()); + const defaultProps: PersonAttributeFieldProps = { + field: { + name: 'testAttribute', + type: 'personAttribute', + label: 'Test Attribute', + attributeTypeUuid: 'test-uuid', + } as SearchFieldConfig, + formState: initialState, + inTabletOrOverlay: false, + isTablet: false, + onInputChange: mockOnInputChange, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('String Attribute Type', () => { + beforeEach(() => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'java.lang.String', + display: 'Test String Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + }); + + it('renders text input for string attribute type', () => { + render(); + + expect(screen.getByLabelText('Test Attribute')).toBeInTheDocument(); + expect(screen.getByRole('textbox')).toBeInTheDocument(); + }); + + it('handles input changes correctly', async () => { + render(); + + await user.type(screen.getByRole('textbox'), 'test value'); + expect(mockOnInputChange).toHaveBeenCalledWith('testAttribute'); + }); + }); + + describe('Concept Attribute Type', () => { + beforeEach(() => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'org.openmrs.Concept', + display: 'Test Concept Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + + mockUseAttributeConceptAnswers.mockReturnValue({ + data: [ + { uuid: 'concept1', display: 'Concept 1' }, + { uuid: 'concept2', display: 'Concept 2' }, + ], + isLoading: false, + error: null, + }); + }); + + it('renders combobox for concept attribute type', async () => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'org.openmrs.Concept', + display: 'Test Concept Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + mockUseAttributeConceptAnswers.mockReturnValue({ + data: [ + { uuid: 'concept-answer-1-uuid', display: 'concept-answer-1' }, + { uuid: 'concept-answer-2-uuid', display: 'concept-answer-2' }, + ], + isLoading: false, + error: null, + }); + const propsWithAnswerConceptSetUuid: PersonAttributeFieldProps = { + ...defaultProps, + field: { + ...defaultProps.field, + answerConceptSetUuid: 'test-concept-set-uuid', + }, + }; + + render(); + + const combobox = screen.getByRole('combobox'); + + expect(combobox).toBeInTheDocument(); + expect(screen.getByText('Test Attribute')).toBeInTheDocument(); + await user.click(combobox); + expect(screen.getByText('concept-answer-1')).toBeInTheDocument(); + expect(screen.getByText('concept-answer-2')).toBeInTheDocument(); + }); + + it('handles custom concept answers', async () => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'org.openmrs.Concept', + display: 'Test Concept Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + const propsWithCustomConcepts: PersonAttributeFieldProps = { + ...defaultProps, + field: { + ...defaultProps.field, + answerConceptSetUuid: 'test-concept-set-uuid', + customConceptAnswers: [ + { uuid: 'concept-answer-1-uuid', label: 'concept-answer-1' }, + { uuid: 'concept-answer-2-uuid', label: 'concept-answer-2' }, + ], + }, + }; + + render(); + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + expect(screen.getByText('concept-answer-1')).toBeInTheDocument(); + expect(screen.getByText('concept-answer-2')).toBeInTheDocument(); + }); + + it('handles concept selection', async () => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'org.openmrs.Concept', + display: 'Test Concept Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + const propsWithAnswerConceptUuidAndCustomAnswers: PersonAttributeFieldProps = { + ...defaultProps, + field: { + ...defaultProps.field, + answerConceptSetUuid: 'test-concept-set-uuid', + customConceptAnswers: [ + { uuid: 'concept-answer-1-uuid', label: 'concept-answer-1' }, + { uuid: 'concept-answer-2-uuid', label: 'concept-answer-2' }, + ], + }, + }; + + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + await user.click(screen.getByText('concept-answer-1')); + + expect(mockOnInputChange).toHaveBeenCalled(); + }); + }); + + describe('Location Attribute Type', () => { + beforeEach(() => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'org.openmrs.Location', + display: 'Test Location Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + + mockUseLocations.mockReturnValue({ + locations: [ + { resource: { id: 'location-1-uuid', name: 'Location 1' } }, + { + resource: { + id: 'location-2-uuid', + name: 'Location 2', + }, + }, + ] as LocationEntry[], + isLoading: false, + loadingNewData: false, + }); + }); + + it('renders location combo box', () => { + render(); + + expect(screen.getByRole('combobox')).toBeInTheDocument(); + expect(screen.getByText('Test Attribute')).toBeInTheDocument(); + }); + + it('handles location search', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.type(combobox, 'test'); + + expect(useLocations).toHaveBeenCalledWith(null, 'test'); + }); + + it('handles location selection', async () => { + render(); + + const combobox = screen.getByRole('combobox'); + await user.click(combobox); + await user.click(screen.getByText('Location 1')); + + expect(mockOnInputChange).toHaveBeenCalled(); + }); + }); + + describe('Error Handling', () => { + it('shows error notification when attribute type loading fails', () => { + mockUsePersonAttributeType.mockReturnValue({ + data: null, + isLoading: false, + error: new Error('Failed to load attribute type'), + }); + + render(); + + expect(screen.getByText('Error loading attribute type')).toBeInTheDocument(); + }); + + it('shows error for unsupported attribute format', () => { + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'unsupported.format', + display: 'Unsupported Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + + render(); + + expect(screen.getByText('Unsupported attribute format: unsupported.format')).toBeInTheDocument(); + }); + }); + + describe('Form State Integration', () => { + it('displays existing attribute values', () => { + const propsWithValue = { + ...defaultProps, + formState: { + ...initialState, + attributes: { + testAttribute: 'existing value', + }, + }, + }; + + mockUsePersonAttributeType.mockReturnValue({ + data: { + format: 'java.lang.String', + display: 'Test String Attribute', + } as PersonAttributeTypeResponse, + isLoading: false, + error: null, + }); + + render(); + + expect(screen.getByDisplayValue('existing value')).toBeInTheDocument(); + }); + }); +}); diff --git a/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attributes.resource.ts b/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attributes.resource.ts index be7975295..9be7680e1 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attributes.resource.ts +++ b/packages/esm-patient-search-app/src/patient-search-page/refine-search/person-attributes.resource.ts @@ -44,6 +44,7 @@ export function useLocations( locations: Array; isLoading: boolean; loadingNewData: boolean; + error: any; } { const debouncedQuery = useDebounce(searchQuery); @@ -77,6 +78,7 @@ export function useLocations( locations: data?.data?.entry || [], isLoading, loadingNewData: isValidating, + error, }), [data, isLoading, isValidating], ); diff --git a/packages/esm-patient-search-app/src/patient-search-page/refine-search/refine-search-tablet.component.tsx b/packages/esm-patient-search-app/src/patient-search-page/refine-search/refine-search-tablet.component.tsx index 92751b5ee..d121b92be 100644 --- a/packages/esm-patient-search-app/src/patient-search-page/refine-search/refine-search-tablet.component.tsx +++ b/packages/esm-patient-search-app/src/patient-search-page/refine-search/refine-search-tablet.component.tsx @@ -162,7 +162,7 @@ export const RefineSearchTablet: React.FC = ({ {t('refineSearch', 'Refine search')}
-
+ {renderSearchFields()}