diff --git a/package-lock.json b/package-lock.json index 79ab1fa87d504d5a0522547ceebce4f5a23fe7ee..6b0f180ae1af3034b094315e8ebee545c8a592f0 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 cc259882ab521253bac4b434e85f01e7d3f26885..c5b84e0b7f15fcc134842f6d3965fdad63c86bac 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 6ff16dd9408560c69b4e2d7a7baf3b117dcfbc3d..3f87400ff1f13608194de724b0693ed3f0d30cc3 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 c7a7ed78442b794fe370eb11c4815b8b71626015..3ab05530b0776d285bb698ea023174f678521398 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 0000000000000000000000000000000000000000..184c604e0beef021d101047566bd4aa0ee4edb4c --- /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 0000000000000000000000000000000000000000..acf6e51664ffb871b4aab35e36ba3199bbfe9a29 --- /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 20d0401abf66cd12e2ab5578c637963cf56b7c54..0f409f880c75c18fccfda813288283c40598b89f 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 8bea0c9075f3f692b84680b50c0aa268e19ecedf..5cad414f88a9fa979d8e31d3dce15301add65831 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 a80407e303374c2da8c3829dfe0b8d66824918c4..a7ffb39839e07fea132e5b3fbb7093aadbc03ff4 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 765b30b0ea67439b1de5933022a122fd2a7811da..47d6c6ce41a1bf0d636052c8320b2edadb189d53 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 0cb37b138acc5057e5b395567743831169c10fca..396210de5a5ecc323906578122a9580eb5596e1b 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 0000000000000000000000000000000000000000..87d9acc4cf914be57fec7b97619571ec441883a3 --- /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 0000000000000000000000000000000000000000..33ae2c131dd197f72d3dd9f6db66d0b7e74bde21 --- /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 0000000000000000000000000000000000000000..703e8be58ae84570971fe358c02eec6d2b0ecac6 --- /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 0000000000000000000000000000000000000000..9bf978eb4c0da78d93283425be14f5e7ea29d15d --- /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 0000000000000000000000000000000000000000..7b2dd7b1454b314195af963dc315e1cf13de6ba6 --- /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 d23bffc005c9cb4587b70e60f574aee2720198af..56612a14e9e8daf2f6e7915bc9d21d915c96c21c 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 776993edc272561e3cdead6f3d8b6fc2f3e58706..78fe878877bd1d08196f637cbbe28cb4e42a0c2a 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 0000000000000000000000000000000000000000..3c109ae8c9124dc08e0687a06cd0088029110028 --- /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 48ce02f4ce25e3b734ae97c905b767a03edbb101..1c4f03124c6a958b253de2a4997f84f0ce45bcf7 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 1400797ae523cac389458436f9dfd5280aba24b4..b82e801326c7fe2b878bd0de1071a486427bbe70 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 b5b51d289aa49fa406b619e3e1fa1924a5afd9f1..c8d5debb90a39e025338a35f8315a3fbd478e00d 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 c175948124fa5fd2e193f5e0cca003d9743fad44..6d4571b17a138c6f8086be3601144fb30fe97837 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 3efecc0f2cb75ea779eba64db65743dcfc8b98c1..dac619af4c947292c841180c69339927708af7e0 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 8a9478b171d44dfc00afb4a23f34517b14ab6d33..071b06826fa06e98e6e71ef6a528032f1caa0e38 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 0000000000000000000000000000000000000000..8673c7a6182481e37fa9236a7b7c80e6f93b6ecf --- /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 0000000000000000000000000000000000000000..47eeea91d64d513ba3e6fe8ac5f5890a67baaa88 --- /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 0000000000000000000000000000000000000000..88cfb4c4341bbd5234d8b174e59637f2268a715b --- /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 0000000000000000000000000000000000000000..51ef6bfa992578d3a90ded8ea377de68c6f077a3 --- /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 0000000000000000000000000000000000000000..02bd81cd1a77eb600ea5accac1317868c60ef7e2 --- /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 d0d3e67326e6a7fa49a0f5764368ea525da40eb6..147673c20c3b42eb732a133aee975fa77ab404ae 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 5f7f81bfcf6b1adfeaae6564a25189960f933263..38b9e6b3b02c92a5de57a41fea8c27bacb411fe4 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 2528c7dd106fbfecfa1fb45e59263a5e158e2b1b..e6d62a59a905c48ba08725f2fda53b95e056c08e 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 08e60559574b402e40ef3ddbcb37129f5306d856..6a1c66294f1e93789a74b9dde710b77d429e47f9 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 35e89dd672982213bf379b23f852e968abc8eb4f..8bb07ab4b640cad88d069ea788061e0bc693a794 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 8c34d284fe32a5f1725b2572fdd0d78a870412d2..318cfec45965ca4dfeead85f32baff55c33dc6cc 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 b3659e7aea7c613a186c23e36fe728cd4c152586..83a44e90ee68b5889b6461a167d26dd78dd49425 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 0000000000000000000000000000000000000000..cf2a56fa4b8be87addbede3d14a1971310bf5f7f --- /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 0000000000000000000000000000000000000000..af110ac0f2bf6e3813d09daa697427bf445a93da --- /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 0000000000000000000000000000000000000000..22c12cd0e7bc8a8d832788d4895a4b9765cac19a --- /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 9f44645148c1bb09e68044a8d3a46aa3c4b2d56c..8c7c992b13f50fb1b7aea03170a5358cf03dfb1c 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"