diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitiesFeatures.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitiesFeatures.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b9f9178f75ec0851bfd69bd93797d87ce59f12f --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitiesFeatures.ts @@ -0,0 +1,25 @@ +import { PinType } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types'; +import { ONE } from '@/constants/common'; +import { BioEntity } from '@/types/models'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { getBioEntitySingleFeature } from './getBioEntitySingleFeature'; + +export const getBioEntitiesFeatures = ( + bioEntites: BioEntity[], + { + pointToProjection, + type, + }: { + pointToProjection: UsePointToProjectionResult; + type: PinType; + }, +): Feature[] => { + return bioEntites.map((bioEntity, index) => + getBioEntitySingleFeature(bioEntity, { + pointToProjection, + type, + value: index + ONE, + }), + ); +}; diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntititesFeatures.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntititesFeatures.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c0eb2e3a1619022fd9ef785c0b94d49b011012a --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntititesFeatures.test.ts @@ -0,0 +1,45 @@ +import { PinType } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types'; +import { bioEntitiesContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { UsePointToProjectionResult, usePointToProjection } from '@/utils/map/usePointToProjection'; +import { + GetReduxWrapperUsingSliceReducer, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { Feature } from 'ol'; +import Style from 'ol/style/Style'; +import { getBioEntitiesFeatures } from './getBioEntitiesFeatures'; + +const getPointToProjection = ( + wrapper: ReturnType<GetReduxWrapperUsingSliceReducer>['Wrapper'], +): UsePointToProjectionResult => { + const { result: usePointToProjectionHook } = renderHook(() => usePointToProjection(), { + wrapper, + }); + + return usePointToProjectionHook.current; +}; + +describe('getBioEntitiesFeatures - subUtil', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + }); + const bioEntititesContent = bioEntitiesContentFixture; + const bioEntities = bioEntititesContent.map(({ bioEntity }) => bioEntity); + const pointToProjection = getPointToProjection(Wrapper); + + const pinTypes: PinType[] = ['bioEntity', 'drugs', 'chemicals', 'mirna', 'none']; + + it.each(pinTypes)('should return array of instances of Feature with Style type=%s', type => { + const result = getBioEntitiesFeatures(bioEntities, { + pointToProjection, + type, + }); + + result.forEach(resultElement => { + expect(resultElement).toBeInstanceOf(Feature); + expect(resultElement.getStyle()).toBeInstanceOf(Style); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..79d66146891233a301d9528594bc2e8020e8d47e --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.test.ts @@ -0,0 +1,66 @@ +import { PinType } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types'; +import { PINS_COLORS } from '@/constants/canvas'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { UsePointToProjectionResult, usePointToProjection } from '@/utils/map/usePointToProjection'; +import { + GetReduxWrapperUsingSliceReducer, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { Feature } from 'ol'; +import Style from 'ol/style/Style'; +import { getBioEntitySingleFeature } from './getBioEntitySingleFeature'; +import * as getPinStyle from './getPinStyle'; + +jest.mock('./getPinStyle', () => ({ + __esModule: true, + ...jest.requireActual('./getPinStyle'), +})); + +const getPinStyleSpy = jest.spyOn(getPinStyle, 'getPinStyle'); + +const getPointToProjection = ( + wrapper: ReturnType<GetReduxWrapperUsingSliceReducer>['Wrapper'], +): UsePointToProjectionResult => { + const { result: usePointToProjectionHook } = renderHook(() => usePointToProjection(), { + wrapper, + }); + + return usePointToProjectionHook.current; +}; + +describe('getBioEntitySingleFeature - subUtil', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + }); + const { bioEntity } = bioEntityContentFixture; + const pointToProjection = getPointToProjection(Wrapper); + + const value = 1448; + const pinTypes: PinType[] = ['bioEntity', 'drugs', 'chemicals', 'mirna', 'none']; + + it.each(pinTypes)('should return instance of Feature with Style type=%s', type => { + const result = getBioEntitySingleFeature(bioEntity, { + pointToProjection, + type, + value, + }); + + expect(result).toBeInstanceOf(Feature); + expect(result.getStyle()).toBeInstanceOf(Style); + }); + + it.each(pinTypes)('should run getPinStyle with valid args for type=%s', async type => { + getBioEntitySingleFeature(bioEntity, { + pointToProjection, + type, + value, + }); + + expect(getPinStyleSpy).toHaveBeenCalledWith({ + color: PINS_COLORS[type], + value, + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..b910627e271eeb0d4ea7717e5ed9ea6bc56aabb3 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getBioEntitySingleFeature.ts @@ -0,0 +1,29 @@ +import { PinType } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types'; +import { PINS_COLORS } from '@/constants/canvas'; +import { BioEntity } from '@/types/models'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { getPinFeature } from './getPinFeature'; +import { getPinStyle } from './getPinStyle'; + +export const getBioEntitySingleFeature = ( + bioEntity: BioEntity, + { + pointToProjection, + type, + value, + }: { + pointToProjection: UsePointToProjectionResult; + type: PinType; + value: number; + }, +): Feature => { + const feature = getPinFeature(bioEntity, pointToProjection); + const style = getPinStyle({ + color: PINS_COLORS[type], + value, + }); + + feature.setStyle(style); + return feature; +}; diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..64f3decc886751a4913a9d1b04cb0ff53241270b --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts @@ -0,0 +1,38 @@ +import { HALF } from '@/constants/dividers'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { Feature } from 'ol'; +import { getPinFeature } from './getPinFeature'; + +describe('getPinFeature - subUtil', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + }); + const { bioEntity } = bioEntityContentFixture; + const { result: usePointToProjectionHook } = renderHook(() => usePointToProjection(), { + wrapper: Wrapper, + }); + const pointToProjection = usePointToProjectionHook.current; + const result = getPinFeature(bioEntity, pointToProjection); + + it('should return instance of Feature', () => { + expect(result).toBeInstanceOf(Feature); + }); + + it('should return id as name', () => { + expect(result.get('name')).toBe(bioEntity.id); + }); + + it('should return point parsed with point to projection', () => { + const [x, y] = result.getGeometry()?.getExtent() || []; + const geometryPoint = pointToProjection({ + x: bioEntity.x + bioEntity.width / HALF, + y: bioEntity.y + bioEntity.height / HALF, + }); + + expect([x, y]).toStrictEqual(geometryPoint); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec09a90d48d11cf3d2f743fca21bbd5f1475244b --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts @@ -0,0 +1,20 @@ +import { HALF } from '@/constants/dividers'; +import { BioEntity } from '@/types/models'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { Point } from 'ol/geom'; + +export const getPinFeature = ( + { x, y, width, height, id }: BioEntity, + pointToProjection: UsePointToProjectionResult, +): Feature => { + const point = { + x: x + width / HALF, + y: y + height / HALF, + }; + + return new Feature({ + geometry: new Point(pointToProjection(point)), + name: id, + }); +}; diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..add22cbf0eb2d01e969e0dd421bbf9eafe462fc7 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.test.ts @@ -0,0 +1,27 @@ +import { PIN_SIZE } from '@/constants/canvas'; +import { ZERO } from '@/constants/common'; +import Style from 'ol/style/Style'; +import { getPinStyle } from './getPinStyle'; + +describe('getPinStyle - subUtil', () => { + const input = { + color: '#000000', + value: 420, + }; + + const result = getPinStyle(input); + + it('should return instance of Style', () => { + expect(result).toBeInstanceOf(Style); + }); + + it('should return image object with displacament of pin size height', () => { + const image = result.getImage(); + expect(image.getDisplacement()).toStrictEqual([ZERO, PIN_SIZE.height]); + }); + + it('should return image of pin size', () => { + const image = result.getImage(); + expect(image.getImageSize()).toStrictEqual([PIN_SIZE.width, PIN_SIZE.height]); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e91dfc047c45e2d6ac6a872564028abe8deb2d7 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinStyle.ts @@ -0,0 +1,18 @@ +import { PIN_SIZE } from '@/constants/canvas'; +import { ZERO } from '@/constants/common'; +import Icon from 'ol/style/Icon'; +import Style from 'ol/style/Style'; +import { getCanvasIcon } from '../getCanvasIcon'; + +export const getPinStyle = ({ value, color }: { value: number; color: string }): Style => + new Style({ + image: new Icon({ + displacement: [ZERO, PIN_SIZE.height], + anchorXUnits: 'fraction', + anchorYUnits: 'pixels', + img: getCanvasIcon({ + color, + value, + }), + }), + }); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cfcbe85e373b7d8bc10bbfb927a83e7e1b3224e7 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.test.ts @@ -0,0 +1,17 @@ +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import VectorLayer from 'ol/layer/Vector'; +import { useOlMapPinsLayer } from './useOlMapPinsLayer'; + +describe('useOlMapPinsLayer - util', () => { + const { Wrapper } = getReduxWrapperWithStore(); + + it('should return VectorLayer', () => { + const { result } = renderHook(() => useOlMapPinsLayer(), { + wrapper: Wrapper, + }); + + expect(result.current).toBeInstanceOf(VectorLayer); + expect(result.current.getSourceState()).toBe('ready'); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..51523f777916b3ef9d36b7d23d21f429d8f38f43 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/useOlMapPinsLayer.ts @@ -0,0 +1,36 @@ +/* eslint-disable no-magic-numbers */ +import { allBioEntitesSelectorOfCurrentMap } from '@/redux/bioEntity/bioEntity.selectors'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import BaseLayer from 'ol/layer/Base'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getBioEntitiesFeatures } from './getBioEntitiesFeatures'; + +export const useOlMapPinsLayer = (): BaseLayer => { + const pointToProjection = usePointToProjection(); + const contentBioEntites = useSelector(allBioEntitesSelectorOfCurrentMap); + + const bioEntityFeatures = useMemo( + () => + [getBioEntitiesFeatures(contentBioEntites, { pointToProjection, type: 'bioEntity' })].flat(), + [contentBioEntites, pointToProjection], + ); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features: [...bioEntityFeatures], + }); + }, [bioEntityFeatures]); + + const pinsLayer = useMemo( + () => + new VectorLayer({ + source: vectorSource, + }), + [vectorSource], + ); + + return pinsLayer; +}; diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.test.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4857318954de7c35d8eed0637a372a4850861fdf --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.test.ts @@ -0,0 +1,54 @@ +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { LinePoint } from '@/types/reactions'; +import { UsePointToProjectionResult, usePointToProjection } from '@/utils/map/usePointToProjection'; +import { + GetReduxWrapperUsingSliceReducer, + getReduxWrapperWithStore, +} from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import { Feature } from 'ol'; +import { LineString } from 'ol/geom'; +import { getLineFeature } from './getLineFeature'; + +const getPointToProjection = ( + wrapper: ReturnType<GetReduxWrapperUsingSliceReducer>['Wrapper'], +): UsePointToProjectionResult => { + const { result: usePointToProjectionHook } = renderHook(() => usePointToProjection(), { + wrapper, + }); + + return usePointToProjectionHook.current; +}; + +describe('getLineFeature', () => { + const { Wrapper } = getReduxWrapperWithStore({ + map: initialMapStateFixture, + }); + const pointToProjection = getPointToProjection(Wrapper); + + const linePoints: LinePoint = [ + { + x: 16, + y: 32, + }, + { + x: 54, + y: 16, + }, + ]; + + it('should return valid Feature object', () => { + const result = getLineFeature(linePoints, pointToProjection); + + expect(result).toBeInstanceOf(Feature); + }); + + it('should return valid Feature object with LineString geometry', () => { + const result = getLineFeature(linePoints, pointToProjection); + const geometry = result.getGeometry(); + + expect(geometry).toBeInstanceOf(LineString); + // eslint-disable-next-line no-magic-numbers + expect(geometry?.getExtent()).toStrictEqual([Infinity, -238107693, Infinity, -238107693]); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts new file mode 100644 index 0000000000000000000000000000000000000000..40211171605c35ff87995e6ed9abf02bc300c76b --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/getLineFeature.ts @@ -0,0 +1,15 @@ +import { LinePoint } from '@/types/reactions'; +import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import { Feature } from 'ol'; +import { LineString } from 'ol/geom'; + +export const getLineFeature = ( + linePoints: LinePoint, + pointToProjection: UsePointToProjectionResult, +): Feature => { + const points = linePoints.map(pointToProjection); + + return new Feature({ + geometry: new LineString(points), + }); +}; diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..675e6b2fed86fba7182b4031ea80f1ddf5f06b5c --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.test.ts @@ -0,0 +1,31 @@ +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; +import { renderHook } from '@testing-library/react'; +import VectorLayer from 'ol/layer/Vector'; +import Style from 'ol/style/Style'; +import { useOlMapReactionsLayer } from './useOlMapReactionsLayer'; + +describe('useOlMapReactionsLayer - util', () => { + const { Wrapper } = getReduxWrapperWithStore(); + + it('should return VectorLayer', () => { + const { result } = renderHook(() => useOlMapReactionsLayer(), { + wrapper: Wrapper, + }); + + expect(result.current).toBeInstanceOf(VectorLayer); + expect(result.current.getSourceState()).toBe('ready'); + }); + + it('should return VectorLayer with valid Style', () => { + const { result } = renderHook(() => useOlMapReactionsLayer(), { + wrapper: Wrapper, + }); + + const vectorLayer = result.current; + const style = vectorLayer.getStyle(); + + expect(style).not.toBeInstanceOf(Array); + expect((style as Style).getFill()).toEqual({ color_: '#00AAFF' }); + expect((style as Style).getStroke()).toMatchObject({ color_: '#00AAFF', width_: 6 }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c4fbb6483e8b8bce865b2a5077846b28b637575 --- /dev/null +++ b/src/components/Map/MapViewer/utils/config/reactionsLayer/useOlMapReactionsLayer.ts @@ -0,0 +1,49 @@ +/* eslint-disable no-magic-numbers */ +import { LINE_COLOR, LINE_WIDTH } from '@/constants/canvas'; +import { allReactionsSelectorOfCurrentMap } from '@/redux/reactions/reactions.selector'; +import { Reaction } from '@/types/models'; +import { LinePoint } from '@/types/reactions'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import Geometry from 'ol/geom/Geometry'; +import VectorLayer from 'ol/layer/Vector'; +import VectorSource from 'ol/source/Vector'; +import Fill from 'ol/style/Fill'; +import Stroke from 'ol/style/Stroke'; +import Style from 'ol/style/Style'; +import { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { getLineFeature } from './getLineFeature'; + +const getReactionsLines = (reactions: Reaction[]): LinePoint[] => + reactions.map(({ lines }) => lines.map(({ start, end }): LinePoint => [start, end])).flat(); + +export const useOlMapReactionsLayer = (): VectorLayer<VectorSource<Geometry>> => { + const pointToProjection = usePointToProjection(); + const reactions = useSelector(allReactionsSelectorOfCurrentMap); + const reactionsLines = getReactionsLines(reactions); + + const reactionsLinesFeatures = useMemo( + () => reactionsLines.map(linePoint => getLineFeature(linePoint, pointToProjection)), + [reactionsLines, pointToProjection], + ); + + const vectorSource = useMemo(() => { + return new VectorSource({ + features: [...reactionsLinesFeatures], + }); + }, [reactionsLinesFeatures]); + + const reactionsLayer = useMemo( + () => + new VectorLayer({ + source: vectorSource, + style: new Style({ + fill: new Fill({ color: LINE_COLOR }), + stroke: new Stroke({ color: LINE_COLOR, width: LINE_WIDTH }), + }), + }), + [vectorSource], + ); + + return reactionsLayer; +}; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts index c240ae88a5017c0108be17e74e272dcf84abe855..cee56323c59bb31a4071c527285eca44a37c9c44 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.test.ts @@ -1,16 +1,16 @@ /* eslint-disable no-magic-numbers */ +import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; import { MAP_DATA_INITIAL_STATE, OPENED_MAPS_INITIAL_STATE } from '@/redux/map/map.constants'; -import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { initialMapStateFixture } from '@/redux/map/map.fixtures'; -import { BACKGROUND_INITIAL_STATE_MOCK } from '@/redux/backgrounds/background.mock'; +import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { renderHook, waitFor } from '@testing-library/react'; import { Map } from 'ol'; import BaseLayer from 'ol/layer/Base'; import TileLayer from 'ol/layer/Tile'; import VectorLayer from 'ol/layer/Vector'; import React from 'react'; -import { useOlMapLayers } from './useOlMapLayers'; import { useOlMap } from '../useOlMap'; +import { useOlMapLayers } from './useOlMapLayers'; const useRefValue = { current: null, @@ -98,17 +98,24 @@ describe('useOlMapLayers - util', () => { return result.current; }; - it('should return valid TileLayer instance', () => { + it('should return valid TileLayer instance [1]', () => { const result = getRenderedHookResults(); expect(result[0]).toBeInstanceOf(TileLayer); expect(result[0].getSourceState()).toBe('ready'); }); - it('should return valid VectorLayer instance', () => { + it('should return valid VectorLayer instance [2]', () => { const result = getRenderedHookResults(); expect(result[1]).toBeInstanceOf(VectorLayer); expect(result[1].getSourceState()).toBe('ready'); }); + + it('should return valid VectorLayer instance [3]', () => { + const result = getRenderedHookResults(); + + expect(result[2]).toBeInstanceOf(VectorLayer); + expect(result[2].getSourceState()).toBe('ready'); + }); }); diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index c092a1465003e3c94414fd18582a6012dd74993a..950ae0ca14e9c581dd0016b2645bb71e0433c089 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -1,7 +1,8 @@ /* eslint-disable no-magic-numbers */ import { useEffect } from 'react'; import { MapConfig, MapInstance } from '../../MapViewer.types'; -import { useOlMapPinsLayer } from './useOlMapPinsLayer'; +import { useOlMapPinsLayer } from './pinsLayer/useOlMapPinsLayer'; +import { useOlMapReactionsLayer } from './reactionsLayer/useOlMapReactionsLayer'; import { useOlMapTileLayer } from './useOlMapTileLayer'; interface UseOlMapLayersInput { @@ -11,14 +12,15 @@ interface UseOlMapLayersInput { export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig['layers'] => { const tileLayer = useOlMapTileLayer(); const pinsLayer = useOlMapPinsLayer(); + const reactionsLayer = useOlMapReactionsLayer(); useEffect(() => { if (!mapInstance) { return; } - mapInstance.setLayers([tileLayer, pinsLayer]); - }, [tileLayer, pinsLayer, mapInstance]); + mapInstance.setLayers([tileLayer, reactionsLayer, pinsLayer]); + }, [reactionsLayer, tileLayer, pinsLayer, mapInstance]); - return [tileLayer, pinsLayer]; + return [tileLayer, pinsLayer, reactionsLayer]; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts deleted file mode 100644 index 3244cf3a75e75a2a03fb23fc8dedb7eb43677bbf..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/utils/config/useOlMapPinsLayer.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { PIN_SIZE } from '@/constants/canvas'; -import { allBioEntitesSelectorOfCurrentMap } from '@/redux/bioEntity/bioEntity.selectors'; -import { BioEntity } from '@/types/models'; -import { UsePointToProjectionResult, usePointToProjection } from '@/utils/map/usePointToProjection'; -import { Feature } from 'ol'; -import { Point as OlPoint } from 'ol/geom'; -import BaseLayer from 'ol/layer/Base'; -import VectorLayer from 'ol/layer/Vector'; -import VectorSource from 'ol/source/Vector'; -import Icon from 'ol/style/Icon'; -import Style from 'ol/style/Style'; -import { useMemo } from 'react'; -import { useSelector } from 'react-redux'; -import { getCanvasIcon } from './getCanvasIcon'; - -const getPinFeature = ( - { x, y, width, height, name }: BioEntity, - pointToProjection: UsePointToProjectionResult, -): Feature => { - const point = { - x: x + width / 2, - y: y + height / 2, - }; - - return new Feature({ - geometry: new OlPoint(pointToProjection(point)), - name, - }); -}; - -const getPinStyle = ({ value, color }: { value: number; color: string }): Style => - new Style({ - image: new Icon({ - displacement: [0, PIN_SIZE.height], - anchorXUnits: 'fraction', - anchorYUnits: 'pixels', - img: getCanvasIcon({ - color, - value, - }), - }), - }); - -export const useOlMapPinsLayer = (): BaseLayer => { - const pointToProjection = usePointToProjection(); - const bioEntites = useSelector(allBioEntitesSelectorOfCurrentMap); - - const bioEntityFeatures = useMemo( - () => - bioEntites.map(({ bioEntity }, index) => { - const feature = getPinFeature(bioEntity, pointToProjection); - const style = getPinStyle({ - color: '#106AD7', - value: index + 1, - }); - - feature.setStyle(style); - return feature; - }), - [bioEntites, pointToProjection], - ); - - const vectorSource = useMemo(() => { - return new VectorSource({ - features: [...bioEntityFeatures], - }); - }, [bioEntityFeatures]); - - const pinsLayer = useMemo( - () => - new VectorLayer({ - source: vectorSource, - }), - [vectorSource], - ); - - return pinsLayer; -}; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5533b62d166c032bd0341524805b0dad29836c8c --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.test.ts @@ -0,0 +1,102 @@ +/* eslint-disable no-magic-numbers */ +import { + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, +} from '@/models/mocks/elementSearchResultMock'; +import { apiPath } from '@/redux/apiPath'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { getSearchResults } from './getSearchResults'; + +const mockedAxiosOldClient = mockNetworkResponse(); + +describe('getSearchResults - util', () => { + describe('when results type is ALIAS', () => { + 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 }; + + beforeAll(() => { + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + }); + + it('returns valid array of objects', async () => { + const result = await getSearchResults({ + coordinate, + mapSize, + modelId, + }); + + expect(result).toEqual([ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + }); + }); + + describe('when results type is REACTION', () => { + const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_REACTION; + const mapSize = { + width: 270, + height: 270, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const coordinate = [270, 270]; + const point = { x: 540.0072763538013, y: 539.9927236461986 }; + + beforeAll(() => { + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]); + }); + + it('returns valid array of objects', async () => { + const result = await getSearchResults({ + coordinate, + mapSize, + modelId, + }); + + expect(result).toEqual([ELEMENT_SEARCH_RESULT_MOCK_REACTION]); + }); + }); + + describe('when results type is invalid', () => { + 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 }; + + beforeAll(() => { + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, { + invalidObject: true, + }); + }); + + it('should return undefined', async () => { + const result = await getSearchResults({ + coordinate, + mapSize, + modelId, + }); + + expect(result).toEqual(undefined); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.ts new file mode 100644 index 0000000000000000000000000000000000000000..df376d4a846df12e4fa6820e893ca9e3b7773b3e --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/getSearchResults.ts @@ -0,0 +1,22 @@ +import { MapSize } from '@/redux/map/map.types'; +import { ElementSearchResult } from '@/types/models'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { getElementsByPoint } from '@/utils/search/getElementsByCoordinates'; +import { Coordinate } from 'ol/coordinate'; +import { toLonLat } from 'ol/proj'; + +interface GetSearchResultsInput { + coordinate: Coordinate; + mapSize: MapSize; + modelId: number; +} + +export const getSearchResults = async ({ + coordinate, + mapSize, + modelId, +}: GetSearchResultsInput): Promise<ElementSearchResult[] | undefined> => { + const [lng, lat] = toLonLat(coordinate); + const point = latLngToPoint([lat, lng], mapSize); + return getElementsByPoint({ point, currentModelId: modelId }); +}; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..701539ac758b99655a2537758fc04bca64f2f4c3 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts @@ -0,0 +1,37 @@ +import { FIRST, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS } from '@/models/mocks/elementSearchResultMock'; +import { apiPath } from '@/redux/apiPath'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { waitFor } from '@testing-library/react'; +import { HttpStatusCode } from 'axios'; +import { handleAliasResults } from './handleAliasResults'; + +const mockedAxiosOldClient = mockNetworkResponse(); + +describe('handleAliasResults - util', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + + mockedAxiosOldClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), + isPerfectMatch: true, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + beforeAll(async () => { + handleAliasResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); + }); + + it('should run getBioEntityAction', async () => { + await waitFor(() => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[FIRST].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 new file mode 100644 index 0000000000000000000000000000000000000000..024f627edf2bd8f150a2bb19e738005a433917e3 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts @@ -0,0 +1,15 @@ +import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; +import { AppDispatch } from '@/redux/store'; +import { ElementSearchResult } from '@/types/models'; + +/* prettier-ignore */ +export const handleAliasResults = + (dispatch: AppDispatch) => + async ({ id }: ElementSearchResult): Promise<void> => { + dispatch( + getMultiBioEntity({ + searchQueries: [id.toString()], + isPerfectMatch: true + }), + ); + }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f188b5fc5828b89f43f98e6f590a05da7c66bbbd --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.test.ts @@ -0,0 +1,15 @@ +import { FIRST } from '@/constants/common'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { handleDataReset } from './handleDataReset'; + +describe('handleDataReset', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + + it('should dispatch resetReactionsData action', () => { + dispatch(handleDataReset); + + const actions = store.getActions(); + expect(actions[FIRST].type).toBe('reactions/resetReactionsData'); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d3e1d6d526ee22725aff6b92ccbf273eec5ff79 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleDataReset.ts @@ -0,0 +1,6 @@ +import { resetReactionsData } from '@/redux/reactions/reactions.slice'; +import { AppDispatch } from '@/redux/store'; + +export const handleDataReset = (dispatch: AppDispatch): void => { + dispatch(resetReactionsData()); +}; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..21c6524d2eb8656cf19a870edb07e15d06963d6e --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts @@ -0,0 +1,57 @@ +/* eslint-disable no-magic-numbers */ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { reactionsFixture } from '@/models/fixtures/reactionFixture'; +import { + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, +} from '@/models/mocks/elementSearchResultMock'; +import { apiPath } from '@/redux/apiPath'; +import { mockNetworkNewAPIResponse, mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { HttpStatusCode } from 'axios'; +import { handleReactionResults } from './handleReactionResults'; + +const mockedAxiosOldClient = mockNetworkResponse(); +const mockedAxiosNewClient = mockNetworkNewAPIResponse(); + +describe('handleReactionResults - util', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + + mockedAxiosNewClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), + isPerfectMatch: true, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosOldClient + .onGet(apiPath.getReactionsWithIds([ELEMENT_SEARCH_RESULT_MOCK_REACTION.id])) + .reply(HttpStatusCode.Ok, reactionsFixture); + + beforeAll(async () => { + handleReactionResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_REACTION); + }); + + it('should run getReactionsByIds as first action', () => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[0].type).toEqual('reactions/getByIds/pending'); + expect(actions[1].type).toEqual('reactions/getByIds/fulfilled'); + }); + + it('should run setBioEntityContent to empty array as second action', () => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[2].type).toEqual('project/getMultiBioEntity/pending'); + }); + + it('should run getBioEntity as third action', () => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[3].type).toEqual('project/getBioEntityContents/pending'); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts new file mode 100644 index 0000000000000000000000000000000000000000..5c5ae1a20eada4b0349d1b2972059cdb04265d69 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -0,0 +1,30 @@ +import { FIRST, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; +import { getReactionsByIds } from '@/redux/reactions/reactions.thunks'; +import { AppDispatch } from '@/redux/store'; +import { ElementSearchResult, Reaction } from '@/types/models'; +import { PayloadAction } from '@reduxjs/toolkit'; + +/* prettier-ignore */ +export const handleReactionResults = + (dispatch: AppDispatch) => + async ({ id }: ElementSearchResult): Promise<void> => { + const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>; + const payload = data?.payload; + if (!data || !payload || payload.length === SIZE_OF_EMPTY_ARRAY) { + return; + } + + const { products, reactants, modifiers } = payload[FIRST]; + const productsIds = products.map(p => p.aliasId); + const reactantsIds = reactants.map(r => r.aliasId); + const modifiersIds = modifiers.map(m => m.aliasId); + const bioEntitiesIds = [...productsIds, ...reactantsIds, ...modifiersIds].map(identifier => String(identifier)); + + await dispatch( + getMultiBioEntity({ + searchQueries: bioEntitiesIds, + isPerfectMatch: true }, + ), + ); + }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8492539613ec74eec4792b914632d354d64733cb --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.test.ts @@ -0,0 +1,46 @@ +import { + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, +} from '@/models/mocks/elementSearchResultMock'; +import * as handleAliasResults from './handleAliasResults'; +import * as handleReactionResults from './handleReactionResults'; +import { handleSearchResultAction } from './handleSearchResultAction'; + +jest.mock('./handleAliasResults', () => ({ + __esModule: true, + handleAliasResults: jest.fn().mockImplementation(() => (): null => null), +})); + +jest.mock('./handleReactionResults', () => ({ + __esModule: true, + handleReactionResults: jest.fn().mockImplementation(() => (): null => null), +})); + +const handleAliasResultsSpy = jest.spyOn(handleAliasResults, 'handleAliasResults'); +const handleReactionResultsSpy = jest.spyOn(handleReactionResults, 'handleReactionResults'); + +describe('handleSearchResultAction - util', () => { + const dispatch = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('on ALIAS search results', () => { + const searchResults = [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]; + + it('should fire handleAliasResults', async () => { + await handleSearchResultAction({ searchResults, dispatch }); + expect(handleAliasResultsSpy).toBeCalled(); + }); + }); + + describe('on REACTION search results', () => { + const searchResults = [ELEMENT_SEARCH_RESULT_MOCK_REACTION]; + + it('should fire handleReactionResults', async () => { + await handleSearchResultAction({ searchResults, dispatch }); + expect(handleReactionResultsSpy).toBeCalled(); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts new file mode 100644 index 0000000000000000000000000000000000000000..e60af27ce2a06b6db202af658868857d57b341a7 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts @@ -0,0 +1,24 @@ +import { FIRST } from '@/constants/common'; +import { AppDispatch } from '@/redux/store'; +import { ElementSearchResult } from '@/types/models'; +import { handleAliasResults } from './handleAliasResults'; +import { handleReactionResults } from './handleReactionResults'; + +interface HandleSearchResultActionInput { + searchResults: ElementSearchResult[]; + dispatch: AppDispatch; +} + +export const handleSearchResultAction = async ({ + searchResults, + dispatch, +}: HandleSearchResultActionInput): Promise<void> => { + const closestSearchResult = searchResults[FIRST]; + const { type } = closestSearchResult; + const action = { + ALIAS: handleAliasResults, + REACTION: handleReactionResults, + }[type]; + + await action(dispatch)(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 new file mode 100644 index 0000000000000000000000000000000000000000..806d2a7ca5638841276a7fb90c6ae2b692bc0648 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts @@ -0,0 +1,175 @@ +/* eslint-disable no-magic-numbers */ +import { + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, +} from '@/models/mocks/elementSearchResultMock'; +import { apiPath } from '@/redux/apiPath'; +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 * as handleDataReset from './handleDataReset'; +import * as handleSearchResultAction from './handleSearchResultAction'; +import { onMapSingleClick } from './onMapSingleClick'; + +jest.mock('./handleSearchResultAction', () => ({ + __esModule: true, + ...jest.requireActual('./handleSearchResultAction'), +})); +jest.mock('./handleDataReset', () => ({ + __esModule: true, + ...jest.requireActual('./handleDataReset'), +})); + +const mockedAxiosOldClient = mockNetworkResponse(); + +const handleSearchResultActionSpy = jest.spyOn( + handleSearchResultAction, + 'handleSearchResultAction', +); +const handleDataResetSpy = jest.spyOn(handleDataReset, 'handleDataReset'); + +const getEvent = (coordinate: MapBrowserEvent<UIEvent>['coordinate']): MapBrowserEvent<UIEvent> => + ({ + coordinate, + }) as unknown as MapBrowserEvent<UIEvent>; + +describe('onMapSingleClick - util', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + describe('when always', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const modelId = 1000; + const mapSize = { + width: 90, + height: 90, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const handler = onMapSingleClick(mapSize, modelId, dispatch); + const coordinate = [90, 90]; + const event = getEvent(coordinate); + + it('should fire data reset handler', async () => { + await handler(event); + expect(handleDataResetSpy).toBeCalled(); + }); + }); + + describe('when searchResults are undefined', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const modelId = 1000; + const mapSize = { + width: 90, + height: 90, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const handler = onMapSingleClick(mapSize, modelId, dispatch); + const coordinate = [90, 90]; + const point = { x: 180.0008084837557, y: 179.99919151624428 }; + const event = getEvent(coordinate); + + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, undefined); + + it('does not fire search result action', async () => { + await handler(event); + expect(handleSearchResultActionSpy).not.toBeCalled(); + }); + }); + + describe('when searchResults are empty', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + + const modelId = 1000; + const mapSize = { + width: 180, + height: 180, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + + const handler = onMapSingleClick(mapSize, modelId, dispatch); + const coordinate = [180, 180]; + const point = { x: 360.0032339350228, y: 359.9967660649771 }; + const event = getEvent(coordinate); + + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, []); + + it('does not fire search result action', async () => { + await handler(event); + expect(handleSearchResultActionSpy).not.toBeCalled(); + }); + }); + + describe('when searchResults are valid', () => { + describe('when results type is ALIAS', () => { + 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]); + + it('does fire search result action handler', async () => { + const handler = onMapSingleClick(mapSize, modelId, dispatch); + await handler(event); + await waitFor(() => expect(handleSearchResultActionSpy).toBeCalled()); + }); + }); + + describe('when results type is REACTION', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_REACTION; + const mapSize = { + width: 0, + height: 0, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const coordinate = [0, 0]; + const point = { + x: 0, + y: 0, + }; + const event = getEvent(coordinate); + + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]); + + it('does fire search result action - handle reaction', async () => { + const handler = onMapSingleClick(mapSize, modelId, dispatch); + await handler(event); + 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 new file mode 100644 index 0000000000000000000000000000000000000000..cdd4abbf45f1025b0695c3c40dfb141348fdccbf --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts @@ -0,0 +1,21 @@ +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 { getSearchResults } from './getSearchResults'; +import { handleDataReset } from './handleDataReset'; +import { handleSearchResultAction } from './handleSearchResultAction'; + +/* prettier-ignore */ +export const onMapSingleClick = + (mapSize: MapSize, modelId: number, dispatch: AppDispatch) => + async ({ coordinate }: MapBrowserEvent<UIEvent>): Promise<void> => { + dispatch(handleDataReset); + + const searchResults = await getSearchResults({ coordinate, mapSize, modelId }); + if (!searchResults || searchResults.length === SIZE_OF_EMPTY_ARRAY) { + return; + } + + handleSearchResultAction({ searchResults, dispatch }); + }; diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts deleted file mode 100644 index ac9007988cf2707be414031310258426ec1f4230..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -/* eslint-disable no-magic-numbers */ -import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; -import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; -import { reactionsFixture } from '@/models/fixtures/reactionFixture'; -import { - ELEMENT_SEARCH_RESULT_MOCK_ALIAS, - ELEMENT_SEARCH_RESULT_MOCK_REACTION, -} from '@/models/mocks/elementSearchResultMock'; -import { apiPath } from '@/redux/apiPath'; -import { mockNetworkNewAPIResponse, 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 * as onMapSingleClickUtils from './onMapSingleClick'; -import { handleAliasResults, handleReactionResults } from './onMapSingleClick'; - -const { onMapSingleClick } = onMapSingleClickUtils; -const mockedAxiosOldClient = mockNetworkResponse(); -const mockedAxiosNewClient = mockNetworkNewAPIResponse(); - -const getEvent = (coordinate: MapBrowserEvent<UIEvent>['coordinate']): MapBrowserEvent<UIEvent> => - ({ - coordinate, - }) as unknown as MapBrowserEvent<UIEvent>; - -describe('onMapSingleClick - util', () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - }); - - describe('when searchResults are undefined', () => { - const { store } = getReduxStoreWithActionsListener(); - const { dispatch } = store; - const modelId = 1000; - const mapSize = { - width: 90, - height: 90, - tileSize: 256, - minZoom: 2, - maxZoom: 9, - }; - const handler = onMapSingleClick(mapSize, modelId, dispatch); - const coordinate = [90, 90]; - const point = { x: 180.0008084837557, y: 179.99919151624428 }; - const event = getEvent(coordinate); - - mockedAxiosOldClient - .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) - .reply(HttpStatusCode.Ok, undefined); - - it('does not fire search result action', async () => { - await handler(event); - const actions = store.getActions(); - expect(actions.length).toBe(SIZE_OF_EMPTY_ARRAY); - }); - }); - - describe('when searchResults are empty', () => { - const { store } = getReduxStoreWithActionsListener(); - const { dispatch } = store; - - const modelId = 1000; - const mapSize = { - width: 180, - height: 180, - tileSize: 256, - minZoom: 2, - maxZoom: 9, - }; - - const handler = onMapSingleClick(mapSize, modelId, dispatch); - const coordinate = [180, 180]; - const point = { x: 360.0032339350228, y: 359.9967660649771 }; - const event = getEvent(coordinate); - - mockedAxiosOldClient - .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) - .reply(HttpStatusCode.Ok, []); - - it('does not fire search result action', async () => { - await handler(event); - const actions = store.getActions(); - expect(actions.length).toBe(SIZE_OF_EMPTY_ARRAY); - }); - }); - - describe('when searchResults are valid', () => { - describe('when results type is ALIAS', () => { - 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]); - - beforeAll(async () => { - const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); - }); - - it('does fire search result action - getBioEntity', async () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).toEqual('project/getMultiBioEntity/pending'); - }); - - it('does NOT fire search result action - getReactionsByIds', async () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).not.toEqual('reactions/getByIds/pending'); - }); - }); - - describe('when results type is REACTION', () => { - const { store } = getReduxStoreWithActionsListener(); - const { dispatch } = store; - const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_REACTION; - const mapSize = { - width: 0, - height: 0, - tileSize: 256, - minZoom: 2, - maxZoom: 9, - }; - const coordinate = [0, 0]; - const point = { - x: 0, - y: 0, - }; - const event = getEvent(coordinate); - - mockedAxiosOldClient - .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) - .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]); - - beforeAll(async () => { - const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); - }); - - it('does NOT fire search result action - getBioEntity', async () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).not.toEqual('project/getBioEntityContents/pending'); - }); - - it('does fire search result action - getReactionsByIds', async () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).toEqual('reactions/getByIds/pending'); - }); - }); - }); -}); - -describe('handleAliasResults - util', () => { - const { store } = getReduxStoreWithActionsListener(); - const { dispatch } = store; - - mockedAxiosOldClient - .onGet( - apiPath.getBioEntityContentsStringWithQuery({ - searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), - isPerfectMatch: true, - }), - ) - .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - - beforeAll(async () => { - handleAliasResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); - }); - - it('should run getBioEntityAction', async () => { - await waitFor(() => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).toEqual('project/getMultiBioEntity/pending'); - }); - }); -}); - -describe('handleReactionResults - util', () => { - const { store } = getReduxStoreWithActionsListener(); - const { dispatch } = store; - - mockedAxiosNewClient - .onGet( - apiPath.getBioEntityContentsStringWithQuery({ - searchQuery: ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), - isPerfectMatch: true, - }), - ) - .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - - mockedAxiosOldClient - .onGet(apiPath.getReactionsWithIds([ELEMENT_SEARCH_RESULT_MOCK_REACTION.id])) - .reply(HttpStatusCode.Ok, reactionsFixture); - - beforeAll(async () => { - handleReactionResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_REACTION); - }); - - it('should run getReactionsByIds as first action', () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[0].type).toEqual('reactions/getByIds/pending'); - expect(actions[1].type).toEqual('reactions/getByIds/fulfilled'); - }); - - it('should run setBioEntityContent to empty array as second action', () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[2].type).toEqual('project/getMultiBioEntity/pending'); - }); - - it('should run getBioEntity as third action', () => { - const actions = store.getActions(); - expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[3].type).toEqual('project/getBioEntityContents/pending'); - }); -}); diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts deleted file mode 100644 index 368c42414cfdc80d64f7ba8de84d1b32ab74c0e0..0000000000000000000000000000000000000000 --- a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; -import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; -import { MapSize } from '@/redux/map/map.types'; -import { AppDispatch } from '@/redux/store'; -import { ElementSearchResult, Reaction } from '@/types/models'; -import { latLngToPoint } from '@/utils/map/latLngToPoint'; -import { getElementsByPoint } from '@/utils/search/getElementsByCoordinates'; -import { PayloadAction } from '@reduxjs/toolkit'; -import { MapBrowserEvent } from 'ol'; -import { toLonLat } from 'ol/proj'; -import { getReactionsByIds } from '../../../../../redux/reactions/reactions.thunks'; - -const FIRST = 0; - -/* prettier-ignore */ -export const handleAliasResults = - (dispatch: AppDispatch) => - async ({ id }: ElementSearchResult): Promise<void> => { - dispatch( - getMultiBioEntity({ - searchQueries: [id.toString()], - isPerfectMatch: true - }), - ); - }; - -/* prettier-ignore */ -export const handleReactionResults = - (dispatch: AppDispatch) => - async ({ id }: ElementSearchResult): Promise<void> => { - const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>; - const payload = data?.payload; - if (!data || !payload || payload.length === SIZE_OF_EMPTY_ARRAY) { - return; - } - - const { products, reactants, modifiers } = payload[FIRST]; - const productsIds = products.map(p => p.aliasId); - const reactantsIds = reactants.map(r => r.aliasId); - const modifiersIds = modifiers.map(m => m.aliasId); - const bioEntitiesIds = [...productsIds, ...reactantsIds, ...modifiersIds].map(identifier => String(identifier)); - - await dispatch( - getMultiBioEntity({ - searchQueries: bioEntitiesIds, - isPerfectMatch: true }, - ), - ); - }; - -/* prettier-ignore */ -export const onMapSingleClick = - (mapSize: MapSize, modelId: number, dispatch: AppDispatch) => - async (e: MapBrowserEvent<UIEvent>): Promise<void> => { - const [lng, lat] = toLonLat(e.coordinate); - const point = latLngToPoint([lat, lng], mapSize); - const searchResults = await getElementsByPoint({ point, currentModelId: modelId }); - - if (!searchResults || searchResults.length === SIZE_OF_EMPTY_ARRAY) { - return; - } - - const closestSearchResult = searchResults[FIRST]; - const { type } = closestSearchResult; - const action = { - 'ALIAS': handleAliasResults, - 'REACTION': handleReactionResults, - }[type]; - - await action(dispatch)(closestSearchResult); - }; diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts index 0f409f880c75c18fccfda813288283c40598b89f..68b1c28b94dcdaef277951c2f726be845a6c61df 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts @@ -3,8 +3,8 @@ import mapSlice from '@/redux/map/map.slice'; import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrapperUsingSliceReducer'; import { renderHook } from '@testing-library/react'; import { View } from 'ol'; +import * as singleClickListener from './mapSingleClick/onMapSingleClick'; import * as positionListener from './onMapPositionChange'; -import * as singleClickListener from './onMapSingleClick'; import { useOlMapListeners } from './useOlMapListeners'; jest.mock('./onMapPositionChange', () => ({ @@ -12,7 +12,7 @@ jest.mock('./onMapPositionChange', () => ({ onMapPositionChange: jest.fn(), })); -jest.mock('./onMapSingleClick', () => ({ +jest.mock('./mapSingleClick/onMapSingleClick', () => ({ __esModule: true, onMapSingleClick: jest.fn(), })); diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 5cad414f88a9fa979d8e31d3dce15301add65831..efab477c9004c4a64600f3c6df52d1e7487a2d1d 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -8,8 +8,8 @@ import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useDebouncedCallback } from 'use-debounce'; import { MapInstance } from '../../MapViewer.types'; +import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapPositionChange } from './onMapPositionChange'; -import { onMapSingleClick } from './onMapSingleClick'; interface UseOlMapListenersInput { view: View; diff --git a/src/constants/canvas.ts b/src/constants/canvas.ts index b9741f3001cb3defe3931b799a208860b4110879..31b30eb16de6358c622dcbb10f395bba7fb9556d 100644 --- a/src/constants/canvas.ts +++ b/src/constants/canvas.ts @@ -1,3 +1,5 @@ +import { PinType } from '@/components/Map/Drawer/SearchDrawerWrapper/ResultsList/PinsList/PinsList.types'; + export const PIN_PATH2D = 'M12.3077 0C6.25641 0 0 4.61538 0 12.3077C0 19.5897 11.0769 30.9744 11.5897 31.4872C11.7949 31.6923 12 31.7949 12.3077 31.7949C12.6154 31.7949 12.8205 31.6923 13.0256 31.4872C13.5385 30.9744 24.6154 19.6923 24.6154 12.3077C24.6154 4.61538 18.359 0 12.3077 0Z'; @@ -5,3 +7,15 @@ export const PIN_SIZE = { width: 25, height: 32, }; + +export const PINS_COLORS: Record<PinType, string> = { + drugs: '#F48C41', + chemicals: '#640CE3', + bioEntity: '#106AD7', + mirna: '#F1009F', + none: '#000', +}; + +export const LINE_COLOR = '#00AAFF'; + +export const LINE_WIDTH = 6; diff --git a/src/constants/common.ts b/src/constants/common.ts index 2651c7063a81f9981f27c21bce15a092c7eb5fd1..1825686b98af79ba59146ff6c567b8a50170940e 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -1,2 +1,5 @@ export const SIZE_OF_EMPTY_ARRAY = 0; export const ZERO = 0; +export const FIRST = 0; + +export const ONE = 1; diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index bbf46eb1fa4380fc2d6338ada7aaacaf0be8014a..bcaeda20ef55efa082341b3d6600b688128f4f27 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -1,10 +1,11 @@ import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { rootSelector } from '@/redux/root/root.selectors'; -import { BioEntityContent } from '@/types/models'; -import { createSelector } from '@reduxjs/toolkit'; import { MultiSearchData } from '@/types/fetchDataState'; -import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors'; +import { BioEntity, BioEntityContent } from '@/types/models'; +import { createSelector } from '@reduxjs/toolkit'; import { currentSelectedSearchElement } from '../drawer/drawer.selectors'; +import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors'; +import { reduceToJoinedMultiSearchResult } from './bioEntity.utils'; export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); @@ -22,15 +23,38 @@ export const loadingBioEntityStatusSelector = createSelector( state => state?.loading, ); -export const allBioEntitesSelectorOfCurrentMap = createSelector( +export const bioEntitiesForSelectedSearchElementOrAllFallback = createSelector( + bioEntitySelector, bioEntitiesForSelectedSearchElement, + currentSelectedSearchElement, + ( + bioEntitiesState, + bioEntitiesOfSelected, + currentSearchElement, + ): MultiSearchData<BioEntityContent[]> | undefined => { + if (!currentSearchElement) { + const bioEntitiesFiltered = bioEntitiesState.data.filter(d => d !== undefined); + + return bioEntitiesFiltered.length === SIZE_OF_EMPTY_ARRAY + ? undefined + : bioEntitiesFiltered.reduce(reduceToJoinedMultiSearchResult); + } + + return bioEntitiesOfSelected; + }, +); + +export const allBioEntitesSelectorOfCurrentMap = createSelector( + bioEntitiesForSelectedSearchElementOrAllFallback, currentModelIdSelector, - (bioEntities, currentModelId): BioEntityContent[] => { + (bioEntities, currentModelId): BioEntity[] => { if (!bioEntities) { return []; } - return (bioEntities?.data || []).filter(({ bioEntity }) => bioEntity.model === currentModelId); + return (bioEntities?.data || []) + .filter(({ bioEntity }) => bioEntity.model === currentModelId) + .map(({ bioEntity }) => bioEntity); }, ); diff --git a/src/redux/bioEntity/bioEntity.utils.ts b/src/redux/bioEntity/bioEntity.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..8aa0c336e06f2fd827d7748b5403a1e5bf62ba1b --- /dev/null +++ b/src/redux/bioEntity/bioEntity.utils.ts @@ -0,0 +1,14 @@ +import { MultiSearchData } from '@/types/fetchDataState'; +import { BioEntityContent } from '@/types/models'; + +type MultiSearchBioEntityData = MultiSearchData<BioEntityContent[]>; +type ToJoinedMultiSearchResultFun = ( + a: MultiSearchBioEntityData, + b: MultiSearchBioEntityData, +) => MultiSearchBioEntityData; + +export const reduceToJoinedMultiSearchResult: ToJoinedMultiSearchResultFun = (a, b) => ({ + ...b, + searchQueryElement: `${a.searchQueryElement};${b.searchQueryElement}`, + data: (a.data || []).concat(b.data || []), +}); diff --git a/src/redux/reactions/reactions.constants.ts b/src/redux/reactions/reactions.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7b9f099193540abc66d052d902faee2d5775c76 --- /dev/null +++ b/src/redux/reactions/reactions.constants.ts @@ -0,0 +1,7 @@ +import { ReactionsState } from './reactions.types'; + +export const REACTIONS_INITIAL_STATE: ReactionsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; diff --git a/src/redux/reactions/reactions.reducers.ts b/src/redux/reactions/reactions.reducers.ts index 8673c7a6182481e37fa9236a7b7c80e6f93b6ecf..92ecef9cf7d86e0830255a74b481772e085d52c1 100644 --- a/src/redux/reactions/reactions.reducers.ts +++ b/src/redux/reactions/reactions.reducers.ts @@ -1,4 +1,5 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { REACTIONS_INITIAL_STATE } from './reactions.constants'; import { getReactionsByIds } from './reactions.thunks'; import { ReactionsState } from './reactions.types'; @@ -15,3 +16,9 @@ export const getReactionsReducer = (builder: ActionReducerMapBuilder<ReactionsSt // TODO: error management to be discussed in the team }); }; + +export const resetReactionsDataReducer = (state: ReactionsState): void => { + state.data = REACTIONS_INITIAL_STATE.data; + state.error = REACTIONS_INITIAL_STATE.error; + state.loading = REACTIONS_INITIAL_STATE.loading; +}; diff --git a/src/redux/reactions/reactions.selector.ts b/src/redux/reactions/reactions.selector.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f907b39550b23aacbb831687631e8562eec6a50 --- /dev/null +++ b/src/redux/reactions/reactions.selector.ts @@ -0,0 +1,19 @@ +import { Reaction } from '@/types/models'; +import { createSelector } from '@reduxjs/toolkit'; +import { currentModelIdSelector } from '../models/models.selectors'; +import { rootSelector } from '../root/root.selectors'; + +export const reactionsSelector = createSelector(rootSelector, state => state.reactions); + +export const reactionsDataSelector = createSelector( + reactionsSelector, + reactions => reactions?.data || [], +); + +export const allReactionsSelectorOfCurrentMap = createSelector( + reactionsDataSelector, + currentModelIdSelector, + (reactions, currentModelId): Reaction[] => { + return reactions.filter(({ modelId }) => modelId === currentModelId); + }, +); diff --git a/src/redux/reactions/reactions.slice.ts b/src/redux/reactions/reactions.slice.ts index 47eeea91d64d513ba3e6fe8ac5f5890a67baaa88..f97e9050155d87e496cb8adada0f8e28f0d0f4ba 100644 --- a/src/redux/reactions/reactions.slice.ts +++ b/src/redux/reactions/reactions.slice.ts @@ -1,20 +1,18 @@ import { createSlice } from '@reduxjs/toolkit'; -import { getReactionsReducer } from './reactions.reducers'; -import { ReactionsState } from './reactions.types'; - -const initialState: ReactionsState = { - data: [], - loading: 'idle', - error: { name: '', message: '' }, -}; +import { REACTIONS_INITIAL_STATE } from './reactions.constants'; +import { getReactionsReducer, resetReactionsDataReducer } from './reactions.reducers'; export const reactionsSlice = createSlice({ name: 'reactions', - initialState, - reducers: {}, + initialState: REACTIONS_INITIAL_STATE, + reducers: { + resetReactionsData: resetReactionsDataReducer, + }, extraReducers: builder => { getReactionsReducer(builder); }, }); +export const { resetReactionsData } = reactionsSlice.actions; + export default reactionsSlice.reducer; diff --git a/src/types/models.ts b/src/types/models.ts index 2033c775a05a4431881a77f1f7c450d299ca244a..27b6246e82c88f5b95e699fb154a5d3292bf3633 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -13,6 +13,7 @@ import { organism } from '@/models/organism'; import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/project'; import { reactionSchema } from '@/models/reaction'; +import { reactionLineSchema } from '@/models/reactionLineSchema'; import { targetSchema } from '@/models/targetSchema'; import { z } from 'zod'; @@ -31,5 +32,6 @@ export type BioEntityContent = z.infer<typeof bioEntityContentSchema>; export type BioEntityResponse = z.infer<typeof bioEntityResponseSchema>; export type Chemical = z.infer<typeof chemicalSchema>; export type Reaction = z.infer<typeof reactionSchema>; +export type ReactionLine = z.infer<typeof reactionLineSchema>; export type ElementSearchResult = z.infer<typeof elementSearchResult>; export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; diff --git a/src/types/reactions.ts b/src/types/reactions.ts new file mode 100644 index 0000000000000000000000000000000000000000..44c8237a4d76618c7df9fdd5a868aaf2d23560fb --- /dev/null +++ b/src/types/reactions.ts @@ -0,0 +1,3 @@ +import { Point } from './map'; + +export type LinePoint = [Point, Point]; diff --git a/src/utils/testing/getReduxWrapperWithStore.tsx b/src/utils/testing/getReduxWrapperWithStore.tsx index 5c799a25d2413cc0d6fffdd57fc40537bd5c29bd..d1f0c3dfe1529bbe9cb64a9d09ec1f16ac219951 100644 --- a/src/utils/testing/getReduxWrapperWithStore.tsx +++ b/src/utils/testing/getReduxWrapperWithStore.tsx @@ -8,7 +8,7 @@ interface WrapperProps { export type InitialStoreState = Partial<RootState>; -type GetReduxWrapperUsingSliceReducer = (initialState?: InitialStoreState) => { +export type GetReduxWrapperUsingSliceReducer = (initialState?: InitialStoreState) => { Wrapper: ({ children }: WrapperProps) => JSX.Element; store: StoreType; };