From b356879edb8eb5a902f230be35af4f44c4765c8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adrian=20Or=C5=82=C3=B3w?= <adrian.orlow@fishbrain.com> Date: Tue, 14 Nov 2023 02:26:54 +0100 Subject: [PATCH] feat(map): add interactive base layer --- package-lock.json | 21 ++ package.json | 3 + .../utils/config/useOlMapView.test.ts | 8 +- .../listeners/onMapPositionChange.test.ts | 8 +- .../utils/listeners/onMapSingleClick.test.ts | 235 ++++++++++++++++++ .../utils/listeners/onMapSingleClick.ts | 76 ++++++ .../utils/listeners/useOlMapListeners.test.ts | 22 +- .../utils/listeners/useOlMapListeners.ts | 29 ++- .../Map/MapViewer/utils/useOlMap.ts | 2 +- src/constants/map.ts | 3 +- src/constants/time.ts | 1 + src/models/elementSearchResult.ts | 9 + .../fixtures/elementSearchResultFixture.ts | 10 + src/models/fixtures/reactionFixture.ts | 10 + src/models/mocks/elementSearchResultMock.ts | 13 + src/models/reaction.ts | 27 ++ src/redux/apiPath.test.ts | 2 +- src/redux/apiPath.ts | 20 +- src/redux/bioEntity/bioEntity.constants.ts | 3 + src/redux/bioEntity/bioEntity.reducers.ts | 9 +- src/redux/bioEntity/bioEntity.slice.ts | 10 +- src/redux/bioEntity/bioEntity.thunks.test.ts | 87 +++++-- src/redux/bioEntity/bioEntity.thunks.ts | 47 +++- src/redux/bioEntity/bioEntity.types.ts | 12 + src/redux/models/models.selectors.ts | 6 + src/redux/reactions/reactions.reducers.ts | 17 ++ src/redux/reactions/reactions.slice.ts | 20 ++ src/redux/reactions/reactions.thunks.test.ts | 50 ++++ src/redux/reactions/reactions.thunks.ts | 23 ++ src/redux/reactions/reactions.types.ts | 4 + src/redux/store.ts | 2 + src/types/models.ts | 5 + src/utils/map/getPointOffset.test.ts | 4 +- src/utils/map/getPointOffset.ts | 2 +- src/utils/map/latLngToPoint.test.ts | 16 +- src/utils/map/pointToLatLng.test.ts | 4 +- src/utils/map/usePointToProjection.test.tsx | 2 +- .../search/getElementsByCoordinates.test.ts | 42 ++++ src/utils/search/getElementsByCoordinates.ts | 25 ++ .../testing/getReduxStoreActionsListener.tsx | 30 +++ yarn.lock | 56 ++++- 41 files changed, 915 insertions(+), 60 deletions(-) create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts create mode 100644 src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts create mode 100644 src/models/elementSearchResult.ts create mode 100644 src/models/fixtures/elementSearchResultFixture.ts create mode 100644 src/models/fixtures/reactionFixture.ts create mode 100644 src/models/mocks/elementSearchResultMock.ts create mode 100644 src/models/reaction.ts create mode 100644 src/redux/bioEntity/bioEntity.constants.ts create mode 100644 src/redux/reactions/reactions.reducers.ts create mode 100644 src/redux/reactions/reactions.slice.ts create mode 100644 src/redux/reactions/reactions.thunks.test.ts create mode 100644 src/redux/reactions/reactions.thunks.ts create mode 100644 src/redux/reactions/reactions.types.ts create mode 100644 src/utils/search/getElementsByCoordinates.test.ts create mode 100644 src/utils/search/getElementsByCoordinates.ts create mode 100644 src/utils/testing/getReduxStoreActionsListener.tsx diff --git a/package-lock.json b/package-lock.json index 79ab1fa8..6b0f180a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,7 @@ "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", "react-redux": "^8.1.2", + "redux-thunk": "^2.4.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "ts-deepmerge": "^6.2.0", @@ -40,6 +41,7 @@ "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", + "@types/redux-mock-store": "^1.0.6", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", "axios-mock-adapter": "^1.22.0", @@ -68,6 +70,7 @@ "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", "prettier-plugin-tailwindcss": "^0.5.6", + "redux-mock-store": "^1.5.4", "typescript": "^5.2.2", "zod-fixture": "^2.5.0" } @@ -2311,6 +2314,15 @@ "redux": "^4.0.0" } }, + "node_modules/@types/redux-mock-store": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz", + "integrity": "sha512-eg5RDfhJTXuoJjOMyXiJbaDb1B8tfTaJixscmu+jOusj6adGC0Krntz09Tf4gJgXeCqCrM5bBMd+B7ez0izcAQ==", + "dev": true, + "dependencies": { + "redux": "^4.0.5" + } + }, "node_modules/@types/scheduler": { "version": "0.16.4", "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz", @@ -11175,6 +11187,15 @@ "@babel/runtime": "^7.9.2" } }, + "node_modules/redux-mock-store": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz", + "integrity": "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==", + "dev": true, + "dependencies": { + "lodash.isplainobject": "^4.0.6" + } + }, "node_modules/redux-thunk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz", diff --git a/package.json b/package.json index cc259882..c5b84e0b 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "react-accessible-accordion": "^5.0.0", "react-dom": "18.2.0", "react-redux": "^8.1.2", + "redux-thunk": "^2.4.2", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "ts-deepmerge": "^6.2.0", @@ -54,6 +55,7 @@ "@testing-library/react": "^14.0.0", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", + "@types/redux-mock-store": "^1.0.6", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", "axios-mock-adapter": "^1.22.0", @@ -82,6 +84,7 @@ "prettier": "^3.0.3", "prettier-2": "npm:prettier@^2", "prettier-plugin-tailwindcss": "^0.5.6", + "redux-mock-store": "^1.5.4", "typescript": "^5.2.2", "zod-fixture": "^2.5.0" }, diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 6ff16dd9..3f87400f 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts @@ -73,12 +73,12 @@ describe('useOlMapView - util', () => { }, position: { initial: { - x: 256, - y: 256, + x: 128, + y: 128, }, last: { - x: 256, - y: 256, + x: 128, + y: 128, }, }, }, diff --git a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts index c7a7ed78..3ab05530 100644 --- a/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/onMapPositionChange.test.ts @@ -30,8 +30,8 @@ describe('onMapPositionChange - util', () => { zoom: 6, }, { - x: 9177, - y: 8641, + x: 4589, + y: 4320, z: 6, }, ], @@ -48,8 +48,8 @@ describe('onMapPositionChange - util', () => { zoom: 6.68620779943448, }, { - x: 2957, - y: 1163, + x: 1479, + y: 581, z: 7, }, ], diff --git a/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts new file mode 100644 index 00000000..184c604e --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.test.ts @@ -0,0 +1,235 @@ +/* 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 { getReduxStoreActionsListener } 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 } = getReduxStoreActionsListener(); + 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 } = getReduxStoreActionsListener(); + 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 } = getReduxStoreActionsListener(); + 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/getBioEntityContents/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 } = getReduxStoreActionsListener(); + 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 } = getReduxStoreActionsListener(); + const { dispatch } = store; + + mockedAxiosOldClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery(ELEMENT_SEARCH_RESULT_MOCK_ALIAS.id.toString(), { + perfectMatch: 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/getBioEntityContents/pending'); + }); + }); +}); + +describe('handleReactionResults - util', () => { + const { store } = getReduxStoreActionsListener(); + const { dispatch } = store; + + mockedAxiosNewClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery( + ELEMENT_SEARCH_RESULT_MOCK_REACTION.id.toString(), + { + perfectMatch: 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('bioEntityContents/setBioEntityContent'); + }); + + 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 new file mode 100644 index 00000000..acf6e516 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onMapSingleClick.ts @@ -0,0 +1,76 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { setBioEntityContent } from '@/redux/bioEntity/bioEntity.slice'; +import { getBioEntity } 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( + getBioEntity({ + query: id.toString(), + params: { + perfectMatch: 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 } = payload[FIRST]; + const productsIds = products.map(p => p.aliasId); + const reactantsIds = reactants.map(r => r.aliasId); + const bioEntitiesIds = [...productsIds, ...reactantsIds].map(identifier => String(identifier)); + + dispatch(setBioEntityContent([])); + await dispatch( + getBioEntity({ + query: bioEntitiesIds, + params: { + perfectMatch: 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 20d0401a..0f409f88 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.test.ts @@ -4,6 +4,7 @@ import { getReduxWrapperUsingSliceReducer } from '@/utils/testing/getReduxWrappe import { renderHook } from '@testing-library/react'; import { View } from 'ol'; import * as positionListener from './onMapPositionChange'; +import * as singleClickListener from './onMapSingleClick'; import { useOlMapListeners } from './useOlMapListeners'; jest.mock('./onMapPositionChange', () => ({ @@ -11,6 +12,11 @@ jest.mock('./onMapPositionChange', () => ({ onMapPositionChange: jest.fn(), })); +jest.mock('./onMapSingleClick', () => ({ + __esModule: true, + onMapSingleClick: jest.fn(), +})); + jest.mock('use-debounce', () => { return { useDebounce: () => {}, @@ -27,13 +33,25 @@ describe('useOlMapListeners - util', () => { describe('on change:center view event', () => { it('should run onMapPositionChange event', () => { - const view = new View(); const CALLED_ONCE = 1; + const view = new View(); - renderHook(() => useOlMapListeners({ view }), { wrapper: Wrapper }); + renderHook(() => useOlMapListeners({ view, mapInstance: undefined }), { wrapper: Wrapper }); view.dispatchEvent('change:center'); expect(positionListener.onMapPositionChange).toBeCalledTimes(CALLED_ONCE); }); }); + + describe('on singleclick view event', () => { + it('should run onMapPositionChange event', () => { + const CALLED_ONCE = 1; + const view = new View(); + + renderHook(() => useOlMapListeners({ view, mapInstance: undefined }), { wrapper: Wrapper }); + view.dispatchEvent('singleclick'); + + expect(singleClickListener.onMapSingleClick).toBeCalledTimes(CALLED_ONCE); + }); + }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 8bea0c90..5cad414f 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -1,18 +1,24 @@ import { OPTIONS } from '@/constants/map'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { currentModelIdSelector } from '@/redux/models/models.selectors'; import { View } from 'ol'; +import { unByKey } from 'ol/Observable'; import { useEffect } from 'react'; import { useSelector } from 'react-redux'; import { useDebouncedCallback } from 'use-debounce'; +import { MapInstance } from '../../MapViewer.types'; import { onMapPositionChange } from './onMapPositionChange'; +import { onMapSingleClick } from './onMapSingleClick'; interface UseOlMapListenersInput { view: View; + mapInstance: MapInstance; } -export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => { +export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput): void => { const mapSize = useSelector(mapDataSizeSelector); + const modelId = useSelector(currentModelIdSelector); const dispatch = useAppDispatch(); const handleChangeCenter = useDebouncedCallback( @@ -21,7 +27,26 @@ export const useOlMapListeners = ({ view }: UseOlMapListenersInput): void => { { leading: false }, ); + const handleMapSingleClick = useDebouncedCallback( + onMapSingleClick(mapSize, modelId, dispatch), + OPTIONS.clickPersistTime, + { leading: false }, + ); + useEffect(() => { - view.on('change:center', handleChangeCenter); + const key = view.on('change:center', handleChangeCenter); + + return () => unByKey(key); }, [view, handleChangeCenter]); + + useEffect(() => { + if (!mapInstance) { + return; + } + + const key = mapInstance.on('singleclick', handleMapSingleClick); + + // eslint-disable-next-line consistent-return + return () => unByKey(key); + }, [mapInstance, handleMapSingleClick]); }; diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts index a80407e3..a7ffb398 100644 --- a/src/components/Map/MapViewer/utils/useOlMap.ts +++ b/src/components/Map/MapViewer/utils/useOlMap.ts @@ -20,7 +20,7 @@ export const useOlMap: UseOlMap = ({ target } = {}) => { const [mapInstance, setMapInstance] = useState<MapInstance>(undefined); const view = useOlMapView({ mapInstance }); useOlMapLayers({ mapInstance }); - useOlMapListeners({ view }); + useOlMapListeners({ view, mapInstance }); useEffect(() => { // checking if innerHTML is empty due to possibility of target element cloning by openlayers map instance diff --git a/src/constants/map.ts b/src/constants/map.ts index 765b30b0..47d6c6ce 100644 --- a/src/constants/map.ts +++ b/src/constants/map.ts @@ -1,6 +1,6 @@ import { LatLng, Point } from '@/types/map'; import { z } from 'zod'; -import { HALF_SECOND_MS } from './time'; +import { HALF_SECOND_MS, ONE_HUNDRED_MS } from './time'; export const DEFAULT_TILE_SIZE = 256; export const DEFAULT_MIN_ZOOM = 2; @@ -21,6 +21,7 @@ export const OPTIONS = { showFullExtent: false, wrapXInTileLayer: false, queryPersistTime: HALF_SECOND_MS, + clickPersistTime: ONE_HUNDRED_MS, }; export const VALID_MAP_SIZE_SCHEMA = z.object({ diff --git a/src/constants/time.ts b/src/constants/time.ts index 0cb37b13..396210de 100644 --- a/src/constants/time.ts +++ b/src/constants/time.ts @@ -1 +1,2 @@ +export const ONE_HUNDRED_MS = 100; export const HALF_SECOND_MS = 500; diff --git a/src/models/elementSearchResult.ts b/src/models/elementSearchResult.ts new file mode 100644 index 00000000..87d9acc4 --- /dev/null +++ b/src/models/elementSearchResult.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const elementSearchResultType = z.union([z.literal('ALIAS'), z.literal('REACTION')]); + +export const elementSearchResult = z.object({ + id: z.number(), + modelId: z.number(), + type: elementSearchResultType, +}); diff --git a/src/models/fixtures/elementSearchResultFixture.ts b/src/models/fixtures/elementSearchResultFixture.ts new file mode 100644 index 00000000..33ae2c13 --- /dev/null +++ b/src/models/fixtures/elementSearchResultFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { elementSearchResult } from '../elementSearchResult'; + +export const elementSearchResultFixture = createFixture(z.array(elementSearchResult), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/fixtures/reactionFixture.ts b/src/models/fixtures/reactionFixture.ts new file mode 100644 index 00000000..703e8be5 --- /dev/null +++ b/src/models/fixtures/reactionFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { reactionSchema } from '../reaction'; + +export const reactionsFixture = createFixture(z.array(reactionSchema), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/mocks/elementSearchResultMock.ts b/src/models/mocks/elementSearchResultMock.ts new file mode 100644 index 00000000..9bf978eb --- /dev/null +++ b/src/models/mocks/elementSearchResultMock.ts @@ -0,0 +1,13 @@ +import { ElementSearchResult } from '@/types/models'; + +export const ELEMENT_SEARCH_RESULT_MOCK_ALIAS: ElementSearchResult = { + id: 4, + modelId: 1000, + type: 'ALIAS', +}; + +export const ELEMENT_SEARCH_RESULT_MOCK_REACTION: ElementSearchResult = { + id: 5, + modelId: 1000, + type: 'REACTION', +}; diff --git a/src/models/reaction.ts b/src/models/reaction.ts new file mode 100644 index 00000000..7b2dd7b1 --- /dev/null +++ b/src/models/reaction.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; +import { positionSchema } from './positionSchema'; +import { productsSchema } from './products'; +import { referenceSchema } from './referenceSchema'; + +export const reactionSchema = z.object({ + centerPoint: positionSchema, + hierarchyVisibilityLevel: z.string(), + id: z.number(), + kineticLaw: z.null(), + lines: z.array( + z.object({ + start: positionSchema, + end: positionSchema, + type: z.string(), + }), + ), + modelId: z.number(), + modifiers: z.array(z.unknown()), + name: z.string(), + notes: z.string(), + products: z.array(productsSchema), + reactants: z.array(productsSchema), + reactionId: z.string(), + references: z.array(referenceSchema), + type: z.string(), +}); diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts index d23bffc0..56612a14 100644 --- a/src/redux/apiPath.test.ts +++ b/src/redux/apiPath.test.ts @@ -16,7 +16,7 @@ describe('api path', () => { it('should return url string for bio entity content', () => { expect(apiPath.getBioEntityContentsStringWithQuery('park7')).toBe( - `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000`, + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=park7&size=1000&perfectMatch=false`, ); }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 776993ed..78fe8788 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -1,8 +1,24 @@ import { PROJECT_ID } from '@/constants'; +import { Point } from '@/types/map'; +import { DEFAULT_BIOENTITY_PARAMS } from './bioEntity/bioEntity.constants'; export const apiPath = { - getBioEntityContentsStringWithQuery: (searchQuery: string): string => - `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000`, + getBioEntityContentsStringWithQuery: ( + searchQuery: string, + { perfectMatch }: { perfectMatch: boolean } = DEFAULT_BIOENTITY_PARAMS, + ): string => + `projects/${PROJECT_ID}/models/*/bioEntities/:search?query=${searchQuery}&size=1000&perfectMatch=${String( + perfectMatch, + )}`, + getSingleBioEntityContentsStringWithCoordinates: ( + { x, y }: Point, + currentModelId: number, + ): string => { + const coordinates = [x, y].join(); + return `projects/${PROJECT_ID}/models/${currentModelId}/bioEntities:search/?coordinates=${coordinates}&count=1`; + }, + getReactionsWithIds: (ids: number[]): string => + `projects/${PROJECT_ID}/models/*/bioEntities/reactions/?id=${ids.join(',')}&size=1000`, getDrugsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, getMirnasStringWithQuery: (searchQuery: string): string => diff --git a/src/redux/bioEntity/bioEntity.constants.ts b/src/redux/bioEntity/bioEntity.constants.ts new file mode 100644 index 00000000..3c109ae8 --- /dev/null +++ b/src/redux/bioEntity/bioEntity.constants.ts @@ -0,0 +1,3 @@ +export const DEFAULT_BIOENTITY_PARAMS = { + perfectMatch: false, +}; diff --git a/src/redux/bioEntity/bioEntity.reducers.ts b/src/redux/bioEntity/bioEntity.reducers.ts index 48ce02f4..1c4f0312 100644 --- a/src/redux/bioEntity/bioEntity.reducers.ts +++ b/src/redux/bioEntity/bioEntity.reducers.ts @@ -1,6 +1,13 @@ import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; -import { BioEntityContentsState } from './bioEntity.types'; import { getBioEntity } from './bioEntity.thunks'; +import { BioEntityContentsState, SetBioEntityContentAction } from './bioEntity.types'; + +export const setBioEntityContentReducer = ( + state: BioEntityContentsState, + action: SetBioEntityContentAction, +): void => { + state.data = action.payload; +}; export const getBioEntityContentsReducer = ( builder: ActionReducerMapBuilder<BioEntityContentsState>, diff --git a/src/redux/bioEntity/bioEntity.slice.ts b/src/redux/bioEntity/bioEntity.slice.ts index 1400797a..b82e8013 100644 --- a/src/redux/bioEntity/bioEntity.slice.ts +++ b/src/redux/bioEntity/bioEntity.slice.ts @@ -1,6 +1,6 @@ -import { createSlice } from '@reduxjs/toolkit'; import { BioEntityContentsState } from '@/redux/bioEntity/bioEntity.types'; -import { getBioEntityContentsReducer } from './bioEntity.reducers'; +import { createSlice } from '@reduxjs/toolkit'; +import { getBioEntityContentsReducer, setBioEntityContentReducer } from './bioEntity.reducers'; const initialState: BioEntityContentsState = { data: [], @@ -11,10 +11,14 @@ const initialState: BioEntityContentsState = { export const bioEntityContentsSlice = createSlice({ name: 'bioEntityContents', initialState, - reducers: {}, + reducers: { + setBioEntityContent: setBioEntityContentReducer, + }, extraReducers: builder => { getBioEntityContentsReducer(builder); }, }); +export const { setBioEntityContent } = bioEntityContentsSlice.actions; + export default bioEntityContentsSlice.reducer; diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index b5b51d28..c8d5debb 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -1,17 +1,18 @@ import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; -import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; -import { apiPath } from '@/redux/apiPath'; -import { getBioEntity } from './bioEntity.thunks'; import contentsReducer from './bioEntity.slice'; +import { getBioEntity } from './bioEntity.thunks'; import { BioEntityContentsState } from './bioEntity.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'park7'; +const SEARCH_QUERY_IDS = ['1000', '2000']; describe('bioEntityContents thunks', () => { let store = {} as ToolkitStoreWithSingleSlice<BioEntityContentsState>; @@ -19,21 +20,77 @@ describe('bioEntityContents thunks', () => { store = createStoreInstanceUsingSliceReducer('bioEntityContents', contentsReducer); }); describe('getBioEntityContents', () => { - it('should return data when data response from API is valid', async () => { - mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + describe('on default query search', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); - const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); - expect(payload).toEqual(bioEntityResponseFixture.content); + const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + expect(payload).toEqual(bioEntityResponseFixture.content); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); + expect(payload).toEqual(undefined); + }); }); - it('should return undefined when data response from API is not valid ', async () => { - mockedAxiosClient - .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY)) - .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); - const { payload } = await store.dispatch(getBioEntity(SEARCH_QUERY)); - expect(payload).toEqual(undefined); + describe('on multi query search', () => { + it('should return data when data response from API is valid', async () => { + const FIRST = 0; + const SECOND = 1; + const params = { + perfectMatch: true, + }; + + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[FIRST], params)) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[SECOND], params)) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + const { payload } = await store.dispatch( + getBioEntity({ + query: SEARCH_QUERY_IDS, + params, + }), + ); + + expect(payload).toEqual([ + ...bioEntityResponseFixture.content, + ...bioEntityResponseFixture.content, + ]); + }); + it('should return undefined when data response from API is not valid ', async () => { + const FIRST = 0; + const SECOND = 1; + const params = { + perfectMatch: true, + }; + + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[FIRST], params)) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getBioEntityContentsStringWithQuery(SEARCH_QUERY_IDS[SECOND], params)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch( + getBioEntity({ + query: SEARCH_QUERY_IDS, + params, + }), + ); + + expect(payload).toEqual(undefined); + }); }); }); }); diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts index c1759481..6d4571b1 100644 --- a/src/redux/bioEntity/bioEntity.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -1,19 +1,48 @@ -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; +import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; -import { apiPath } from '@/redux/apiPath'; -import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { DEFAULT_BIOENTITY_PARAMS } from './bioEntity.constants'; +import { BioEntityContentSearchQuery } from './bioEntity.types'; + +const getQueryResponse = async ({ + query, + params, +}: BioEntityContentSearchQuery): Promise<BioEntityResponse> => { + const queries = typeof query === 'string' ? [query] : query; + const responses = await Promise.all( + queries.map(searchQuery => + axiosInstanceNewAPI.get<BioEntityResponse>( + apiPath.getBioEntityContentsStringWithQuery(searchQuery, params), + ), + ), + ); + + const responsesData = responses.map(response => response.data); + + return responsesData.reduce((acc, next) => ({ + ...acc, + size: acc.size + next.size, + totalPages: acc.totalPages + next.totalPages, + totalElements: acc.totalElements + next.totalElements, + numberOfElements: acc.numberOfElements + next.numberOfElements, + content: [...acc.content, ...next.content], + })); +}; export const getBioEntity = createAsyncThunk( 'project/getBioEntityContents', - async (searchQuery: string): Promise<BioEntityContent[] | undefined> => { - const response = await axiosInstanceNewAPI.get<BioEntityResponse>( - apiPath.getBioEntityContentsStringWithQuery(searchQuery), - ); + async ( + searchQuery: string | BioEntityContentSearchQuery, + ): Promise<BioEntityContent[] | undefined> => { + const query = typeof searchQuery === 'string' ? searchQuery : searchQuery.query; + const params = typeof searchQuery === 'string' ? DEFAULT_BIOENTITY_PARAMS : searchQuery.params; - const isDataValid = validateDataUsingZodSchema(response.data, bioEntityResponseSchema); + const response = await getQueryResponse({ query, params }); + const isDataValid = validateDataUsingZodSchema(response, bioEntityResponseSchema); - return isDataValid ? response.data.content : undefined; + return isDataValid ? response.content : undefined; }, ); diff --git a/src/redux/bioEntity/bioEntity.types.ts b/src/redux/bioEntity/bioEntity.types.ts index 3efecc0f..dac619af 100644 --- a/src/redux/bioEntity/bioEntity.types.ts +++ b/src/redux/bioEntity/bioEntity.types.ts @@ -1,4 +1,16 @@ import { FetchDataState } from '@/types/fetchDataState'; import { BioEntityContent } from '@/types/models'; +import { PayloadAction } from '@reduxjs/toolkit'; export type BioEntityContentsState = FetchDataState<BioEntityContent[]>; + +export type BioEntityContentSearchQuery = { + query: string | string[]; + params: { + perfectMatch: boolean; + }; +}; + +export type SetBioEntityContentActionPayload = BioEntityContent[]; + +export type SetBioEntityContentAction = PayloadAction<SetBioEntityContentActionPayload>; diff --git a/src/redux/models/models.selectors.ts b/src/redux/models/models.selectors.ts index 8a9478b1..071b0682 100644 --- a/src/redux/models/models.selectors.ts +++ b/src/redux/models/models.selectors.ts @@ -11,3 +11,9 @@ export const currentModelSelector = createSelector( mapDataSelector, (models, mapData) => models.find(model => model.idObject === mapData.modelId), ); + +export const currentModelIdSelector = createSelector( + currentModelSelector, + // eslint-disable-next-line no-magic-numbers + model => model?.idObject || 0, +); diff --git a/src/redux/reactions/reactions.reducers.ts b/src/redux/reactions/reactions.reducers.ts new file mode 100644 index 00000000..8673c7a6 --- /dev/null +++ b/src/redux/reactions/reactions.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getReactionsByIds } from './reactions.thunks'; +import { ReactionsState } from './reactions.types'; + +export const getReactionsReducer = (builder: ActionReducerMapBuilder<ReactionsState>): void => { + builder.addCase(getReactionsByIds.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getReactionsByIds.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getReactionsByIds.rejected, state => { + state.loading = 'failed'; + // TODO: error management to be discussed in the team + }); +}; diff --git a/src/redux/reactions/reactions.slice.ts b/src/redux/reactions/reactions.slice.ts new file mode 100644 index 00000000..47eeea91 --- /dev/null +++ b/src/redux/reactions/reactions.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { getReactionsReducer } from './reactions.reducers'; +import { ReactionsState } from './reactions.types'; + +const initialState: ReactionsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const reactionsSlice = createSlice({ + name: 'reactions', + initialState, + reducers: {}, + extraReducers: builder => { + getReactionsReducer(builder); + }, +}); + +export default reactionsSlice.reducer; diff --git a/src/redux/reactions/reactions.thunks.test.ts b/src/redux/reactions/reactions.thunks.test.ts new file mode 100644 index 00000000..88cfb4c4 --- /dev/null +++ b/src/redux/reactions/reactions.thunks.test.ts @@ -0,0 +1,50 @@ +/* eslint-disable no-magic-numbers */ +import { reactionsFixture } from '@/models/fixtures/reactionFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { apiPath } from '../apiPath'; +import reactionsReducer from './reactions.slice'; +import { getReactionsByIds } from './reactions.thunks'; +import { ReactionsState } from './reactions.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('reactions thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ReactionsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('reactions', reactionsReducer); + }); + + describe('getReactionsByIds', () => { + it('should return data when data response from API is valid', async () => { + const ids = [1]; + + mockedAxiosClient + .onGet(apiPath.getReactionsWithIds(ids)) + .reply(HttpStatusCode.Ok, reactionsFixture); + + const { payload } = await store.dispatch(getReactionsByIds(ids)); + expect(payload).toEqual(reactionsFixture); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getReactionsWithIds([])) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getReactionsByIds([])); + expect(payload).toEqual(undefined); + }); + + it('should return empty array when data response from API is empty', async () => { + mockedAxiosClient.onGet(apiPath.getReactionsWithIds([100])).reply(HttpStatusCode.Ok, []); + + const { payload } = await store.dispatch(getReactionsByIds([100])); + expect(payload).toEqual([]); + }); + }); +}); diff --git a/src/redux/reactions/reactions.thunks.ts b/src/redux/reactions/reactions.thunks.ts new file mode 100644 index 00000000..51ef6bfa --- /dev/null +++ b/src/redux/reactions/reactions.thunks.ts @@ -0,0 +1,23 @@ +import { reactionSchema } from '@/models/reaction'; +import { apiPath } from '@/redux/apiPath'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Reaction } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; + +const ZERO = 0; + +export const getReactionsByIds = createAsyncThunk<Reaction[] | undefined, number[]>( + 'reactions/getByIds', + async (ids: number[]): Promise<Reaction[] | undefined> => { + const response = await axiosInstance.get<Reaction[]>(apiPath.getReactionsWithIds(ids)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(reactionSchema)); + + if (!isDataValid || response?.data?.length === ZERO) { + return isDataValid ? response.data : undefined; + } + + return response.data; + }, +); diff --git a/src/redux/reactions/reactions.types.ts b/src/redux/reactions/reactions.types.ts new file mode 100644 index 00000000..02bd81cd --- /dev/null +++ b/src/redux/reactions/reactions.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Reaction } from '@/types/models'; + +export type ReactionsState = FetchDataState<Reaction[]>; diff --git a/src/redux/store.ts b/src/redux/store.ts index d0d3e673..147673c2 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -8,6 +8,7 @@ import mirnasReducer from '@/redux/mirnas/mirnas.slice'; import modelsReducer from '@/redux/models/models.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; +import reactionsReducer from '@/redux/reactions/reactions.slice'; import searchReducer from '@/redux/search/search.slice'; import { AnyAction, @@ -30,6 +31,7 @@ export const reducers = { backgrounds: backgroundsReducer, overlays: overlaysReducer, models: modelsReducer, + reactions: reactionsReducer, }; export const middlewares = [mapListenerMiddleware.middleware]; diff --git a/src/types/models.ts b/src/types/models.ts index 5f7f81bf..38b9e6b3 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -4,6 +4,7 @@ import { bioEntitySchema } from '@/models/bioEntitySchema'; import { chemicalSchema } from '@/models/chemicalSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; +import { elementSearchResult, elementSearchResultType } from '@/models/elementSearchResult'; import { mapBackground } from '@/models/mapBackground'; import { mapOverlay } from '@/models/mapOverlay'; import { mirnaSchema } from '@/models/mirnaSchema'; @@ -11,6 +12,7 @@ import { mapModelSchema } from '@/models/modelSchema'; import { organism } from '@/models/organism'; import { overviewImageView } from '@/models/overviewImageView'; import { projectSchema } from '@/models/project'; +import { reactionSchema } from '@/models/reaction'; import { targetSchema } from '@/models/targetSchema'; import { z } from 'zod'; @@ -28,3 +30,6 @@ export type BioEntity = z.infer<typeof bioEntitySchema>; 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 ElementSearchResult = z.infer<typeof elementSearchResult>; +export type ElementSearchResultType = z.infer<typeof elementSearchResultType>; diff --git a/src/utils/map/getPointOffset.test.ts b/src/utils/map/getPointOffset.test.ts index 2528c7dd..e6d62a59 100644 --- a/src/utils/map/getPointOffset.test.ts +++ b/src/utils/map/getPointOffset.test.ts @@ -18,8 +18,8 @@ describe('getPointOffset - util', () => { }; const results = { - x: -115.2, - y: -102.4, + x: -102.4, + y: -76.8, }; it('should return valid point origin and shifted values', () => { diff --git a/src/utils/map/getPointOffset.ts b/src/utils/map/getPointOffset.ts index 08e60559..6a1c6629 100644 --- a/src/utils/map/getPointOffset.ts +++ b/src/utils/map/getPointOffset.ts @@ -15,7 +15,7 @@ export const getPointOffset = (point: Point, mapSize: MapSize): GetPointOffsetRe const longestSide = Math.max(mapSize.width, mapSize.height); const minZoomShifted = mapSize.minZoom * 2 ** mapSize.minZoom; - const zoomFactor = longestSide / (mapSize.tileSize / minZoomShifted); + const zoomFactor = longestSide / (mapSize.tileSize / minZoomShifted) / 2; const pointOrigin: Point = { x: mapSize.tileSize / 2, diff --git a/src/utils/map/latLngToPoint.test.ts b/src/utils/map/latLngToPoint.test.ts index 35e89dd6..8bb07ab4 100644 --- a/src/utils/map/latLngToPoint.test.ts +++ b/src/utils/map/latLngToPoint.test.ts @@ -16,8 +16,8 @@ describe('latLngToPoint - util', () => { [ [84.480312233386, -159.90463877126223], { - x: 2308.7337233905396, - y: 719.5731221907884, + x: 1154.3668616952698, + y: 359.7865610953942, }, { rounded: false, @@ -26,8 +26,8 @@ describe('latLngToPoint - util', () => { [ [84.20644283660516, -153.43406886300772], { - x: 3052, - y: 1039, + x: 1526, + y: 519, }, { rounded: true, @@ -53,8 +53,8 @@ describe('latLngToPoint - util', () => { [ [843.480312233386, -84.90463877126223], { - x: 56590.721159659464, - y: 66154.2246606772, + x: 28295.360579829732, + y: 33077.1123303386, }, { rounded: false, @@ -63,8 +63,8 @@ describe('latLngToPoint - util', () => { [ [32443.4536345435, -5546654.543645645], { - x: -3300676187, - y: 78350, + x: -1650338094, + y: 39175, }, { rounded: true, diff --git a/src/utils/map/pointToLatLng.test.ts b/src/utils/map/pointToLatLng.test.ts index 8c34d284..318cfec4 100644 --- a/src/utils/map/pointToLatLng.test.ts +++ b/src/utils/map/pointToLatLng.test.ts @@ -44,7 +44,7 @@ describe('pointToLatLng - util', () => { describe('when all args are valid', () => { const validPoint = { x: -256 * 5, - y: 256 * 10, + y: 256 * 5, }; const validMapSize = { @@ -55,7 +55,7 @@ describe('pointToLatLng - util', () => { maxZoom: 10, }; - const results = [-270, 0]; + const results = [-360, 0]; it('should return valid lat lng value', () => { expect(pointToLngLat(validPoint, validMapSize)).toStrictEqual(results); diff --git a/src/utils/map/usePointToProjection.test.tsx b/src/utils/map/usePointToProjection.test.tsx index b3659e7a..83a44e90 100644 --- a/src/utils/map/usePointToProjection.test.tsx +++ b/src/utils/map/usePointToProjection.test.tsx @@ -76,7 +76,7 @@ describe('usePointToProjection - util', () => { maxZoom: 10, }; - const results = [180337575, -180337344]; + const results = [380712659, -238107693]; it('should return valid lat lng value on function call', () => { act(() => { diff --git a/src/utils/search/getElementsByCoordinates.test.ts b/src/utils/search/getElementsByCoordinates.test.ts new file mode 100644 index 00000000..cf2a56fa --- /dev/null +++ b/src/utils/search/getElementsByCoordinates.test.ts @@ -0,0 +1,42 @@ +import { elementSearchResultFixture } from '@/models/fixtures/elementSearchResultFixture'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '../mockNetworkResponse'; +import { getElementsByPoint } from './getElementsByCoordinates'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('getElementsByPoint - utils', () => { + const point = { + x: 0, + y: 0, + }; + const currentModelId = 1000; + + it('should return data when data response from API is valid', async () => { + mockedAxiosClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId)) + .reply(HttpStatusCode.Ok, elementSearchResultFixture); + + const response = await getElementsByPoint({ point, currentModelId }); + expect(response).toEqual(elementSearchResultFixture); + }); + + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const response = await getElementsByPoint({ point, currentModelId }); + expect(response).toEqual(undefined); + }); + + it('should return empty array when data response from API is empty', async () => { + mockedAxiosClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId)) + .reply(HttpStatusCode.Ok, []); + + const response = await getElementsByPoint({ point, currentModelId }); + expect(response).toEqual([]); + }); +}); diff --git a/src/utils/search/getElementsByCoordinates.ts b/src/utils/search/getElementsByCoordinates.ts new file mode 100644 index 00000000..af110ac0 --- /dev/null +++ b/src/utils/search/getElementsByCoordinates.ts @@ -0,0 +1,25 @@ +import { elementSearchResult } from '@/models/elementSearchResult'; +import { apiPath } from '@/redux/apiPath'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Point } from '@/types/map'; +import { ElementSearchResult } from '@/types/models'; +import { z } from 'zod'; +import { validateDataUsingZodSchema } from '../validateDataUsingZodSchema'; + +interface Args { + point: Point; + currentModelId: number; +} + +export const getElementsByPoint = async ({ + point, + currentModelId, +}: Args): Promise<ElementSearchResult[] | undefined> => { + const response = await axiosInstance.get<ElementSearchResult[]>( + apiPath.getSingleBioEntityContentsStringWithCoordinates(point, currentModelId), + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(elementSearchResult)); + + return isDataValid ? response.data : undefined; +}; diff --git a/src/utils/testing/getReduxStoreActionsListener.tsx b/src/utils/testing/getReduxStoreActionsListener.tsx new file mode 100644 index 00000000..22c12cd0 --- /dev/null +++ b/src/utils/testing/getReduxStoreActionsListener.tsx @@ -0,0 +1,30 @@ +/* eslint-disable import/no-extraneous-dependencies */ +import { AppDispatch, RootState, middlewares } from '@/redux/store'; +import { Provider } from 'react-redux'; +import configureStore, { MockStoreEnhanced } from 'redux-mock-store'; +import thunk from 'redux-thunk'; + +interface WrapperProps { + children: React.ReactNode; +} + +export type InitialStoreState = Partial<RootState>; + +type GetReduxStoreActionsListener = (initialState?: InitialStoreState) => { + Wrapper: ({ children }: WrapperProps) => JSX.Element; + store: MockStoreEnhanced<Partial<RootState>, AppDispatch>; +}; + +export const getReduxStoreActionsListener: GetReduxStoreActionsListener = ( + preloadedState: InitialStoreState = {}, +) => { + const testStore = configureStore<Partial<RootState>, AppDispatch>([thunk, ...middlewares])( + preloadedState, + ); + + const Wrapper = ({ children }: WrapperProps): JSX.Element => ( + <Provider store={testStore}>{children}</Provider> + ); + + return { Wrapper, store: testStore }; +}; diff --git a/yarn.lock b/yarn.lock index 9f446451..8c7c992b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -852,6 +852,46 @@ "resolved" "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-13.4.19.tgz" "version" "13.4.19" +"@next/swc-darwin-x64@13.4.19": + "integrity" "sha512-jyzO6wwYhx6F+7gD8ddZfuqO4TtpJdw3wyOduR4fxTUCm3aLw7YmHGYNjS0xRSYGAkLpBkH1E0RcelyId6lNsw==" + "resolved" "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-linux-arm64-gnu@13.4.19": + "integrity" "sha512-vdlnIlaAEh6H+G6HrKZB9c2zJKnpPVKnA6LBwjwT2BTjxI7e0Hx30+FoWCgi50e+YO49p6oPOtesP9mXDRiiUg==" + "resolved" "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-linux-arm64-musl@13.4.19": + "integrity" "sha512-aU0HkH2XPgxqrbNRBFb3si9Ahu/CpaR5RPmN2s9GiM9qJCiBBlZtRTiEca+DC+xRPyCThTtWYgxjWHgU7ZkyvA==" + "resolved" "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-linux-x64-gnu@13.4.19": + "integrity" "sha512-htwOEagMa/CXNykFFeAHHvMJeqZfNQEoQvHfsA4wgg5QqGNqD5soeCer4oGlCol6NGUxknrQO6VEustcv+Md+g==" + "resolved" "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-linux-x64-musl@13.4.19": + "integrity" "sha512-4Gj4vvtbK1JH8ApWTT214b3GwUh9EKKQjY41hH/t+u55Knxi/0wesMzwQRhppK6Ddalhu0TEttbiJ+wRcoEj5Q==" + "resolved" "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-win32-arm64-msvc@13.4.19": + "integrity" "sha512-bUfDevQK4NsIAHXs3/JNgnvEY+LRyneDN788W2NYiRIIzmILjba7LaQTfihuFawZDhRtkYCv3JDC3B4TwnmRJw==" + "resolved" "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-win32-ia32-msvc@13.4.19": + "integrity" "sha512-Y5kikILFAr81LYIFaw6j/NrOtmiM4Sf3GtOc0pn50ez2GCkr+oejYuKGcwAwq3jiTKuzF6OF4iT2INPoxRycEA==" + "resolved" "https://registry.npmjs.org/@next/swc-win32-ia32-msvc/-/swc-win32-ia32-msvc-13.4.19.tgz" + "version" "13.4.19" + +"@next/swc-win32-x64-msvc@13.4.19": + "integrity" "sha512-YzA78jBDXMYiINdPdJJwGgPNT3YqBNNGhsthsDoWHL9p24tEJn9ViQf/ZqTbwSpX/RrkPupLfuuTH2sf73JBAw==" + "resolved" "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-13.4.19.tgz" + "version" "13.4.19" + "@nodelib/fs.scandir@2.1.5": "integrity" "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==" "resolved" "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz" @@ -1153,6 +1193,13 @@ "@types/scheduler" "*" "csstype" "^3.0.2" +"@types/redux-mock-store@^1.0.6": + "integrity" "sha512-eg5RDfhJTXuoJjOMyXiJbaDb1B8tfTaJixscmu+jOusj6adGC0Krntz09Tf4gJgXeCqCrM5bBMd+B7ez0izcAQ==" + "resolved" "https://registry.npmjs.org/@types/redux-mock-store/-/redux-mock-store-1.0.6.tgz" + "version" "1.0.6" + dependencies: + "redux" "^4.0.5" + "@types/scheduler@*": "integrity" "sha512-2L9ifAGl7wmXwP4v3pN4p2FLhD0O1qsJpvKmNin5VA8+UvNVb447UDaAEV6UdrkA+m/Xs58U1RFps44x6TFsVQ==" "resolved" "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.4.tgz" @@ -6102,12 +6149,19 @@ "indent-string" "^4.0.0" "strip-indent" "^3.0.0" +"redux-mock-store@^1.5.4": + "integrity" "sha512-xmcA0O/tjCLXhh9Fuiq6pMrJCwFRaouA8436zcikdIpYWWCjU76CRk+i2bHx8EeiSiMGnB85/lZdU3wIJVXHTA==" + "resolved" "https://registry.npmjs.org/redux-mock-store/-/redux-mock-store-1.5.4.tgz" + "version" "1.5.4" + dependencies: + "lodash.isplainobject" "^4.0.6" + "redux-thunk@^2.4.2": "integrity" "sha512-+P3TjtnP0k/FEjcBL5FZpoovtvrTNT/UXd4/sluaSyrURlSlhLSzEdfsTBW7WsKB6yPvgd7q/iZPICFjW4o57Q==" "resolved" "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.4.2.tgz" "version" "2.4.2" -"redux@^4", "redux@^4 || ^5.0.0-beta.0", "redux@^4.0.0", "redux@^4.2.1": +"redux@^4", "redux@^4 || ^5.0.0-beta.0", "redux@^4.0.0", "redux@^4.0.5", "redux@^4.2.1": "integrity" "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==" "resolved" "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz" "version" "4.2.1" -- GitLab