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/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(() => {