diff --git a/docs/plugins/events.md b/docs/plugins/events.md index 957b28a29c0bbde50cd8fc105001e55976bbe822..63072dc23baf38719a6e45a4362f1c1d1db1b263 100644 --- a/docs/plugins/events.md +++ b/docs/plugins/events.md @@ -201,6 +201,34 @@ To listen for specific events, plugins can use the `addListener` method in `even } ``` +- onPinIconClick - triggered when someone clicks on a pin icon; the element to which the pin is attached is passed as an argument. Example argument: + +```javascript +{ + "id": 40072, +} +``` + +```javascript +{ + "id": "b0a478ad-7e7a-47f5-8130-e96cbeaa0cfe", // marker pin +} +``` + +- onSurfaceClick - triggered when someone clicks on a overlay surface; the element to which the pin is attached is passed as an argument. Example argument: + +```javascript +{ + "id": 18, +} +``` + +```javascript +{ + "id": "a3a5305f-acfa-47ff-bf77-a26d017c6eb3", // surface marker overlay +} +``` + - onSubmapOpen - triggered when submap opens; the submap identifier is passed as an argument. Example argument: ``` diff --git a/package-lock.json b/package-lock.json index 016ad6996bbef60abdeef8fe404277e991e7dca9..a1966db0917e65b4f510518c19e2655f98633fc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", + "is-uuid": "^1.0.2", "molart": "github:davidhoksza/MolArt", "next": "13.4.19", "ol": "^8.1.0", @@ -49,6 +50,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", "@types/crypto-js": "^4.2.2", + "@types/is-uuid": "^1.0.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", @@ -2319,6 +2321,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-S+gWwUEApOjGCCO5LQrft4kciGWatvB0LyiyWTXSlDkclZBr6glSgstET573GsC5QPFdw2NeOw2PHOOMuTDFSQ==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7968,6 +7976,11 @@ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "dev": true }, + "node_modules/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -15724,6 +15737,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-S+gWwUEApOjGCCO5LQrft4kciGWatvB0LyiyWTXSlDkclZBr6glSgstET573GsC5QPFdw2NeOw2PHOOMuTDFSQ==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -19786,6 +19805,11 @@ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "dev": true }, + "is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" + }, "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", diff --git a/package.json b/package.json index f3a8ec09f2e124b622df2947627ea0a7576d99a3..f44f22efed3236434a8bad77d7b52e0245284733 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", + "is-uuid": "^1.0.2", "molart": "github:davidhoksza/MolArt", "next": "13.4.19", "ol": "^8.1.0", @@ -63,6 +64,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", "@types/crypto-js": "^4.2.2", + "@types/is-uuid": "^1.0.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 2ee7eb5b5a6eb3e445c572fe5fa593062bd482a2..03c3f49587c5fb3ea0f8a6fb4bfd58549f77ee26 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx @@ -1,7 +1,7 @@ import { ZERO } from '@/constants/common'; import { - searchedFromMapBioEntityElement, - searchedFromMapBioEntityElementRelatedSubmapSelector, + currentDrawerBioEntityRelatedSubmapSelector, + currentDrawerBioEntitySelector, } from '@/redux/bioEntity/bioEntity.selectors'; import { getChemicalsForBioEntityDrawerTarget, @@ -22,8 +22,8 @@ const TARGET_PREFIX: ElementSearchResultType = `ALIAS`; export const BioEntityDrawer = (): React.ReactNode => { const dispatch = useAppDispatch(); - const bioEntityData = useAppSelector(searchedFromMapBioEntityElement); - const relatedSubmap = useAppSelector(searchedFromMapBioEntityElementRelatedSubmapSelector); + const bioEntityData = useAppSelector(currentDrawerBioEntitySelector); + const relatedSubmap = useAppSelector(currentDrawerBioEntityRelatedSubmapSelector); const currentTargetId = bioEntityData?.id ? `${TARGET_PREFIX}:${bioEntityData.id}` : ''; const fetchChemicalsForTarget = (): void => { diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts index 121bfe2bb2b3c25f79ecb2f0dd7809a4378ace40..a7daf8437d747dce8f28e45f30268d6b8a3e3505 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts @@ -1,5 +1,18 @@ -import Polygon, { fromExtent } from 'ol/geom/Polygon'; +import { FEATURE_TYPE } from '@/constants/features'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import isUUID from 'is-uuid'; import Feature from 'ol/Feature'; +import Polygon, { fromExtent } from 'ol/geom/Polygon'; + +export const createFeatureFromExtent = ( + [xMin, yMin, xMax, yMax]: number[], + entityId: OverlayBioEntityRender['id'], +): Feature<Polygon> => { + const isMarker = isUUID.anyNonNil(`${entityId}`); -export const createFeatureFromExtent = ([xMin, yMin, xMax, yMax]: number[]): Feature<Polygon> => - new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); + return new Feature({ + geometry: fromExtent([xMin, yMin, xMax, yMax]), + id: entityId, + type: isMarker ? FEATURE_TYPE.SURFACE_MARKER : FEATURE_TYPE.SURFACE_OVERLAY, + }); +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts index 8ee122219c608c7e6bedd00e1fd1e269e54255b1..4c94444ea89c6cdb72ce6c8bb3e4c292c8dbfc5c 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts @@ -1,3 +1,4 @@ +import { FEATURE_TYPE } from '@/constants/features'; import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; describe('createOverlayGeometryFeature', () => { @@ -7,8 +8,13 @@ describe('createOverlayGeometryFeature', () => { const xMax = 10; const yMax = 10; const colorHexString = '#FF0000'; + const entityId = 2007; - const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + const feature = createOverlayGeometryFeature( + [xMin, yMin, xMax, yMax], + colorHexString, + entityId, + ); expect(feature.getGeometry()!.getCoordinates()).toEqual([ [ @@ -22,6 +28,9 @@ describe('createOverlayGeometryFeature', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - getStyle() is not typed expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + + expect(feature.get('id')).toBe(entityId); + expect(feature.get('type')).toBe(FEATURE_TYPE.SURFACE_OVERLAY); }); it('should create a feature with the correct geometry and style when using a different color', () => { @@ -30,8 +39,13 @@ describe('createOverlayGeometryFeature', () => { const xMax = 5; const yMax = 5; const colorHexString = '#00FF00'; + const entityId = 'a6e21d64-fd3c-4f7c-8acc-5fc305f4395a'; - const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + const feature = createOverlayGeometryFeature( + [xMin, yMin, xMax, yMax], + colorHexString, + entityId, + ); expect(feature.getGeometry()!.getCoordinates()).toEqual([ [ @@ -45,5 +59,8 @@ describe('createOverlayGeometryFeature', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - getStyle() is not typed expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + + expect(feature.get('id')).toBe(entityId); + expect(feature.get('type')).toBe(FEATURE_TYPE.SURFACE_MARKER); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts index b294d492153d7f74aea80a7836f30c0557032ba5..e11025d81b6401b8b26fc0879d8b51d255a1e034 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts @@ -1,6 +1,7 @@ -import { Fill, Stroke, Style } from 'ol/style'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; +import { Fill, Stroke, Style } from 'ol/style'; import { createFeatureFromExtent } from './createFeatureFromExtent'; const getBioEntityOverlayFeatureStyle = (color: string): Style => @@ -9,8 +10,9 @@ const getBioEntityOverlayFeatureStyle = (color: string): Style => export const createOverlayGeometryFeature = ( [xMin, yMin, xMax, yMax]: number[], color: string, + entityId: OverlayBioEntityRender['id'], ): Feature<Polygon> => { - const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax], entityId); feature.setStyle(getBioEntityOverlayFeatureStyle(color)); return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts index 06d6074af86160a0318c919e1cda4a95105be466..cd5ba930bb62471fd335944f41d5c6e67b820751 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts @@ -25,13 +25,13 @@ const CASES = [ describe('createOverlaySubmapLinkRectangleFeature - util', () => { it.each(CASES)('should return Feature instance', points => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); expect(feature).toBeInstanceOf(Feature); }); it.each(CASES)('should return Feature instance with valid style and stroke', points => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); const style = feature.getStyle(); expect(style).toMatchObject({ @@ -43,7 +43,7 @@ describe('createOverlaySubmapLinkRectangleFeature - util', () => { }); }); it('should return object with transparent fill and black stroke color when color is null', () => { - const feature = createOverlaySubmapLinkRectangleFeature([0, 0, 0, 0], null); + const feature = createOverlaySubmapLinkRectangleFeature([0, 0, 0, 0], null, 1234); const style = feature.getStyle(); expect(style).toMatchObject({ @@ -55,13 +55,13 @@ describe('createOverlaySubmapLinkRectangleFeature - util', () => { }); }); it.each(CASES)('should return Feature instance with valid geometry', (points, extent) => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); const geometry = feature.getGeometry(); expect(geometry?.getExtent()).toEqual(extent); }); it('should throw error if extent is not valid', () => { - expect(() => createOverlaySubmapLinkRectangleFeature([100, 100, 0, 0], COLOR)).toThrow(); + expect(() => createOverlaySubmapLinkRectangleFeature([100, 100, 0, 0], COLOR, 1234)).toThrow(); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts index cef983542a7777c57a8c551ed6e9d96aa1dcb35c..8a051f2f9fda0e7b21c601e40e1b9f6c96309831 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts @@ -1,4 +1,5 @@ /* eslint-disable no-magic-numbers */ +import { OverlayBioEntityRender } from '@/types/OLrendering'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; import { createFeatureFromExtent } from './createFeatureFromExtent'; @@ -7,8 +8,9 @@ import { getOverlaySubmapLinkRectangleFeatureStyle } from './getOverlaySubmapLin export const createOverlaySubmapLinkRectangleFeature = ( [xMin, yMin, xMax, yMax]: number[], color: string | null, + entityId: OverlayBioEntityRender['id'], ): Feature<Polygon> => { - const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax], entityId); feature.setStyle(getOverlaySubmapLinkRectangleFeatureStyle(color)); return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts index 93e24ec85be8ac6dbab4bcfcf307448311c16fc0..107add31699aceb508557e10d9b54420c4d5171a 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts @@ -35,6 +35,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: entity.x2, y: entity.y2 }), ], entity?.hexColor || color, + entity.id, ); }), [getOverlayBioEntityColorByAvailableProperties, markersRender, pointToProjection], @@ -67,6 +68,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: xMax, y: entity.y2 }), ], entity.value === Infinity ? null : color, + entity.id, ); } @@ -77,6 +79,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: xMax, y: entity.y2 }), ], color, + entity.id, ); } diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts index 28610fd818aa15ae7b6b9dc89fec3ab2e9042786..80af09f63b9e14b963a21b64de18e09c396451c8 100644 --- a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts @@ -24,7 +24,7 @@ describe('getPinFeature - subUtil', () => { }); it('should return id as name', () => { - expect(result.get('name')).toBe(bioEntity.id); + expect(result.get('id')).toBe(bioEntity.id); }); it('should return point parsed with point to projection', () => { diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts index ff7bfb00b7af92fa23a3d9be3ae2e4d826320252..51d4f8d363697addadc40b00dae775744530ac0d 100644 --- a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts @@ -1,8 +1,10 @@ import { ZERO } from '@/constants/common'; import { HALF } from '@/constants/dividers'; +import { FEATURE_TYPE } from '@/constants/features'; import { Marker } from '@/redux/markers/markers.types'; import { BioEntity } from '@/types/models'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import isUUID from 'is-uuid'; import { Feature } from 'ol'; import { Point } from 'ol/geom'; @@ -10,13 +12,18 @@ export const getPinFeature = ( { x, y, width, height, id }: Pick<BioEntity, 'id' | 'width' | 'height' | 'x' | 'y'> | Marker, pointToProjection: UsePointToProjectionResult, ): Feature => { + const isMarker = isUUID.anyNonNil(`${id}`); + const point = { x: x + (width || ZERO) / HALF, y: y + (height || ZERO) / HALF, }; - return new Feature({ + const feature = new Feature({ geometry: new Point(pointToProjection(point)), - name: id, + id, + type: isMarker ? FEATURE_TYPE.PIN_ICON_MARKER : FEATURE_TYPE.PIN_ICON_BIOENTITY, }); + + return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index ff40ba9134e054c7dd1cb444337071f2a7aef2ed..a42e967759c12b43c1ce2da7158b4a2641a2620d 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -22,7 +22,7 @@ export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig[ return; } - mapInstance.setLayers([tileLayer, reactionsLayer, pinsLayer, overlaysLayer]); + mapInstance.setLayers([tileLayer, reactionsLayer, overlaysLayer, pinsLayer]); }, [reactionsLayer, tileLayer, pinsLayer, mapInstance, overlaysLayer]); return [tileLayer, pinsLayer, reactionsLayer, overlaysLayer]; diff --git a/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts b/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts index 858b57fb89c8d9ef42c65f70287ed453e718aa50..29056a8fe5467120d207ff2790af3828d3230661 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts @@ -20,5 +20,5 @@ export const handleSearchResultForRightClickAction = async ({ REACTION: handleReactionResults, }[type]; - await action(dispatch)(closestSearchResult); + await action(dispatch, closestSearchResult)(closestSearchResult); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts b/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts index 3d66ff05ac02bda8f981efb70e7cba7f813814d5..a957771e4eb8f3cd53824aab5de92347eb96af7f 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts @@ -1,12 +1,12 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { openContextMenu } from '@/redux/contextMenu/contextMenu.slice'; +import { MapSize } from '@/redux/map/map.types'; import { AppDispatch } from '@/redux/store'; -import { Pixel } from 'ol/pixel'; import { Coordinate } from 'ol/coordinate'; -import { MapSize } from '@/redux/map/map.types'; -import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { Pixel } from 'ol/pixel'; +import { getSearchResults } from '../mapSingleClick/getSearchResults'; import { handleDataReset } from '../mapSingleClick/handleDataReset'; import { handleSearchResultForRightClickAction } from './handleSearchResultForRightClickAction'; -import { getSearchResults } from '../mapSingleClick/getSearchResults'; /* prettier-ignore */ export const onMapRightClick = diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts index 44ac11c9c8e12c0b7a4d357f7851aae3292ee68d..088aa80fec038461d5e0664c44e3eead9fa29709 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts @@ -1,4 +1,9 @@ -import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { + FIRST_ARRAY_ELEMENT, + SECOND_ARRAY_ELEMENT, + SIZE_OF_EMPTY_ARRAY, + THIRD_ARRAY_ELEMENT, +} from '@/constants/common'; import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS } from '@/models/mocks/elementSearchResultMock'; import { apiPath } from '@/redux/apiPath'; @@ -24,22 +29,33 @@ describe('handleAliasResults - util', () => { .reply(HttpStatusCode.Ok, bioEntityResponseFixture); beforeAll(async () => { - handleAliasResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); + handleAliasResults( + dispatch, + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + )(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); }); - it('should run openBioEntityDrawerById as first action', async () => { + it('should run selectTab as first action', async () => { await waitFor(() => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[FIRST_ARRAY_ELEMENT].type).toEqual('drawer/openBioEntityDrawerById'); + expect(actions[FIRST_ARRAY_ELEMENT].type).toEqual('drawer/selectTab'); }); }); - it('should run getMultiBioEntity as second action', async () => { + it('should run openBioEntityDrawerById as second action', async () => { await waitFor(() => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[SECOND_ARRAY_ELEMENT].type).toEqual('project/getMultiBioEntity/pending'); + expect(actions[SECOND_ARRAY_ELEMENT].type).toEqual('drawer/openBioEntityDrawerById'); + }); + }); + + it('should run getMultiBioEntity as third action', async () => { + await waitFor(() => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[THIRD_ARRAY_ELEMENT].type).toEqual('project/getMultiBioEntity/pending'); }); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts index cd7aefe90caeee8e5270d58052dd2442a7a14672..40874a2d452162994079b8fe60b7be1fbf56959b 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts @@ -1,14 +1,16 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; -import { openBioEntityDrawerById } from '@/redux/drawer/drawer.slice'; +import { openBioEntityDrawerById, selectTab } from '@/redux/drawer/drawer.slice'; import { AppDispatch } from '@/redux/store'; import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult } from '@/types/models'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; /* prettier-ignore */ export const handleAliasResults = - (dispatch: AppDispatch, hasFitBounds?: boolean, fitBoundsZoom?: number) => + (dispatch: AppDispatch, closestSearchResult: ElementSearchResult, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { + dispatch(selectTab(`${id}`)); dispatch(openBioEntityDrawerById(id)); dispatch( getMultiBioEntity({ @@ -16,7 +18,14 @@ export const handleAliasResults = isPerfectMatch: true }), ) - .unwrap().then(() => { + .unwrap().then((bioEntityContents) => { + + PluginsEventBus.dispatchEvent('onSearch', { + type: 'bioEntity', + searchValues: [closestSearchResult], + results: [bioEntityContents], + }); + if (hasFitBounds) { searchFitBounds(fitBoundsZoom); } diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2f23be1f53f13cfe3e5aea6ee8bd0674db12f26 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts @@ -0,0 +1,117 @@ +import { FEATURE_TYPE } from '@/constants/features'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { Feature } from 'ol'; +import { handleFeaturesClick } from './handleFeaturesClick'; + +describe('handleFeaturesClick - util', () => { + beforeEach(() => { + PluginsEventBus.events = []; + }); + + describe('when feature contains pin icon marker', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.PIN_ICON_MARKER, + }), + ]; + + it('should dispatch event onPinIconClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onPinIconClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); + + describe('when feature contains pin icon bioentity', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.PIN_ICON_BIOENTITY, + }), + ]; + + it('should dispatch event onPinIconClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onPinIconClick', { id: featureId }); + }); + + it('should dispatch actions regarding opening entity drawer', () => { + const { store: localStore } = getReduxStoreWithActionsListener(); + const { dispatch: localDispatch } = localStore; + handleFeaturesClick(features, localDispatch); + expect(store.getActions()).toStrictEqual([ + { payload: undefined, type: 'search/clearSearchData' }, + { payload: 1234, type: 'drawer/openBioEntityDrawerById' }, + ]); + }); + + it('should return shouldBlockCoordSearch=true', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: true, + }); + }); + }); + + describe('when feature contains surface overlay', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.SURFACE_OVERLAY, + }), + ]; + + it('should dispatch event onSurfaceClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onSurfaceClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); + + describe('when feature contains surface marker', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.SURFACE_MARKER, + }), + ]; + + it('should dispatch event onSurfaceClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onSurfaceClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c1e5923d2290df828598a4b46d8c175979f020c --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts @@ -0,0 +1,39 @@ +import { FEATURE_TYPE, PIN_ICON_ANY, SURFACE_ANY } from '@/constants/features'; +import { openBioEntityDrawerById } from '@/redux/drawer/drawer.slice'; +import { clearSearchData } from '@/redux/search/search.slice'; +import { AppDispatch } from '@/redux/store'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { FeatureLike } from 'ol/Feature'; + +interface HandleFeaturesClickResult { + shouldBlockCoordSearch: boolean; +} + +export const handleFeaturesClick = ( + features: FeatureLike[], + dispatch: AppDispatch, +): HandleFeaturesClickResult => { + let shouldBlockCoordSearch = false; + const pinFeatures = features.filter(feature => PIN_ICON_ANY.includes(feature.get('type'))); + const surfaceFeatures = features.filter(feature => SURFACE_ANY.includes(feature.get('type'))); + + pinFeatures.forEach(pin => { + const pinId = pin.get('id') as string | number; + PluginsEventBus.dispatchEvent('onPinIconClick', { id: pinId }); + + if (pin.get('type') === FEATURE_TYPE.PIN_ICON_BIOENTITY) { + dispatch(clearSearchData()); + dispatch(openBioEntityDrawerById(pinId)); + shouldBlockCoordSearch = true; + } + }); + + surfaceFeatures.forEach(surface => { + const surfaceId = surface.get('id') as string | number; + PluginsEventBus.dispatchEvent('onSurfaceClick', { id: surfaceId }); + }); + + return { + shouldBlockCoordSearch, + }; +}; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts index b514095b3654a64d101e79df172d841b60abb01d..020f0c6480a1298b8799319fab20ca3e3f9c77b1 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts @@ -33,7 +33,10 @@ describe('handleReactionResults - util', () => { .reply(HttpStatusCode.Ok, reactionsFixture); beforeAll(async () => { - handleReactionResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_REACTION); + handleReactionResults( + dispatch, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, + )(ELEMENT_SEARCH_RESULT_MOCK_REACTION); }); it('should run getReactionsByIds as first action', () => { diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts index d82828178e0319f6b6176e2ae6764820f55ff7f4..24332182531f770a76a2372c29e6586a89cf2e33 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -6,10 +6,11 @@ import { AppDispatch } from '@/redux/store'; import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult, Reaction } from '@/types/models'; import { PayloadAction } from '@reduxjs/toolkit'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; /* prettier-ignore */ export const handleReactionResults = - (dispatch: AppDispatch, hasFitBounds?: boolean, fitBoundsZoom?: number) => + (dispatch: AppDispatch, closestSearchResult: ElementSearchResult, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>; const payload = data?.payload; @@ -30,7 +31,13 @@ export const handleReactionResults = searchQueries: bioEntitiesIds, isPerfectMatch: true }, ) - ).unwrap().then(() => { + ).unwrap().then((bioEntityContents) => { + PluginsEventBus.dispatchEvent('onSearch', { + type: 'bioEntity', + searchValues: [closestSearchResult], + results: [bioEntityContents], + }); + if (hasFitBounds) { searchFitBounds(fitBoundsZoom); } diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts index c3663e417a6d055fcaf2f6e8e787c4bcf99fabad..39dea1009c6cbacb44eb5819f49911b801aa7a04 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts @@ -25,7 +25,7 @@ export const handleSearchResultAction = async ({ REACTION: handleReactionResults, }[type]; - await action(dispatch, hasFitBounds, fitBoundsZoom)(closestSearchResult); + await action(dispatch, closestSearchResult, hasFitBounds, fitBoundsZoom)(closestSearchResult); if (type === 'ALIAS') { PluginsEventBus.dispatchEvent('onBioEntityClick', closestSearchResult); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts index 806d2a7ca5638841276a7fb90c6ae2b692bc0648..4270a32407558717ed920b0a1740c2286a75f7a2 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-magic-numbers */ +import { FEATURE_TYPE } from '@/constants/features'; import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS, ELEMENT_SEARCH_RESULT_MOCK_REACTION, @@ -8,7 +10,7 @@ import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { waitFor } from '@testing-library/react'; import { HttpStatusCode } from 'axios'; -import { MapBrowserEvent } from 'ol'; +import { Feature, Map, MapBrowserEvent } from 'ol'; import * as handleDataReset from './handleDataReset'; import * as handleSearchResultAction from './handleSearchResultAction'; import { onMapSingleClick } from './onMapSingleClick'; @@ -56,8 +58,12 @@ describe('onMapSingleClick - util', () => { const coordinate = [90, 90]; const event = getEvent(coordinate); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('should fire data reset handler', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleDataResetSpy).toBeCalled(); }); }); @@ -82,8 +88,12 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, undefined); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does not fire search result action', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleSearchResultActionSpy).not.toBeCalled(); }); }); @@ -110,12 +120,53 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, []); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does not fire search result action', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleSearchResultActionSpy).not.toBeCalled(); }); }); + describe('when clicked on feature type = pin icon bioentity', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_ALIAS; + const mapSize = { + width: 270, + height: 270, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const coordinate = [270, 270]; + const point = { x: 540.0072763538013, y: 539.9927236461986 }; + const event = getEvent(coordinate); + + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + + const mapInstanceMock = { + forEachFeatureAtPixel: (pixel: any, mappingFunction: (feature: Feature) => void): void => { + [ + new Feature({ + id: 1000, + type: FEATURE_TYPE.PIN_ICON_BIOENTITY, + }), + ].forEach(mappingFunction); + }, + } as unknown as Map; + + it('does NOT fire search result action handler', async () => { + const handler = onMapSingleClick(mapSize, modelId, dispatch); + await handler(event, mapInstanceMock); + await waitFor(() => expect(handleSearchResultActionSpy).not.toBeCalled()); + }); + }); + describe('when searchResults are valid', () => { describe('when results type is ALIAS', () => { const { store } = getReduxStoreWithActionsListener(); @@ -136,9 +187,13 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does fire search result action handler', async () => { const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); + await handler(event, mapInstanceMock); await waitFor(() => expect(handleSearchResultActionSpy).toBeCalled()); }); }); @@ -165,9 +220,13 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does fire search result action - handle reaction', async () => { const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); + await handler(event, mapInstanceMock); await waitFor(() => expect(handleSearchResultActionSpy).toBeCalled()); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts index 6940b226047f357dbbcb9f900c32d78ffafd5934..3d1e425c2e5a8d894df37ecc3f04eceee865ead5 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts @@ -1,15 +1,25 @@ import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { MapSize } from '@/redux/map/map.types'; import { AppDispatch } from '@/redux/store'; -import { MapBrowserEvent } from 'ol'; +import { Map, MapBrowserEvent } from 'ol'; +import { FeatureLike } from 'ol/Feature'; import { getSearchResults } from './getSearchResults'; import { handleDataReset } from './handleDataReset'; +import { handleFeaturesClick } from './handleFeaturesClick'; import { handleSearchResultAction } from './handleSearchResultAction'; /* prettier-ignore */ export const onMapSingleClick = (mapSize: MapSize, modelId: number, dispatch: AppDispatch) => - async ({ coordinate }: MapBrowserEvent<UIEvent>): Promise<void> => { + async ({ coordinate, pixel }: Pick<MapBrowserEvent<UIEvent>, 'coordinate' | 'pixel'>, mapInstance: Map): Promise<void> => { + const featuresAtPixel: FeatureLike[] = []; + mapInstance.forEachFeatureAtPixel(pixel, (feature) => featuresAtPixel.push(feature)); + const { shouldBlockCoordSearch } = handleFeaturesClick(featuresAtPixel, dispatch); + + if (shouldBlockCoordSearch) { + return; + } + // 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); diff --git a/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts b/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..db8737b1a2cb77c716a60f8cb3a2754949ef843b --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Feature, Map, MapBrowserEvent } from 'ol'; +import { onPointerMove } from './onPointerMove'; + +const TARGET_STRING = 'abcd'; + +const EVENT_DRAGGING_MOCK = { + dragging: true, +} as unknown as MapBrowserEvent<PointerEvent>; + +const EVENT_MOCK = { + dragging: false, + originalEvent: undefined, +} as unknown as MapBrowserEvent<PointerEvent>; + +const MAP_INSTANCE_BASE_MOCK = { + getEventPixel: (): void => {}, + forEachFeatureAtPixel: (): void => {}, +}; + +describe('onPointerMove - util', () => { + describe('when event dragging', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_DRAGGING_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe(''); + }); + }); + + describe('when pin feature present and target is html', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => new Feature(), + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe('pointer'); + }); + }); + + describe('when pin feature present and target is string', () => { + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => new Feature(), + getTarget: () => TARGET_STRING, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget()).toBe(TARGET_STRING); + }); + }); + + describe('when pin feature is not present and target is html', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => undefined, + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe(''); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts b/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts new file mode 100644 index 0000000000000000000000000000000000000000..868c3f3359df5e457e793f4ae6e676d6adf16442 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts @@ -0,0 +1,29 @@ +import { PIN_ICON_ANY } from '@/constants/features'; +import { Map } from 'ol'; +import MapBrowserEvent from 'ol/MapBrowserEvent'; + +const isTargetHTMLElement = (target: string | HTMLElement | undefined): target is HTMLElement => + !!target && typeof target !== 'string' && 'style' in target; + +/* prettier-ignore */ +export const onPointerMove = + (mapInstance: Map, event: MapBrowserEvent<PointerEvent>): void => { + if (event.dragging) { + return; + } + + const pixel = mapInstance.getEventPixel(event.originalEvent); + const feature = mapInstance.forEachFeatureAtPixel(pixel, firstFeature => { + const isPinIcon = PIN_ICON_ANY.includes(firstFeature.get('type')); + if (!isPinIcon) { + return undefined; + } + + return firstFeature; + }); + + const target = mapInstance.getTarget(); + if (isTargetHTMLElement(target)) { + target.style.cursor = feature ? 'pointer' : ''; + } + }; diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 1742d6fa7304541ec8f13ba93f5811b7be0b8bde..7ca778b5965bafa6ac38cae39e887afbb5cc6c21 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -13,6 +13,7 @@ import { useDebouncedCallback } from 'use-debounce'; import { onMapRightClick } from './mapRightClick/onMapRightClick'; import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapPositionChange } from './onMapPositionChange'; +import { onPointerMove } from './onPointerMove'; interface UseOlMapListenersInput { view: View; @@ -58,7 +59,20 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) return; } - const key = mapInstance.on('singleclick', handleMapSingleClick); + const key = mapInstance.on('pointermove', event => onPointerMove(mapInstance, event)); + + // eslint-disable-next-line consistent-return + return () => unByKey(key); + }, [mapInstance]); + + useEffect(() => { + if (!mapInstance) { + return; + } + + const key = mapInstance.on('singleclick', event => + handleMapSingleClick({ coordinate: event.coordinate, pixel: event.pixel }, mapInstance), + ); // eslint-disable-next-line consistent-return return () => unByKey(key); diff --git a/src/constants/features.ts b/src/constants/features.ts new file mode 100644 index 0000000000000000000000000000000000000000..4995bbcf07ff0173cb4c2760bf56874b1b9b9b1c --- /dev/null +++ b/src/constants/features.ts @@ -0,0 +1,9 @@ +export const FEATURE_TYPE = { + PIN_ICON_BIOENTITY: 'PIN_ICON_BIOENTITY', + PIN_ICON_MARKER: 'PIN_ICON_MARKER', + SURFACE_OVERLAY: 'SURFACE_OVERLAY', + SURFACE_MARKER: 'SURFACE_MARKER', +} as const; + +export const PIN_ICON_ANY = [FEATURE_TYPE.PIN_ICON_BIOENTITY, FEATURE_TYPE.PIN_ICON_MARKER]; +export const SURFACE_ANY = [FEATURE_TYPE.SURFACE_OVERLAY, FEATURE_TYPE.SURFACE_MARKER]; diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index b9566eb75fbac7db3ed74d8ccdfdda56bd607adb..8bf9877d86bb70a7665c50a5f400895b585baaae 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -3,13 +3,19 @@ import { rootSelector } from '@/redux/root/root.selectors'; import { MultiSearchData } from '@/types/fetchDataState'; import { BioEntity, BioEntityContent, MapModel } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; -import { searchedChemicalsBioEntitesOfCurrentMapSelector } from '../chemicals/chemicals.selectors'; +import { + allChemicalsBioEntitesOfAllMapsSelector, + searchedChemicalsBioEntitesOfCurrentMapSelector, +} from '../chemicals/chemicals.selectors'; import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector'; import { currentSearchedBioEntityId, currentSelectedSearchElement, } from '../drawer/drawer.selectors'; -import { searchedDrugsBioEntitesOfCurrentMapSelector } from '../drugs/drugs.selectors'; +import { + allDrugsBioEntitesOfAllMapsSelector, + searchedDrugsBioEntitesOfCurrentMapSelector, +} from '../drugs/drugs.selectors'; import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors'; export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); @@ -122,3 +128,40 @@ export const allVisibleBioEntitiesSelector = createSelector( return [content, chemicals, drugs].flat(); }, ); + +export const allContentBioEntitesSelectorOfAllMaps = createSelector( + bioEntitySelector, + (bioEntities): BioEntity[] => { + if (!bioEntities) { + return []; + } + + return (bioEntities?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ bioEntity }) => bioEntity); + }, +); + +export const allBioEntitiesSelector = createSelector( + allContentBioEntitesSelectorOfAllMaps, + allChemicalsBioEntitesOfAllMapsSelector, + allDrugsBioEntitesOfAllMapsSelector, + (content, chemicals, drugs): BioEntity[] => { + return [content, chemicals, drugs].flat(); + }, +); + +export const currentDrawerBioEntitySelector = createSelector( + allBioEntitiesSelector, + currentSearchedBioEntityId, + (bioEntities, currentBioEntityId): BioEntity | undefined => + bioEntities.find(({ id }) => id === currentBioEntityId), +); + +export const currentDrawerBioEntityRelatedSubmapSelector = createSelector( + currentDrawerBioEntitySelector, + modelsDataSelector, + (bioEntity, models): MapModel | undefined => + models.find(({ idObject }) => idObject === bioEntity?.submodel?.mapId), +); diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index b45d1941398d5ac6f99c10e30b7b3ab6c4b03587..9757ab4b3dee487935863834a79107d980384fa9 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -7,7 +7,7 @@ import { import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import contentsReducer from './bioEntity.slice'; -import { getBioEntity } from './bioEntity.thunks'; +import { getBioEntity, getMultiBioEntity } from './bioEntity.thunks'; import { BioEntityContentsState } from './bioEntity.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -56,4 +56,51 @@ describe('bioEntityContents thunks', () => { expect(payload).toEqual(undefined); }); }); + describe('getMultiBioEntity', () => { + it('should return transformed bioEntityContent array', async () => { + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + const data = await store + .dispatch( + getMultiBioEntity({ + searchQueries: [SEARCH_QUERY], + isPerfectMatch: false, + }), + ) + .unwrap(); + + expect(data).toEqual(bioEntityResponseFixture.content); + }); + it('should combine all returned bioEntityContent arrays and return array with all provided bioEntityContent elements', async () => { + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + const data = await store + .dispatch( + getMultiBioEntity({ + searchQueries: [SEARCH_QUERY, SEARCH_QUERY], + isPerfectMatch: false, + }), + ) + .unwrap(); + + expect(data).toEqual([ + ...bioEntityResponseFixture.content, + ...bioEntityResponseFixture.content, + ]); + }); + }); }); diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts index e1b59d1ed97a7fc4bb77006b7350a8d3e79980d7..32185755ccf261b6aa81edace068e49ba432f872 100644 --- a/src/redux/bioEntity/bioEntity.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -1,5 +1,5 @@ import { PerfectMultiSearchParams, PerfectSearchParams } from '@/types/search'; -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; @@ -25,17 +25,26 @@ export const getBioEntity = createAsyncThunk( ); type GetMultiBioEntityProps = PerfectMultiSearchParams; +type GetMultiBioEntityActions = PayloadAction<BioEntityContent[] | undefined>[]; export const getMultiBioEntity = createAsyncThunk( 'project/getMultiBioEntity', async ( { searchQueries, isPerfectMatch }: GetMultiBioEntityProps, { dispatch }, - ): Promise<void> => { + ): Promise<BioEntityContent[]> => { const asyncGetBioEntityFunctions = searchQueries.map(searchQuery => dispatch(getBioEntity({ searchQuery, isPerfectMatch })), ); - await Promise.all(asyncGetBioEntityFunctions); + const bioEntityContentsActions = (await Promise.all( + asyncGetBioEntityFunctions, + )) as GetMultiBioEntityActions; + + const bioEntityContents = bioEntityContentsActions + .map(bioEntityContentsAction => bioEntityContentsAction.payload || []) + .flat(); + + return bioEntityContents; }, ); diff --git a/src/redux/chemicals/chemicals.selectors.ts b/src/redux/chemicals/chemicals.selectors.ts index 03f829f8de425665f9f0a4f6d15d94fd37fee2b8..bb8d4aee3e28ffe7c65490e541e04a96ffdee38d 100644 --- a/src/redux/chemicals/chemicals.selectors.ts +++ b/src/redux/chemicals/chemicals.selectors.ts @@ -41,6 +41,18 @@ export const searchedChemicalsBioEntitesOfCurrentMapSelector = createSelector( }, ); +export const allChemicalsBioEntitesOfAllMapsSelector = createSelector( + chemicalsSelector, + (chemicalsState): BioEntity[] => { + return (chemicalsState?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ targets }) => targets.map(({ targetElements }) => targetElements)) + .flat() + .flat(); + }, +); + export const loadingChemicalsStatusSelector = createSelector( chemicalsForSelectedSearchElementSelector, state => state?.loading, diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 3a72aa534ed977ae551c3a289ae05c4a29a3582a..29333ec2c70a27cb8088e55cee67a010d4d27b0f 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -111,7 +111,6 @@ export const openBioEntityDrawerByIdReducer = ( state.isOpen = true; state.drawerName = 'bio-entity'; state.bioEntityDrawerState.bioentityId = action.payload; - state.searchDrawerState.selectedSearchElement = action.payload.toString(); }; export const getBioEntityDrugsForTargetReducers = ( diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index a348517b0e6beea005555e28e870734205a4a090..a0d5198979607da4cd56dd22cfd129a5c3517418 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -43,3 +43,6 @@ export type OpenReactionDrawerByIdAction = PayloadAction<OpenReactionDrawerByIdP export type OpenBioEntityDrawerByIdPayload = number | string; export type OpenBioEntityDrawerByIdAction = PayloadAction<OpenBioEntityDrawerByIdPayload>; + +export type SetSelectedSearchElementPayload = string; +export type SetSelectedSearchElementAction = PayloadAction<SetSelectedSearchElementPayload>; diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts index 45e9c16c828d894e31b3b5b3faf04a9fdd0981dc..f5c74de2ffeaf4b06ef5a34bbf8ae899e276e717 100644 --- a/src/redux/drugs/drugs.selectors.ts +++ b/src/redux/drugs/drugs.selectors.ts @@ -54,3 +54,15 @@ export const searchedDrugsBioEntitesOfCurrentMapSelector = createSelector( .filter(bioEntity => bioEntity.model === currentModelId); }, ); + +export const allDrugsBioEntitesOfAllMapsSelector = createSelector( + drugsSelector, + (drugsState): BioEntity[] => { + return (drugsState?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ targets }) => targets.map(({ targetElements }) => targetElements)) + .flat() + .flat(); + }, +); diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts index 5fd9ea0a98a72dbfe3eacd96d8d708f81c3058bd..2969f8ab7be3e86ef9f44f871c4eace00d0e97a4 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts @@ -14,6 +14,8 @@ const PLUGINS_EVENTS = { onZoomChanged: 'onZoomChanged', onCenterChanged: 'onCenterChanged', onBioEntityClick: 'onBioEntityClick', + onPinIconClick: 'onPinIconClick', + onSurfaceClick: 'onSurfaceClick', }, search: { onSearch: 'onSearch', diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts index 5227eabbdc6e6ea1f43b84d809fe6718af2fcc4c..66d4ab43fd2d6dd73422f714ada6c8a897592d0e 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts @@ -4,6 +4,8 @@ import { ALLOWED_PLUGINS_EVENTS, LISTENER_NOT_FOUND } from './pluginsEventBus.co import type { CenteredCoordinates, ClickedBioEntity, + ClickedPinIcon, + ClickedSurfaceOverlay, Events, EventsData, PluginsEventBusType, @@ -21,6 +23,8 @@ export function dispatchEvent(type: 'onSubmapClose', submapId: number): void; export function dispatchEvent(type: 'onZoomChanged', data: ZoomChanged): void; export function dispatchEvent(type: 'onCenterChanged', data: CenteredCoordinates): void; export function dispatchEvent(type: 'onBioEntityClick', data: ClickedBioEntity): void; +export function dispatchEvent(type: 'onPinIconClick', data: ClickedPinIcon): void; +export function dispatchEvent(type: 'onSurfaceClick', data: ClickedSurfaceOverlay): void; export function dispatchEvent(type: 'onSearch', data: SearchData): void; export function dispatchEvent(type: Events, data: EventsData): void { if (!ALLOWED_PLUGINS_EVENTS.includes(type)) throw new Error(`Invalid event type: ${type}`); diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts index 7679bb0af514be8208c15a0a23c55c3de096aafe..0fdec91300559d50089301f264031c2d069f5fc4 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts @@ -1,4 +1,11 @@ -import { BioEntityContent, Chemical, CreatedOverlay, Drug, MapOverlay } from '@/types/models'; +import { + BioEntityContent, + Chemical, + CreatedOverlay, + Drug, + ElementSearchResult, + MapOverlay, +} from '@/types/models'; import { dispatchEvent } from './pluginsEventBus'; export type BackgroundEvents = 'onBackgroundOverlayChange'; @@ -6,13 +13,15 @@ export type OverlayEvents = | 'onAddDataOverlay' | 'onRemoveDataOverlay' | 'onShowOverlay' - | 'onHideOverlay'; + | 'onHideOverlay' + | 'onSurfaceClick'; export type SubmapEvents = | 'onSubmapOpen' | 'onSubmapClose' | 'onZoomChanged' | 'onCenterChanged' - | 'onBioEntityClick'; + | 'onBioEntityClick' + | 'onPinIconClick'; export type SearchEvents = 'onSearch'; export type Events = OverlayEvents | BackgroundEvents | SubmapEvents | SearchEvents; @@ -34,9 +43,17 @@ export type ClickedBioEntity = { modelId: number; }; +export type ClickedPinIcon = { + id: number | string; +}; + +export type ClickedSurfaceOverlay = { + id: number | string; +}; + export type SearchDataBioEntity = { type: 'bioEntity'; - searchValues: string[]; + searchValues: string[] | ElementSearchResult[]; results: BioEntityContent[][]; }; @@ -61,6 +78,8 @@ export type EventsData = | ZoomChanged | CenteredCoordinates | ClickedBioEntity + | ClickedPinIcon + | ClickedSurfaceOverlay | SearchData; export type PluginsEventBusType = {