diff --git a/docs/plugins/search.md b/docs/plugins/search.md new file mode 100644 index 0000000000000000000000000000000000000000..05856f1703724d85759ff8752a2d5ebad04595b7 --- /dev/null +++ b/docs/plugins/search.md @@ -0,0 +1,49 @@ +### Search + +Search can be done by query or coordinates. To search, plugins can use the `triggerSearch` method in `window.minerva.map` object available globally. + +**Search by query:** +If we want to search using a query, we need to provide an object with the following properties as an argument: + +- query: this should be the search string. +- perfectSearch: this property indicates whether results should be a perfect match or not. Its value should be a boolean type. This property is optional, and by default, its value is `false`. +- fitBounds: should the map be resized to the rectangle fitting all results. Its value should be a boolean type. This property is optional, and by default, its value is `false`. + +##### Example of search by query: + +```javascript +window.minerva.map.triggerSearch({ query: 'NR4A2;SNCA;aspirin', perfectSearch: true }); + +window.minerva.map.triggerSearch({ query: 'NR4A2;SNCA;aspirin;morphine;PINK1' }); + +window.minerva.map.triggerSearch({ query: 'PINK1', fitBounds: true }); +``` + +**Search by coordinates**: +If we want to search using coordinates, we need to provide an object with the following properties as an argument: + +- coordinates: this property should indicate the x and y coordinates on the map. Its value should be an object type with x and y properties +- modelId: this property should indicate submap identifier. Its value should be a number type +- zoom: this property should indicate zoom level at which we want to trigger search. Its value should be a number type +- fitBounds: should the map be resized to the rectangle fitting all results. Its value should be a boolean type. This property is optional, and by default, its value is `false`. + +##### Example of search by query: + +```javascript +window.minerva.map.triggerSearch({ coordinates: { x: 947, y: 503 }, modelId: 60 }); + +window.minerva.map.triggerSearch({ + coordinates: { x: 1947, y: 5203 }, + modelId: 52, + fitBounds: true, +}); + +window.minerva.map.triggerSearch({ coordinates: { x: 1947, y: 5203 }, modelId: 60, zoom: 5 }); + +window.minerva.map.triggerSearch({ + coordinates: { x: 1947, y: 5203 }, + modelId: 51, + fitBounds: true, + zoom: 6, +}); +``` diff --git a/index.d.ts b/index.d.ts index 248f6f8ecb2d8aa13e21e2859fbfafacc263eba2..e093f9d7f2f5f9e64d9cdacb27aaa24c1e471471 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,4 @@ +import { triggerSearch } from '@/services/pluginsManager/map/triggerSearch'; import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager'; type Plugin = { @@ -22,6 +23,9 @@ declare global { plugins: { registerPlugin: RegisterPlugin; }; + map: { + triggerSearch: typeof triggerSearch; + }; }; } } diff --git a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx index f6a1cbfe164bc1eeda880d4490e52ff858c30d0c..d944235a29d04b7a1c376869c37cdc29e502ed2d 100644 --- a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx +++ b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.test.tsx @@ -6,6 +6,7 @@ import { import { StoreType } from '@/redux/store'; import { initialMapDataFixture, openedMapsThreeSubmapsFixture } from '@/redux/map/map.fixtures'; import { act, render, screen, within } from '@testing-library/react'; +import { MODELS_MOCK } from '@/redux/compartmentPathways/compartmentPathways.mock'; import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; import { MapNavigation } from './MapNavigation.component'; @@ -104,9 +105,8 @@ describe('MapNavigation - component', () => { expect(modelId).toBe(MAIN_MAP_ID); }); - it('should close map and open main map if closed currently selected map', async () => { + it('should close currently selected map map and open main map', async () => { const { store } = renderComponent({ - models: MODELS_DATA_MOCK_WITH_MAIN_MAP, map: { data: { ...initialMapDataFixture, @@ -116,6 +116,11 @@ describe('MapNavigation - component', () => { loading: 'succeeded', error: { message: '', name: '' }, }, + models: { + loading: 'succeeded', + error: { message: '', name: '' }, + data: MODELS_MOCK, + }, }); const histamineMapButton = screen.getByRole('button', { name: 'Histamine signaling' }); diff --git a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx index bab3d90aede6a9df3860d67ab25c4a10c546d8d7..c1d4d1206263c5392b481c06f990f482b9f62b16 100644 --- a/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx +++ b/src/components/FunctionalArea/MapNavigation/MapNavigation.component.tsx @@ -23,7 +23,12 @@ export const MapNavigation = (): JSX.Element => { const onCloseSubmap = (event: MouseEvent<HTMLDivElement>, map: OppenedMap): void => { event.stopPropagation(); if (isActive(map.modelId)) { - dispatch(closeMapAndSetMainMapActive({ modelId: map.modelId })); + dispatch( + closeMapAndSetMainMapActive({ + modelId: mainMapModel.idObject, + currentModelId: map.modelId, + }), + ); PluginsEventBus.dispatchEvent('onSubmapClose', map.modelId); PluginsEventBus.dispatchEvent('onSubmapOpen', mainMapModel.idObject); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsList.component.tsx index b563e3f9b9e784e735317cc0ad3eb3f03c40dec0..1a45d10890ed519bf5bc9a3e65744643a1888431 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsList.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesPinsList/BioEntitiesPinsList.component.tsx @@ -7,7 +7,7 @@ interface BioEntitiesPinsListProps { export const BioEntitiesPinsList = ({ bioEnititesPins }: BioEntitiesPinsListProps): JSX.Element => { return ( - <ul className="h-[calc(100vh-198px)] overflow-auto px-6 py-2"> + <ul className="h-[calc(100%-224px)] max-h-[calc(100%-224px)] overflow-auto px-6 py-2"> {bioEnititesPins && bioEnititesPins.map(result => ( <BioEntitiesPinsListItem diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesResultsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesResultsList.component.tsx index eea837b23ac9548a2d50b73feaff0a78ab63d958..1b3b839c7d8218cd0e72571ab671c8fe5805078e 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesResultsList.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/BioEntitiesResultsList/BioEntitiesResultsList.component.tsx @@ -16,7 +16,7 @@ export const BioEntitiesResultsList = (): JSX.Element => { }; return ( - <div> + <div className="h-full"> <DrawerHeadingBackwardButton backwardFunction={navigateToGroupedSearchResults}> {mapName} </DrawerHeadingBackwardButton> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx index eef4ed38a8ad4996169ab725701bd98810587017..5d83a4e57d6e8c99daa314a800653e521a41927c 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/GroupedSearchResults.component.tsx @@ -7,7 +7,10 @@ export const CLOSE_BUTTON_ROLE = 'close-drawer-button'; export const GroupedSearchResults = (): JSX.Element => { return ( - <div className="flex flex-col" data-testid="grouped-search-results"> + <div + className="flex h-[calc(100%-124px)] max-h-[calc(100%-124px)] flex-col overflow-auto" + data-testid="grouped-search-results" + > <div className="px-6"> <Accordion allowZeroExpanded> <BioEntitiesAccordion /> diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx index 1ab513f990ea5738852006d056830ca135f7a7b2..6e91ddbfc171362a1fece6eb2ac85673645c519d 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.component.tsx @@ -12,7 +12,7 @@ export const PinsList = ({ pinsList, type }: PinsListProps): JSX.Element => { switch (type) { case 'drugs': return ( - <div className="h-[calc(100vh-198px)] overflow-auto"> + <div className="h-[calc(100%-214px)] max-h-[calc(100%-214px)] overflow-auto"> <AccordionsDetails pinsList={pinsList} type={type} /> <ul className="px-6 py-2" data-testid="pins-list"> {pinsList.map(result => { @@ -27,7 +27,7 @@ export const PinsList = ({ pinsList, type }: PinsListProps): JSX.Element => { return <div />; case 'chemicals': return ( - <div className="h-[calc(100vh-198px)] overflow-auto"> + <div className="h-[calc(100%-214px)] max-h-[calc(100%-214px)] overflow-auto"> <AccordionsDetails pinsList={pinsList} type={type} /> <ul className="px-6 py-2" data-testid="pins-list"> {pinsList.map(result => { diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx index 0d5ab7ed03ef93748ebe2b19702337d69c097d2a..28f99dd6d6019c02fcda5dc4885eaa601770b3a5 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/ResultsList/ResultsList.component.tsx @@ -15,7 +15,7 @@ export const ResultsList = (): JSX.Element => { }; return ( - <div> + <div className="h-full"> <DrawerHeadingBackwardButton backwardFunction={navigateToGroupedSearchResults}> <span className="capitalize" data-testid="drawer-heading-text"> {stepType} diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx index 77f0275ef06565495a0983434e306ef6851f9e5f..573435d06b78f5c47a9028078886e65112eadd67 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/SearchDrawerWrapper.component.tsx @@ -22,18 +22,18 @@ export const SearchDrawerWrapper = (): JSX.Element => { <> <SearchDrawerHeader /> <SearchDrawerTabs /> - <div data-testid="search-drawer-content"> + <div data-testid="search-drawer-content" className="h-full max-h-full"> {/* first step for displaying search results, drawers etc */} {currentStep === STEP.FIRST && <GroupedSearchResults />} {/* 2nd step for bioEntities aka content */} {currentStep === STEP.SECOND && isBioEntityType && ( - <div data-testid="search-second-step"> + <div data-testid="search-second-step" className="h-full"> <BioEntitiesResultsList /> </div> )} {/* 2nd step for drugs,chemicals */} {currentStep === STEP.SECOND && isChemicalsOrDrugsType && ( - <div data-testid="search-second-step"> + <div data-testid="search-second-step" className="h-full"> <ResultsList /> </div> )} diff --git a/src/redux/map/map.reducers.ts b/src/redux/map/map.reducers.ts index de06329e34159742ad61042ffbcb0c658542fc83..362745fc00327c7fff640f6d74c388b85400bb07 100644 --- a/src/redux/map/map.reducers.ts +++ b/src/redux/map/map.reducers.ts @@ -1,9 +1,7 @@ -import { ZERO } from '@/constants/common'; import { DEFAULT_ZOOM } from '@/constants/map'; import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; import { getPointMerged } from '../../utils/object/getPointMerged'; -import { MAIN_MAP } from './map.constants'; import { initMapBackground, initMapPosition, @@ -12,6 +10,7 @@ import { } from './map.thunks'; import { CloseMapAction, + CloseMapActionAndSetMainMapActive, MapState, OpenMapAndSetActiveAction, SetActiveMapAction, @@ -99,13 +98,12 @@ export const closeMapReducer = (state: MapState, action: CloseMapAction): void = export const closeMapAndSetMainMapActiveReducer = ( state: MapState, - action: CloseMapAction, + action: CloseMapActionAndSetMainMapActive, ): void => { state.openedMaps = state.openedMaps.filter( - openedMap => openedMap.modelId !== action.payload.modelId, + openedMap => openedMap.modelId !== action.payload.currentModelId, ); - state.data.modelId = - state.openedMaps.find(openedMap => openedMap.modelName === MAIN_MAP)?.modelId || ZERO; + state.data.modelId = action.payload.modelId; }; export const setMapBackgroundReducer = (state: MapState, action: SetBackgroundAction): void => { diff --git a/src/redux/map/map.types.ts b/src/redux/map/map.types.ts index 3d15719aa783b34b5796321c166e30c02a2d9eb6..b11c5cfefe794fb850de5629934e181c4f94877d 100644 --- a/src/redux/map/map.types.ts +++ b/src/redux/map/map.types.ts @@ -64,6 +64,12 @@ export type CloseMapActionPayload = Pick<OppenedMap, 'modelId'>; export type CloseMapAction = PayloadAction<CloseMapActionPayload>; +export type CloseMapActionAndSetMainMapActive = PayloadAction< + { + currentModelId: number; + } & Pick<OppenedMap, 'modelId'> +>; + export type InitMapDataActionParams = { queryData: QueryData }; export type InitMapDataAction = PayloadAction<SetMapDataAction>; diff --git a/src/services/pluginsManager/map/triggerSearch/displaySearchDrawerWithSelectedDefaultTab.ts b/src/services/pluginsManager/map/triggerSearch/displaySearchDrawerWithSelectedDefaultTab.ts new file mode 100644 index 0000000000000000000000000000000000000000..135025d1ddafca34db9642d2d33d863d74c0e5c6 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/displaySearchDrawerWithSelectedDefaultTab.ts @@ -0,0 +1,16 @@ +import { getDefaultSearchTab } from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; +import { isDrawerOpenSelector } from '@/redux/drawer/drawer.selectors'; +import { openSearchDrawerWithSelectedTab, selectTab } from '@/redux/drawer/drawer.slice'; +import { store } from '@/redux/store'; + +export const displaySearchDrawerWithSelectedDefaultTab = (searchValues: string[]): void => { + const { dispatch, getState } = store; + const isDrawerOpen = isDrawerOpenSelector(getState()); + const defaultSearchTab = getDefaultSearchTab(searchValues); + + if (!isDrawerOpen) { + dispatch(openSearchDrawerWithSelectedTab(defaultSearchTab)); + } else { + dispatch(selectTab(defaultSearchTab)); + } +}; diff --git a/src/services/pluginsManager/map/triggerSearch/index.ts b/src/services/pluginsManager/map/triggerSearch/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e33cfdc81a3dd7b8d8d8aaf5ec89614628b480c7 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/index.ts @@ -0,0 +1 @@ +export { triggerSearch } from './triggerSearch'; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts new file mode 100644 index 0000000000000000000000000000000000000000..bdd6a539ddbfc7eca3934a8700061acc54d8de24 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts @@ -0,0 +1,27 @@ +import { handleDataReset } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset'; +import { handleSearchResultAction } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction'; +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { store } from '@/redux/store'; +import { getElementsByPoint } from '@/utils/search/getElementsByCoordinates'; +import { Coordinates } from './triggerSearch.types'; + +export const searchByCoordinates = async ( + coordinates: Coordinates, + modelId: number, +): Promise<void> => { + const { dispatch } = store; + // side-effect below is to prevent complications with data update - old data may conflict with new data + // so we need to reset all the data before updating + dispatch(handleDataReset); + + const searchResults = await getElementsByPoint({ + point: coordinates, + currentModelId: modelId, + }); + + if (!searchResults || searchResults?.length === SIZE_OF_EMPTY_ARRAY) { + return; + } + + handleSearchResultAction({ searchResults, dispatch }); +}; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..01d0603743171cebd905be553cf7d61aa6824fc1 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts @@ -0,0 +1,14 @@ +import { getSearchValuesArrayAndTrimToSeven } from '@/components/FunctionalArea/TopBar/SearchBar/SearchBar.utils'; +import { getSearchData } from '@/redux/search/search.thunks'; +import { store } from '@/redux/store'; +import { displaySearchDrawerWithSelectedDefaultTab } from './displaySearchDrawerWithSelectedDefaultTab'; + +export const searchByQuery = (query: string, perfectSearch: boolean | undefined): void => { + const { dispatch } = store; + const searchValues = getSearchValuesArrayAndTrimToSeven(query); + const isPerfectMatch = !!perfectSearch; + + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })); + + displaySearchDrawerWithSelectedDefaultTab(searchValues); +}; diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c7408ebf99514fc11abc6bd75118bae26192724 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts @@ -0,0 +1,205 @@ +/* eslint-disable no-magic-numbers */ +import { mockNetworkNewAPIResponse, mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { RootState, store } from '@/redux/store'; +import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS } from '@/models/mocks/elementSearchResultMock'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { waitFor } from '@testing-library/react'; +import { handleSearchResultAction } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction'; +import { triggerSearch } from './triggerSearch'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); +const mockedAxiosOldClient = mockNetworkResponse(); +const SEARCH_QUERY = 'park7'; +const point = { x: 545.8013, y: 500.9926 }; +const modelId = 1000; + +jest.mock('../../../../redux/store'); +jest.mock( + '../../../../components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction', +); + +describe('triggerSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('search by query', () => { + it('should throw error if query param is wrong type', async () => { + const invalidParams = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + query: 123 as any, + }; + + await expect(triggerSearch(invalidParams)).rejects.toThrowError( + 'Invalid query type. The query should be of string type', + ); + }); + it('should search for provided query and open drawer when it is not open', async () => { + const getState = jest.spyOn(store, 'getState').mockImplementation( + () => + ({ + drawer: { + isOpen: false, + }, + }) as RootState, + ); + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + await expect( + triggerSearch({ + query: SEARCH_QUERY, + }), + ).resolves.toBe(undefined); + + expect(store.dispatch).toHaveBeenCalledTimes(2); + + expect(store.dispatch).not.toHaveBeenCalledWith({ + payload: SEARCH_QUERY, + type: 'drawer/selectTab', + }); + + expect(store.dispatch).toHaveBeenLastCalledWith({ + payload: SEARCH_QUERY, + type: 'drawer/openSearchDrawerWithSelectedTab', + }); + getState.mockRestore(); + }); + it('should search for provided query and select default tab when drawer is already open', async () => { + const getState = jest.spyOn(store, 'getState').mockImplementation( + () => + ({ + drawer: { + isOpen: true, + }, + }) as RootState, + ); + + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + await expect( + triggerSearch({ + query: SEARCH_QUERY, + }), + ).resolves.toBe(undefined); + + expect(getState).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledTimes(2); + expect(store.dispatch).not.toHaveBeenLastCalledWith({ + payload: SEARCH_QUERY, + type: 'drawer/openSearchDrawerWithSelectedTab', + }); + expect(store.dispatch).toHaveBeenLastCalledWith({ + payload: SEARCH_QUERY, + type: 'drawer/selectTab', + }); + + getState.mockRestore(); + }); + }); + describe('search by coordinations', () => { + it('should throw error if coordinates param is wrong type', async () => { + const invalidParams = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + coordinates: {} as any, + modelId: 53, + }; + + await expect(triggerSearch(invalidParams)).rejects.toThrowError( + 'Invalid coordinates type or values', + ); + }); + it('should throw error if model id param is wrong type', async () => { + const invalidParams = { + coordinates: { x: 992, y: 993 }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + modelId: '53' as any, + }; + + await expect(triggerSearch(invalidParams)).rejects.toThrowError( + 'Invalid model id type. The model should be of number type', + ); + }); + it('should search result with proper data', async () => { + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), + isPerfectMatch: true, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + const params = { + coordinates: point, + modelId, + }; + + await expect(triggerSearch(params)).resolves.toBe(undefined); + + await waitFor(() => { + expect(handleSearchResultAction).toHaveBeenCalledWith({ + searchResults: [ELEMENT_SEARCH_RESULT_MOCK_ALIAS], + dispatch: store.dispatch, + }); + }); + }); + it('should not search result if there is no bio entity with specific coordinates', async () => { + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, []); + + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), + isPerfectMatch: true, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + const params = { + coordinates: point, + modelId, + }; + + await expect(triggerSearch(params)).resolves.toBe(undefined); + + expect(handleSearchResultAction).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a747095a9d1fdf446b603ea06e0760b40c9a906 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts @@ -0,0 +1,28 @@ +import { SearchParams } from './triggerSearch.types'; +import { searchByQuery } from './searchByQuery'; +import { searchByCoordinates } from './searchByCoordinates'; + +export async function triggerSearch(params: SearchParams): Promise<void> { + if ('query' in params) { + if (typeof params.query !== 'string') { + throw new Error('Invalid query type. The query should be of string type'); + } + searchByQuery(params.query, params.perfectSearch); + } else { + const areCoordinatesInvalidType = + typeof params.coordinates !== 'object' || params.coordinates === null; + const areCoordinatesMissingKeys = !('x' in params.coordinates) || !('y' in params.coordinates); + const areCoordinatesValuesInvalid = + typeof params.coordinates.x !== 'number' || typeof params.coordinates.y !== 'number'; + + if (areCoordinatesInvalidType || areCoordinatesMissingKeys || areCoordinatesValuesInvalid) { + throw new Error('Invalid coordinates type or values'); + } + + if (typeof params.modelId !== 'number') { + throw new Error('Invalid model id type. The model should be of number type'); + } + + searchByCoordinates(params.coordinates, params.modelId); + } +} diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.types.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa6cd67ad8cc1ad8db58bc7ca2cf7ddd15dba6ab --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.types.ts @@ -0,0 +1,19 @@ +export type SearchByQueryParams = { + query: string; + perfectSearch?: boolean; + fitBounds?: boolean; +}; + +export type Coordinates = { + x: number; + y: number; +}; + +export type SearchByCoordinatesParams = { + coordinates: Coordinates; + modelId: number; + fitBounds?: boolean; + zoom?: number; +}; + +export type SearchParams = SearchByCoordinatesParams | SearchByQueryParams; diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts index 83a734c7fd958fc03b15259866441bd644517ae9..1d6c0bc0e2f58d5dcc3ce3182e01d48ea5d97676 100644 --- a/src/services/pluginsManager/pluginsManager.ts +++ b/src/services/pluginsManager/pluginsManager.ts @@ -4,6 +4,7 @@ import { store } from '@/redux/store'; import md5 from 'crypto-js/md5'; import type { PluginsManagerType } from './pluginsManager.types'; import { configurationMapper } from './pluginsManager.utils'; +import { triggerSearch } from './map/triggerSearch'; import { PluginsEventBus } from './pluginsEventBus'; export const PluginsManager: PluginsManagerType = { @@ -20,6 +21,9 @@ export const PluginsManager: PluginsManagerType = { plugins: { registerPlugin: PluginsManager.registerPlugin, }, + map: { + triggerSearch, + }, }; const unsubscribe = store.subscribe(() => {