Skip to content
Snippets Groups Projects
Commit 624f4d38 authored by mateusz-winiarczyk's avatar mateusz-winiarczyk
Browse files

feat(plugins): plugins trigger search (MIN-221)

parent e16099c3
No related branches found
No related tags found
2 merge requests!223reset the pin numbers before search results are fetch (so the results will be...,!134feat(plugins): plugins trigger search (MIN-221)
### 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,
});
```
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;
};
};
}
}
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));
}
};
export { triggerSearch } from './triggerSearch';
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 });
};
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);
};
/* 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();
});
});
});
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);
}
}
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;
......@@ -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(() => {
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment