Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
  • minerva/frontend
1 result
Show changes
Commits on Source (6)
Showing
with 402 additions and 18 deletions
### 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;
};
};
}
}
......@@ -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' });
......
......@@ -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);
......
......@@ -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
......
......@@ -16,7 +16,7 @@ export const BioEntitiesResultsList = (): JSX.Element => {
};
return (
<div>
<div className="h-full">
<DrawerHeadingBackwardButton backwardFunction={navigateToGroupedSearchResults}>
{mapName}
</DrawerHeadingBackwardButton>
......
......@@ -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 />
......
......@@ -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 => {
......
......@@ -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}
......
......@@ -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>
)}
......
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 => {
......
......@@ -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>;
......
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(() => {
......