diff --git a/docs/plugins/bounds.md b/docs/plugins/bounds.md new file mode 100644 index 0000000000000000000000000000000000000000..a9863e90f417737c0f37cbf9799ba3d9e4f0cf2f --- /dev/null +++ b/docs/plugins/bounds.md @@ -0,0 +1,43 @@ +### Bounds + +#### Get Bounds + +To get bounds of the current active map, plugins can use the `getBounds` method defined in `window.minerva.map.data` object available globally. It returns object with properties x1, y1, x2, y2 + +- x1, y1 - top left corner coordinates +- x2, y2 - right bottom corner coordinates + +Example of returned object: + +```javascript +{ + x1: 12853, + y1: 4201, + x2: 23327, + y2: 9575 +} +``` + +##### Example of getBounds method usage: + +```javascript +window.minerva.map.data.getBounds(); +``` + +#### Fit bounds + +To zoom in the map in a way that rectangle defined by coordinates is visible, plugins can use the `fitBounds` method defined in `window.minerva.map` object available globally. This method takes one argument: object with properties x1, y1, x2, y2. + +- x1, y1 - top left corner coordinates +- x2, y2 - right bottom corner coordinates + +##### Example of fitBounds method usage: + +```javascript +window.minerva.map.fitBounds({ + x1: 14057.166666666668, + y1: 6805.337365980873, + x2: 14057.166666666668, + y2: 6805.337365980873, +}); +``` diff --git a/docs/plugins/errors.md b/docs/plugins/errors.md index 08d08e23e7c71c881d0728d1fbc73d6c15dea2bc..fd7d29c75311eb1ca2028e60e570e57e7955e371 100644 --- a/docs/plugins/errors.md +++ b/docs/plugins/errors.md @@ -4,13 +4,15 @@ - **Map with provided id does not exist**: This error occurs when the provided map id does not correspond to any existing map. +- **Unable to retrieve the id of the active map: the modelId is not a number**: This error occurs when the modelId parameter provided from store to retrieve the id of the active map is not a number. + ## Search Errors - **Invalid query type. The query should be of string type**: This error occurs when the query parameter is not of string type. - **Invalid coordinates type or values**: This error occurs when the coordinates parameter is missing keys, or its values are not of number type. -- **Invalid model id type. The model should be of number type**: This error occurs when the modelId parameter is not of number type. +- **Invalid model id type. The model id should be a number**: This error occurs when the modelId parameter is not of number type. ## Project Errors diff --git a/docs/plugins/events.md b/docs/plugins/events.md index 957b28a29c0bbde50cd8fc105001e55976bbe822..63072dc23baf38719a6e45a4362f1c1d1db1b263 100644 --- a/docs/plugins/events.md +++ b/docs/plugins/events.md @@ -201,6 +201,34 @@ To listen for specific events, plugins can use the `addListener` method in `even } ``` +- onPinIconClick - triggered when someone clicks on a pin icon; the element to which the pin is attached is passed as an argument. Example argument: + +```javascript +{ + "id": 40072, +} +``` + +```javascript +{ + "id": "b0a478ad-7e7a-47f5-8130-e96cbeaa0cfe", // marker pin +} +``` + +- onSurfaceClick - triggered when someone clicks on a overlay surface; the element to which the pin is attached is passed as an argument. Example argument: + +```javascript +{ + "id": 18, +} +``` + +```javascript +{ + "id": "a3a5305f-acfa-47ff-bf77-a26d017c6eb3", // surface marker overlay +} +``` + - onSubmapOpen - triggered when submap opens; the submap identifier is passed as an argument. Example argument: ``` diff --git a/docs/plugins/submaps.md b/docs/plugins/submaps.md index 54c6a340857d14e1e45fc1cdbd7d70c2d11c7f62..aab6dc23ce8b15e9b7d06273f188104a2dc4282a 100644 --- a/docs/plugins/submaps.md +++ b/docs/plugins/submaps.md @@ -1,5 +1,15 @@ ### Submaps +#### Get current open map id + +To get current open map id, plugins can use the `getOpenMapId` method defined in `window.minerva.map.data` object available globally. It returns id of current open map. + +##### Example of getOpenMapId method usage: + +```javascript +window.minerva.map.data.getOpenMapId(); +``` + #### Get Models To get data about all available submaps, plugins can use the `getModels` method defined in `window.minerva.map.data`. This method returns array with data about all submaps. diff --git a/index.d.ts b/index.d.ts index bbde76a542bd803d1396d3a25d40df60ef9aa927..fcac0d8b269f59bfa4610ff8051b1266636ba44f 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,3 +1,9 @@ +import { getBounds } from '@/services/pluginsManager/map/data/getBounds'; +import { fitBounds } from '@/services/pluginsManager/map/fitBounds'; +import { getOpenMapId } from '@/services/pluginsManager/map/getOpenMapId'; +import { triggerSearch } from '@/services/pluginsManager/map/triggerSearch'; +import { MinervaConfiguration } from '@/services/pluginsManager/pluginsManager'; +import { MapInstance } from '@/types/map'; import { getModels } from '@/services/pluginsManager/map/models/getModels'; import { openMap } from '@/services/pluginsManager/map/openMap'; import { getCenter } from '@/services/pluginsManager/map/position/getCenter'; @@ -39,8 +45,11 @@ declare global { }; map: { data: { + getBounds: typeof getBounds; + getOpenMapId: typeof getOpenMapId; getModels: typeof getModels; }; + fitBounds: typeof fitBounds; openMap: typeof openMap; triggerSearch: typeof triggerSearch; getZoom: typeof getZoom; diff --git a/package-lock.json b/package-lock.json index 016ad6996bbef60abdeef8fe404277e991e7dca9..18eaa14c5846910e2f3e54e7f06fffe2987b4f93 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", + "is-uuid": "^1.0.2", "molart": "github:davidhoksza/MolArt", "next": "13.4.19", "ol": "^8.1.0", @@ -34,6 +35,7 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", + "sonner": "^1.4.3", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "ts-deepmerge": "^6.2.0", @@ -49,6 +51,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", "@types/crypto-js": "^4.2.2", + "@types/is-uuid": "^1.0.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", @@ -2319,6 +2322,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "node_modules/@types/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-S+gWwUEApOjGCCO5LQrft4kciGWatvB0LyiyWTXSlDkclZBr6glSgstET573GsC5QPFdw2NeOw2PHOOMuTDFSQ==", + "dev": true + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -7968,6 +7977,11 @@ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "dev": true }, + "node_modules/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" + }, "node_modules/is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -12397,6 +12411,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/sonner": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.4.3.tgz", + "integrity": "sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==", + "peerDependencies": { + "react": "^18.0.0", + "react-dom": "^18.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -15724,6 +15747,12 @@ "hoist-non-react-statics": "^3.3.0" } }, + "@types/is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@types/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-S+gWwUEApOjGCCO5LQrft4kciGWatvB0LyiyWTXSlDkclZBr6glSgstET573GsC5QPFdw2NeOw2PHOOMuTDFSQ==", + "dev": true + }, "@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -19786,6 +19815,11 @@ "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", "dev": true }, + "is-uuid": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-uuid/-/is-uuid-1.0.2.tgz", + "integrity": "sha512-tCByphFcJgf2qmiMo5hMCgNAquNSagOetVetDvBXswGkNfoyEMvGH1yDlF8cbZbKnbVBr4Y5/rlpMz9umxyBkQ==" + }, "is-weakmap": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", @@ -22939,6 +22973,12 @@ } } }, + "sonner": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.4.3.tgz", + "integrity": "sha512-SArYlHbkjqRuLiR0iGY2ZSr09oOrxw081ZZkQPfXrs8aZQLIBOLOdzTYxGJB5yIZ7qL56UEPmrX1YqbODwG0Lw==", + "requires": {} + }, "source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", diff --git a/package.json b/package.json index f3a8ec09f2e124b622df2947627ea0a7576d99a3..54dfdef6f81b5365f64141c7b74a92f419bdae94 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "crypto-js": "^4.2.0", "downshift": "^8.2.3", "eslint-config-next": "13.4.19", + "is-uuid": "^1.0.2", "molart": "github:davidhoksza/MolArt", "next": "13.4.19", "ol": "^8.1.0", @@ -48,6 +49,7 @@ "react-dom": "18.2.0", "react-dropzone": "^14.2.3", "react-redux": "^8.1.2", + "sonner": "^1.4.3", "tailwind-merge": "^1.14.0", "tailwindcss": "3.3.3", "ts-deepmerge": "^6.2.0", @@ -63,6 +65,7 @@ "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.5.2", "@types/crypto-js": "^4.2.2", + "@types/is-uuid": "^1.0.2", "@types/jest": "^29.5.5", "@types/react-redux": "^7.1.26", "@types/redux-mock-store": "^1.0.6", diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 3b59e82bec77dc93528d14168014000abd095242..5014ee69cbf7e8d0baed57003a58d81dfe049335 100644 --- a/src/components/AppWrapper/AppWrapper.component.tsx +++ b/src/components/AppWrapper/AppWrapper.component.tsx @@ -2,6 +2,7 @@ import { store } from '@/redux/store'; import { MapInstanceProvider } from '@/utils/context/mapInstanceContext'; import { ReactNode } from 'react'; import { Provider } from 'react-redux'; +import { Toaster } from 'sonner'; interface AppWrapperProps { children: ReactNode; @@ -9,6 +10,17 @@ interface AppWrapperProps { export const AppWrapper = ({ children }: AppWrapperProps): JSX.Element => ( <MapInstanceProvider> - <Provider store={store}>{children}</Provider> + <Provider store={store}> + <> + <Toaster + position="top-center" + visibleToasts={1} + style={{ + width: '700px', + }} + /> + {children} + </> + </Provider> </MapInstanceProvider> ); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index f0934d79f6f54d7ea9008e43f5e84ba7f5faac04..990e4fbdb080ffe640223359edc15521dcfb5053 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -11,10 +11,13 @@ import { HttpStatusCode } from 'axios'; import { DEFAULT_ERROR } from '@/constants/errors'; import { act } from 'react-dom/test-utils'; import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; +import { showToast } from '@/utils/showToast'; import { Modal } from '../Modal.component'; const mockedAxiosClient = mockNetworkResponse(); +jest.mock('../../../../utils/showToast'); + const renderComponent = (initialStoreState: InitialStoreState = {}): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStoreState); @@ -31,6 +34,9 @@ const renderComponent = (initialStoreState: InitialStoreState = {}): { store: St }; describe('EditOverlayModal - component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('should render modal with correct data', () => { renderComponent({ modal: { @@ -101,6 +107,39 @@ describe('EditOverlayModal - component', () => { expect(loading).toBe('succeeded'); expect(removeButton).not.toBeVisible(); }); + it('should show toast after successful removing user overlay', async () => { + renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + mockedAxiosClient + .onDelete(apiPath.removeOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, {}); + + const removeButton = screen.getByTestId('remove-button'); + expect(removeButton).toBeVisible(); + await act(() => { + removeButton.click(); + }); + + expect(showToast).toHaveBeenCalledWith({ + message: 'User overlay removed successfully', + type: 'success', + }); + }); it('should handle save edited user overlay', async () => { const { store } = renderComponent({ user: { @@ -133,6 +172,39 @@ describe('EditOverlayModal - component', () => { expect(loading).toBe('succeeded'); expect(saveButton).not.toBeVisible(); }); + it('should show toast after successful editing user overlay', async () => { + renderComponent({ + user: { + authenticated: true, + loading: 'succeeded', + error: DEFAULT_ERROR, + login: 'test', + }, + modal: { + isOpen: true, + modalTitle: overlayFixture.name, + modalName: 'edit-overlay', + editOverlayState: overlayFixture, + molArtState: {}, + overviewImagesState: {}, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + mockedAxiosClient + .onPatch(apiPath.updateOverlay(overlayFixture.idObject)) + .reply(HttpStatusCode.Ok, overlayFixture); + + const saveButton = screen.getByTestId('save-button'); + expect(saveButton).toBeVisible(); + await act(() => { + saveButton.click(); + }); + + expect(showToast).toHaveBeenCalledWith({ + message: 'User overlay updated successfully', + type: 'success', + }); + }); it('should handle cancel edit user overlay', async () => { const { store } = renderComponent({ diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts index 63a98d5e96cacc5fb1d6c5f34f0a5ba94a5f2ec2..a8d02776d6eb4320f841e7dbea919cffb3134a37 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.ts @@ -76,10 +76,10 @@ export const useEditOverlay = (): UseEditOverlayReturn => { }; const handleSaveEditedOverlay = async (): Promise<void> => { - if (!currentEditedOverlay || !name || !description || !login) return; + if (!currentEditedOverlay || !name || !login) return; await handleUpdateOverlay({ editedOverlay: currentEditedOverlay, - overlayDescription: description, + overlayDescription: description || '', overlayName: name, }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.constants.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..e628e141901704881c7243aea0324512518107ad --- /dev/null +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.constants.ts @@ -0,0 +1 @@ +export const PLUGIN_LOADING_ERROR_PREFIX = 'Failed to load plugin'; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts index 0ea19da098cf1edb70be6367ba25b12d06b7e992..f54c8f5bd2c0bf9ce531f8482d7c0cee13928338 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.test.ts @@ -7,10 +7,12 @@ import { renderHook, waitFor } from '@testing-library/react'; import axios, { HttpStatusCode } from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { act } from 'react-dom/test-utils'; +import { showToast } from '@/utils/showToast'; import { useLoadPlugin } from './useLoadPlugin'; const mockedAxiosClient = new MockAdapter(axios); jest.mock('../../../../../../services/pluginsManager/pluginsManager'); +jest.mock('../../../../../../utils/showToast'); describe('useLoadPlugin', () => { afterEach(() => { @@ -86,4 +88,31 @@ describe('useLoadPlugin', () => { }); }); }); + it('should show toast if plugin failed to load', async () => { + const hash = 'pluginHash'; + const pluginUrl = 'http://example.com/plugin.js'; + + const { Wrapper } = getReduxStoreWithActionsListener(INITIAL_STORE_STATE_MOCK); + + mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Forbidden, null); + + const { + result: { + current: { togglePlugin }, + }, + } = renderHook(() => useLoadPlugin({ hash, pluginUrl }), { + wrapper: Wrapper, + }); + + togglePlugin(); + + await waitFor(() => { + expect(showToast).toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith({ + message: + "Failed to load plugin: Access Forbidden! You don't have permission to access this resource.", + type: 'error', + }); + }); + }); }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts index 454347303ae79a4683debd742c0108152eef805b..ebce11ec92f224772b10aa40c48e33b1cc9051b7 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPlugin/hooks/useLoadPlugin.ts @@ -7,7 +7,10 @@ import { } from '@/redux/plugins/plugins.selectors'; import { removePlugin } from '@/redux/plugins/plugins.slice'; import { PluginsManager } from '@/services/pluginsManager'; +import { showToast } from '@/utils/showToast'; import axios from 'axios'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { PLUGIN_LOADING_ERROR_PREFIX } from '../../AvailablePluginsDrawer.constants'; type UseLoadPluginReturnType = { togglePlugin: () => void; @@ -37,21 +40,29 @@ export const useLoadPlugin = ({ const dispatch = useAppDispatch(); const handleLoadPlugin = async (): Promise<void> => { - const response = await axios(pluginUrl); - const pluginScript = response.data; + try { + const response = await axios(pluginUrl); + const pluginScript = response.data; - /* eslint-disable no-new-func */ - const loadPlugin = new Function(pluginScript); + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); - PluginsManager.setHashedPlugin({ - pluginUrl, - pluginScript, - }); + PluginsManager.setHashedPlugin({ + pluginUrl, + pluginScript, + }); - loadPlugin(); + loadPlugin(); - if (onPluginLoaded) { - onPluginLoaded(); + if (onPluginLoaded) { + onPluginLoaded(); + } + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: PLUGIN_LOADING_ERROR_PREFIX, + }); + showToast({ type: 'error', message: errorMessage }); } }; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx index b74995ed19be857c1ae509c14fd7f27c32bbfda5..1b347d6eec569ab743acd711450aa4479f0cb808 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -9,15 +9,18 @@ import { StoreType } from '@/redux/store'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { InitialStoreState } from '@/utils/testing/getReduxStoreActionsListener'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; -import { fireEvent, render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import axios, { HttpStatusCode } from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { act } from 'react-dom/test-utils'; +import { showToast } from '@/utils/showToast'; import { LoadPluginFromUrl } from './LoadPluginFromUrl.component'; const mockedAxiosApiClient = mockNetworkResponse(); const mockedAxiosClient = new MockAdapter(axios); +jest.mock('../../../../../utils/showToast'); + const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { const { Wrapper, store } = getReduxWrapperWithStore(initialStore); return ( @@ -163,5 +166,65 @@ describe('LoadPluginFromUrl - component', () => { const button = screen.getByTestId('load-plugin-button'); expect(button).toBeDisabled(); }); + it('should show toast if plugin failed to load', async () => { + const pluginUrl = 'http://example.com/plugin.js'; + mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Unauthorized, null); + + global.URL.canParse = jest.fn().mockReturnValue(true); + + renderComponent(); + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: pluginUrl } }); + }); + + const button = screen.getByTestId('load-plugin-button'); + + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(showToast).toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith({ + message: + "Failed to load plugin: You're not authorized to access this resource. Please log in or check your credentials.", + type: 'error', + }); + }); + }); + it('should set plugin active tab in drawer as loaded plugin', async () => { + const pluginUrl = 'http://example.com/plugin.js'; + const pluginScript = `function init() {} init()`; + mockedAxiosClient.onGet(pluginUrl).reply(HttpStatusCode.Ok, pluginScript); + + global.URL.canParse = jest.fn().mockReturnValue(true); + + const { store } = renderComponent(); + + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + const input = screen.getByTestId('load-plugin-input-url'); + expect(input).toBeVisible(); + + act(() => { + fireEvent.change(input, { target: { value: pluginUrl } }); + }); + + const button = screen.getByTestId('load-plugin-button'); + + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith({ + payload: 'e008fb2ceb97e3d6139ffe38a1b39d5d', + type: 'plugins/setCurrentDrawerPluginHash', + }); + }); + }); }); }); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index 123e220ccc3e05c23d54bbcf62e8d9c607acb373..f95bdf7e4beaa78a655090c648652a8ff20fa388 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -1,8 +1,13 @@ +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { activePluginsDataSelector } from '@/redux/plugins/plugins.selectors'; +import { setCurrentDrawerPluginHash } from '@/redux/plugins/plugins.slice'; import { PluginsManager } from '@/services/pluginsManager'; +import { showToast } from '@/utils/showToast'; import axios from 'axios'; import { ChangeEvent, useMemo, useState } from 'react'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { PLUGIN_LOADING_ERROR_PREFIX } from '../../AvailablePluginsDrawer.constants'; type UseLoadPluginReturnType = { handleChangePluginUrl: (event: ChangeEvent<HTMLInputElement>) => void; @@ -15,12 +20,17 @@ export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { const [pluginUrl, setPluginUrl] = useState(''); const [isLoading, setIsLoading] = useState(false); const activePlugins = useAppSelector(activePluginsDataSelector); + const dispatch = useAppDispatch(); const isPending = useMemo( () => !pluginUrl || isLoading || !URL.canParse(pluginUrl), [pluginUrl, isLoading], ); + const handleSetCurrentDrawerPluginHash = (hash: string): void => { + dispatch(setCurrentDrawerPluginHash(hash)); + }; + const handleLoadPlugin = async (): Promise<void> => { try { setIsLoading(true); @@ -40,6 +50,14 @@ export const useLoadPluginFromUrl = (): UseLoadPluginReturnType => { } setPluginUrl(''); + + handleSetCurrentDrawerPluginHash(hash); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PLUGIN_LOADING_ERROR_PREFIX }); + showToast({ + type: 'error', + message: errorMessage, + }); } finally { setIsLoading(false); } diff --git a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx index 2ee7eb5b5a6eb3e445c572fe5fa593062bd482a2..03c3f49587c5fb3ea0f8a6fb4bfd58549f77ee26 100644 --- a/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx +++ b/src/components/Map/Drawer/BioEntityDrawer/BioEntityDrawer.component.tsx @@ -1,7 +1,7 @@ import { ZERO } from '@/constants/common'; import { - searchedFromMapBioEntityElement, - searchedFromMapBioEntityElementRelatedSubmapSelector, + currentDrawerBioEntityRelatedSubmapSelector, + currentDrawerBioEntitySelector, } from '@/redux/bioEntity/bioEntity.selectors'; import { getChemicalsForBioEntityDrawerTarget, @@ -22,8 +22,8 @@ const TARGET_PREFIX: ElementSearchResultType = `ALIAS`; export const BioEntityDrawer = (): React.ReactNode => { const dispatch = useAppDispatch(); - const bioEntityData = useAppSelector(searchedFromMapBioEntityElement); - const relatedSubmap = useAppSelector(searchedFromMapBioEntityElementRelatedSubmapSelector); + const bioEntityData = useAppSelector(currentDrawerBioEntitySelector); + const relatedSubmap = useAppSelector(currentDrawerBioEntityRelatedSubmapSelector); const currentTargetId = bioEntityData?.id ? `${TARGET_PREFIX}:${bioEntityData.id}` : ''; const fetchChemicalsForTarget = (): void => { diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx index 474934d8f7bf9805432330ca0361a9ee2a7572e3..b9966147dd4a91994d39bf8b39a03f66f9e6224c 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx @@ -21,8 +21,11 @@ import { } from '@/models/fixtures/overlaysFixture'; import { OVERLAYS_INITIAL_STATE_MOCK } from '@/redux/overlays/overlays.mock'; import { DEFAULT_ERROR } from '@/constants/errors'; +import { showToast } from '@/utils/showToast'; import { UserOverlayForm } from './UserOverlayForm.component'; +jest.mock('../../../../../utils/showToast'); + const mockedAxiosClient = mockNetworkResponse(); const renderComponent = (initialStore?: InitialStoreState): { store: StoreType } => { @@ -57,6 +60,9 @@ const renderComponentWithActionListener = ( }; describe('UserOverlayForm - Component', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); it('renders the UserOverlayForm component', () => { renderComponent(); @@ -249,4 +255,58 @@ describe('UserOverlayForm - Component', () => { expect(refetchedUserOverlays).toEqual(overlaysFixture); }); }); + it('should show toast after successful creating user overlays', async () => { + mockedAxiosClient + .onPost(apiPath.createOverlayFile()) + .reply(HttpStatusCode.Ok, createdOverlayFileFixture); + + mockedAxiosClient + .onPost(apiPath.uploadOverlayFileContent(createdOverlayFileFixture.id)) + .reply(HttpStatusCode.Ok, uploadedOverlayFileContentFixture); + + mockedAxiosClient + .onPost(apiPath.createOverlay(projectFixture.projectId)) + .reply(HttpStatusCode.Ok, createdOverlayFixture); + + mockedAxiosClient + .onGet(apiPath.getAllUserOverlaysByCreatorQuery({ creator: 'test', publicOverlay: false })) + .reply(HttpStatusCode.Ok, overlaysFixture); + + const { store } = renderComponent({ + user: { + authenticated: true, + error: DEFAULT_ERROR, + login: 'test', + loading: 'succeeded', + }, + project: { + data: projectFixture, + loading: 'succeeded', + error: DEFAULT_ERROR, + }, + overlays: OVERLAYS_INITIAL_STATE_MOCK, + }); + + const userOverlays = store.getState().overlays.userOverlays.data; + + expect(userOverlays).toEqual([]); + + fireEvent.change(screen.getByTestId('overlay-name'), { target: { value: 'Test Overlay' } }); + fireEvent.change(screen.getByTestId('overlay-description'), { + target: { value: 'Description Overlay' }, + }); + fireEvent.change(screen.getByTestId('overlay-elements-list'), { + target: { value: 'Elements List Overlay' }, + }); + + fireEvent.click(screen.getByLabelText('upload overlay')); + + await waitFor(() => { + expect(showToast).toHaveBeenCalled(); + expect(showToast).toHaveBeenCalledWith({ + message: 'User overlay added successfully', + type: 'success', + }); + }); + }); }); diff --git a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx index 483bf03b5cd68d898b3b539a97f204a24796365e..47f2e4b205195d6f35e457fb5920f79e327c516a 100644 --- a/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx +++ b/src/components/Map/Drawer/SearchDrawerWrapper/GroupedSearchResults/BioEntitiesAccordion/BioEntitiesSubmapItem/BioEntitiesSubmapItem.component.tsx @@ -1,11 +1,13 @@ -import { useAppSelector } from '@/redux/hooks/useAppSelector'; -import { Icon } from '@/shared/Icon'; +import { LOCATION_BTN_ID } from '@/components/Map/MapAdditionalActions/MappAdditionalActions.constants'; +import { ZERO } from '@/constants/common'; import { displayBioEntitiesList } from '@/redux/drawer/drawer.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { BioEntityContent } from '@/types/models'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { mapModelIdSelector, mapOpenedMapsSelector } from '@/redux/map/map.selectors'; import { openMapAndSetActive, setActiveMap } from '@/redux/map/map.slice'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { Icon } from '@/shared/Icon'; +import { BioEntityContent } from '@/types/models'; export interface BioEntitiesSubmapItemProps { mapName: string; @@ -42,6 +44,11 @@ export const BioEntitiesSubmapItem = ({ const onSubmapClick = (): void => { openSubmap(); dispatch(displayBioEntitiesList(bioEntities)); + + const locationButton = document.querySelector<HTMLButtonElement>(`#${LOCATION_BTN_ID}`); + if (locationButton) { + setTimeout(() => locationButton?.click(), ZERO); + } }; return ( diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx index f65f5b605510ad4afb566e763c886fddbc9b1225..06bd09feed567c3515c7d3f305bc8eecb9426e78 100644 --- a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.test.tsx @@ -54,7 +54,7 @@ const renderComponentWithMapInstance = (initialStore?: InitialStoreState): { sto const { Wrapper, store } = getReduxWrapperWithStore(initialStore, { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }); diff --git a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx index 96f3870a818b1b86b7f482c47b9cccafc7351982..9faad208a93b93275a8b0b3f780cc62494ac2571 100644 --- a/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx +++ b/src/components/Map/MapAdditionalActions/MapAdditionalActions.component.tsx @@ -1,5 +1,6 @@ import { Icon } from '@/shared/Icon'; import { twMerge } from 'tailwind-merge'; +import { LOCATION_BTN_ID } from './MappAdditionalActions.constants'; import { useAddtionalActions } from './utils/useAdditionalActions'; export const MapAdditionalActions = (): JSX.Element => { @@ -18,6 +19,7 @@ export const MapAdditionalActions = (): JSX.Element => { onClick={zoomInToBioEntities} data-testid="location-button" title="Center map" + id={LOCATION_BTN_ID} > <Icon className="h-[28px] w-[28px]" name="location" /> </button> diff --git a/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts b/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts index 0803794471425c623acb0dd038ef4f4389e83898..b56bf65d7cfda35cf8d51acc091e5aa6650c5fe4 100644 --- a/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts +++ b/src/components/Map/MapAdditionalActions/MappAdditionalActions.constants.ts @@ -1,3 +1,5 @@ export const MAP_ZOOM_IN_DELTA = 1; export const MAP_ZOOM_OUT_DELTA = -1; + +export const LOCATION_BTN_ID = 'location-button'; diff --git a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts index f711b392c959c975ed207dccd23b09365166ed6b..898b10e0424587a0420f66981339a2940e648c13 100644 --- a/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts +++ b/src/components/Map/MapAdditionalActions/utils/useAdditionalActions.test.ts @@ -1,13 +1,13 @@ /* eslint-disable no-magic-numbers */ import { FIRST_ARRAY_ELEMENT } from '@/constants/common'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { initialMapDataFixture } from '@/redux/map/map.fixtures'; import { INITIAL_STORE_STATE_MOCK } from '@/redux/root/root.fixtures'; import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { getReduxWrapperWithStore } from '@/utils/testing/getReduxWrapperWithStore'; import { renderHook } from '@testing-library/react'; import Map from 'ol/Map'; -import { initialMapDataFixture } from '@/redux/map/map.fixtures'; -import { modelsFixture } from '@/models/fixtures/modelsFixture'; import { useAddtionalActions } from './useAdditionalActions'; import { useVisibleBioEntitiesPolygonCoordinates } from './useVisibleBioEntitiesPolygonCoordinates'; @@ -99,7 +99,7 @@ describe('useAddtionalActions - hook', () => { { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); @@ -185,7 +185,7 @@ describe('useAddtionalActions - hook', () => { const position = store.getState().map?.data.position; expect(position?.last).toEqual(MAP_CONFIG.position); expect(actions[0]).toEqual({ - payload: { x: 1750, y: 1000, z: 5 }, + payload: { x: 1750, y: 1000, z: 4 }, type: 'map/setMapPosition', }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts index 121bfe2bb2b3c25f79ecb2f0dd7809a4378ace40..a7daf8437d747dce8f28e45f30268d6b8a3e3505 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createFeatureFromExtent.ts @@ -1,5 +1,18 @@ -import Polygon, { fromExtent } from 'ol/geom/Polygon'; +import { FEATURE_TYPE } from '@/constants/features'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; +import isUUID from 'is-uuid'; import Feature from 'ol/Feature'; +import Polygon, { fromExtent } from 'ol/geom/Polygon'; + +export const createFeatureFromExtent = ( + [xMin, yMin, xMax, yMax]: number[], + entityId: OverlayBioEntityRender['id'], +): Feature<Polygon> => { + const isMarker = isUUID.anyNonNil(`${entityId}`); -export const createFeatureFromExtent = ([xMin, yMin, xMax, yMax]: number[]): Feature<Polygon> => - new Feature({ geometry: fromExtent([xMin, yMin, xMax, yMax]) }); + return new Feature({ + geometry: fromExtent([xMin, yMin, xMax, yMax]), + id: entityId, + type: isMarker ? FEATURE_TYPE.SURFACE_MARKER : FEATURE_TYPE.SURFACE_OVERLAY, + }); +}; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts index 8ee122219c608c7e6bedd00e1fd1e269e54255b1..4c94444ea89c6cdb72ce6c8bb3e4c292c8dbfc5c 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.test.ts @@ -1,3 +1,4 @@ +import { FEATURE_TYPE } from '@/constants/features'; import { createOverlayGeometryFeature } from './createOverlayGeometryFeature'; describe('createOverlayGeometryFeature', () => { @@ -7,8 +8,13 @@ describe('createOverlayGeometryFeature', () => { const xMax = 10; const yMax = 10; const colorHexString = '#FF0000'; + const entityId = 2007; - const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + const feature = createOverlayGeometryFeature( + [xMin, yMin, xMax, yMax], + colorHexString, + entityId, + ); expect(feature.getGeometry()!.getCoordinates()).toEqual([ [ @@ -22,6 +28,9 @@ describe('createOverlayGeometryFeature', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - getStyle() is not typed expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + + expect(feature.get('id')).toBe(entityId); + expect(feature.get('type')).toBe(FEATURE_TYPE.SURFACE_OVERLAY); }); it('should create a feature with the correct geometry and style when using a different color', () => { @@ -30,8 +39,13 @@ describe('createOverlayGeometryFeature', () => { const xMax = 5; const yMax = 5; const colorHexString = '#00FF00'; + const entityId = 'a6e21d64-fd3c-4f7c-8acc-5fc305f4395a'; - const feature = createOverlayGeometryFeature([xMin, yMin, xMax, yMax], colorHexString); + const feature = createOverlayGeometryFeature( + [xMin, yMin, xMax, yMax], + colorHexString, + entityId, + ); expect(feature.getGeometry()!.getCoordinates()).toEqual([ [ @@ -45,5 +59,8 @@ describe('createOverlayGeometryFeature', () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - getStyle() is not typed expect(feature.getStyle().getFill().getColor()).toEqual(colorHexString); + + expect(feature.get('id')).toBe(entityId); + expect(feature.get('type')).toBe(FEATURE_TYPE.SURFACE_MARKER); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts index b294d492153d7f74aea80a7836f30c0557032ba5..e11025d81b6401b8b26fc0879d8b51d255a1e034 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlayGeometryFeature.ts @@ -1,6 +1,7 @@ -import { Fill, Stroke, Style } from 'ol/style'; +import { OverlayBioEntityRender } from '@/types/OLrendering'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; +import { Fill, Stroke, Style } from 'ol/style'; import { createFeatureFromExtent } from './createFeatureFromExtent'; const getBioEntityOverlayFeatureStyle = (color: string): Style => @@ -9,8 +10,9 @@ const getBioEntityOverlayFeatureStyle = (color: string): Style => export const createOverlayGeometryFeature = ( [xMin, yMin, xMax, yMax]: number[], color: string, + entityId: OverlayBioEntityRender['id'], ): Feature<Polygon> => { - const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax], entityId); feature.setStyle(getBioEntityOverlayFeatureStyle(color)); return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts index 06d6074af86160a0318c919e1cda4a95105be466..cd5ba930bb62471fd335944f41d5c6e67b820751 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.test.ts @@ -25,13 +25,13 @@ const CASES = [ describe('createOverlaySubmapLinkRectangleFeature - util', () => { it.each(CASES)('should return Feature instance', points => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); expect(feature).toBeInstanceOf(Feature); }); it.each(CASES)('should return Feature instance with valid style and stroke', points => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); const style = feature.getStyle(); expect(style).toMatchObject({ @@ -43,7 +43,7 @@ describe('createOverlaySubmapLinkRectangleFeature - util', () => { }); }); it('should return object with transparent fill and black stroke color when color is null', () => { - const feature = createOverlaySubmapLinkRectangleFeature([0, 0, 0, 0], null); + const feature = createOverlaySubmapLinkRectangleFeature([0, 0, 0, 0], null, 1234); const style = feature.getStyle(); expect(style).toMatchObject({ @@ -55,13 +55,13 @@ describe('createOverlaySubmapLinkRectangleFeature - util', () => { }); }); it.each(CASES)('should return Feature instance with valid geometry', (points, extent) => { - const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR); + const feature = createOverlaySubmapLinkRectangleFeature(points, COLOR, 1234); const geometry = feature.getGeometry(); expect(geometry?.getExtent()).toEqual(extent); }); it('should throw error if extent is not valid', () => { - expect(() => createOverlaySubmapLinkRectangleFeature([100, 100, 0, 0], COLOR)).toThrow(); + expect(() => createOverlaySubmapLinkRectangleFeature([100, 100, 0, 0], COLOR, 1234)).toThrow(); }); }); diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts index cef983542a7777c57a8c551ed6e9d96aa1dcb35c..8a051f2f9fda0e7b21c601e40e1b9f6c96309831 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/createOverlaySubmapLinkRectangleFeature.ts @@ -1,4 +1,5 @@ /* eslint-disable no-magic-numbers */ +import { OverlayBioEntityRender } from '@/types/OLrendering'; import Feature from 'ol/Feature'; import type Polygon from 'ol/geom/Polygon'; import { createFeatureFromExtent } from './createFeatureFromExtent'; @@ -7,8 +8,9 @@ import { getOverlaySubmapLinkRectangleFeatureStyle } from './getOverlaySubmapLin export const createOverlaySubmapLinkRectangleFeature = ( [xMin, yMin, xMax, yMax]: number[], color: string | null, + entityId: OverlayBioEntityRender['id'], ): Feature<Polygon> => { - const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax]); + const feature = createFeatureFromExtent([xMin, yMin, xMax, yMax], entityId); feature.setStyle(getOverlaySubmapLinkRectangleFeatureStyle(color)); return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts index 93e24ec85be8ac6dbab4bcfcf307448311c16fc0..107add31699aceb508557e10d9b54420c4d5171a 100644 --- a/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts +++ b/src/components/Map/MapViewer/utils/config/overlaysLayer/useOverlayFeatures.ts @@ -35,6 +35,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: entity.x2, y: entity.y2 }), ], entity?.hexColor || color, + entity.id, ); }), [getOverlayBioEntityColorByAvailableProperties, markersRender, pointToProjection], @@ -67,6 +68,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: xMax, y: entity.y2 }), ], entity.value === Infinity ? null : color, + entity.id, ); } @@ -77,6 +79,7 @@ export const useOverlayFeatures = (): Feature<Polygon>[] | Feature<SimpleGeometr ...pointToProjection({ x: xMax, y: entity.y2 }), ], color, + entity.id, ); } diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts index 28610fd818aa15ae7b6b9dc89fec3ab2e9042786..80af09f63b9e14b963a21b64de18e09c396451c8 100644 --- a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.test.ts @@ -24,7 +24,7 @@ describe('getPinFeature - subUtil', () => { }); it('should return id as name', () => { - expect(result.get('name')).toBe(bioEntity.id); + expect(result.get('id')).toBe(bioEntity.id); }); it('should return point parsed with point to projection', () => { diff --git a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts index ff7bfb00b7af92fa23a3d9be3ae2e4d826320252..51d4f8d363697addadc40b00dae775744530ac0d 100644 --- a/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts +++ b/src/components/Map/MapViewer/utils/config/pinsLayer/getPinFeature.ts @@ -1,8 +1,10 @@ import { ZERO } from '@/constants/common'; import { HALF } from '@/constants/dividers'; +import { FEATURE_TYPE } from '@/constants/features'; import { Marker } from '@/redux/markers/markers.types'; import { BioEntity } from '@/types/models'; import { UsePointToProjectionResult } from '@/utils/map/usePointToProjection'; +import isUUID from 'is-uuid'; import { Feature } from 'ol'; import { Point } from 'ol/geom'; @@ -10,13 +12,18 @@ export const getPinFeature = ( { x, y, width, height, id }: Pick<BioEntity, 'id' | 'width' | 'height' | 'x' | 'y'> | Marker, pointToProjection: UsePointToProjectionResult, ): Feature => { + const isMarker = isUUID.anyNonNil(`${id}`); + const point = { x: x + (width || ZERO) / HALF, y: y + (height || ZERO) / HALF, }; - return new Feature({ + const feature = new Feature({ geometry: new Point(pointToProjection(point)), - name: id, + id, + type: isMarker ? FEATURE_TYPE.PIN_ICON_MARKER : FEATURE_TYPE.PIN_ICON_BIOENTITY, }); + + return feature; }; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts index ff40ba9134e054c7dd1cb444337071f2a7aef2ed..a42e967759c12b43c1ce2da7158b4a2641a2620d 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapLayers.ts @@ -22,7 +22,7 @@ export const useOlMapLayers = ({ mapInstance }: UseOlMapLayersInput): MapConfig[ return; } - mapInstance.setLayers([tileLayer, reactionsLayer, pinsLayer, overlaysLayer]); + mapInstance.setLayers([tileLayer, reactionsLayer, overlaysLayer, pinsLayer]); }, [reactionsLayer, tileLayer, pinsLayer, mapInstance, overlaysLayer]); return [tileLayer, pinsLayer, reactionsLayer, overlaysLayer]; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts index b50d5c150dd2291feab71d5c5a5e0477f05e95aa..a636c57f4210f5ea5564bc93baa8e3bc37e0aecd 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts @@ -3,6 +3,9 @@ import { OPTIONS } from '@/constants/map'; import { currentBackgroundImagePathSelector } from '@/redux/backgrounds/background.selectors'; import { mapDataSizeSelector } from '@/redux/map/map.selectors'; import { projectDataSelector } from '@/redux/project/project.selectors'; +import { Point } from '@/types/map'; +import { usePointToProjection } from '@/utils/map/usePointToProjection'; +import { Extent, boundingExtent } from 'ol/extent'; import BaseLayer from 'ol/layer/Base'; import TileLayer from 'ol/layer/Tile'; import { XYZ } from 'ol/source'; @@ -16,6 +19,21 @@ export const useOlMapTileLayer = (): BaseLayer => { const mapSize = useSelector(mapDataSizeSelector); const currentBackgroundImagePath = useSelector(currentBackgroundImagePathSelector); const project = useSelector(projectDataSelector); + const pointToProjection = usePointToProjection(); + + const tileExtent = useMemo((): Extent => { + const topLeftPoint: Point = { + x: mapSize.width, + y: mapSize.height, + }; + + const bottomRightPoint: Point = { + x: 0, + y: 0, + }; + + return boundingExtent([topLeftPoint, bottomRightPoint].map(pointToProjection)); + }, [pointToProjection, mapSize]); const sourceUrl = useMemo( () => getMapTileUrl({ projectDirectory: project?.directory, currentBackgroundImagePath }), @@ -39,8 +57,9 @@ export const useOlMapTileLayer = (): BaseLayer => { new TileLayer({ visible: true, source, + extent: tileExtent, }), - [source], + [source, tileExtent], ); return tileLayer; diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts index 48b643ceab96db55df795a156aa6880fa3ac3e53..1b52b84c6ef55181b3415866093efc806539e979 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.test.ts @@ -48,7 +48,7 @@ describe('useOlMapView - util', () => { }); const setViewSpy = jest.spyOn(hohResult.current.mapInstance as Map, 'setView'); - const CALLED_ONCE = 1; + const CALLED_TWICE = 2; await act(() => { store.dispatch( @@ -63,7 +63,7 @@ describe('useOlMapView - util', () => { wrapper: Wrapper, }); - await waitFor(() => expect(setViewSpy).toBeCalledTimes(CALLED_ONCE)); + await waitFor(() => expect(setViewSpy).toBeCalledTimes(CALLED_TWICE)); }); it('should return valid View instance', async () => { diff --git a/src/components/Map/MapViewer/utils/config/useOlMapView.ts b/src/components/Map/MapViewer/utils/config/useOlMapView.ts index cd4f2ac09559adf8dfdb4ab35378b52c6da6ab41..ffa0b76e1564bd0fbcd0c638744ef529a650a27b 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapView.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapView.ts @@ -1,9 +1,10 @@ /* eslint-disable no-magic-numbers */ -import { OPTIONS } from '@/constants/map'; +import { EXTENT_PADDING_MULTIPLICATOR, OPTIONS } from '@/constants/map'; import { mapDataInitialPositionSelector, mapDataSizeSelector } from '@/redux/map/map.selectors'; import { MapInstance, Point } from '@/types/map'; import { usePointToProjection } from '@/utils/map/usePointToProjection'; import { View } from 'ol'; +import { Extent, boundingExtent } from 'ol/extent'; import { useEffect, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { MapConfig } from '../../MapViewer.types'; @@ -17,6 +18,25 @@ export const useOlMapView = ({ mapInstance }: UseOlMapViewInput): MapConfig['vie const mapSize = useSelector(mapDataSizeSelector); const pointToProjection = usePointToProjection(); + const extent = useMemo((): Extent => { + const extentPadding = { + horizontal: mapSize.width * EXTENT_PADDING_MULTIPLICATOR, + vertical: mapSize.height * EXTENT_PADDING_MULTIPLICATOR, + }; + + const topLeftPoint: Point = { + x: mapSize.width + extentPadding.horizontal, + y: mapSize.height + extentPadding.vertical, + }; + + const bottomRightPoint: Point = { + x: -extentPadding.horizontal, + y: -extentPadding.vertical, + }; + + return boundingExtent([topLeftPoint, bottomRightPoint].map(pointToProjection)); + }, [pointToProjection, mapSize]); + const center = useMemo((): Point => { const centerPoint: Point = { x: mapInitialPosition.x, @@ -38,8 +58,9 @@ export const useOlMapView = ({ mapInstance }: UseOlMapViewInput): MapConfig['vie showFullExtent: OPTIONS.showFullExtent, maxZoom: mapSize.maxZoom, minZoom: mapSize.minZoom, + extent, }), - [center.x, center.y, mapInitialPosition.z, mapSize.maxZoom, mapSize.minZoom], + [mapInitialPosition.z, mapSize.maxZoom, mapSize.minZoom, center, extent], ); const view = useMemo(() => new View(viewConfig), [viewConfig]); diff --git a/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts b/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts index 858b57fb89c8d9ef42c65f70287ed453e718aa50..29056a8fe5467120d207ff2790af3828d3230661 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapRightClick/handleSearchResultForRightClickAction.ts @@ -20,5 +20,5 @@ export const handleSearchResultForRightClickAction = async ({ REACTION: handleReactionResults, }[type]; - await action(dispatch)(closestSearchResult); + await action(dispatch, closestSearchResult)(closestSearchResult); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts b/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts index 3d66ff05ac02bda8f981efb70e7cba7f813814d5..a957771e4eb8f3cd53824aab5de92347eb96af7f 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapRightClick/onMapRightClick.ts @@ -1,12 +1,12 @@ +import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { openContextMenu } from '@/redux/contextMenu/contextMenu.slice'; +import { MapSize } from '@/redux/map/map.types'; import { AppDispatch } from '@/redux/store'; -import { Pixel } from 'ol/pixel'; import { Coordinate } from 'ol/coordinate'; -import { MapSize } from '@/redux/map/map.types'; -import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { Pixel } from 'ol/pixel'; +import { getSearchResults } from '../mapSingleClick/getSearchResults'; import { handleDataReset } from '../mapSingleClick/handleDataReset'; import { handleSearchResultForRightClickAction } from './handleSearchResultForRightClickAction'; -import { getSearchResults } from '../mapSingleClick/getSearchResults'; /* prettier-ignore */ export const onMapRightClick = diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts index 44ac11c9c8e12c0b7a4d357f7851aae3292ee68d..088aa80fec038461d5e0664c44e3eead9fa29709 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.test.ts @@ -1,4 +1,9 @@ -import { FIRST_ARRAY_ELEMENT, SECOND_ARRAY_ELEMENT, SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; +import { + FIRST_ARRAY_ELEMENT, + SECOND_ARRAY_ELEMENT, + SIZE_OF_EMPTY_ARRAY, + THIRD_ARRAY_ELEMENT, +} from '@/constants/common'; import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS } from '@/models/mocks/elementSearchResultMock'; import { apiPath } from '@/redux/apiPath'; @@ -24,22 +29,33 @@ describe('handleAliasResults - util', () => { .reply(HttpStatusCode.Ok, bioEntityResponseFixture); beforeAll(async () => { - handleAliasResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); + handleAliasResults( + dispatch, + ELEMENT_SEARCH_RESULT_MOCK_ALIAS, + )(ELEMENT_SEARCH_RESULT_MOCK_ALIAS); }); - it('should run openBioEntityDrawerById as first action', async () => { + it('should run selectTab as first action', async () => { await waitFor(() => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[FIRST_ARRAY_ELEMENT].type).toEqual('drawer/openBioEntityDrawerById'); + expect(actions[FIRST_ARRAY_ELEMENT].type).toEqual('drawer/selectTab'); }); }); - it('should run getMultiBioEntity as second action', async () => { + it('should run openBioEntityDrawerById as second action', async () => { await waitFor(() => { const actions = store.getActions(); expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); - expect(actions[SECOND_ARRAY_ELEMENT].type).toEqual('project/getMultiBioEntity/pending'); + expect(actions[SECOND_ARRAY_ELEMENT].type).toEqual('drawer/openBioEntityDrawerById'); + }); + }); + + it('should run getMultiBioEntity as third action', async () => { + await waitFor(() => { + const actions = store.getActions(); + expect(actions.length).toBeGreaterThan(SIZE_OF_EMPTY_ARRAY); + expect(actions[THIRD_ARRAY_ELEMENT].type).toEqual('project/getMultiBioEntity/pending'); }); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts index b14875831efa1192261f2f9dc7f3099a87c41a03..bab0bd6227775d92db2c5edfc8f15c1d9377aa6b 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts @@ -1,18 +1,33 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; -import { openBioEntityDrawerById } from '@/redux/drawer/drawer.slice'; +import { openBioEntityDrawerById, selectTab } from '@/redux/drawer/drawer.slice'; import { AppDispatch } from '@/redux/store'; +import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult } from '@/types/models'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; /* prettier-ignore */ export const handleAliasResults = - (dispatch: AppDispatch) => + (dispatch: AppDispatch, closestSearchResult: ElementSearchResult, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { + dispatch(selectTab(`${id}`)); dispatch(openBioEntityDrawerById(id)); dispatch( getMultiBioEntity({ searchQueries: [id.toString()], isPerfectMatch: true }), - ); + ) + .unwrap().then((bioEntityContents) => { + + PluginsEventBus.dispatchEvent('onSearch', { + type: 'bioEntity', + searchValues: [closestSearchResult], + results: [bioEntityContents], + }); + + if (hasFitBounds) { + searchFitBounds(fitBoundsZoom); + } + }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2f23be1f53f13cfe3e5aea6ee8bd0674db12f26 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.test.ts @@ -0,0 +1,117 @@ +import { FEATURE_TYPE } from '@/constants/features'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; +import { Feature } from 'ol'; +import { handleFeaturesClick } from './handleFeaturesClick'; + +describe('handleFeaturesClick - util', () => { + beforeEach(() => { + PluginsEventBus.events = []; + }); + + describe('when feature contains pin icon marker', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.PIN_ICON_MARKER, + }), + ]; + + it('should dispatch event onPinIconClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onPinIconClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); + + describe('when feature contains pin icon bioentity', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.PIN_ICON_BIOENTITY, + }), + ]; + + it('should dispatch event onPinIconClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onPinIconClick', { id: featureId }); + }); + + it('should dispatch actions regarding opening entity drawer', () => { + const { store: localStore } = getReduxStoreWithActionsListener(); + const { dispatch: localDispatch } = localStore; + handleFeaturesClick(features, localDispatch); + expect(store.getActions()).toStrictEqual([ + { payload: undefined, type: 'search/clearSearchData' }, + { payload: 1234, type: 'drawer/openBioEntityDrawerById' }, + ]); + }); + + it('should return shouldBlockCoordSearch=true', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: true, + }); + }); + }); + + describe('when feature contains surface overlay', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.SURFACE_OVERLAY, + }), + ]; + + it('should dispatch event onSurfaceClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onSurfaceClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); + + describe('when feature contains surface marker', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const featureId = 1234; + const features = [ + new Feature({ + id: featureId, + type: FEATURE_TYPE.SURFACE_MARKER, + }), + ]; + + it('should dispatch event onSurfaceClick', () => { + const dispatchEventSpy = jest.spyOn(PluginsEventBus, 'dispatchEvent'); + handleFeaturesClick(features, dispatch); + expect(dispatchEventSpy).toHaveBeenCalledWith('onSurfaceClick', { id: featureId }); + }); + + it('should return shouldBlockCoordSearch=false', () => { + expect(handleFeaturesClick(features, dispatch)).toStrictEqual({ + shouldBlockCoordSearch: false, + }); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts new file mode 100644 index 0000000000000000000000000000000000000000..3c1e5923d2290df828598a4b46d8c175979f020c --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleFeaturesClick.ts @@ -0,0 +1,39 @@ +import { FEATURE_TYPE, PIN_ICON_ANY, SURFACE_ANY } from '@/constants/features'; +import { openBioEntityDrawerById } from '@/redux/drawer/drawer.slice'; +import { clearSearchData } from '@/redux/search/search.slice'; +import { AppDispatch } from '@/redux/store'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { FeatureLike } from 'ol/Feature'; + +interface HandleFeaturesClickResult { + shouldBlockCoordSearch: boolean; +} + +export const handleFeaturesClick = ( + features: FeatureLike[], + dispatch: AppDispatch, +): HandleFeaturesClickResult => { + let shouldBlockCoordSearch = false; + const pinFeatures = features.filter(feature => PIN_ICON_ANY.includes(feature.get('type'))); + const surfaceFeatures = features.filter(feature => SURFACE_ANY.includes(feature.get('type'))); + + pinFeatures.forEach(pin => { + const pinId = pin.get('id') as string | number; + PluginsEventBus.dispatchEvent('onPinIconClick', { id: pinId }); + + if (pin.get('type') === FEATURE_TYPE.PIN_ICON_BIOENTITY) { + dispatch(clearSearchData()); + dispatch(openBioEntityDrawerById(pinId)); + shouldBlockCoordSearch = true; + } + }); + + surfaceFeatures.forEach(surface => { + const surfaceId = surface.get('id') as string | number; + PluginsEventBus.dispatchEvent('onSurfaceClick', { id: surfaceId }); + }); + + return { + shouldBlockCoordSearch, + }; +}; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts index b514095b3654a64d101e79df172d841b60abb01d..020f0c6480a1298b8799319fab20ca3e3f9c77b1 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.test.ts @@ -33,7 +33,10 @@ describe('handleReactionResults - util', () => { .reply(HttpStatusCode.Ok, reactionsFixture); beforeAll(async () => { - handleReactionResults(dispatch)(ELEMENT_SEARCH_RESULT_MOCK_REACTION); + handleReactionResults( + dispatch, + ELEMENT_SEARCH_RESULT_MOCK_REACTION, + )(ELEMENT_SEARCH_RESULT_MOCK_REACTION); }); it('should run getReactionsByIds as first action', () => { diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts index 55c244ad04bd1a9cb6021085247101025742fa68..bcadc4a1e476956a6c62d7a190f502924294251b 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -3,12 +3,14 @@ import { getMultiBioEntity } from '@/redux/bioEntity/bioEntity.thunks'; import { openReactionDrawerById } from '@/redux/drawer/drawer.slice'; import { getReactionsByIds } from '@/redux/reactions/reactions.thunks'; import { AppDispatch } from '@/redux/store'; +import { searchFitBounds } from '@/services/pluginsManager/map/triggerSearch/searchFitBounds'; import { ElementSearchResult, Reaction } from '@/types/models'; import { PayloadAction } from '@reduxjs/toolkit'; +import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; /* prettier-ignore */ export const handleReactionResults = - (dispatch: AppDispatch) => + (dispatch: AppDispatch, closestSearchResult: ElementSearchResult, hasFitBounds?: boolean, fitBoundsZoom?: number) => async ({ id }: ElementSearchResult): Promise<void> => { const data = await dispatch(getReactionsByIds([id])) as PayloadAction<Reaction[] | undefined>; const payload = data?.payload; @@ -28,6 +30,16 @@ export const handleReactionResults = getMultiBioEntity({ searchQueries: bioEntitiesIds, isPerfectMatch: true }, - ), - ); + ) + ).unwrap().then((bioEntityContents) => { + PluginsEventBus.dispatchEvent('onSearch', { + type: 'bioEntity', + searchValues: [closestSearchResult], + results: [bioEntityContents], + }); + + if (hasFitBounds) { + searchFitBounds(fitBoundsZoom); + } + }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts index 23ae912e98c9d24124f0c61be6ff6fa655ed6ca9..39dea1009c6cbacb44eb5819f49911b801aa7a04 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction.ts @@ -8,11 +8,15 @@ import { handleReactionResults } from './handleReactionResults'; interface HandleSearchResultActionInput { searchResults: ElementSearchResult[]; dispatch: AppDispatch; + hasFitBounds?: boolean; + fitBoundsZoom?: number; } export const handleSearchResultAction = async ({ searchResults, dispatch, + hasFitBounds, + fitBoundsZoom, }: HandleSearchResultActionInput): Promise<void> => { const closestSearchResult = searchResults[FIRST_ARRAY_ELEMENT]; const { type } = closestSearchResult; @@ -21,7 +25,7 @@ export const handleSearchResultAction = async ({ REACTION: handleReactionResults, }[type]; - await action(dispatch)(closestSearchResult); + await action(dispatch, closestSearchResult, hasFitBounds, fitBoundsZoom)(closestSearchResult); if (type === 'ALIAS') { PluginsEventBus.dispatchEvent('onBioEntityClick', closestSearchResult); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts index 806d2a7ca5638841276a7fb90c6ae2b692bc0648..4270a32407558717ed920b0a1740c2286a75f7a2 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.test.ts @@ -1,4 +1,6 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable no-magic-numbers */ +import { FEATURE_TYPE } from '@/constants/features'; import { ELEMENT_SEARCH_RESULT_MOCK_ALIAS, ELEMENT_SEARCH_RESULT_MOCK_REACTION, @@ -8,7 +10,7 @@ import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { getReduxStoreWithActionsListener } from '@/utils/testing/getReduxStoreActionsListener'; import { waitFor } from '@testing-library/react'; import { HttpStatusCode } from 'axios'; -import { MapBrowserEvent } from 'ol'; +import { Feature, Map, MapBrowserEvent } from 'ol'; import * as handleDataReset from './handleDataReset'; import * as handleSearchResultAction from './handleSearchResultAction'; import { onMapSingleClick } from './onMapSingleClick'; @@ -56,8 +58,12 @@ describe('onMapSingleClick - util', () => { const coordinate = [90, 90]; const event = getEvent(coordinate); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('should fire data reset handler', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleDataResetSpy).toBeCalled(); }); }); @@ -82,8 +88,12 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, undefined); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does not fire search result action', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleSearchResultActionSpy).not.toBeCalled(); }); }); @@ -110,12 +120,53 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, []); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does not fire search result action', async () => { - await handler(event); + await handler(event, mapInstanceMock); expect(handleSearchResultActionSpy).not.toBeCalled(); }); }); + describe('when clicked on feature type = pin icon bioentity', () => { + const { store } = getReduxStoreWithActionsListener(); + const { dispatch } = store; + const { modelId } = ELEMENT_SEARCH_RESULT_MOCK_ALIAS; + const mapSize = { + width: 270, + height: 270, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }; + const coordinate = [270, 270]; + const point = { x: 540.0072763538013, y: 539.9927236461986 }; + const event = getEvent(coordinate); + + mockedAxiosOldClient + .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) + .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + + const mapInstanceMock = { + forEachFeatureAtPixel: (pixel: any, mappingFunction: (feature: Feature) => void): void => { + [ + new Feature({ + id: 1000, + type: FEATURE_TYPE.PIN_ICON_BIOENTITY, + }), + ].forEach(mappingFunction); + }, + } as unknown as Map; + + it('does NOT fire search result action handler', async () => { + const handler = onMapSingleClick(mapSize, modelId, dispatch); + await handler(event, mapInstanceMock); + await waitFor(() => expect(handleSearchResultActionSpy).not.toBeCalled()); + }); + }); + describe('when searchResults are valid', () => { describe('when results type is ALIAS', () => { const { store } = getReduxStoreWithActionsListener(); @@ -136,9 +187,13 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_ALIAS]); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does fire search result action handler', async () => { const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); + await handler(event, mapInstanceMock); await waitFor(() => expect(handleSearchResultActionSpy).toBeCalled()); }); }); @@ -165,9 +220,13 @@ describe('onMapSingleClick - util', () => { .onGet(apiPath.getSingleBioEntityContentsStringWithCoordinates(point, modelId)) .reply(HttpStatusCode.Ok, [ELEMENT_SEARCH_RESULT_MOCK_REACTION]); + const mapInstanceMock = { + forEachFeatureAtPixel: (): void => {}, + } as unknown as Map; + it('does fire search result action - handle reaction', async () => { const handler = onMapSingleClick(mapSize, modelId, dispatch); - await handler(event); + await handler(event, mapInstanceMock); await waitFor(() => expect(handleSearchResultActionSpy).toBeCalled()); }); }); diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts index 6940b226047f357dbbcb9f900c32d78ffafd5934..3d1e425c2e5a8d894df37ecc3f04eceee865ead5 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/onMapSingleClick.ts @@ -1,15 +1,25 @@ import { SIZE_OF_EMPTY_ARRAY } from '@/constants/common'; import { MapSize } from '@/redux/map/map.types'; import { AppDispatch } from '@/redux/store'; -import { MapBrowserEvent } from 'ol'; +import { Map, MapBrowserEvent } from 'ol'; +import { FeatureLike } from 'ol/Feature'; import { getSearchResults } from './getSearchResults'; import { handleDataReset } from './handleDataReset'; +import { handleFeaturesClick } from './handleFeaturesClick'; import { handleSearchResultAction } from './handleSearchResultAction'; /* prettier-ignore */ export const onMapSingleClick = (mapSize: MapSize, modelId: number, dispatch: AppDispatch) => - async ({ coordinate }: MapBrowserEvent<UIEvent>): Promise<void> => { + async ({ coordinate, pixel }: Pick<MapBrowserEvent<UIEvent>, 'coordinate' | 'pixel'>, mapInstance: Map): Promise<void> => { + const featuresAtPixel: FeatureLike[] = []; + mapInstance.forEachFeatureAtPixel(pixel, (feature) => featuresAtPixel.push(feature)); + const { shouldBlockCoordSearch } = handleFeaturesClick(featuresAtPixel, dispatch); + + if (shouldBlockCoordSearch) { + return; + } + // side-effect below is to prevent complications with data update - old data may conflict with new data // so we need to reset all the data before updating dispatch(handleDataReset); diff --git a/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts b/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..db8737b1a2cb77c716a60f8cb3a2754949ef843b --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onPointerMove.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Feature, Map, MapBrowserEvent } from 'ol'; +import { onPointerMove } from './onPointerMove'; + +const TARGET_STRING = 'abcd'; + +const EVENT_DRAGGING_MOCK = { + dragging: true, +} as unknown as MapBrowserEvent<PointerEvent>; + +const EVENT_MOCK = { + dragging: false, + originalEvent: undefined, +} as unknown as MapBrowserEvent<PointerEvent>; + +const MAP_INSTANCE_BASE_MOCK = { + getEventPixel: (): void => {}, + forEachFeatureAtPixel: (): void => {}, +}; + +describe('onPointerMove - util', () => { + describe('when event dragging', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_DRAGGING_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe(''); + }); + }); + + describe('when pin feature present and target is html', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => new Feature(), + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe('pointer'); + }); + }); + + describe('when pin feature present and target is string', () => { + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => new Feature(), + getTarget: () => TARGET_STRING, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget()).toBe(TARGET_STRING); + }); + }); + + describe('when pin feature is not present and target is html', () => { + const target = document.createElement('div'); + const mapInstance = { + ...MAP_INSTANCE_BASE_MOCK, + forEachFeatureAtPixel: () => undefined, + getTarget: () => target, + } as unknown as Map; + + it('should return nothing and not modify target', () => { + expect(onPointerMove(mapInstance, EVENT_MOCK)).toBe(undefined); + expect((mapInstance as any).getTarget().style.cursor).toBe(''); + }); + }); +}); diff --git a/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts b/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts new file mode 100644 index 0000000000000000000000000000000000000000..868c3f3359df5e457e793f4ae6e676d6adf16442 --- /dev/null +++ b/src/components/Map/MapViewer/utils/listeners/onPointerMove.ts @@ -0,0 +1,29 @@ +import { PIN_ICON_ANY } from '@/constants/features'; +import { Map } from 'ol'; +import MapBrowserEvent from 'ol/MapBrowserEvent'; + +const isTargetHTMLElement = (target: string | HTMLElement | undefined): target is HTMLElement => + !!target && typeof target !== 'string' && 'style' in target; + +/* prettier-ignore */ +export const onPointerMove = + (mapInstance: Map, event: MapBrowserEvent<PointerEvent>): void => { + if (event.dragging) { + return; + } + + const pixel = mapInstance.getEventPixel(event.originalEvent); + const feature = mapInstance.forEachFeatureAtPixel(pixel, firstFeature => { + const isPinIcon = PIN_ICON_ANY.includes(firstFeature.get('type')); + if (!isPinIcon) { + return undefined; + } + + return firstFeature; + }); + + const target = mapInstance.getTarget(); + if (isTargetHTMLElement(target)) { + target.style.cursor = feature ? 'pointer' : ''; + } + }; diff --git a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts index 5d7631ff007bc82ddcc675b81e67bd5eb384fdf4..72016ec314a2fcfbdd4c31adf3b79416cc99fcb1 100644 --- a/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts +++ b/src/components/Map/MapViewer/utils/listeners/useOlMapListeners.ts @@ -13,6 +13,7 @@ import { useDebouncedCallback } from 'use-debounce'; import { onMapRightClick } from './mapRightClick/onMapRightClick'; import { onMapSingleClick } from './mapSingleClick/onMapSingleClick'; import { onMapPositionChange } from './onMapPositionChange'; +import { onPointerMove } from './onPointerMove'; interface UseOlMapListenersInput { view: View; @@ -57,7 +58,20 @@ export const useOlMapListeners = ({ view, mapInstance }: UseOlMapListenersInput) return; } - const key = mapInstance.on('singleclick', handleMapSingleClick); + const key = mapInstance.on('pointermove', event => onPointerMove(mapInstance, event)); + + // eslint-disable-next-line consistent-return + return () => unByKey(key); + }, [mapInstance]); + + useEffect(() => { + if (!mapInstance) { + return; + } + + const key = mapInstance.on('singleclick', event => + handleMapSingleClick({ coordinate: event.coordinate, pixel: event.pixel }, mapInstance), + ); // eslint-disable-next-line consistent-return return () => unByKey(key); diff --git a/src/components/Map/MapViewer/utils/useOlMap.ts b/src/components/Map/MapViewer/utils/useOlMap.ts index 326e8ec8822585de8fe9397ec380143a7f040016..49ec30027077460bfdde9b9ff2b6e89961c0273a 100644 --- a/src/components/Map/MapViewer/utils/useOlMap.ts +++ b/src/components/Map/MapViewer/utils/useOlMap.ts @@ -19,7 +19,7 @@ type UseOlMap = (input?: UseOlMapInput) => UseOlMapOutput; export const useOlMap: UseOlMap = ({ target } = {}) => { const mapRef = React.useRef<null | HTMLDivElement>(null); - const { mapInstance, setMapInstance } = useMapInstance(); + const { mapInstance, handleSetMapInstance } = useMapInstance(); const view = useOlMapView({ mapInstance }); useOlMapLayers({ mapInstance }); useOlMapListeners({ view, mapInstance }); @@ -41,8 +41,8 @@ export const useOlMap: UseOlMap = ({ target } = {}) => { } }); - setMapInstance(currentMap => currentMap || map); - }, [target, setMapInstance]); + handleSetMapInstance(map); + }, [target, handleSetMapInstance]); return { mapRef, diff --git a/src/constants/features.ts b/src/constants/features.ts new file mode 100644 index 0000000000000000000000000000000000000000..4995bbcf07ff0173cb4c2760bf56874b1b9b9b1c --- /dev/null +++ b/src/constants/features.ts @@ -0,0 +1,9 @@ +export const FEATURE_TYPE = { + PIN_ICON_BIOENTITY: 'PIN_ICON_BIOENTITY', + PIN_ICON_MARKER: 'PIN_ICON_MARKER', + SURFACE_OVERLAY: 'SURFACE_OVERLAY', + SURFACE_MARKER: 'SURFACE_MARKER', +} as const; + +export const PIN_ICON_ANY = [FEATURE_TYPE.PIN_ICON_BIOENTITY, FEATURE_TYPE.PIN_ICON_MARKER]; +export const SURFACE_ANY = [FEATURE_TYPE.SURFACE_OVERLAY, FEATURE_TYPE.SURFACE_MARKER]; diff --git a/src/constants/map.ts b/src/constants/map.ts index 47d6c6ce41a1bf0d636052c8320b2edadb189d53..ae669ac9cae9e68cb574d6146438918f9814f88c 100644 --- a/src/constants/map.ts +++ b/src/constants/map.ts @@ -5,11 +5,12 @@ import { HALF_SECOND_MS, ONE_HUNDRED_MS } from './time'; export const DEFAULT_TILE_SIZE = 256; export const DEFAULT_MIN_ZOOM = 2; export const DEFAULT_MAX_ZOOM = 9; -export const DEFAULT_ZOOM = 5; +export const DEFAULT_ZOOM = 4; export const DEFAULT_CENTER_X = 0; export const DEFAULT_CENTER_Y = 0; // eslint-disable-next-line no-magic-numbers export const LATLNG_FALLBACK: LatLng = [0, 0]; +export const EXTENT_PADDING_MULTIPLICATOR = 1; export const DEFAULT_CENTER_POINT: Point = { x: DEFAULT_CENTER_X, diff --git a/src/redux/backgrounds/backgrounds.constants.ts b/src/redux/backgrounds/backgrounds.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fa103f27a2b1c77b8521798570f223a6ac4f6d0 --- /dev/null +++ b/src/redux/backgrounds/backgrounds.constants.ts @@ -0,0 +1 @@ +export const BACKGROUNDS_FETCHING_ERROR_PREFIX = 'Failed to fetch backgrounds'; diff --git a/src/redux/backgrounds/backgrounds.reducers.test.ts b/src/redux/backgrounds/backgrounds.reducers.test.ts index 9b99161ae9290ee475248566ffe020cc56dc0561..4f70938fa505c0da1813ec8df6cf16e2f9fe4ad8 100644 --- a/src/redux/backgrounds/backgrounds.reducers.test.ts +++ b/src/redux/backgrounds/backgrounds.reducers.test.ts @@ -11,6 +11,8 @@ import backgroundsReducer from './backgrounds.slice'; import { getAllBackgroundsByProjectId } from './backgrounds.thunks'; import { BackgroundsState } from './backgrounds.types'; +jest.mock('../../utils/showToast'); + const mockedAxiosClient = mockNetworkResponse(); const INITIAL_STATE: BackgroundsState = { @@ -49,13 +51,16 @@ describe('backgrounds reducer', () => { .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) .reply(HttpStatusCode.NotFound, []); - const { type } = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); + const { type, payload } = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); const { data, loading, error } = store.getState().backgrounds; expect(type).toBe('backgrounds/getAllBackgroundsByProjectId/rejected'); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual([]); + expect(payload).toBe( + "Failed to fetch backgrounds: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); }); it('should update store on loading getAllBackgroundsByProjectId query', async () => { diff --git a/src/redux/backgrounds/backgrounds.thunks.ts b/src/redux/backgrounds/backgrounds.thunks.ts index 3741a1c8ef88c80078adf1462bdeb39807f92e65..18a0c56bcfed6861ffabd3fb944378930dad13c4 100644 --- a/src/redux/backgrounds/backgrounds.thunks.ts +++ b/src/redux/backgrounds/backgrounds.thunks.ts @@ -4,17 +4,26 @@ import { MapBackground } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; +import { BACKGROUNDS_FETCHING_ERROR_PREFIX } from './backgrounds.constants'; -export const getAllBackgroundsByProjectId = createAsyncThunk( +export const getAllBackgroundsByProjectId = createAsyncThunk<MapBackground[], string, ThunkConfig>( 'backgrounds/getAllBackgroundsByProjectId', - async (projectId: string): Promise<MapBackground[]> => { - const response = await axiosInstance.get<MapBackground[]>( - apiPath.getAllBackgroundsByProjectIdQuery(projectId), - ); + async (projectId: string, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<MapBackground[]>( + apiPath.getAllBackgroundsByProjectIdQuery(projectId), + ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapBackground)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapBackground)); - return isDataValid ? response.data : []; + return isDataValid ? response.data : []; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: BACKGROUNDS_FETCHING_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/bioEntity/bioEntity.constants.ts b/src/redux/bioEntity/bioEntity.constants.ts index 3c109ae8c9124dc08e0687a06cd0088029110028..b719afdedbecef74f12b3712ae166430ab7d20f1 100644 --- a/src/redux/bioEntity/bioEntity.constants.ts +++ b/src/redux/bioEntity/bioEntity.constants.ts @@ -1,3 +1,6 @@ export const DEFAULT_BIOENTITY_PARAMS = { perfectMatch: false, }; + +export const BIO_ENTITY_FETCHING_ERROR_PREFIX = 'Failed to fetch bio entity'; +export const MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX = 'Failed to fetch multi bio entity'; diff --git a/src/redux/bioEntity/bioEntity.reducers.test.ts b/src/redux/bioEntity/bioEntity.reducers.test.ts index 52062b406da85f623f5d3a273ead35a9dabb59ae..78482731a21eb2acf4c47279fc272f8a236dec44 100644 --- a/src/redux/bioEntity/bioEntity.reducers.test.ts +++ b/src/redux/bioEntity/bioEntity.reducers.test.ts @@ -72,7 +72,7 @@ describe('bioEntity reducer', () => { ) .reply(HttpStatusCode.NotFound, bioEntityResponseFixture); - const { type } = await store.dispatch( + const { type, payload } = await store.dispatch( getBioEntity({ searchQuery: SEARCH_QUERY, isPerfectMatch: false, @@ -84,6 +84,9 @@ describe('bioEntity reducer', () => { bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); expect(type).toBe('project/getBioEntityContents/rejected'); + expect(payload).toBe( + "Failed to fetch bio entity: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(bioEnityWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, data: undefined, diff --git a/src/redux/bioEntity/bioEntity.selectors.ts b/src/redux/bioEntity/bioEntity.selectors.ts index b9566eb75fbac7db3ed74d8ccdfdda56bd607adb..8bf9877d86bb70a7665c50a5f400895b585baaae 100644 --- a/src/redux/bioEntity/bioEntity.selectors.ts +++ b/src/redux/bioEntity/bioEntity.selectors.ts @@ -3,13 +3,19 @@ import { rootSelector } from '@/redux/root/root.selectors'; import { MultiSearchData } from '@/types/fetchDataState'; import { BioEntity, BioEntityContent, MapModel } from '@/types/models'; import { createSelector } from '@reduxjs/toolkit'; -import { searchedChemicalsBioEntitesOfCurrentMapSelector } from '../chemicals/chemicals.selectors'; +import { + allChemicalsBioEntitesOfAllMapsSelector, + searchedChemicalsBioEntitesOfCurrentMapSelector, +} from '../chemicals/chemicals.selectors'; import { currentSelectedBioEntityIdSelector } from '../contextMenu/contextMenu.selector'; import { currentSearchedBioEntityId, currentSelectedSearchElement, } from '../drawer/drawer.selectors'; -import { searchedDrugsBioEntitesOfCurrentMapSelector } from '../drugs/drugs.selectors'; +import { + allDrugsBioEntitesOfAllMapsSelector, + searchedDrugsBioEntitesOfCurrentMapSelector, +} from '../drugs/drugs.selectors'; import { currentModelIdSelector, modelsDataSelector } from '../models/models.selectors'; export const bioEntitySelector = createSelector(rootSelector, state => state.bioEntity); @@ -122,3 +128,40 @@ export const allVisibleBioEntitiesSelector = createSelector( return [content, chemicals, drugs].flat(); }, ); + +export const allContentBioEntitesSelectorOfAllMaps = createSelector( + bioEntitySelector, + (bioEntities): BioEntity[] => { + if (!bioEntities) { + return []; + } + + return (bioEntities?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ bioEntity }) => bioEntity); + }, +); + +export const allBioEntitiesSelector = createSelector( + allContentBioEntitesSelectorOfAllMaps, + allChemicalsBioEntitesOfAllMapsSelector, + allDrugsBioEntitesOfAllMapsSelector, + (content, chemicals, drugs): BioEntity[] => { + return [content, chemicals, drugs].flat(); + }, +); + +export const currentDrawerBioEntitySelector = createSelector( + allBioEntitiesSelector, + currentSearchedBioEntityId, + (bioEntities, currentBioEntityId): BioEntity | undefined => + bioEntities.find(({ id }) => id === currentBioEntityId), +); + +export const currentDrawerBioEntityRelatedSubmapSelector = createSelector( + currentDrawerBioEntitySelector, + modelsDataSelector, + (bioEntity, models): MapModel | undefined => + models.find(({ idObject }) => idObject === bioEntity?.submodel?.mapId), +); diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index b45d1941398d5ac6f99c10e30b7b3ab6c4b03587..f2b54f3abdd5a5e258c51bfc8d224a3877d02fdf 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -7,7 +7,7 @@ import { import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import contentsReducer from './bioEntity.slice'; -import { getBioEntity } from './bioEntity.thunks'; +import { getBioEntity, getMultiBioEntity } from './bioEntity.thunks'; import { BioEntityContentsState } from './bioEntity.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); @@ -55,5 +55,72 @@ describe('bioEntityContents thunks', () => { ); expect(payload).toEqual(undefined); }); + it('should handle error message when getBioEntityContents failed', async () => { + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.NotFound, null); + + const { payload } = await store.dispatch( + getBioEntity({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ); + expect(payload).toEqual( + "Failed to fetch bio entity: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); + }); + }); + describe('getMultiBioEntity', () => { + it('should return transformed bioEntityContent array', async () => { + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + const data = await store + .dispatch( + getMultiBioEntity({ + searchQueries: [SEARCH_QUERY], + isPerfectMatch: false, + }), + ) + .unwrap(); + + expect(data).toEqual(bioEntityResponseFixture.content); + }); + it('should combine all returned bioEntityContent arrays and return array with all provided bioEntityContent elements', async () => { + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + const data = await store + .dispatch( + getMultiBioEntity({ + searchQueries: [SEARCH_QUERY, SEARCH_QUERY], + isPerfectMatch: false, + }), + ) + .unwrap(); + + expect(data).toEqual([ + ...bioEntityResponseFixture.content, + ...bioEntityResponseFixture.content, + ]); + }); }); }); diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts index e1b59d1ed97a7fc4bb77006b7350a8d3e79980d7..20c312f52071290648b7dec2949e4bfd1e6e113c 100644 --- a/src/redux/bioEntity/bioEntity.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -1,19 +1,25 @@ import { PerfectMultiSearchParams, PerfectSearchParams } from '@/types/search'; -import { createAsyncThunk } from '@reduxjs/toolkit'; +import { PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { bioEntityResponseSchema } from '@/models/bioEntityResponseSchema'; import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; +import { + BIO_ENTITY_FETCHING_ERROR_PREFIX, + MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX, +} from './bioEntity.constants'; type GetBioEntityProps = PerfectSearchParams; -export const getBioEntity = createAsyncThunk( - 'project/getBioEntityContents', - async ({ - searchQuery, - isPerfectMatch, - }: GetBioEntityProps): Promise<BioEntityContent[] | undefined> => { +export const getBioEntity = createAsyncThunk< + BioEntityContent[] | undefined, + GetBioEntityProps, + ThunkConfig +>('project/getBioEntityContents', async ({ searchQuery, isPerfectMatch }, { rejectWithValue }) => { + try { const response = await axiosInstanceNewAPI.get<BioEntityResponse>( apiPath.getBioEntityContentsStringWithQuery({ searchQuery, isPerfectMatch }), ); @@ -21,21 +27,47 @@ export const getBioEntity = createAsyncThunk( const isDataValid = validateDataUsingZodSchema(response.data, bioEntityResponseSchema); return isDataValid ? response.data.content : undefined; - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); + return rejectWithValue(errorMessage); + } +}); type GetMultiBioEntityProps = PerfectMultiSearchParams; +type GetMultiBioEntityActions = PayloadAction<BioEntityContent[] | undefined>[]; -export const getMultiBioEntity = createAsyncThunk( +export const getMultiBioEntity = createAsyncThunk< + BioEntityContent[], + GetMultiBioEntityProps, + ThunkConfig +>( 'project/getMultiBioEntity', - async ( - { searchQueries, isPerfectMatch }: GetMultiBioEntityProps, - { dispatch }, - ): Promise<void> => { - const asyncGetBioEntityFunctions = searchQueries.map(searchQuery => - dispatch(getBioEntity({ searchQuery, isPerfectMatch })), - ); + // eslint-disable-next-line consistent-return + async ({ searchQueries, isPerfectMatch }, { dispatch, rejectWithValue }) => { + try { + const asyncGetBioEntityFunctions = searchQueries.map(searchQuery => + dispatch(getBioEntity({ searchQuery, isPerfectMatch })), + ); + + const bioEntityContentsActions = (await Promise.all( + asyncGetBioEntityFunctions, + )) as GetMultiBioEntityActions; + + const bioEntityContents = bioEntityContentsActions + .map(bioEntityContentsAction => bioEntityContentsAction.payload || []) + .flat(); + + return bioEntityContents; + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); - await Promise.all(asyncGetBioEntityFunctions); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/chemicals/chemicals.constants.ts b/src/redux/chemicals/chemicals.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..3311122e0b0eaf0ea055af2892ef537c9807d0b6 --- /dev/null +++ b/src/redux/chemicals/chemicals.constants.ts @@ -0,0 +1,2 @@ +export const CHEMICALS_FETCHING_ERROR_PREFIX = 'Failed to fetch chemicals'; +export const MULTI_CHEMICALS_FETCHING_ERROR_PREFIX = 'Failed to fetch multi chemicals'; diff --git a/src/redux/chemicals/chemicals.reducers.test.ts b/src/redux/chemicals/chemicals.reducers.test.ts index 59a87b20f784e059d63c5ea030274275b2562a6b..9b66afe34421293ef0e89ec2b02b45767c5c151c 100644 --- a/src/redux/chemicals/chemicals.reducers.test.ts +++ b/src/redux/chemicals/chemicals.reducers.test.ts @@ -57,7 +57,7 @@ describe('chemicals reducer', () => { .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.NotFound, chemicalsFixture); - const { type } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const { type, payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); const { data } = store.getState().chemicals; const chemicalsWithSearchElement = data.find( @@ -65,6 +65,9 @@ describe('chemicals reducer', () => { ); expect(type).toBe('project/getChemicals/rejected'); + expect(payload).toBe( + "Failed to fetch chemicals: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(chemicalsWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, data: undefined, diff --git a/src/redux/chemicals/chemicals.selectors.ts b/src/redux/chemicals/chemicals.selectors.ts index 03f829f8de425665f9f0a4f6d15d94fd37fee2b8..bb8d4aee3e28ffe7c65490e541e04a96ffdee38d 100644 --- a/src/redux/chemicals/chemicals.selectors.ts +++ b/src/redux/chemicals/chemicals.selectors.ts @@ -41,6 +41,18 @@ export const searchedChemicalsBioEntitesOfCurrentMapSelector = createSelector( }, ); +export const allChemicalsBioEntitesOfAllMapsSelector = createSelector( + chemicalsSelector, + (chemicalsState): BioEntity[] => { + return (chemicalsState?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ targets }) => targets.map(({ targetElements }) => targetElements)) + .flat() + .flat(); + }, +); + export const loadingChemicalsStatusSelector = createSelector( chemicalsForSelectedSearchElementSelector, state => state?.loading, diff --git a/src/redux/chemicals/chemicals.thunks.test.ts b/src/redux/chemicals/chemicals.thunks.test.ts index 88926792155e930d39ae396f25733fcb4e1fe099..73cb2d0ef46659c702a4cda23ea6eda640bb871e 100644 --- a/src/redux/chemicals/chemicals.thunks.test.ts +++ b/src/redux/chemicals/chemicals.thunks.test.ts @@ -35,5 +35,15 @@ describe('chemicals thunks', () => { const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); expect(payload).toEqual(undefined); }); + it('should handle error message when getChemiclas failed ', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Forbidden, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(payload).toEqual( + "Failed to fetch chemicals: Access Forbidden! You don't have permission to access this resource.", + ); + }); }); }); diff --git a/src/redux/chemicals/chemicals.thunks.ts b/src/redux/chemicals/chemicals.thunks.ts index 0afc94db5f8f686b845e8e514082866b18219908..5b25951d7bf7e4ebdaefecdca4afa4bd54501346 100644 --- a/src/redux/chemicals/chemicals.thunks.ts +++ b/src/redux/chemicals/chemicals.thunks.ts @@ -2,30 +2,51 @@ import { chemicalSchema } from '@/models/chemicalSchema'; import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Chemical } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { ThunkConfig } from '@/types/store'; +import { + CHEMICALS_FETCHING_ERROR_PREFIX, + MULTI_CHEMICALS_FETCHING_ERROR_PREFIX, +} from './chemicals.constants'; -export const getChemicals = createAsyncThunk( +export const getChemicals = createAsyncThunk<Chemical[] | undefined, string, ThunkConfig>( 'project/getChemicals', - async (searchQuery: string): Promise<Chemical[] | undefined> => { - const response = await axiosInstanceNewAPI.get<Chemical[]>( - apiPath.getChemicalsStringWithQuery(searchQuery), - ); + async (searchQuery, { rejectWithValue }) => { + try { + const response = await axiosInstanceNewAPI.get<Chemical[]>( + apiPath.getChemicalsStringWithQuery(searchQuery), + ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(chemicalSchema)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(chemicalSchema)); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: CHEMICALS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); -export const getMultiChemicals = createAsyncThunk( +export const getMultiChemicals = createAsyncThunk<void, string[], ThunkConfig>( 'project/getMultChemicals', - async (searchQueries: string[], { dispatch }): Promise<void> => { - const asyncGetChemicalsFunctions = searchQueries.map(searchQuery => - dispatch(getChemicals(searchQuery)), - ); + // eslint-disable-next-line consistent-return + async (searchQueries, { dispatch, rejectWithValue }) => { + try { + const asyncGetChemicalsFunctions = searchQueries.map(searchQuery => + dispatch(getChemicals(searchQuery)), + ); - await Promise.all(asyncGetChemicalsFunctions); + await Promise.all(asyncGetChemicalsFunctions); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: MULTI_CHEMICALS_FETCHING_ERROR_PREFIX, + }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/compartmentPathways/comparmentPathways.constants.ts b/src/redux/compartmentPathways/comparmentPathways.constants.ts index 2bf4d5195d6e6e7fca140310ea21ce69f373da03..cc54322af2521fa94bbe3c5b201b1cd77f5fffd7 100644 --- a/src/redux/compartmentPathways/comparmentPathways.constants.ts +++ b/src/redux/compartmentPathways/comparmentPathways.constants.ts @@ -1 +1,3 @@ export const MAX_NUMBER_OF_IDS_IN_GET_QUERY = 100; + +export const COMPARMENT_PATHWAYS_FETCHING_ERROR_PREFIX = 'Failed to fetch compartment pathways'; diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts index 037eefa24fe80a7a82141bdedf7ce5c757e0b9b6..94d445b845aa63f1c45fef92ea66a5a75a05fd00 100644 --- a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts @@ -112,8 +112,11 @@ describe('compartmentPathways reducer', () => { expect(loading).toEqual('pending'); expect(data).toEqual([]); - await compartmentPathwaysPromise; + const dispatchData = await compartmentPathwaysPromise; + expect(dispatchData.payload).toBe( + "Failed to fetch compartment pathways: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); const { loading: promiseFulfilled, data: dataFulfilled } = store.getState().compartmentPathways; expect(promiseFulfilled).toEqual('failed'); diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.ts index e0d69617a704c2361043c98bf1104fb7ced0e827..b8143dbe2627fe7239a9eadf8546497e70b5bbfe 100644 --- a/src/redux/compartmentPathways/compartmentPathways.thunks.ts +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.ts @@ -8,7 +8,11 @@ import { compartmentPathwaySchema, } from '@/models/compartmentPathwaySchema'; import { z } from 'zod'; -import { MAX_NUMBER_OF_IDS_IN_GET_QUERY } from './comparmentPathways.constants'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { + COMPARMENT_PATHWAYS_FETCHING_ERROR_PREFIX, + MAX_NUMBER_OF_IDS_IN_GET_QUERY, +} from './comparmentPathways.constants'; import { apiPath } from '../apiPath'; /** UTILS */ @@ -112,9 +116,18 @@ export const fetchCompartmentPathways = async ( export const getCompartmentPathways = createAsyncThunk( 'compartmentPathways/getCompartmentPathways', - async (modelsIds: number[] | undefined) => { - const compartmentIds = await fetchCompartmentPathwaysIds(modelsIds); - const comparmentPathways = await fetchCompartmentPathways(compartmentIds); - return comparmentPathways; + async (modelsIds: number[] | undefined, { rejectWithValue }) => { + try { + const compartmentIds = await fetchCompartmentPathwaysIds(modelsIds); + const comparmentPathways = await fetchCompartmentPathways(compartmentIds); + + return comparmentPathways; + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: COMPARMENT_PATHWAYS_FETCHING_ERROR_PREFIX, + }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/configuration/configuration.constants.ts b/src/redux/configuration/configuration.constants.ts index bcf3c906f5ab72e0012749ad77fe2d710e2cd866..9a2762d91efeac7a80edb56d071e2fee999a172d 100644 --- a/src/redux/configuration/configuration.constants.ts +++ b/src/redux/configuration/configuration.constants.ts @@ -19,3 +19,7 @@ export const SBGN_ML_HANDLER_NAME_ID = 'SBGN-ML'; export const PNG_IMAGE_HANDLER_NAME_ID = 'PNG image'; export const PDF_HANDLER_NAME_ID = 'PDF'; export const SVG_IMAGE_HANDLER_NAME_ID = 'SVG image'; + +export const CONFIGURATION_OPTIONS_FETCHING_ERROR_PREFIX = 'Failed to fetch configuration options'; + +export const CONFIGURATION_FETCHING_ERROR_PREFIX = 'Failed to fetch configuration'; diff --git a/src/redux/configuration/configuration.thunks.ts b/src/redux/configuration/configuration.thunks.ts index 012e8b1c5184c9ed39c948d76b4124d29dac57d4..8b4db5ea19083cc0f044966eb567cc12610aa66f 100644 --- a/src/redux/configuration/configuration.thunks.ts +++ b/src/redux/configuration/configuration.thunks.ts @@ -5,11 +5,20 @@ import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { configurationSchema } from '@/models/configurationSchema'; import { configurationOptionSchema } from '@/models/configurationOptionSchema'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; +import { + CONFIGURATION_FETCHING_ERROR_PREFIX, + CONFIGURATION_OPTIONS_FETCHING_ERROR_PREFIX, +} from './configuration.constants'; -export const getConfigurationOptions = createAsyncThunk( - 'configuration/getConfigurationOptions', - async (): Promise<ConfigurationOption[] | undefined> => { +export const getConfigurationOptions = createAsyncThunk< + ConfigurationOption[] | undefined, + void, + ThunkConfig +>('configuration/getConfigurationOptions', async (_, { rejectWithValue }) => { + try { const response = await axiosInstance.get<ConfigurationOption[]>( apiPath.getConfigurationOptions(), ); @@ -20,16 +29,27 @@ export const getConfigurationOptions = createAsyncThunk( ); return isDataValid ? response.data : undefined; - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: CONFIGURATION_OPTIONS_FETCHING_ERROR_PREFIX, + }); + return rejectWithValue(errorMessage); + } +}); -export const getConfiguration = createAsyncThunk( +export const getConfiguration = createAsyncThunk<Configuration | undefined, void, ThunkConfig>( 'configuration/getConfiguration', - async (): Promise<Configuration | undefined> => { - const response = await axiosInstance.get<Configuration>(apiPath.getConfiguration()); + async (_, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<Configuration>(apiPath.getConfiguration()); - const isDataValid = validateDataUsingZodSchema(response.data, configurationSchema); + const isDataValid = validateDataUsingZodSchema(response.data, configurationSchema); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: CONFIGURATION_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/drawer/drawer.constants.ts b/src/redux/drawer/drawer.constants.ts index 33890a23abf2b03c24b47f53326ea00cf37024f8..bef3fd3d8a0b585baf19e421da3536e218e1403a 100644 --- a/src/redux/drawer/drawer.constants.ts +++ b/src/redux/drawer/drawer.constants.ts @@ -19,3 +19,7 @@ export const DRAWER_INITIAL_STATE: DrawerState = { currentStep: 0, }, }; + +export const DRUGS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX = 'Failed to fetch drugs for bio entity'; +export const CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX = + 'Failed to fetch chemicals for bio entity'; diff --git a/src/redux/drawer/drawer.reducers.ts b/src/redux/drawer/drawer.reducers.ts index 3a72aa534ed977ae551c3a289ae05c4a29a3582a..29333ec2c70a27cb8088e55cee67a010d4d27b0f 100644 --- a/src/redux/drawer/drawer.reducers.ts +++ b/src/redux/drawer/drawer.reducers.ts @@ -111,7 +111,6 @@ export const openBioEntityDrawerByIdReducer = ( state.isOpen = true; state.drawerName = 'bio-entity'; state.bioEntityDrawerState.bioentityId = action.payload; - state.searchDrawerState.selectedSearchElement = action.payload.toString(); }; export const getBioEntityDrugsForTargetReducers = ( diff --git a/src/redux/drawer/drawer.thunks.ts b/src/redux/drawer/drawer.thunks.ts index 7e60536f9d9218e75db2e516c8edbf649e43e050..f08955f9c5aafd0d79a7b960fe584abf7b688ac1 100644 --- a/src/redux/drawer/drawer.thunks.ts +++ b/src/redux/drawer/drawer.thunks.ts @@ -5,7 +5,13 @@ import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Chemical, Drug, TargetSearchNameResult } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { apiPath } from '../apiPath'; +import { + CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, + DRUGS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, +} from './drawer.constants'; const QUERY_COLUMN_NAME = 'name'; @@ -28,16 +34,25 @@ const getDrugsByName = async (drugName: string): Promise<Drug[]> => { return response.data.filter(isDataValid); }; -export const getDrugsForBioEntityDrawerTarget = createAsyncThunk( +export const getDrugsForBioEntityDrawerTarget = createAsyncThunk<Drug[], string, ThunkConfig>( 'drawer/getDrugsForBioEntityDrawerTarget', - async (target: string): Promise<Drug[]> => { - const drugsNames = await getDrugsNamesForTarget(target); - const drugsArrays = await Promise.all( - drugsNames.map(({ name }) => getDrugsByName(encodeURIComponent(name))), - ); - const drugs = drugsArrays.flat(); - - return drugs; + async (target, { rejectWithValue }) => { + try { + const drugsNames = await getDrugsNamesForTarget(target); + const drugsArrays = await Promise.all( + drugsNames.map(({ name }) => getDrugsByName(encodeURIComponent(name))), + ); + const drugs = drugsArrays.flat(); + + return drugs; + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: DRUGS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); + + return rejectWithValue(errorMessage); + } }, ); @@ -63,9 +78,12 @@ const getChemicalsByName = async (chemicalName: string): Promise<Chemical[]> => return response.data.filter(isDataValid); }; -export const getChemicalsForBioEntityDrawerTarget = createAsyncThunk( - 'drawer/getChemicalsForBioEntityDrawerTarget', - async (target: string): Promise<Chemical[]> => { +export const getChemicalsForBioEntityDrawerTarget = createAsyncThunk< + Chemical[], + string, + ThunkConfig +>('drawer/getChemicalsForBioEntityDrawerTarget', async (target, { rejectWithValue }) => { + try { const chemicalsNames = await getChemicalsNamesForTarget(target); const chemicalsArrays = await Promise.all( chemicalsNames.map(({ name }) => getChemicalsByName(encodeURIComponent(name))), @@ -73,5 +91,12 @@ export const getChemicalsForBioEntityDrawerTarget = createAsyncThunk( const chemicals = chemicalsArrays.flat(); return chemicals; - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); + + return rejectWithValue(errorMessage); + } +}); diff --git a/src/redux/drawer/drawer.types.ts b/src/redux/drawer/drawer.types.ts index a348517b0e6beea005555e28e870734205a4a090..a0d5198979607da4cd56dd22cfd129a5c3517418 100644 --- a/src/redux/drawer/drawer.types.ts +++ b/src/redux/drawer/drawer.types.ts @@ -43,3 +43,6 @@ export type OpenReactionDrawerByIdAction = PayloadAction<OpenReactionDrawerByIdP export type OpenBioEntityDrawerByIdPayload = number | string; export type OpenBioEntityDrawerByIdAction = PayloadAction<OpenBioEntityDrawerByIdPayload>; + +export type SetSelectedSearchElementPayload = string; +export type SetSelectedSearchElementAction = PayloadAction<SetSelectedSearchElementPayload>; diff --git a/src/redux/drugs/drugs.constants.ts b/src/redux/drugs/drugs.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..c31cff0fe0dd4e776ff6aa8a3a7044bb7ffedf78 --- /dev/null +++ b/src/redux/drugs/drugs.constants.ts @@ -0,0 +1,2 @@ +export const DRUGS_FETCHING_ERROR_PREFIX = 'Failed to fetch drugs'; +export const MULTI_DRUGS_FETCHING_ERROR_PREFIX = 'Failed to fetch multi drugs'; diff --git a/src/redux/drugs/drugs.reducers.test.ts b/src/redux/drugs/drugs.reducers.test.ts index 3ad034db1d614a691017c221f541dfc39e4b6fcf..f6e6c671bc29975194a163390e717a5bc91275e3 100644 --- a/src/redux/drugs/drugs.reducers.test.ts +++ b/src/redux/drugs/drugs.reducers.test.ts @@ -56,12 +56,15 @@ describe('drugs reducer', () => { .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.NotFound, []); - const { type } = await store.dispatch(getDrugs(SEARCH_QUERY)); + const { type, payload } = await store.dispatch(getDrugs(SEARCH_QUERY)); const { data } = store.getState().drugs; const drugsWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); + expect(payload).toBe( + "Failed to fetch drugs: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(type).toBe('project/getDrugs/rejected'); expect(drugsWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, diff --git a/src/redux/drugs/drugs.selectors.ts b/src/redux/drugs/drugs.selectors.ts index 45e9c16c828d894e31b3b5b3faf04a9fdd0981dc..f5c74de2ffeaf4b06ef5a34bbf8ae899e276e717 100644 --- a/src/redux/drugs/drugs.selectors.ts +++ b/src/redux/drugs/drugs.selectors.ts @@ -54,3 +54,15 @@ export const searchedDrugsBioEntitesOfCurrentMapSelector = createSelector( .filter(bioEntity => bioEntity.model === currentModelId); }, ); + +export const allDrugsBioEntitesOfAllMapsSelector = createSelector( + drugsSelector, + (drugsState): BioEntity[] => { + return (drugsState?.data || []) + .map(({ data }) => data || []) + .flat() + .map(({ targets }) => targets.map(({ targetElements }) => targetElements)) + .flat() + .flat(); + }, +); diff --git a/src/redux/drugs/drugs.thunks.ts b/src/redux/drugs/drugs.thunks.ts index 30074e33d6ee751c2f8886e3793050ba84e1d41f..6bd3b532665b877cdd8a7a95263ebdd9ac2bb5de 100644 --- a/src/redux/drugs/drugs.thunks.ts +++ b/src/redux/drugs/drugs.thunks.ts @@ -2,30 +2,45 @@ import { drugSchema } from '@/models/drugSchema'; import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Drug } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { ThunkConfig } from '@/types/store'; +import { DRUGS_FETCHING_ERROR_PREFIX, MULTI_DRUGS_FETCHING_ERROR_PREFIX } from './drugs.constants'; -export const getDrugs = createAsyncThunk( +export const getDrugs = createAsyncThunk<Drug[] | undefined, string, ThunkConfig>( 'project/getDrugs', - async (searchQuery: string): Promise<Drug[] | undefined> => { - const response = await axiosInstanceNewAPI.get<Drug[]>( - apiPath.getDrugsStringWithQuery(searchQuery), - ); + async (searchQuery: string, { rejectWithValue }) => { + try { + const response = await axiosInstanceNewAPI.get<Drug[]>( + apiPath.getDrugsStringWithQuery(searchQuery), + ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(drugSchema)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(drugSchema)); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: DRUGS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); -export const getMultiDrugs = createAsyncThunk( +export const getMultiDrugs = createAsyncThunk<void, string[], ThunkConfig>( 'project/getMultiDrugs', - async (searchQueries: string[], { dispatch }): Promise<void> => { - const asyncGetDrugsFunctions = searchQueries.map(searchQuery => - dispatch(getDrugs(searchQuery)), - ); + // eslint-disable-next-line consistent-return + async (searchQueries, { dispatch, rejectWithValue }) => { + try { + const asyncGetDrugsFunctions = searchQueries.map(searchQuery => + dispatch(getDrugs(searchQuery)), + ); - await Promise.all(asyncGetDrugsFunctions); + await Promise.all(asyncGetDrugsFunctions); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: MULTI_DRUGS_FETCHING_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/export/export.constants.ts b/src/redux/export/export.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..8f464a4d9241f6d8e4dc875e48c75a4611c16da8 --- /dev/null +++ b/src/redux/export/export.constants.ts @@ -0,0 +1,2 @@ +export const ELEMENTS_DOWNLOAD_ERROR_PREFIX = 'Failed to download elements'; +export const NETWORK_DOWNLOAD_ERROR_PREFIX = 'Failed to download network'; diff --git a/src/redux/export/export.reducers.test.ts b/src/redux/export/export.reducers.test.ts index 894ee98a802339b948da3a12556716284b4aa41d..778aca4f95a58b094b126f449643804fcab85287 100644 --- a/src/redux/export/export.reducers.test.ts +++ b/src/redux/export/export.reducers.test.ts @@ -76,7 +76,7 @@ describe('export reducer', () => { mockedAxiosClient .onPost(apiPath.downloadNetworkCsv()) .reply(HttpStatusCode.NotFound, undefined); - await store.dispatch( + const { payload, type } = await store.dispatch( downloadNetwork({ annotations: [], columns: [], @@ -85,8 +85,11 @@ describe('export reducer', () => { submaps: [], }), ); + expect(type).toBe('export/downloadNetwork/rejected'); + expect(payload).toBe( + "Failed to download network: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); const { loading } = store.getState().export.downloadNetwork; - expect(loading).toEqual('failed'); }); @@ -132,7 +135,7 @@ describe('export reducer', () => { mockedAxiosClient .onPost(apiPath.downloadElementsCsv()) .reply(HttpStatusCode.NotFound, undefined); - await store.dispatch( + const { payload } = await store.dispatch( downloadElements({ annotations: [], columns: [], @@ -144,5 +147,8 @@ describe('export reducer', () => { const { loading } = store.getState().export.downloadElements; expect(loading).toEqual('failed'); + expect(payload).toEqual( + "Failed to download elements: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); }); }); diff --git a/src/redux/export/export.thunks.ts b/src/redux/export/export.thunks.ts index 3a25cd8c40f76e9442d43fa354bd7877d29b4306..bf49e0b04e79e1eea61cac05f2eb79810b23ce55 100644 --- a/src/redux/export/export.thunks.ts +++ b/src/redux/export/export.thunks.ts @@ -5,8 +5,11 @@ import { PROJECT_ID } from '@/constants'; import { ExportNetwork, ExportElements } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { exportNetworkchema, exportElementsSchema } from '@/models/exportSchema'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; import { downloadFileFromBlob } from './export.utils'; +import { ELEMENTS_DOWNLOAD_ERROR_PREFIX, NETWORK_DOWNLOAD_ERROR_PREFIX } from './export.constants'; type DownloadElementsBodyRequest = { columns: string[]; @@ -16,9 +19,13 @@ type DownloadElementsBodyRequest = { excludedCompartmentIds: number[]; }; -export const downloadElements = createAsyncThunk( - 'export/downloadElements', - async (data: DownloadElementsBodyRequest): Promise<void> => { +export const downloadElements = createAsyncThunk< + undefined, + DownloadElementsBodyRequest, + ThunkConfig + // eslint-disable-next-line consistent-return +>('export/downloadElements', async (data, { rejectWithValue }) => { + try { const response = await axiosInstanceNewAPI.post<ExportElements>( apiPath.downloadElementsCsv(), data, @@ -32,8 +39,12 @@ export const downloadElements = createAsyncThunk( if (isDataValid) { downloadFileFromBlob(response.data, `${PROJECT_ID}-elementExport.csv`); } - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: ELEMENTS_DOWNLOAD_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } +}); type DownloadNetworkBodyRequest = { columns: string[]; @@ -43,21 +54,28 @@ type DownloadNetworkBodyRequest = { excludedCompartmentIds: number[]; }; -export const downloadNetwork = createAsyncThunk( +export const downloadNetwork = createAsyncThunk<undefined, DownloadNetworkBodyRequest, ThunkConfig>( 'export/downloadNetwork', - async (data: DownloadNetworkBodyRequest): Promise<void> => { - const response = await axiosInstanceNewAPI.post<ExportNetwork>( - apiPath.downloadNetworkCsv(), - data, - { - withCredentials: true, - }, - ); + // eslint-disable-next-line consistent-return + async (data, { rejectWithValue }) => { + try { + const response = await axiosInstanceNewAPI.post<ExportNetwork>( + apiPath.downloadNetworkCsv(), + data, + { + withCredentials: true, + }, + ); - const isDataValid = validateDataUsingZodSchema(response.data, exportNetworkchema); + const isDataValid = validateDataUsingZodSchema(response.data, exportNetworkchema); - if (isDataValid) { - downloadFileFromBlob(response.data, `${PROJECT_ID}-networkExport.csv`); + if (isDataValid) { + downloadFileFromBlob(response.data, `${PROJECT_ID}-networkExport.csv`); + } + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: NETWORK_DOWNLOAD_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); } }, ); diff --git a/src/redux/map/map.constants.ts b/src/redux/map/map.constants.ts index 27a63ef2a593b6e1fd2f031870ae4b489ba6ba25..70126c71e6dbd43d510b1662096b9178d20452f1 100644 --- a/src/redux/map/map.constants.ts +++ b/src/redux/map/map.constants.ts @@ -54,3 +54,11 @@ export const MAP_INITIAL_STATE: MapState = { error: { name: '', message: '' }, openedMaps: OPENED_MAPS_INITIAL_STATE, }; + +export const INIT_MAP_SIZE_MODEL_ID_ERROR_PREFIX = 'Failed to initialize map size and model ID'; + +export const INIT_MAP_POSITION_ERROR_PREFIX = 'Failed to initialize map position'; + +export const INIT_MAP_BACKGROUND_ERROR_PREFIX = 'Failed to initialize map background'; + +export const INIT_OPENED_MAPS_ERROR_PREFIX = 'Failed to initialize opened maps'; diff --git a/src/redux/map/map.thunks.test.ts b/src/redux/map/map.thunks.test.ts index 6d5e94a4f207d2e0e176a3a1c18e4744a0bcc893..bdcf388d147f15d8d91dd8976c00bae44e0f04b8 100644 --- a/src/redux/map/map.thunks.test.ts +++ b/src/redux/map/map.thunks.test.ts @@ -2,16 +2,16 @@ import { MODELS_MOCK } from '@/models/mocks/modelsMock'; /* eslint-disable no-magic-numbers */ import { QueryData } from '@/types/query'; import { BACKGROUNDS_MOCK, BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; -import { RootState } from '../store'; -import { INITIAL_STORE_STATE_MOCK } from '../root/root.fixtures'; import { MODELS_INITIAL_STATE_MOCK } from '../models/models.mock'; +import { INITIAL_STORE_STATE_MOCK } from '../root/root.fixtures'; +import { RootState } from '../store'; +import { initialMapDataFixture, initialMapStateFixture } from './map.fixtures'; import { getBackgroundId, getInitMapPosition, getInitMapSizeAndModelId, getOpenedMaps, } from './map.thunks'; -import { initialMapDataFixture, initialMapStateFixture } from './map.fixtures'; const EMPTY_QUERY_DATA: QueryData = { modelId: undefined, @@ -84,8 +84,8 @@ describe('map thunks - utils', () => { it('should return valid map position if query params do not include position', () => { const position = getInitMapPosition(STATE_WITH_MODELS, EMPTY_QUERY_DATA); expect(position).toEqual({ - initial: { x: 13389.625, y: 6751.5, z: 5 }, - last: { x: 13389.625, y: 6751.5, z: 5 }, + initial: { x: 13389.625, y: 6751.5, z: 4 }, + last: { x: 13389.625, y: 6751.5, z: 4 }, }); }); it('should return default map position', () => { diff --git a/src/redux/map/map.thunks.ts b/src/redux/map/map.thunks.ts index e9c3c7ae8703638ab1942c366cfb6ecc32006955..05b675bba64d223d63cab0865e8692f5f9620da4 100644 --- a/src/redux/map/map.thunks.ts +++ b/src/redux/map/map.thunks.ts @@ -5,6 +5,8 @@ import { QueryData } from '@/types/query'; import { DEFAULT_ZOOM } from '@/constants/map'; import { getPointMerged } from '@/utils/object/getPointMerged'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import type { AppDispatch, RootState } from '../store'; import { InitMapBackgroundActionPayload, @@ -26,7 +28,14 @@ import { modelByIdSelector, modelsDataSelector, } from '../models/models.selectors'; -import { DEFAULT_POSITION, MAIN_MAP } from './map.constants'; +import { + DEFAULT_POSITION, + MAIN_MAP, + INIT_MAP_BACKGROUND_ERROR_PREFIX, + INIT_MAP_POSITION_ERROR_PREFIX, + INIT_MAP_SIZE_MODEL_ID_ERROR_PREFIX, + INIT_OPENED_MAPS_ERROR_PREFIX, +} from './map.constants'; /** UTILS - in the same file because of dependancy cycle */ @@ -135,47 +144,62 @@ export const getOpenedMaps = (state: RootState, queryData: QueryData): OppenedMa export const initMapSizeAndModelId = createAsyncThunk< InitMapSizeAndModelIdActionPayload, InitMapSizeAndModelIdParams, - { dispatch: AppDispatch; state: RootState } ->( - 'map/initMapSizeAndModelId', - async ({ queryData }, { getState }): Promise<InitMapSizeAndModelIdActionPayload> => { + { dispatch: AppDispatch; state: RootState } & ThunkConfig +>('map/initMapSizeAndModelId', async ({ queryData }, { getState, rejectWithValue }) => { + try { const state = getState(); return getInitMapSizeAndModelId(state, queryData); - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: INIT_MAP_SIZE_MODEL_ID_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } +}); export const initMapPosition = createAsyncThunk< InitMapPositionActionPayload, InitMapPositionParams, - { dispatch: AppDispatch; state: RootState } ->( - 'map/initMapPosition', - async ({ queryData }, { getState }): Promise<InitMapPositionActionPayload> => { + { dispatch: AppDispatch; state: RootState } & ThunkConfig +>('map/initMapPosition', async ({ queryData }, { getState, rejectWithValue }) => { + try { const state = getState(); return getInitMapPosition(state, queryData); - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: INIT_MAP_POSITION_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } +}); export const initMapBackground = createAsyncThunk< InitMapBackgroundActionPayload, InitMapBackgroundParams, - { dispatch: AppDispatch; state: RootState } ->( - 'map/initMapBackground', - async ({ queryData }, { getState }): Promise<InitMapBackgroundActionPayload> => { + { dispatch: AppDispatch; state: RootState } & ThunkConfig +>('map/initMapBackground', async ({ queryData }, { getState, rejectWithValue }) => { + try { const state = getState(); return getBackgroundId(state, queryData); - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: INIT_MAP_BACKGROUND_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } +}); export const initOpenedMaps = createAsyncThunk< InitOpenedMapsActionPayload, InitOpenedMapsProps, - { dispatch: AppDispatch; state: RootState } ->('appInit/initOpenedMaps', async ({ queryData }, { getState }): Promise<OppenedMap[]> => { - const state = getState(); + { dispatch: AppDispatch; state: RootState } & ThunkConfig +>('appInit/initOpenedMaps', async ({ queryData }, { getState, rejectWithValue }) => { + try { + const state = getState(); + + return getOpenedMaps(state, queryData); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: INIT_OPENED_MAPS_ERROR_PREFIX }); - return getOpenedMaps(state, queryData); + return rejectWithValue(errorMessage); + } }); diff --git a/src/redux/middlewares/error.middleware.test.ts b/src/redux/middlewares/error.middleware.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7662714e2f08a2dccd3a6329db08716d0103bc2d --- /dev/null +++ b/src/redux/middlewares/error.middleware.test.ts @@ -0,0 +1,87 @@ +import { showToast } from '@/utils/showToast'; +import { errorMiddlewareListener } from './error.middleware'; + +jest.mock('../../utils/showToast', () => ({ + showToast: jest.fn(), +})); + +describe('errorMiddlewareListener', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should show toast with error message when action is rejected with value', async () => { + const action = { + type: 'action/rejected', + payload: 'Error message', + meta: { + requestId: '421', + rejectedWithValue: true, + requestStatus: 'rejected', + }, + }; + await errorMiddlewareListener(action); + expect(showToast).toHaveBeenCalledWith({ type: 'error', message: 'Error message' }); + }); + + it('should show toast with unknown error when action is rejected without value', async () => { + const action = { + type: 'action/rejected', + payload: null, + meta: { + requestId: '421', + rejectedWithValue: true, + requestStatus: 'rejected', + }, + }; + await errorMiddlewareListener(action); + expect(showToast).toHaveBeenCalledWith({ + type: 'error', + message: 'An unknown error occurred. Please try again later.', + }); + }); + + it('should not show toast when action is not rejected', async () => { + const action = { + type: 'action/loading', + payload: null, + meta: { + requestId: '421', + requestStatus: 'fulfilled', + }, + }; + await errorMiddlewareListener(action); + expect(showToast).not.toHaveBeenCalled(); + }); + + it('should show toast with unknown error when action payload is not a string', async () => { + const action = { + type: 'action/rejected', + payload: {}, + meta: { + requestId: '421', + rejectedWithValue: true, + requestStatus: 'rejected', + }, + }; + await errorMiddlewareListener(action); + expect(showToast).toHaveBeenCalledWith({ + type: 'error', + message: 'An unknown error occurred. Please try again later.', + }); + }); + + it('should show toast with custom message when action payload is a string', async () => { + const action = { + type: 'action/rejected', + payload: 'Failed to fetch', + meta: { + requestId: '421', + rejectedWithValue: true, + requestStatus: 'rejected', + }, + }; + await errorMiddlewareListener(action); + expect(showToast).toHaveBeenCalledWith({ type: 'error', message: 'Failed to fetch' }); + }); +}); diff --git a/src/redux/middlewares/error.middleware.ts b/src/redux/middlewares/error.middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..0ba6a0f75452822908c449ba4d3dcb0ac59dddd9 --- /dev/null +++ b/src/redux/middlewares/error.middleware.ts @@ -0,0 +1,34 @@ +import type { AppStartListening } from '@/redux/store'; +import { UNKNOWN_ERROR } from '@/utils/getErrorMessage/getErrorMessage.constants'; +import { showToast } from '@/utils/showToast'; +import { + Action, + createListenerMiddleware, + isRejected, + isRejectedWithValue, +} from '@reduxjs/toolkit'; + +export const errorListenerMiddleware = createListenerMiddleware(); + +const startListening = errorListenerMiddleware.startListening as AppStartListening; + +export const errorMiddlewareListener = async (action: Action): Promise<void> => { + if (isRejectedWithValue(action)) { + let message: string; + if (typeof action.payload === 'string') { + message = action.payload; + } else { + message = UNKNOWN_ERROR; + } + + showToast({ + type: 'error', + message, + }); + } +}; + +startListening({ + matcher: isRejected, + effect: errorMiddlewareListener, +}); diff --git a/src/redux/models/models.constants.ts b/src/redux/models/models.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..8855ad369fa1edf8e68a99b6171983a52ea42793 --- /dev/null +++ b/src/redux/models/models.constants.ts @@ -0,0 +1 @@ +export const MODELS_FETCHING_ERROR_PREFIX = 'Failed to fetch models'; diff --git a/src/redux/models/models.reducers.test.ts b/src/redux/models/models.reducers.test.ts index 1677afdfd86b83f6a1ea7834cbc15ceb0d71018e..fc6e0af1e28e47a3bb0e2ed3a38186549c053907 100644 --- a/src/redux/models/models.reducers.test.ts +++ b/src/redux/models/models.reducers.test.ts @@ -44,10 +44,13 @@ describe('models reducer', () => { it('should update store after failed getModels query', async () => { mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.NotFound, []); - const { type } = await store.dispatch(getModels()); + const { type, payload } = await store.dispatch(getModels()); const { data, loading, error } = store.getState().models; expect(type).toBe('project/getModels/rejected'); + expect(payload).toBe( + "Failed to fetch models: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual([]); diff --git a/src/redux/models/models.thunks.ts b/src/redux/models/models.thunks.ts index 5880ddcd4cd8f2494790f321e05fac0ab0c25021..2e0fd68d89e09cff7b66e6c5e1aefff3acc15486 100644 --- a/src/redux/models/models.thunks.ts +++ b/src/redux/models/models.thunks.ts @@ -2,17 +2,26 @@ import { mapModelSchema } from '@/models/modelSchema'; import { apiPath } from '@/redux/apiPath'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { MapModel } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { ThunkConfig } from '@/types/store'; +import { MODELS_FETCHING_ERROR_PREFIX } from './models.constants'; -export const getModels = createAsyncThunk( +export const getModels = createAsyncThunk<MapModel[] | undefined, void, ThunkConfig>( 'project/getModels', - async (): Promise<MapModel[] | undefined> => { - const response = await axiosInstance.get<MapModel[]>(apiPath.getModelsString()); + async (_, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<MapModel[]>(apiPath.getModelsString()); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapModelSchema)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapModelSchema)); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: MODELS_FETCHING_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.constants.ts b/src/redux/overlayBioEntity/overlayBioEntity.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b9a636a6b2b287f4ea7f1e259df1b9375df4c1c --- /dev/null +++ b/src/redux/overlayBioEntity/overlayBioEntity.constants.ts @@ -0,0 +1,4 @@ +export const OVERLAY_BIO_ENTITY_FETCHING_ERROR_PREFIX = 'Failed to fetch overlay bio entity'; +export const OVERLAY_BIO_ENTITY_ALL_MODELS_FETCHING_ERROR_PREFIX = + 'Failed to fetch overlay bio entity for all models'; +export const INIT_OVERLAYS_ERROR_PREFIX = 'Failed to initialize overlays'; diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 1fed7a9709ff7807203437b18647776fbd667ee1..30222f300c8a31f6d6104e23634c9d9b0d6d33ac 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -3,6 +3,8 @@ import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { OverlayBioEntity } from '@/types/models'; import { OverlayBioEntityRender } from '@/types/OLrendering'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { getValidOverlayBioEntities, parseOverlayBioEntityToOlRenderingFormat, @@ -13,18 +15,23 @@ import type { RootState } from '../store'; import { setMapBackground } from '../map/map.slice'; import { emptyBackgroundIdSelector } from '../backgrounds/background.selectors'; import { overlaySelector, userOverlaySelector } from '../overlays/overlays.selectors'; +import { + INIT_OVERLAYS_ERROR_PREFIX, + OVERLAY_BIO_ENTITY_ALL_MODELS_FETCHING_ERROR_PREFIX, + OVERLAY_BIO_ENTITY_FETCHING_ERROR_PREFIX, +} from './overlayBioEntity.constants'; type GetOverlayBioEntityThunkProps = { overlayId: number; modelId: number; }; -export const getOverlayBioEntity = createAsyncThunk( - 'overlayBioEntity/getOverlayBioEntity', - async ({ - overlayId, - modelId, - }: GetOverlayBioEntityThunkProps): Promise<OverlayBioEntityRender[] | undefined> => { +export const getOverlayBioEntity = createAsyncThunk< + OverlayBioEntityRender[] | undefined, + GetOverlayBioEntityThunkProps, + ThunkConfig +>('overlayBioEntity/getOverlayBioEntity', async ({ overlayId, modelId }, { rejectWithValue }) => { + try { const response = await axiosInstanceNewAPI.get<OverlayBioEntity[]>( apiPath.getOverlayBioEntity({ overlayId, modelId }), { @@ -39,34 +46,55 @@ export const getOverlayBioEntity = createAsyncThunk( } return undefined; - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: OVERLAY_BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); + + return rejectWithValue(errorMessage); + } +}); type GetOverlayBioEntityForAllModelsThunkProps = { overlayId: number }; export const getOverlayBioEntityForAllModels = createAsyncThunk< void, GetOverlayBioEntityForAllModelsThunkProps, - { state: RootState } + { state: RootState } & ThunkConfig >( 'overlayBioEntity/getOverlayBioEntityForAllModels', - async ({ overlayId }, { dispatch, getState }): Promise<void> => { - const state = getState(); - const modelsIds = modelsIdsSelector(state); + // eslint-disable-next-line consistent-return + async ({ overlayId }, { dispatch, getState, rejectWithValue }) => { + try { + const state = getState(); + const modelsIds = modelsIdsSelector(state); - const asyncGetOverlayBioEntityFunctions = modelsIds.map(id => - dispatch(getOverlayBioEntity({ overlayId, modelId: id })), - ); + const asyncGetOverlayBioEntityFunctions = modelsIds.map(id => + dispatch(getOverlayBioEntity({ overlayId, modelId: id })), + ); - await Promise.all(asyncGetOverlayBioEntityFunctions); + await Promise.all(asyncGetOverlayBioEntityFunctions); + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: OVERLAY_BIO_ENTITY_ALL_MODELS_FETCHING_ERROR_PREFIX, + }); + + return rejectWithValue(errorMessage); + } }, ); type GetInitOverlaysProps = { overlaysId: number[] }; -export const getInitOverlays = createAsyncThunk<void, GetInitOverlaysProps, { state: RootState }>( - 'appInit/getInitOverlays', - async ({ overlaysId }, { dispatch, getState }): Promise<void> => { +export const getInitOverlays = createAsyncThunk< + void, + GetInitOverlaysProps, + { state: RootState } & ThunkConfig + // eslint-disable-next-line consistent-return +>('appInit/getInitOverlays', async ({ overlaysId }, { dispatch, getState, rejectWithValue }) => { + try { const state = getState(); const emptyBackgroundId = emptyBackgroundIdSelector(state); @@ -86,5 +114,9 @@ export const getInitOverlays = createAsyncThunk<void, GetInitOverlaysProps, { st dispatch(getOverlayBioEntityForAllModels({ overlayId: id })); }); - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: INIT_OVERLAYS_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } +}); diff --git a/src/redux/overlays/overlays.constants.ts b/src/redux/overlays/overlays.constants.ts index dda564cda0098e449d5adadb219d86b8320378e4..fa7efe227279b72b850a0e256771b6bb6288163b 100644 --- a/src/redux/overlays/overlays.constants.ts +++ b/src/redux/overlays/overlays.constants.ts @@ -1,2 +1,11 @@ /* eslint-disable no-magic-numbers */ export const CHUNK_SIZE = 65535 * 8; + +export const OVERLAYS_FETCHING_ERROR_PREFIX = 'Failed to fetch overlays'; +export const USER_OVERLAY_ADD_ERROR_PREFIX = 'Failed to add user overlay'; +export const USER_OVERLAY_ADD_SUCCESS_MESSAGE = 'User overlay added successfully'; +export const USER_OVERLAYS_FETCHING_ERROR_PREFIX = 'Failed to fetch user overlays'; +export const USER_OVERLAY_UPDATE_ERROR_PREFIX = 'Failed to update user overlay'; +export const USER_OVERLAY_UPDATE_SUCCESS_MESSAGE = 'User overlay updated successfully'; +export const USER_OVERLAY_REMOVE_ERROR_PREFIX = 'Failed to remove user overlay'; +export const USER_OVERLAY_REMOVE_SUCCESS_MESSAGE = 'User overlay removed successfully'; diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts index f774974f526383c5ed83a27c9343f7a758e2478c..38b4652f0f0c29e09c3917d9de1160f9dc27fc4a 100644 --- a/src/redux/overlays/overlays.reducers.test.ts +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -80,10 +80,13 @@ describe('overlays reducer', () => { .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) .reply(HttpStatusCode.NotFound, []); - const { type } = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); + const { type, payload } = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); const { data, loading, error } = store.getState().overlays; expect(type).toBe('overlays/getAllPublicOverlaysByProjectId/rejected'); + expect(payload).toBe( + "Failed to fetch overlays: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual([]); diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 6331642162ed8cfcc3b37f756bcbafbabf74a1a2..4b1fa460e4d60e8604d9bf36a351597d9db3929f 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -11,51 +11,75 @@ import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; import { PluginsEventBus } from '@/services/pluginsManager/pluginsEventBus'; +import { showToast } from '@/utils/showToast'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; -import { CHUNK_SIZE } from './overlays.constants'; +import { + CHUNK_SIZE, + OVERLAYS_FETCHING_ERROR_PREFIX, + USER_OVERLAYS_FETCHING_ERROR_PREFIX, + USER_OVERLAY_ADD_ERROR_PREFIX, + USER_OVERLAY_ADD_SUCCESS_MESSAGE, + USER_OVERLAY_REMOVE_ERROR_PREFIX, + USER_OVERLAY_REMOVE_SUCCESS_MESSAGE, + USER_OVERLAY_UPDATE_ERROR_PREFIX, + USER_OVERLAY_UPDATE_SUCCESS_MESSAGE, +} from './overlays.constants'; import { closeModal } from '../modal/modal.slice'; import type { RootState } from '../store'; -export const getAllPublicOverlaysByProjectId = createAsyncThunk( +export const getAllPublicOverlaysByProjectId = createAsyncThunk<MapOverlay[], string, ThunkConfig>( 'overlays/getAllPublicOverlaysByProjectId', - async (projectId: string): Promise<MapOverlay[]> => { - const response = await axiosInstance.get<MapOverlay[]>( - apiPath.getAllOverlaysByProjectIdQuery(projectId, { publicOverlay: true }), - ); + async (projectId: string, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<MapOverlay[]>( + apiPath.getAllOverlaysByProjectIdQuery(projectId, { publicOverlay: true }), + ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapOverlay)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapOverlay)); - return isDataValid ? response.data : []; + return isDataValid ? response.data : []; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: OVERLAYS_FETCHING_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); -export const getAllUserOverlaysByCreator = createAsyncThunk( +export const getAllUserOverlaysByCreator = createAsyncThunk<MapOverlay[], void, ThunkConfig>( 'overlays/getAllUserOverlaysByCreator', - async (_, { getState }): Promise<MapOverlay[]> => { - const state = getState() as RootState; - const creator = state.user.login; - if (!creator) return []; - - const response = await axiosInstance<MapOverlay[]>( - apiPath.getAllUserOverlaysByCreatorQuery({ - creator, - publicOverlay: false, - }), - { - withCredentials: true, - }, - ); + async (_, { getState, rejectWithValue }) => { + try { + const state = getState() as RootState; + const creator = state.user.login; + if (!creator) return []; + + const response = await axiosInstance<MapOverlay[]>( + apiPath.getAllUserOverlaysByCreatorQuery({ + creator, + publicOverlay: false, + }), + { + withCredentials: true, + }, + ); - const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapOverlay)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(mapOverlay)); - const sortByOrder = (userOverlayA: MapOverlay, userOverlayB: MapOverlay): number => { - if (userOverlayA.order > userOverlayB.order) return 1; - return -1; - }; + const sortByOrder = (userOverlayA: MapOverlay, userOverlayB: MapOverlay): number => { + if (userOverlayA.order > userOverlayB.order) return 1; + return -1; + }; - const sortedUserOverlays = response.data.sort(sortByOrder); + const sortedUserOverlays = response.data.sort(sortByOrder); - return isDataValid ? sortedUserOverlays : []; + return isDataValid ? sortedUserOverlays : []; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAYS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); @@ -168,68 +192,93 @@ type AddOverlayArgs = { projectId: string; }; -export const addOverlay = createAsyncThunk( +export const addOverlay = createAsyncThunk<undefined, AddOverlayArgs, ThunkConfig>( 'overlays/addOverlay', async ( - { filename, content, description, name, type, projectId }: AddOverlayArgs, - { dispatch }, - ): Promise<void> => { - const createdFile = await createFile({ - filename, - content, - }); - - await uploadContent({ - createdFile, - overlayContent: content, - }); - - await creteOverlay({ - createdFile, - description, - name, - type, - projectId, - }); - - dispatch(getAllUserOverlaysByCreator()); + { filename, content, description, name, type, projectId }, + { rejectWithValue, dispatch }, + // eslint-disable-next-line consistent-return + ) => { + try { + const createdFile = await createFile({ + filename, + content, + }); + + await uploadContent({ + createdFile, + overlayContent: content, + }); + + await creteOverlay({ + createdFile, + description, + name, + type, + projectId, + }); + + await dispatch(getAllUserOverlaysByCreator()); + + showToast({ type: 'success', message: USER_OVERLAY_ADD_SUCCESS_MESSAGE }); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_ADD_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); -export const updateOverlays = createAsyncThunk( +export const updateOverlays = createAsyncThunk<undefined, MapOverlay[], ThunkConfig>( 'overlays/updateOverlays', - async (userOverlays: MapOverlay[]): Promise<void> => { - const userOverlaysPromises = userOverlays.map(userOverlay => - axiosInstance.patch<MapOverlay>( - apiPath.updateOverlay(userOverlay.idObject), - { - overlay: userOverlay, - }, - { - withCredentials: true, - }, - ), - ); - - const userOverlaysResponses = await Promise.all(userOverlaysPromises); - - const updatedUserOverlays = userOverlaysResponses.map( - updatedUserOverlay => updatedUserOverlay.data, - ); - - validateDataUsingZodSchema(updatedUserOverlays, z.array(mapOverlay)); + // eslint-disable-next-line consistent-return + async (userOverlays, { rejectWithValue }) => { + try { + const userOverlaysPromises = userOverlays.map(userOverlay => + axiosInstance.patch<MapOverlay>( + apiPath.updateOverlay(userOverlay.idObject), + { + overlay: userOverlay, + }, + { + withCredentials: true, + }, + ), + ); + + const userOverlaysResponses = await Promise.all(userOverlaysPromises); + + const updatedUserOverlays = userOverlaysResponses.map( + updatedUserOverlay => updatedUserOverlay.data, + ); + + validateDataUsingZodSchema(updatedUserOverlays, z.array(mapOverlay)); + + showToast({ type: 'success', message: USER_OVERLAY_UPDATE_SUCCESS_MESSAGE }); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_UPDATE_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); -export const removeOverlay = createAsyncThunk( +export const removeOverlay = createAsyncThunk<undefined, { overlayId: number }, ThunkConfig>( 'overlays/removeOverlay', - async ({ overlayId }: { overlayId: number }, thunkApi): Promise<void> => { - await axiosInstance.delete(apiPath.removeOverlay(overlayId), { - withCredentials: true, - }); + // eslint-disable-next-line consistent-return + async ({ overlayId }, { dispatch, rejectWithValue }) => { + try { + await axiosInstance.delete(apiPath.removeOverlay(overlayId), { + withCredentials: true, + }); + + PluginsEventBus.dispatchEvent('onRemoveDataOverlay', overlayId); + await dispatch(getAllUserOverlaysByCreator()); + dispatch(closeModal()); - PluginsEventBus.dispatchEvent('onRemoveDataOverlay', overlayId); - await thunkApi.dispatch(getAllUserOverlaysByCreator()); - thunkApi.dispatch(closeModal()); + showToast({ type: 'success', message: USER_OVERLAY_REMOVE_SUCCESS_MESSAGE }); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_REMOVE_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/plugins/plugins.constants.ts b/src/redux/plugins/plugins.constants.ts index cdd616c5580b1f31c7837bfc2cc84e3d1d7940cb..812e85036ae0588e93006646aa700663aa88c0f5 100644 --- a/src/redux/plugins/plugins.constants.ts +++ b/src/redux/plugins/plugins.constants.ts @@ -15,3 +15,7 @@ export const PLUGINS_INITIAL_STATE: PluginsState = { currentPluginHash: undefined, }, }; + +export const PLUGIN_REGISTER_ERROR_PREFIX = 'Failed to register plugin'; +export const PLUGIN_INIT_FETCHING_ERROR_PREFIX = 'Failed to initialize fetching plugin'; +export const PLUGIN_FETCHING_ALL_ERROR_PREFIX = 'Failed to fetch all plugins'; diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts index edc59097d4d53fe239bace4d11948391b3857847..885f421215d099663d278f47f5498a04050ba6a3 100644 --- a/src/redux/plugins/plugins.reducers.test.ts +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -69,7 +69,9 @@ describe('plugins reducer', () => { ); expect(type).toBe('plugins/registerPlugin/rejected'); - expect(payload).toEqual(undefined); + expect(payload).toEqual( + "Failed to register plugin: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); const { data, pluginsId } = store.getState().plugins.activePlugins; expect(data).toEqual({}); diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts index afe657f91d3473aa7cca56d7c2a90a1304a6d129..1d69a6cbb318e9a02a78ce882e9c638199ebd324 100644 --- a/src/redux/plugins/plugins.thunks.ts +++ b/src/redux/plugins/plugins.thunks.ts @@ -5,7 +5,14 @@ import type { MinervaPlugin } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import axios from 'axios'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; +import { + PLUGIN_FETCHING_ALL_ERROR_PREFIX, + PLUGIN_INIT_FETCHING_ERROR_PREFIX, + PLUGIN_REGISTER_ERROR_PREFIX, +} from './plugins.constants'; type RegisterPlugin = { hash: string; @@ -15,38 +22,41 @@ type RegisterPlugin = { isPublic: boolean; }; -export const registerPlugin = createAsyncThunk( +export const registerPlugin = createAsyncThunk< + MinervaPlugin | undefined, + RegisterPlugin, + ThunkConfig +>( 'plugins/registerPlugin', - async ({ - hash, - isPublic, - pluginName, - pluginUrl, - pluginVersion, - }: RegisterPlugin): Promise<MinervaPlugin | undefined> => { - const payload = { - hash, - url: pluginUrl, - name: pluginName, - version: pluginVersion, - isPublic: isPublic.toString(), - } as const; + async ({ hash, isPublic, pluginName, pluginUrl, pluginVersion }, { rejectWithValue }) => { + try { + const payload = { + hash, + url: pluginUrl, + name: pluginName, + version: pluginVersion, + isPublic: isPublic.toString(), + } as const; - const response = await axiosInstance.post<MinervaPlugin>( - apiPath.registerPluign(), - new URLSearchParams(payload), - { - withCredentials: true, - }, - ); + const response = await axiosInstance.post<MinervaPlugin>( + apiPath.registerPluign(), + new URLSearchParams(payload), + { + withCredentials: true, + }, + ); - const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); + const isDataValid = validateDataUsingZodSchema(response.data, pluginSchema); - if (isDataValid) { - return response.data; - } + if (isDataValid) { + return response.data; + } - return undefined; + return undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PLUGIN_REGISTER_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); @@ -61,38 +71,49 @@ type GetInitPluginsProps = { }) => void; }; -export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps>( +export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps, ThunkConfig>( 'plugins/getInitPlugins', - async ({ pluginsId, setHashedPlugin }): Promise<void> => { - /* eslint-disable no-restricted-syntax, no-await-in-loop */ - for (const pluginId of pluginsId) { - const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(pluginId)); + // eslint-disable-next-line consistent-return + async ({ pluginsId, setHashedPlugin }, { rejectWithValue }) => { + try { + /* eslint-disable no-restricted-syntax, no-await-in-loop */ + for (const pluginId of pluginsId) { + const res = await axiosInstance<MinervaPlugin>(apiPath.getPlugin(pluginId)); - const isDataValid = validateDataUsingZodSchema(res.data, pluginSchema); + const isDataValid = validateDataUsingZodSchema(res.data, pluginSchema); - if (isDataValid) { - const { urls } = res.data; - const scriptRes = await axios(urls[0]); - const pluginScript = scriptRes.data; - setHashedPlugin({ pluginUrl: urls[0], pluginScript }); + if (isDataValid) { + const { urls } = res.data; + const scriptRes = await axios(urls[0]); + const pluginScript = scriptRes.data; + setHashedPlugin({ pluginUrl: urls[0], pluginScript }); - /* eslint-disable no-new-func */ - const loadPlugin = new Function(pluginScript); - loadPlugin(); + /* eslint-disable no-new-func */ + const loadPlugin = new Function(pluginScript); + loadPlugin(); + } } + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PLUGIN_INIT_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); } }, ); -export const getAllPlugins = createAsyncThunk( +export const getAllPlugins = createAsyncThunk<MinervaPlugin[], void, ThunkConfig>( 'plugins/getAllPlugins', - async (): Promise<MinervaPlugin[]> => { - const response = await axiosInstance.get<MinervaPlugin[]>(apiPath.getAllPlugins()); + async (_, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<MinervaPlugin[]>(apiPath.getAllPlugins()); - const isPluginDataValid = (pluginData: MinervaPlugin): boolean => - validateDataUsingZodSchema(pluginData, pluginSchema); - const validPlugins = response.data.filter(isPluginDataValid); + const isPluginDataValid = (pluginData: MinervaPlugin): boolean => + validateDataUsingZodSchema(pluginData, pluginSchema); + const validPlugins = response.data.filter(isPluginDataValid); - return validPlugins; + return validPlugins; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PLUGIN_FETCHING_ALL_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/project/project.constants.ts b/src/redux/project/project.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..46757eb5187a0e2e6e8720aa67af4ee97dbb31b4 --- /dev/null +++ b/src/redux/project/project.constants.ts @@ -0,0 +1 @@ +export const PROJECT_FETCHING_ERROR_PREFIX = 'Failed to fetch project by id'; diff --git a/src/redux/project/project.reducers.test.ts b/src/redux/project/project.reducers.test.ts index 744f725261ca8605d915b0da3474c3300526d24d..f3c007766b44ea26789d010ffccb631808e843c0 100644 --- a/src/redux/project/project.reducers.test.ts +++ b/src/redux/project/project.reducers.test.ts @@ -47,10 +47,13 @@ describe('project reducer', () => { it('should update store after failed getProjectById query', async () => { mockedAxiosClient.onGet(apiPath.getProjectById(PROJECT_ID)).reply(HttpStatusCode.NotFound, []); - const { type } = await store.dispatch(getProjectById(PROJECT_ID)); + const { type, payload } = await store.dispatch(getProjectById(PROJECT_ID)); const { data, loading, error } = store.getState().project; expect(type).toBe('project/getProjectById/rejected'); + expect(payload).toBe( + "Failed to fetch project by id: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual(undefined); diff --git a/src/redux/project/project.thunks.ts b/src/redux/project/project.thunks.ts index 649f867d87cf8581543559dc5f2c2a2911ea3388..2ef8de8212f136df6f1a6810bea8e0ebf31806bc 100644 --- a/src/redux/project/project.thunks.ts +++ b/src/redux/project/project.thunks.ts @@ -3,15 +3,23 @@ import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Project } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; +import { PROJECT_FETCHING_ERROR_PREFIX } from './project.constants'; -export const getProjectById = createAsyncThunk( +export const getProjectById = createAsyncThunk<Project | undefined, string, ThunkConfig>( 'project/getProjectById', - async (id: string): Promise<Project | undefined> => { - const response = await axiosInstanceNewAPI.get<Project>(apiPath.getProjectById(id)); + async (id, { rejectWithValue }) => { + try { + const response = await axiosInstanceNewAPI.get<Project>(apiPath.getProjectById(id)); - const isDataValid = validateDataUsingZodSchema(response.data, projectSchema); + const isDataValid = validateDataUsingZodSchema(response.data, projectSchema); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PROJECT_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/publications/publications.constatns.ts b/src/redux/publications/publications.constatns.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f5ea0a3dfe3f353ad71316f677d88888b68fd2a --- /dev/null +++ b/src/redux/publications/publications.constatns.ts @@ -0,0 +1 @@ +export const PUBLICATIONS_FETCHING_ERROR_PREFIX = 'Problem with fetching publications'; diff --git a/src/redux/publications/publications.thunks.ts b/src/redux/publications/publications.thunks.ts index e95a1d06a07c05963b29f22e740bab6e1e264ad4..f50b611eec013b7e09a0b9773775f6fafc9e4cba 100644 --- a/src/redux/publications/publications.thunks.ts +++ b/src/redux/publications/publications.thunks.ts @@ -3,16 +3,25 @@ import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { PublicationsResponse } from '@/types/models'; import { publicationsResponseSchema } from '@/models/publicationsResponseSchema'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { GetPublicationsParams } from './publications.types'; import { apiPath } from '../apiPath'; +import { PUBLICATIONS_FETCHING_ERROR_PREFIX } from './publications.constatns'; -export const getPublications = createAsyncThunk( - 'publications/getPublications', - async (params: GetPublicationsParams): Promise<PublicationsResponse | undefined> => { +export const getPublications = createAsyncThunk< + PublicationsResponse | undefined, + GetPublicationsParams, + ThunkConfig +>('publications/getPublications', async (params, { rejectWithValue }) => { + try { const response = await axiosInstance.get<PublicationsResponse>(apiPath.getPublications(params)); const isDataValid = validateDataUsingZodSchema(response.data, publicationsResponseSchema); return isDataValid ? response.data : undefined; - }, -); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } +}); diff --git a/src/redux/reactions/reactions.constants.ts b/src/redux/reactions/reactions.constants.ts index a7b9f099193540abc66d052d902faee2d5775c76..c5f51f9d4bfb67f52a7e543eb115c69bcec43eef 100644 --- a/src/redux/reactions/reactions.constants.ts +++ b/src/redux/reactions/reactions.constants.ts @@ -5,3 +5,5 @@ export const REACTIONS_INITIAL_STATE: ReactionsState = { loading: 'idle', error: { name: '', message: '' }, }; + +export const REACTIONS_FETCHING_ERROR_PREFIX = 'Failed to fetch reactions'; diff --git a/src/redux/reactions/reactions.thunks.ts b/src/redux/reactions/reactions.thunks.ts index dbbf7fc725b6501dc9f4136705df1ae56b539ba4..fde44e5d8436d162a53b1b76e0eb0fb09b0753fb 100644 --- a/src/redux/reactions/reactions.thunks.ts +++ b/src/redux/reactions/reactions.thunks.ts @@ -2,20 +2,28 @@ import { reactionSchema } from '@/models/reaction'; import { apiPath } from '@/redux/apiPath'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { Reaction } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { ThunkConfig } from '@/types/store'; +import { REACTIONS_FETCHING_ERROR_PREFIX } from './reactions.constants'; -export const getReactionsByIds = createAsyncThunk<Reaction[] | undefined, number[]>( +export const getReactionsByIds = createAsyncThunk<Reaction[] | undefined, number[], ThunkConfig>( '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)); + async (ids, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<Reaction[]>(apiPath.getReactionsWithIds(ids)); + const isDataValid = validateDataUsingZodSchema(response.data, z.array(reactionSchema)); - if (!isDataValid) { - return undefined; - } + if (!isDataValid) { + return undefined; + } - return response.data; + return response.data; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: REACTIONS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/search/search.constants.ts b/src/redux/search/search.constants.ts index 962778465db9e927729e9369e826702d72b1a166..5c13f6fd0e196fd7b0a3119983d6dd229a77c072 100644 --- a/src/redux/search/search.constants.ts +++ b/src/redux/search/search.constants.ts @@ -5,3 +5,5 @@ export const SEARCH_INITIAL_STATE: SearchState = { perfectMatch: false, loading: 'idle', }; + +export const DATA_SEARCHING_ERROR_PREFIX = 'Failed to search data'; diff --git a/src/redux/search/search.thunks.ts b/src/redux/search/search.thunks.ts index 05debcd0c57d9a3adc4872ef02b7bb5d876c8e0f..078947d3586dc7be7319db5b4196992779539f2d 100644 --- a/src/redux/search/search.thunks.ts +++ b/src/redux/search/search.thunks.ts @@ -3,20 +3,34 @@ import { getMultiChemicals } from '@/redux/chemicals/chemicals.thunks'; import { getMultiDrugs } from '@/redux/drugs/drugs.thunks'; import { PerfectMultiSearchParams } from '@/types/search'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import type { RootState } from '../store'; import { dispatchPluginsEvents } from './search.thunks.utils'; +import { DATA_SEARCHING_ERROR_PREFIX } from './search.constants'; type GetSearchDataProps = PerfectMultiSearchParams; -export const getSearchData = createAsyncThunk<void, GetSearchDataProps, { state: RootState }>( +export const getSearchData = createAsyncThunk< + void, + GetSearchDataProps, + { state: RootState } & ThunkConfig +>( 'project/getSearchData', - async ({ searchQueries, isPerfectMatch }, { dispatch, getState }): Promise<void> => { - await Promise.all([ - dispatch(getMultiBioEntity({ searchQueries, isPerfectMatch })), - dispatch(getMultiDrugs(searchQueries)), - dispatch(getMultiChemicals(searchQueries)), - ]); + // eslint-disable-next-line consistent-return + async ({ searchQueries, isPerfectMatch }, { dispatch, getState, rejectWithValue }) => { + try { + await Promise.all([ + dispatch(getMultiBioEntity({ searchQueries, isPerfectMatch })), + dispatch(getMultiDrugs(searchQueries)), + dispatch(getMultiChemicals(searchQueries)), + ]); - dispatchPluginsEvents(searchQueries, getState()); + dispatchPluginsEvents(searchQueries, getState()); + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: DATA_SEARCHING_ERROR_PREFIX }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/statistics/statistics.constants.ts b/src/redux/statistics/statistics.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..8b28b57b0cd8e505631ebdc0a2e16d003cea8544 --- /dev/null +++ b/src/redux/statistics/statistics.constants.ts @@ -0,0 +1 @@ +export const STATISTICS_FETCHING_ERROR_PREFIX = 'Failed to fetch statistics'; diff --git a/src/redux/statistics/statistics.reducers.test.ts b/src/redux/statistics/statistics.reducers.test.ts index af16b53be20193600a546122f08bba5e3d6f9b85..6d620db1720db9fa9f929ed84fe94c1cb32d8b0c 100644 --- a/src/redux/statistics/statistics.reducers.test.ts +++ b/src/redux/statistics/statistics.reducers.test.ts @@ -56,10 +56,13 @@ describe('statistics reducer', () => { .onGet(apiPath.getStatisticsById(PROJECT_ID)) .reply(HttpStatusCode.NotFound, undefined); - const { type } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { type, payload } = await store.dispatch(getStatisticsById(PROJECT_ID)); const { loading } = store.getState().statistics; expect(type).toBe('statistics/getStatisticsById/rejected'); + expect(payload).toBe( + "Failed to fetch statistics: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); waitFor(() => { expect(loading).toEqual('pending'); diff --git a/src/redux/statistics/statistics.thunks.ts b/src/redux/statistics/statistics.thunks.ts index df5b6589b37a03472c231a24a0ea8fa4b0b7bbd2..1a683a4e3ea3e50238f1fce85b47dbde93b59b98 100644 --- a/src/redux/statistics/statistics.thunks.ts +++ b/src/redux/statistics/statistics.thunks.ts @@ -3,15 +3,23 @@ import { Statistics } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { statisticsSchema } from '@/models/statisticsSchema'; +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { ThunkConfig } from '@/types/store'; import { apiPath } from '../apiPath'; +import { STATISTICS_FETCHING_ERROR_PREFIX } from './statistics.constants'; -export const getStatisticsById = createAsyncThunk( +export const getStatisticsById = createAsyncThunk<Statistics | undefined, string, ThunkConfig>( 'statistics/getStatisticsById', - async (id: string): Promise<Statistics | undefined> => { - const response = await axiosInstance.get<Statistics>(apiPath.getStatisticsById(id)); + async (id, { rejectWithValue }) => { + try { + const response = await axiosInstance.get<Statistics>(apiPath.getStatisticsById(id)); - const isDataValid = validateDataUsingZodSchema(response.data, statisticsSchema); + const isDataValid = validateDataUsingZodSchema(response.data, statisticsSchema); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ error, prefix: STATISTICS_FETCHING_ERROR_PREFIX }); + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/store.ts b/src/redux/store.ts index 2391ea22a323314c9cb853939d9492a6d036e924..3fb15d8bb7a91d5e21285b67ba6fcf586ae93a2b 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -29,6 +29,7 @@ import { mapListenerMiddleware } from './map/middleware/map.middleware'; import markersReducer from './markers/markers.slice'; import pluginsReducer from './plugins/plugins.slice'; import publicationsReducer from './publications/publications.slice'; +import { errorListenerMiddleware } from './middlewares/error.middleware'; import statisticsReducer from './statistics/statistics.slice'; export const reducers = { @@ -58,7 +59,7 @@ export const reducers = { markers: markersReducer, }; -export const middlewares = [mapListenerMiddleware.middleware]; +export const middlewares = [mapListenerMiddleware.middleware, errorListenerMiddleware.middleware]; export const store = configureStore({ reducer: reducers, diff --git a/src/redux/user/user.thunks.ts b/src/redux/user/user.thunks.ts index 95c567c2af83b31f62eb05c30d219f6f17bdb6af..e6241bb6f2ecad36de3b16f7f73146951c40dc8b 100644 --- a/src/redux/user/user.thunks.ts +++ b/src/redux/user/user.thunks.ts @@ -4,21 +4,31 @@ import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { loginSchema } from '@/models/loginSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; import { Login, SessionValid } from '@/types/models'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { apiPath } from '../apiPath'; import { closeModal } from '../modal/modal.slice'; export const login = createAsyncThunk( 'user/login', - async (credentials: { login: string; password: string }, { dispatch }) => { - const searchParams = new URLSearchParams(credentials); - const response = await axiosInstance.post<Login>(apiPath.postLogin(), searchParams, { - withCredentials: true, - }); + async (credentials: { login: string; password: string }, { dispatch, rejectWithValue }) => { + try { + const searchParams = new URLSearchParams(credentials); + const response = await axiosInstance.post<Login>(apiPath.postLogin(), searchParams, { + withCredentials: true, + }); - const isDataValid = validateDataUsingZodSchema(response.data, loginSchema); - dispatch(closeModal()); + const isDataValid = validateDataUsingZodSchema(response.data, loginSchema); + dispatch(closeModal()); - return isDataValid ? response.data : undefined; + return isDataValid ? response.data : undefined; + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: 'Login', + }); + + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/services/pluginsManager/errorMessages.ts b/src/services/pluginsManager/errorMessages.ts index 7d35649435f1f99a73a8f6b84b267ac76b01143e..753b06f04cadabf975857a4a773c8574f3b1befb 100644 --- a/src/services/pluginsManager/errorMessages.ts +++ b/src/services/pluginsManager/errorMessages.ts @@ -1,6 +1,7 @@ export const ERROR_MAP_NOT_FOUND = 'Map with provided id does not exist'; export const ERROR_INVALID_QUERY_TYPE = 'Invalid query type. The query should be of string type'; export const ERROR_INVALID_COORDINATES = 'Invalid coordinates type or values'; -export const ERROR_INVALID_MODEL_ID_TYPE = - 'Invalid model id type. The model should be of number type'; +export const ERROR_INVALID_MODEL_ID_TYPE = 'Invalid model id type. The model id should be a number'; export const ERROR_PROJECT_NOT_FOUND = 'Project does not exist'; +export const ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL = + 'Unable to retrieve the id of the active map: the modelId is not a number'; diff --git a/src/services/pluginsManager/map/data/getBounds.test.ts b/src/services/pluginsManager/map/data/getBounds.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..99f587dab60eacdc703580964f06e5333f4f559c --- /dev/null +++ b/src/services/pluginsManager/map/data/getBounds.test.ts @@ -0,0 +1,60 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { store } from '@/redux/store'; +import { Map } from 'ol'; +import { MapManager } from '../mapManager'; +import { getBounds } from './getBounds'; + +describe('getBounds', () => { + it('should return undefined if map instance does not exist', () => { + expect(getBounds()).toEqual(undefined); + }); + it('should return current bounds if map instance exist', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + + jest.spyOn(mapInstance, 'getView').mockImplementation( + () => + ({ + calculateExtent: () => [ + -14409068.309137221, 17994265.029590994, -13664805.690862779, 18376178.970409006, + ], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 26779.25, + height: 13503, + tileSize: 256, + minZoom: 2, + maxZoom: 9, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + expect(getBounds()).toEqual({ + x1: 15044, + y1: 4441, + x2: 17034, + y2: 5461, + }); + }); +}); diff --git a/src/services/pluginsManager/map/data/getBounds.ts b/src/services/pluginsManager/map/data/getBounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..626e13f705c08dcf163ed1a1017e99d857a7723b --- /dev/null +++ b/src/services/pluginsManager/map/data/getBounds.ts @@ -0,0 +1,37 @@ +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { store } from '@/redux/store'; +import { latLngToPoint } from '@/utils/map/latLngToPoint'; +import { toLonLat } from 'ol/proj'; +import { MapManager } from '../mapManager'; + +type GetBoundsReturnType = + | { + x1: number; + x2: number; + y1: number; + y2: number; + } + | undefined; + +export const getBounds = (): GetBoundsReturnType => { + const mapInstance = MapManager.getMapInstance(); + + if (!mapInstance) return undefined; + + const [minx, miny, maxx, maxy] = mapInstance.getView().calculateExtent(mapInstance.getSize()); + + const mapSize = mapDataSizeSelector(store.getState()); + + const [lngX1, latY1] = toLonLat([minx, maxy]); + const [lngX2, latY2] = toLonLat([maxx, miny]); + + const { x: x1, y: y1 } = latLngToPoint([latY1, lngX1], mapSize, { rounded: true }); + const { x: x2, y: y2 } = latLngToPoint([latY2, lngX2], mapSize, { rounded: true }); + + return { + x1, + y1, + x2, + y2, + }; +}; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..af85941c3340682b1b2644705b7e7c887e12182f --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.constants.ts @@ -0,0 +1,5 @@ +import { HALF } from '@/constants/dividers'; +import { DEFAULT_TILE_SIZE } from '@/constants/map'; + +const BOUNDS_PADDING = DEFAULT_TILE_SIZE / HALF; +export const DEFAULT_PADDING = [BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING, BOUNDS_PADDING]; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f45aecfb1e53db5f3d515fdbe3c3722a1ff0f2e --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.test.ts @@ -0,0 +1,122 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_DATA_INITIAL_STATE } from '@/redux/map/map.constants'; +import { Map } from 'ol'; +import { store } from '@/redux/store'; +import { fitBounds } from './fitBounds'; +import { MapManager } from '../mapManager'; + +jest.mock('../../../../redux/store'); + +describe('fitBounds', () => { + beforeEach(() => { + jest.clearAllMocks(); + MapManager.mapInstance = null; + }); + it('fitBounds should return undefined', () => { + expect( + fitBounds({ + x1: 5, + y1: 10, + x2: 15, + y2: 20, + }), + ).toBe(undefined); + }); + + describe('when mapInstance is set', () => { + it('should call and set map instance view properly', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + const view = mapInstance.getView(); + const getViewSpy = jest.spyOn(mapInstance, 'getView'); + const fitSpy = jest.spyOn(view, 'fit'); + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + fitBounds({ + x1: 10, + y1: 10, + x2: 15, + y2: 20, + }); + + expect(getViewSpy).toHaveBeenCalledTimes(1); + expect(fitSpy).toHaveBeenCalledWith([-18472078, 16906648, -17689363, 18472078], { + maxZoom: 1, + padding: [128, 128, 128, 128], + size: undefined, + }); + }); + it('should use max zoom value', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + const view = mapInstance.getView(); + const getViewSpy = jest.spyOn(mapInstance, 'getView'); + const fitSpy = jest.spyOn(view, 'fit'); + const getStateSpy = jest.spyOn(store, 'getState'); + getStateSpy.mockImplementation( + () => + ({ + map: { + data: { + ...MAP_DATA_INITIAL_STATE, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 99, + }, + }, + loading: 'idle', + error: { + name: '', + message: '', + }, + openedMaps: [], + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + fitBounds({ + x1: 10, + y1: 10, + x2: 15, + y2: 20, + }); + + expect(getViewSpy).toHaveBeenCalledTimes(1); + expect(fitSpy).toHaveBeenCalledWith([-18472078, 16906648, -17689363, 18472078], { + maxZoom: 99, + padding: [128, 128, 128, 128], + size: undefined, + }); + }); + }); +}); diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..079c6d9cc5c50bd5f1f5150a4e907de157a81498 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.ts @@ -0,0 +1,45 @@ +import { FitOptions } from 'ol/View'; +import { boundingExtent } from 'ol/extent'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { store } from '@/redux/store'; +import { MapManager } from '../mapManager'; +import { pointToProjection } from './fitBounds.utils'; +import { DEFAULT_PADDING } from './fitBounds.constants'; + +type FitBoundsArgs = { + x1: number; + x2: number; + y1: number; + y2: number; +}; + +export const fitBounds = ({ x1, y1, x2, y2 }: FitBoundsArgs): void => { + const mapInstance = MapManager.getMapInstance(); + + if (!mapInstance) return; + + const mapSize = mapDataSizeSelector(store.getState()); + + const points = [ + { + x: x1, + y: y2, + }, + { + x: x2, + y: y1, + }, + ]; + + const coordinates = points.map(point => pointToProjection(point, mapSize)); + + const extent = boundingExtent(coordinates); + + const options: FitOptions = { + size: mapInstance.getSize(), + padding: DEFAULT_PADDING, + maxZoom: mapSize.maxZoom, + }; + + mapInstance.getView().fit(extent, options); +}; diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6d2f5cace7114605d75e196fed183c2521c477f0 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.test.ts @@ -0,0 +1,66 @@ +/* eslint-disable no-magic-numbers */ +import { pointToProjection } from './fitBounds.utils'; + +describe('pointToProjection - util', () => { + describe('when mapSize arg is invalid', () => { + const validPoint = { + x: 0, + y: 0, + }; + + const invalidMapSize = { + width: -256 * 10, + height: -256 * 10, + tileSize: -256, + minZoom: -1, + maxZoom: -10, + }; + + it('should return fallback value on function call', () => { + expect(pointToProjection(validPoint, invalidMapSize)).toStrictEqual([0, -0]); + }); + }); + + describe('when point and map size is valid', () => { + const validPoint = { + x: 256 * 100, + y: 256 * 100, + }; + + const validMapSize = { + width: 256 * 10, + height: 256 * 10, + tileSize: 256, + minZoom: 1, + maxZoom: 10, + }; + + const results = [380712659, -238107693]; + + it('should return valid lat lng value on function call', () => { + const [x, y] = pointToProjection(validPoint, validMapSize); + + expect(x).toBe(results[0]); + expect(y).toBe(results[1]); + }); + }); + describe('when point arg is invalid', () => { + const invalidPoint = { + x: 'x', + y: 'y', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any; + + const validMapSize = { + width: 256 * 10, + height: 256 * 10, + tileSize: 256, + minZoom: 1, + maxZoom: 10, + }; + + it('should return fallback value on function call', () => { + expect(pointToProjection(invalidPoint, validMapSize)).toStrictEqual([0, 0]); + }); + }); +}); diff --git a/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..de206bd4204bf1b3b86a36b2b95fed66f25621b9 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/fitBounds.utils.ts @@ -0,0 +1,14 @@ +import { LATLNG_FALLBACK } from '@/constants/map'; +import { MapSize } from '@/redux/map/map.types'; +import { Point } from '@/types/map'; +import { pointToLngLat } from '@/utils/map/pointToLatLng'; +import { fromLonLat } from 'ol/proj'; + +export const pointToProjection = (point: Point, mapSize: MapSize): number[] => { + const [lng, lat] = pointToLngLat(point, mapSize); + const projection = fromLonLat([lng, lat]); + const projectionRounded = projection.map(v => Math.round(v)); + const isValid = !projectionRounded.some(v => Number.isNaN(v)); + + return isValid ? projectionRounded : LATLNG_FALLBACK; +}; diff --git a/src/services/pluginsManager/map/fitBounds/index.ts b/src/services/pluginsManager/map/fitBounds/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..28bd7717581efd4f6b5cb49e47cc974f9d5082e9 --- /dev/null +++ b/src/services/pluginsManager/map/fitBounds/index.ts @@ -0,0 +1 @@ +export { fitBounds } from './fitBounds'; diff --git a/src/services/pluginsManager/map/getOpenMapId.test.ts b/src/services/pluginsManager/map/getOpenMapId.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..cdbe214786e9e884bf49fa1f70003e0569b018d5 --- /dev/null +++ b/src/services/pluginsManager/map/getOpenMapId.test.ts @@ -0,0 +1,40 @@ +import { RootState, store } from '@/redux/store'; +import { initialMapStateFixture } from '@/redux/map/map.fixtures'; +import { getOpenMapId } from './getOpenMapId'; +import { ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL } from '../errorMessages'; + +describe('getOpenMapId', () => { + const getStateMock = jest.spyOn(store, 'getState'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return the modelId of the current map', () => { + getStateMock.mockImplementation( + () => + ({ + map: initialMapStateFixture, + }) as RootState, + ); + + expect(getOpenMapId()).toEqual(initialMapStateFixture.data.modelId); + }); + + it('should throw an error if modelId is not a number', () => { + getStateMock.mockImplementation( + () => + ({ + map: { + ...initialMapStateFixture, + data: { + ...initialMapStateFixture.data, + modelId: null, + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + }) as any, + ); + + expect(() => getOpenMapId()).toThrowError(ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL); + }); +}); diff --git a/src/services/pluginsManager/map/getOpenMapId.ts b/src/services/pluginsManager/map/getOpenMapId.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd3cc808d3f66f423719c5dc0e4f84c54e55ad54 --- /dev/null +++ b/src/services/pluginsManager/map/getOpenMapId.ts @@ -0,0 +1,13 @@ +import { store } from '@/redux/store'; +import { ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL } from '../errorMessages'; + +export const getOpenMapId = (): number => { + const currentMap = store.getState().map.data; + const openMapId = currentMap.modelId; + + if (typeof openMapId !== 'number') { + throw new Error(ERROR_INVALID_MODEL_ID_TYPE_FOR_RETRIEVAL); + } + + return openMapId; +}; diff --git a/src/services/pluginsManager/map/mapManager.test.ts b/src/services/pluginsManager/map/mapManager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f3349c99af786ee65b9ca85a57e8ae3e45415e8 --- /dev/null +++ b/src/services/pluginsManager/map/mapManager.test.ts @@ -0,0 +1,34 @@ +import { Map } from 'ol'; + +import { MapInstance } from '@/types/map'; +import { MapManager } from './mapManager'; + +describe('MapManager', () => { + describe('getMapInstance', () => { + it('should return null if no map instance is set', () => { + expect(MapManager.getMapInstance()).toBeNull(); + }); + + it('should return the set map instance', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + expect(MapManager.getMapInstance()).toEqual(mapInstance); + }); + }); + + describe('setMapInstance', () => { + beforeEach(() => { + MapManager.mapInstance = null; + }); + it('should set the map instance', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + MapManager.setMapInstance(mapInstance); + expect(MapManager.mapInstance).toEqual(mapInstance); + }); + it('should throw error if map instance is not valid', () => { + expect(() => MapManager.setMapInstance({} as MapInstance)).toThrow('Not valid map instance'); + }); + }); +}); diff --git a/src/services/pluginsManager/map/mapManager.ts b/src/services/pluginsManager/map/mapManager.ts new file mode 100644 index 0000000000000000000000000000000000000000..a710f208d99e0f8fbf8bdfd521eba778b0708bc4 --- /dev/null +++ b/src/services/pluginsManager/map/mapManager.ts @@ -0,0 +1,18 @@ +import { MapInstance } from '@/types/map'; +import { Map } from 'ol'; + +type MapManagerType = { + mapInstance: null | MapInstance; + setMapInstance: (mapInstance: MapInstance) => void; + getMapInstance: () => MapInstance | null; +}; + +export const MapManager: MapManagerType = { + mapInstance: null, + + setMapInstance: (mapInstance: MapInstance) => { + if (!(mapInstance instanceof Map)) throw new Error('Not valid map instance'); + MapManager.mapInstance = mapInstance; + }, + getMapInstance: () => MapManager.mapInstance, +}; diff --git a/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts b/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts new file mode 100644 index 0000000000000000000000000000000000000000..ce27f4ba68a74409e4623fa2c737161df54c4261 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getPolygonPoints.ts @@ -0,0 +1,33 @@ +import { allVisibleBioEntitiesSelector } from '@/redux/bioEntity/bioEntity.selectors'; +import { store } from '@/redux/store'; +import { isPointValid } from '@/utils/point/isPointValid'; + +type Points = { + x: number; + y: number; +}[]; + +export const getPolygonPoints = (): Points => { + const allVisibleBioEntities = allVisibleBioEntitiesSelector(store.getState()); + const allX = allVisibleBioEntities.map(({ x }) => x); + const allY = allVisibleBioEntities.map(({ y }) => y); + + const minX = Math.min(...allX); + const maxX = Math.max(...allX); + + const minY = Math.min(...allY); + const maxY = Math.max(...allY); + + const points = [ + { + x: minX, + y: maxY, + }, + { + x: maxX, + y: minY, + }, + ]; + + return points.filter(isPointValid); +}; diff --git a/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..47fc1165f558c948e10f02c45d7f274f045372fa --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.test.ts @@ -0,0 +1,158 @@ +/* eslint-disable no-magic-numbers */ +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { RootState, store } from '@/redux/store'; +import { bioEntityContentFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; + +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { getVisibleBioEntitiesPolygonCoordinates } from './getVisibleBioEntitiesPolygonCoordinates'; + +jest.mock('../../../../redux/store'); + +const getStateSpy = jest.spyOn(store, 'getState'); + +describe('getVisibleBioEntitiesPolygonCoordinates', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should return undefined if received array does not contain bioEntities with current map id', () => { + getStateSpy.mockImplementation( + () => + ({ + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + bioEntity: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + }, + }) as RootState, + ); + + expect(getVisibleBioEntitiesPolygonCoordinates()).toBe(undefined); + }); + it('should return coordinates, max zoom, and map instance if received array contain bioEntities with current map id and max zoom', () => { + getStateSpy.mockImplementation( + () => + ({ + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 52, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 5, + }, + }, + }, + bioEntity: { + data: [ + { + searchQueryElement: bioEntityContentFixture.bioEntity.name, + loading: 'succeeded', + error: { name: '', message: '' }, + data: [ + { + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + model: 52, + x: 97, + y: 53, + z: 1, + }, + }, + { + ...bioEntityContentFixture, + bioEntity: { + ...bioEntityContentFixture.bioEntity, + model: 52, + x: 12, + y: 25, + z: 1, + }, + }, + ], + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + data: [ + { + searchQueryElement: '', + loading: 'succeeded', + error: { name: '', message: '' }, + data: undefined, + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + chemicals: { + data: [ + { + searchQueryElement: '', + loading: 'succeeded', + error: { name: '', message: '' }, + data: undefined, + }, + ], + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + searchDrawerState: { + ...DRAWER_INITIAL_STATE.searchDrawerState, + selectedSearchElement: bioEntityContentFixture.bioEntity.name, + }, + }, + }) as RootState, + ); + + expect(getVisibleBioEntitiesPolygonCoordinates()).toEqual({ + mapInstance: null, + maxZoom: 5, + polygonCoordinates: [ + [-18158992, 11740728], + [-4852834, 16123932], + ], + }); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts new file mode 100644 index 0000000000000000000000000000000000000000..c1fba3388515df219601634b120ec9efef05e264 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/getVisibleBioEntitiesPolygonCoordinates.ts @@ -0,0 +1,33 @@ +import { store } from '@/redux/store'; +import { mapDataSizeSelector } from '@/redux/map/map.selectors'; +import { MapInstance } from '@/types/map'; +import { MapManager } from '../mapManager'; +import { pointToProjection } from '../fitBounds/fitBounds.utils'; +import { getPolygonPoints } from './getPolygonPoints'; + +const VALID_POLYGON_COORDINATES_LENGTH = 2; + +export const getVisibleBioEntitiesPolygonCoordinates = (): + | { + polygonCoordinates: number[][]; + maxZoom: number; + mapInstance: MapInstance | null; + } + | undefined => { + const mapSize = mapDataSizeSelector(store.getState()); + const { maxZoom } = mapDataSizeSelector(store.getState()); + + const polygonPoints = getPolygonPoints(); + + const polygonCoordinates = polygonPoints.map(point => pointToProjection(point, mapSize)); + + if (polygonCoordinates.length !== VALID_POLYGON_COORDINATES_LENGTH) { + return undefined; + } + + return { + polygonCoordinates, + maxZoom, + mapInstance: MapManager.getMapInstance(), + }; +}; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts index bdd6a539ddbfc7eca3934a8700061acc54d8de24..c04dfaace49cb9e95f3d76640e407b80a11c3ff1 100644 --- a/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts +++ b/src/services/pluginsManager/map/triggerSearch/searchByCoordinates.ts @@ -8,6 +8,8 @@ import { Coordinates } from './triggerSearch.types'; export const searchByCoordinates = async ( coordinates: Coordinates, modelId: number, + hasFitBounds?: boolean, + fitBoundsZoom?: number, ): Promise<void> => { const { dispatch } = store; // side-effect below is to prevent complications with data update - old data may conflict with new data @@ -23,5 +25,5 @@ export const searchByCoordinates = async ( return; } - handleSearchResultAction({ searchResults, dispatch }); + handleSearchResultAction({ searchResults, dispatch, hasFitBounds, fitBoundsZoom }); }; diff --git a/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e804a294f9ad4f681661e008f15897543d1db1d9 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.test.ts @@ -0,0 +1,119 @@ +import { RootState, store } from '@/redux/store'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFixture'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { DRAWER_INITIAL_STATE } from '@/redux/drawer/drawer.constants'; +import { MODELS_DATA_MOCK_WITH_MAIN_MAP } from '@/redux/models/models.mock'; +import { MAP_INITIAL_STATE } from '@/redux/map/map.constants'; +import { waitFor } from '@testing-library/react'; +import { searchByQuery } from './searchByQuery'; +import { searchFitBounds } from './searchFitBounds'; + +const MOCK_SEARCH_BY_QUERY_STORE = { + models: MODELS_DATA_MOCK_WITH_MAIN_MAP, + map: { + ...MAP_INITIAL_STATE, + data: { + ...MAP_INITIAL_STATE.data, + modelId: 5052, + size: { + width: 256, + height: 256, + tileSize: 256, + minZoom: 1, + maxZoom: 1, + }, + }, + }, + bioEntity: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drugs: { + loading: 'succeeded', + error: { message: '', name: '' }, + }, + drawer: { + ...DRAWER_INITIAL_STATE, + bioEntityDrawerState: { + bioentityId: undefined, + drugs: {}, + chemicals: {}, + }, + }, +}; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); +const SEARCH_QUERY = 'park7'; + +jest.mock('./searchFitBounds'); +jest.mock('../../../../redux/store'); +const dispatchSpy = jest.spyOn(store, 'dispatch'); +const getStateSpy = jest.spyOn(store, 'getState'); + +describe('searchByQuery', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should fit bounds after search if hasFitBounds param is true', async () => { + dispatchSpy.mockImplementation(() => ({ + unwrap: (): Promise<void> => Promise.resolve(), + })); + + getStateSpy.mockImplementation(() => MOCK_SEARCH_BY_QUERY_STORE as RootState); + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + searchByQuery(SEARCH_QUERY, false, true); + + await waitFor(() => { + expect(searchFitBounds).toHaveBeenCalled(); + }); + }); + it('should not fit bounds after search if hasFitBounds param is false', async () => { + dispatchSpy.mockImplementation(() => ({ + unwrap: (): Promise<void> => Promise.resolve(), + })); + + getStateSpy.mockImplementation(() => MOCK_SEARCH_BY_QUERY_STORE as RootState); + mockedAxiosClient + .onGet( + apiPath.getBioEntityContentsStringWithQuery({ + searchQuery: SEARCH_QUERY, + isPerfectMatch: false, + }), + ) + .reply(HttpStatusCode.Ok, bioEntityResponseFixture); + + mockedAxiosClient + .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, drugsFixture); + + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + searchByQuery(SEARCH_QUERY, false, false); + + await waitFor(() => { + expect(searchFitBounds).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts index 01d0603743171cebd905be553cf7d61aa6824fc1..e29c50f97532b73a77fe158f1d4e2571ff58bb4b 100644 --- a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts @@ -2,13 +2,24 @@ import { getSearchValuesArrayAndTrimToSeven } from '@/components/FunctionalArea/ import { getSearchData } from '@/redux/search/search.thunks'; import { store } from '@/redux/store'; import { displaySearchDrawerWithSelectedDefaultTab } from './displaySearchDrawerWithSelectedDefaultTab'; +import { searchFitBounds } from './searchFitBounds'; -export const searchByQuery = (query: string, perfectSearch: boolean | undefined): void => { +export const searchByQuery = ( + query: string, + perfectSearch: boolean | undefined, + hasFitBounds?: boolean, +): void => { const { dispatch } = store; const searchValues = getSearchValuesArrayAndTrimToSeven(query); const isPerfectMatch = !!perfectSearch; - dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })); + dispatch(getSearchData({ searchQueries: searchValues, isPerfectMatch })) + ?.unwrap() + .then(() => { + if (hasFitBounds) { + searchFitBounds(); + } + }); displaySearchDrawerWithSelectedDefaultTab(searchValues); }; diff --git a/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..25847a33e9a61f93189d4d8882e92d6a81d43eac --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.test.ts @@ -0,0 +1,105 @@ +/* eslint-disable no-magic-numbers */ +import { handleSetBounds } from '@/utils/map/useSetBounds'; +import { Map } from 'ol'; +import * as getVisibleBioEntitiesPolygonCoordinates from './getVisibleBioEntitiesPolygonCoordinates'; +import { searchFitBounds } from './searchFitBounds'; + +jest.mock('../../../../utils/map/useSetBounds'); + +jest.mock('./getVisibleBioEntitiesPolygonCoordinates', () => ({ + __esModule: true, + ...jest.requireActual('./getVisibleBioEntitiesPolygonCoordinates'), +})); + +const getVisibleBioEntitiesPolygonCoordinatesSpy = jest.spyOn( + getVisibleBioEntitiesPolygonCoordinates, + 'getVisibleBioEntitiesPolygonCoordinates', +); + +describe('searchFitBounds', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it('should not handle set bounds if data is not valid', () => { + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => undefined); + + searchFitBounds(); + + expect(handleSetBounds).not.toHaveBeenCalled(); + }); + it('should not handle set bounds if map instance is not valid', () => { + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance: null, + maxZoom: 5, + polygonCoordinates: [ + [231, 231], + [842, 271], + ], + })); + + searchFitBounds(); + + expect(handleSetBounds).not.toHaveBeenCalled(); + }); + it('should handle set bounds if provided data is valid', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 5; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, maxZoom, polygonCoordinates); + }); + it('should handle set bounds with max zoom if zoom is not provided in argument', () => { + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 23; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, maxZoom, polygonCoordinates); + }); + it('should handle set bounds with zoom provided in argument instead of max zoom', () => { + const zoom = 12; + const dummyElement = document.createElement('div'); + const mapInstance = new Map({ target: dummyElement }); + const maxZoom = 23; + const polygonCoordinates = [ + [231, 231], + [842, 271], + ]; + + getVisibleBioEntitiesPolygonCoordinatesSpy.mockImplementation(() => ({ + mapInstance, + maxZoom, + polygonCoordinates, + })); + + searchFitBounds(zoom); + + expect(handleSetBounds).toHaveBeenCalled(); + expect(handleSetBounds).toHaveBeenCalledWith(mapInstance, zoom, polygonCoordinates); + }); +}); diff --git a/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c2954ebe5525040cb3550b085613b0b0b781153 --- /dev/null +++ b/src/services/pluginsManager/map/triggerSearch/searchFitBounds.ts @@ -0,0 +1,15 @@ +import { handleSetBounds } from '@/utils/map/useSetBounds'; +import { getVisibleBioEntitiesPolygonCoordinates } from './getVisibleBioEntitiesPolygonCoordinates'; + +export const searchFitBounds = (zoom?: number): void => { + const data = getVisibleBioEntitiesPolygonCoordinates(); + + if (data) { + const { polygonCoordinates, maxZoom, mapInstance } = data; + + if (!mapInstance) return; + + const setBoundsZoom = zoom || maxZoom; + handleSetBounds(mapInstance, setBoundsZoom, polygonCoordinates); + } +}; diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts index 3c7408ebf99514fc11abc6bd75118bae26192724..1b522bb065723a9ef78cf2d24e01869960c3c00a 100644 --- a/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.test.ts @@ -10,6 +10,7 @@ import { bioEntityResponseFixture } from '@/models/fixtures/bioEntityContentsFix import { waitFor } from '@testing-library/react'; import { handleSearchResultAction } from '@/components/Map/MapViewer/utils/listeners/mapSingleClick/handleSearchResultAction'; import { triggerSearch } from './triggerSearch'; +import { ERROR_INVALID_MODEL_ID_TYPE } from '../../errorMessages'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const mockedAxiosOldClient = mockNetworkResponse(); @@ -148,9 +149,7 @@ describe('triggerSearch', () => { modelId: '53' as any, }; - await expect(triggerSearch(invalidParams)).rejects.toThrowError( - 'Invalid model id type. The model should be of number type', - ); + await expect(triggerSearch(invalidParams)).rejects.toThrowError(ERROR_INVALID_MODEL_ID_TYPE); }); it('should search result with proper data', async () => { mockedAxiosOldClient diff --git a/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts index 8f1b42fe3f895d34ee15ea2beb690b16a8d2f202..8a69f07b6570907182960db7bd2e770d5b6e6644 100644 --- a/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts +++ b/src/services/pluginsManager/map/triggerSearch/triggerSearch.ts @@ -12,7 +12,7 @@ export async function triggerSearch(params: SearchParams): Promise<void> { if (typeof params.query !== 'string') { throw new Error(ERROR_INVALID_QUERY_TYPE); } - searchByQuery(params.query, params.perfectSearch); + searchByQuery(params.query, params.perfectSearch, params.fitBounds); } else { const areCoordinatesInvalidType = typeof params.coordinates !== 'object' || params.coordinates === null; @@ -28,6 +28,6 @@ export async function triggerSearch(params: SearchParams): Promise<void> { throw new Error(ERROR_INVALID_MODEL_ID_TYPE); } - searchByCoordinates(params.coordinates, params.modelId); + searchByCoordinates(params.coordinates, params.modelId, params.fitBounds, params.zoom); } } diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts index 5fd9ea0a98a72dbfe3eacd96d8d708f81c3058bd..2969f8ab7be3e86ef9f44f871c4eace00d0e97a4 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.constants.ts @@ -14,6 +14,8 @@ const PLUGINS_EVENTS = { onZoomChanged: 'onZoomChanged', onCenterChanged: 'onCenterChanged', onBioEntityClick: 'onBioEntityClick', + onPinIconClick: 'onPinIconClick', + onSurfaceClick: 'onSurfaceClick', }, search: { onSearch: 'onSearch', diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts index 5227eabbdc6e6ea1f43b84d809fe6718af2fcc4c..66d4ab43fd2d6dd73422f714ada6c8a897592d0e 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.ts @@ -4,6 +4,8 @@ import { ALLOWED_PLUGINS_EVENTS, LISTENER_NOT_FOUND } from './pluginsEventBus.co import type { CenteredCoordinates, ClickedBioEntity, + ClickedPinIcon, + ClickedSurfaceOverlay, Events, EventsData, PluginsEventBusType, @@ -21,6 +23,8 @@ export function dispatchEvent(type: 'onSubmapClose', submapId: number): void; export function dispatchEvent(type: 'onZoomChanged', data: ZoomChanged): void; export function dispatchEvent(type: 'onCenterChanged', data: CenteredCoordinates): void; export function dispatchEvent(type: 'onBioEntityClick', data: ClickedBioEntity): void; +export function dispatchEvent(type: 'onPinIconClick', data: ClickedPinIcon): void; +export function dispatchEvent(type: 'onSurfaceClick', data: ClickedSurfaceOverlay): void; export function dispatchEvent(type: 'onSearch', data: SearchData): void; export function dispatchEvent(type: Events, data: EventsData): void { if (!ALLOWED_PLUGINS_EVENTS.includes(type)) throw new Error(`Invalid event type: ${type}`); diff --git a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts index 7679bb0af514be8208c15a0a23c55c3de096aafe..0fdec91300559d50089301f264031c2d069f5fc4 100644 --- a/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts +++ b/src/services/pluginsManager/pluginsEventBus/pluginsEventBus.types.ts @@ -1,4 +1,11 @@ -import { BioEntityContent, Chemical, CreatedOverlay, Drug, MapOverlay } from '@/types/models'; +import { + BioEntityContent, + Chemical, + CreatedOverlay, + Drug, + ElementSearchResult, + MapOverlay, +} from '@/types/models'; import { dispatchEvent } from './pluginsEventBus'; export type BackgroundEvents = 'onBackgroundOverlayChange'; @@ -6,13 +13,15 @@ export type OverlayEvents = | 'onAddDataOverlay' | 'onRemoveDataOverlay' | 'onShowOverlay' - | 'onHideOverlay'; + | 'onHideOverlay' + | 'onSurfaceClick'; export type SubmapEvents = | 'onSubmapOpen' | 'onSubmapClose' | 'onZoomChanged' | 'onCenterChanged' - | 'onBioEntityClick'; + | 'onBioEntityClick' + | 'onPinIconClick'; export type SearchEvents = 'onSearch'; export type Events = OverlayEvents | BackgroundEvents | SubmapEvents | SearchEvents; @@ -34,9 +43,17 @@ export type ClickedBioEntity = { modelId: number; }; +export type ClickedPinIcon = { + id: number | string; +}; + +export type ClickedSurfaceOverlay = { + id: number | string; +}; + export type SearchDataBioEntity = { type: 'bioEntity'; - searchValues: string[]; + searchValues: string[] | ElementSearchResult[]; results: BioEntityContent[][]; }; @@ -61,6 +78,8 @@ export type EventsData = | ZoomChanged | CenteredCoordinates | ClickedBioEntity + | ClickedPinIcon + | ClickedSurfaceOverlay | SearchData; export type PluginsEventBusType = { diff --git a/src/services/pluginsManager/pluginsManager.ts b/src/services/pluginsManager/pluginsManager.ts index 813274a16b43d74fea00d7250e3424adbd2fe1ce..f432527ab6e8e1f97755f1864745abb1819a687a 100644 --- a/src/services/pluginsManager/pluginsManager.ts +++ b/src/services/pluginsManager/pluginsManager.ts @@ -24,6 +24,10 @@ import { getOrganism } from './project/data/getOrganism'; import { getProjectId } from './project/data/getProjectId'; import { getVersion } from './project/data/getVersion'; +import { getBounds } from './map/data/getBounds'; +import { fitBounds } from './map/fitBounds'; +import { getOpenMapId } from './map/getOpenMapId'; + export const PluginsManager: PluginsManagerType = { hashedPlugins: {}, setHashedPlugin({ pluginUrl, pluginScript }) { @@ -43,8 +47,11 @@ export const PluginsManager: PluginsManagerType = { }, map: { data: { + getBounds, + getOpenMapId, getModels, }, + fitBounds, openMap, triggerSearch, getZoom, diff --git a/src/shared/Toast/Toast.component.test.tsx b/src/shared/Toast/Toast.component.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..610df96f168afee935ae509c76a42019302a38c9 --- /dev/null +++ b/src/shared/Toast/Toast.component.test.tsx @@ -0,0 +1,29 @@ +/* eslint-disable no-magic-numbers */ +import { render, screen, fireEvent } from '@testing-library/react'; +import { Toast } from './Toast.component'; + +describe('Toast component', () => { + it('renders success message correctly', () => { + const message = 'Success message'; + render(<Toast type="success" message={message} onDismiss={() => {}} />); + const toastElement = screen.getByText(message); + expect(toastElement).toBeInTheDocument(); + expect(toastElement).toHaveClass('text-green-500'); + }); + + it('renders error message correctly', () => { + const message = 'Error message'; + render(<Toast type="error" message={message} onDismiss={() => {}} />); + const toastElement = screen.getByText(message); + expect(toastElement).toBeInTheDocument(); + expect(toastElement).toHaveClass('text-red-500'); + }); + + it('calls onDismiss when close button is clicked', () => { + const mockOnDismiss = jest.fn(); + render(<Toast type="success" message="Success message" onDismiss={mockOnDismiss} />); + const closeButton = screen.getByRole('button'); + fireEvent.click(closeButton); + expect(mockOnDismiss).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/shared/Toast/Toast.component.tsx b/src/shared/Toast/Toast.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..02e1d965aa9bfc79143337d7ac11254cc6059f8c --- /dev/null +++ b/src/shared/Toast/Toast.component.tsx @@ -0,0 +1,30 @@ +import { twMerge } from 'tailwind-merge'; +import { Icon } from '../Icon'; + +type ToastArgs = { + type: 'success' | 'error'; + message: string; + onDismiss: () => void; +}; + +export const Toast = ({ type, message, onDismiss }: ToastArgs): React.ReactNode => ( + <div + className={twMerge( + 'flex h-[76px] w-[700px] items-center rounded-l rounded-r-lg bg-white p-4 drop-shadow before:absolute before:inset-y-0 before:left-0 before:block before:w-1 before:rounded-l-lg before:content-[""]', + type === 'error' ? 'before:bg-red-500' : 'before:bg-green-500', + )} + > + <p + className={twMerge( + 'text-base font-bold ', + type === 'error' ? 'text-red-500' : 'text-green-500', + )} + > + {message} + </p> + + <button type="button" onClick={onDismiss} className="ml-auto flex-none"> + <Icon name="close" className="ml-3 h-7 w-7 fill-font-500" /> + </button> + </div> +); diff --git a/src/shared/Toast/index.ts b/src/shared/Toast/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c28ae1894e90798a5f9852ee719937588b8b0814 --- /dev/null +++ b/src/shared/Toast/index.ts @@ -0,0 +1 @@ +export { Toast } from './Toast.component'; diff --git a/src/types/store.ts b/src/types/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..c513f8db4485126f755cd2fa6346e55f126f8fa1 --- /dev/null +++ b/src/types/store.ts @@ -0,0 +1,3 @@ +export type ThunkConfig = { + rejectValue: string; +}; diff --git a/src/utils/context/mapInstanceContext.tsx b/src/utils/context/mapInstanceContext.tsx index 1c0982d8e55a4bcbafdf1416b473b43afcf9f9b9..85fe8fc8f6f02db14da4a38b6933a197c1fddee9 100644 --- a/src/utils/context/mapInstanceContext.tsx +++ b/src/utils/context/mapInstanceContext.tsx @@ -1,14 +1,15 @@ +import { MapManager } from '@/services/pluginsManager/map/mapManager'; import { MapInstance } from '@/types/map'; -import { Dispatch, SetStateAction, createContext, useContext, useMemo, useState } from 'react'; +import { createContext, useCallback, useContext, useMemo, useState } from 'react'; export interface MapInstanceContext { mapInstance: MapInstance; - setMapInstance: Dispatch<SetStateAction<MapInstance>>; + handleSetMapInstance: (mapInstance: MapInstance) => void; } export const MapInstanceContext = createContext<MapInstanceContext>({ mapInstance: undefined, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }); export const useMapInstance = (): MapInstanceContext => useContext(MapInstanceContext); @@ -24,12 +25,22 @@ export const MapInstanceProvider = ({ }: MapInstanceProviderProps): JSX.Element => { const [mapInstance, setMapInstance] = useState<MapInstance>(initialValue?.mapInstance); + const handleSetMapInstance = useCallback( + (map: MapInstance) => { + if (!mapInstance) { + setMapInstance(map); + MapManager.setMapInstance(map); + } + }, + [mapInstance], + ); + const mapInstanceContextValue = useMemo( () => ({ mapInstance, - setMapInstance, + handleSetMapInstance, }), - [mapInstance], + [mapInstance, handleSetMapInstance], ); return ( diff --git a/src/utils/getErrorMessage/getErrorMessage.constants.ts b/src/utils/getErrorMessage/getErrorMessage.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..00b84d7087cf177ecf1a129633867153ce228e48 --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.constants.ts @@ -0,0 +1,11 @@ +export const UNKNOWN_ERROR = 'An unknown error occurred. Please try again later.'; + +export const HTTP_ERROR_MESSAGES = { + 400: "The server couldn't understand your request. Please check your input and try again.", + 401: "You're not authorized to access this resource. Please log in or check your credentials.", + 403: "Access Forbidden! You don't have permission to access this resource.", + 404: "The page you're looking for doesn't exist. Please verify the URL and try again.", + 500: 'Unexpected server error. Please try again later or contact support.', + 501: 'Sorry, this feature is not yet implemented. Please try again later.', + 503: 'Service Unavailable! The server is currently down for maintenance. Please try again later.', +}; diff --git a/src/utils/getErrorMessage/getErrorMessage.test.ts b/src/utils/getErrorMessage/getErrorMessage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f9f2b7cd101c4ea2dccfcf5643190f1b9b25cf2 --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.test.ts @@ -0,0 +1,34 @@ +/* eslint-disable no-magic-numbers */ +import { getErrorMessage } from './getErrorMessage'; +import { mockAxiosError } from './getErrorMessage.test.utils'; + +describe('getErrorMessage function', () => { + it('should return custom message if provided', () => { + const error = new Error('Custom Error'); + const errorMessage = getErrorMessage({ error, message: 'This is a custom message' }); + expect(errorMessage).toBe('This is a custom message'); + }); + + it('should return extracted Axios error message', () => { + const error = mockAxiosError(401, 'Unauthorized'); + const errorMessage = getErrorMessage({ error }); + expect(errorMessage).toBe('Unauthorized'); + }); + + it('should return error message from Error instance', () => { + const error = new Error('Network Error'); + const errorMessage = getErrorMessage({ error }); + expect(errorMessage).toBe('Network Error'); + }); + + it('should return default error message if error is of unknown type', () => { + const errorMessage = getErrorMessage({ error: {} }); + expect(errorMessage).toBe('An unknown error occurred. Please try again later.'); + }); + + it('should prepend prefix to error message', () => { + const error = new Error('Server Error'); + const errorMessage = getErrorMessage({ error, prefix: 'Error occurred' }); + expect(errorMessage).toBe('Error occurred: Server Error'); + }); +}); diff --git a/src/utils/getErrorMessage/getErrorMessage.test.utils.ts b/src/utils/getErrorMessage/getErrorMessage.test.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..cb7983e53d39dbb032410d196c3ab37345042e48 --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.test.utils.ts @@ -0,0 +1,15 @@ +import { AxiosError } from 'axios'; + +type MockAxiosError = AxiosError<{ error: string; reason: string }>; + +export const mockAxiosError = (status: number, reason: string | null): MockAxiosError => + ({ + isAxiosError: true, + response: { + status, + data: { + reason, + error: reason, + }, + }, + }) as MockAxiosError; diff --git a/src/utils/getErrorMessage/getErrorMessage.ts b/src/utils/getErrorMessage/getErrorMessage.ts new file mode 100644 index 0000000000000000000000000000000000000000..20073f02e7d75ea19cbcbe1e4e8573031f25588f --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.ts @@ -0,0 +1,29 @@ +import axios from 'axios'; +import { UNKNOWN_ERROR } from './getErrorMessage.constants'; +import { extractAxiosErrorMessage } from './getErrorMessage.utils'; + +type GetErrorMessageConfig = { + error: unknown; + message?: string; + prefix?: string; +}; + +export const getErrorMessage = ({ error, message, prefix }: GetErrorMessageConfig): string => { + let errorMessage: string; + + switch (true) { + case !!message: + errorMessage = message; + break; + case axios.isAxiosError(error): + errorMessage = extractAxiosErrorMessage(error); + break; + case error instanceof Error: + errorMessage = error.message; + break; + default: + errorMessage = UNKNOWN_ERROR; + } + + return prefix ? `${prefix}: ${errorMessage}` : errorMessage; +}; diff --git a/src/utils/getErrorMessage/getErrorMessage.types.ts b/src/utils/getErrorMessage/getErrorMessage.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..33e843551e3bafc782ab11cc537a5565c9d5242d --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.types.ts @@ -0,0 +1,3 @@ +import { HTTP_ERROR_MESSAGES } from './getErrorMessage.constants'; + +export type HttpStatuses = keyof typeof HTTP_ERROR_MESSAGES; diff --git a/src/utils/getErrorMessage/getErrorMessage.utils.test.ts b/src/utils/getErrorMessage/getErrorMessage.utils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8406dcaf7f0d7751d6c85d3d7e92b2028495e30c --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.utils.test.ts @@ -0,0 +1,22 @@ +/* eslint-disable no-magic-numbers */ +import { mockAxiosError } from './getErrorMessage.test.utils'; +import { extractAxiosErrorMessage } from './getErrorMessage.utils'; + +describe('extractAxiosErrorMessage', () => { + it('should return the error message from Axios error response if exist', () => { + const error = mockAxiosError(404, 'Not Found'); + expect(extractAxiosErrorMessage(error)).toBe('Not Found'); + }); + it('should return error message defined by response status if error response does not exist', () => { + const error = mockAxiosError(500, null); + expect(extractAxiosErrorMessage(error)).toBe( + 'Unexpected server error. Please try again later or contact support.', + ); + }); + it('should return the default error message if status code is not defined in predefined error messages list and error response does not exist', () => { + const error = mockAxiosError(418, null); + expect(extractAxiosErrorMessage(error)).toBe( + 'An unknown error occurred. Please try again later.', + ); + }); +}); diff --git a/src/utils/getErrorMessage/getErrorMessage.utils.ts b/src/utils/getErrorMessage/getErrorMessage.utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..46a46ee43c3c8b694a5100c4d3300c42d9a39e29 --- /dev/null +++ b/src/utils/getErrorMessage/getErrorMessage.utils.ts @@ -0,0 +1,14 @@ +import { AxiosError } from 'axios'; +import { HTTP_ERROR_MESSAGES, UNKNOWN_ERROR } from './getErrorMessage.constants'; +import { HttpStatuses } from './getErrorMessage.types'; + +type Error = { error: string; reason: string }; + +export const extractAxiosErrorMessage = (error: AxiosError<Error>): string => { + if (error.response?.data?.reason) { + return error.response.data.reason; + } + + const status = error.response?.status as HttpStatuses; + return HTTP_ERROR_MESSAGES[status] || UNKNOWN_ERROR; +}; diff --git a/src/utils/getErrorMessage/index.ts b/src/utils/getErrorMessage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..50ca645d9391ddb5593acfcd24d296412d18a731 --- /dev/null +++ b/src/utils/getErrorMessage/index.ts @@ -0,0 +1 @@ +export { getErrorMessage } from './getErrorMessage'; diff --git a/src/utils/map/useSetBounds.test.ts b/src/utils/map/useSetBounds.test.ts index 4e00469d4c7df59c3b33135a5c6da8182b1e0db5..71ee12602b0c344b7276446cb952b1dbb1d687d9 100644 --- a/src/utils/map/useSetBounds.test.ts +++ b/src/utils/map/useSetBounds.test.ts @@ -39,7 +39,7 @@ describe('useSetBounds - hook', () => { { mapInstanceContextValue: { mapInstance: undefined, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); @@ -84,7 +84,7 @@ describe('useSetBounds - hook', () => { { mapInstanceContextValue: { mapInstance, - setMapInstance: () => {}, + handleSetMapInstance: () => {}, }, }, ); diff --git a/src/utils/showToast.test.tsx b/src/utils/showToast.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..01f65adebfafe68142553d337ef699c66b9a75b5 --- /dev/null +++ b/src/utils/showToast.test.tsx @@ -0,0 +1,21 @@ +/* eslint-disable no-magic-numbers */ +import { toast } from 'sonner'; +import { showToast } from './showToast'; + +jest.mock('sonner', () => ({ + toast: { + custom: jest.fn(), + dismiss: jest.fn(), + }, +})); + +describe('showToast', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should call toast.custom on showToast call', () => { + showToast({ type: 'success', message: 'Success message' }); + expect(toast.custom).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/utils/showToast.tsx b/src/utils/showToast.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c7ea4329137a508aec4b93357e63442ef6c3c795 --- /dev/null +++ b/src/utils/showToast.tsx @@ -0,0 +1,13 @@ +import { toast } from 'sonner'; +import { Toast } from '@/shared/Toast'; + +type ShowToastArgs = { + type: 'success' | 'error'; + message: string; +}; + +export const showToast = (args: ShowToastArgs): void => { + toast.custom(t => ( + <Toast message={args.message} onDismiss={() => toast.dismiss(t)} type={args.type} /> + )); +};