From 00c7a1e8db7246e6bf4a6a244937e507aa91c55d Mon Sep 17 00:00:00 2001 From: mateusz-winiarczyk <mateusz.winiarczyk@appunite.com> Date: Mon, 11 Mar 2024 13:07:35 +0100 Subject: [PATCH] feat(toast): add toast and error handling (MIN-283) --- package-lock.json | 16 ++ package.json | 1 + .../AppWrapper/AppWrapper.component.tsx | 14 +- .../EditOverlayModal.component.test.tsx | 72 ++++++ .../EditOverlayModal/hooks/useEditOverlay.ts | 4 +- .../AvailablePluginsDrawer.constants.ts | 1 + .../LoadPlugin/hooks/useLoadPlugin.test.ts | 29 +++ .../LoadPlugin/hooks/useLoadPlugin.ts | 33 ++- .../LoadPluginFromUrl.component.test.tsx | 32 +++ .../hooks/useLoadPluginFromUrl.ts | 9 + .../UserOverlayForm.component.test.tsx | 60 +++++ .../mapSingleClick/handleAliasResults.ts | 2 - .../mapSingleClick/handleReactionResults.ts | 2 - .../backgrounds/backgrounds.constants.ts | 1 + .../backgrounds/backgrounds.reducers.test.ts | 7 +- src/redux/backgrounds/backgrounds.thunks.ts | 23 +- src/redux/bioEntity/bioEntity.constants.ts | 3 + .../bioEntity/bioEntity.reducers.test.ts | 5 +- src/redux/bioEntity/bioEntity.thunks.test.ts | 20 ++ src/redux/bioEntity/bioEntity.thunks.ts | 69 ++++-- src/redux/chemicals/chemicals.constants.ts | 2 + .../chemicals/chemicals.reducers.test.ts | 5 +- src/redux/chemicals/chemicals.thunks.test.ts | 10 + src/redux/chemicals/chemicals.thunks.ts | 47 ++-- .../comparmentPathways.constants.ts | 2 + .../compartmentPathways.reducers.test.ts | 5 +- .../compartmentPathways.thunks.ts | 23 +- .../configuration/configuration.constants.ts | 4 + .../configuration/configuration.thunks.ts | 40 +++- src/redux/drawer/drawer.constants.ts | 4 + src/redux/drawer/drawer.thunks.ts | 53 +++-- src/redux/drugs/drugs.constants.ts | 2 + src/redux/drugs/drugs.reducers.test.ts | 5 +- src/redux/drugs/drugs.thunks.ts | 41 ++-- src/redux/export/export.constants.ts | 2 + src/redux/export/export.reducers.test.ts | 12 +- src/redux/export/export.thunks.ts | 52 +++-- src/redux/map/map.constants.ts | 8 + src/redux/map/map.thunks.ts | 70 ++++-- .../middlewares/error.middleware.test.ts | 87 +++++++ src/redux/middlewares/error.middleware.ts | 34 +++ src/redux/models/models.constants.ts | 1 + src/redux/models/models.reducers.test.ts | 5 +- src/redux/models/models.thunks.ts | 19 +- .../overlayBioEntity.constants.ts | 4 + .../overlayBioEntity.thunk.ts | 74 ++++-- src/redux/overlays/overlays.constants.ts | 9 + src/redux/overlays/overlays.reducers.test.ts | 5 +- src/redux/overlays/overlays.thunks.ts | 213 +++++++++++------- src/redux/plugins/plugins.constants.ts | 4 + src/redux/plugins/plugins.reducers.test.ts | 4 +- src/redux/plugins/plugins.thunks.ts | 117 ++++++---- src/redux/project/project.constants.ts | 1 + src/redux/project/project.reducers.test.ts | 5 +- src/redux/project/project.thunks.ts | 18 +- .../publications/publications.constatns.ts | 1 + src/redux/publications/publications.thunks.ts | 19 +- src/redux/reactions/reactions.constants.ts | 2 + src/redux/reactions/reactions.thunks.ts | 24 +- src/redux/search/search.constants.ts | 2 + src/redux/search/search.thunks.ts | 30 ++- src/redux/statistics/statistics.constants.ts | 1 + .../statistics/statistics.reducers.test.ts | 5 +- src/redux/statistics/statistics.thunks.ts | 18 +- src/redux/store.ts | 3 +- src/redux/user/user.thunks.ts | 26 ++- .../map/triggerSearch/searchByQuery.ts | 3 - src/shared/Toast/Toast.component.test.tsx | 29 +++ src/shared/Toast/Toast.component.tsx | 30 +++ src/shared/Toast/index.ts | 1 + src/types/store.ts | 3 + .../getErrorMessage.constants.ts | 11 + .../getErrorMessage/getErrorMessage.test.ts | 34 +++ .../getErrorMessage.test.utils.ts | 15 ++ src/utils/getErrorMessage/getErrorMessage.ts | 29 +++ .../getErrorMessage/getErrorMessage.types.ts | 3 + .../getErrorMessage.utils.test.ts | 22 ++ .../getErrorMessage/getErrorMessage.utils.ts | 14 ++ src/utils/getErrorMessage/index.ts | 1 + src/utils/showToast.test.tsx | 21 ++ src/utils/showToast.tsx | 13 ++ 81 files changed, 1395 insertions(+), 355 deletions(-) create mode 100644 src/components/Map/Drawer/AvailablePluginsDrawer/AvailablePluginsDrawer.constants.ts create mode 100644 src/redux/backgrounds/backgrounds.constants.ts create mode 100644 src/redux/chemicals/chemicals.constants.ts create mode 100644 src/redux/drugs/drugs.constants.ts create mode 100644 src/redux/export/export.constants.ts create mode 100644 src/redux/middlewares/error.middleware.test.ts create mode 100644 src/redux/middlewares/error.middleware.ts create mode 100644 src/redux/models/models.constants.ts create mode 100644 src/redux/overlayBioEntity/overlayBioEntity.constants.ts create mode 100644 src/redux/project/project.constants.ts create mode 100644 src/redux/publications/publications.constatns.ts create mode 100644 src/redux/statistics/statistics.constants.ts create mode 100644 src/shared/Toast/Toast.component.test.tsx create mode 100644 src/shared/Toast/Toast.component.tsx create mode 100644 src/shared/Toast/index.ts create mode 100644 src/types/store.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.constants.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.test.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.test.utils.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.types.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.utils.test.ts create mode 100644 src/utils/getErrorMessage/getErrorMessage.utils.ts create mode 100644 src/utils/getErrorMessage/index.ts create mode 100644 src/utils/showToast.test.tsx create mode 100644 src/utils/showToast.tsx diff --git a/package-lock.json b/package-lock.json index a1966db0..18eaa14c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,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", @@ -12410,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", @@ -22963,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 f44f22ef..54dfdef6 100644 --- a/package.json +++ b/package.json @@ -49,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", diff --git a/src/components/AppWrapper/AppWrapper.component.tsx b/src/components/AppWrapper/AppWrapper.component.tsx index 3b59e82b..5014ee69 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 f0934d79..990e4fbd 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 63a98d5e..a8d02776 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 00000000..e628e141 --- /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 0ea19da0..f54c8f5b 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 45434730..ebce11ec 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 e6a8094d..1b347d6e 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -13,11 +13,14 @@ 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,6 +166,35 @@ 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()`; diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index 93d0f014..f95bdf7e 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -3,8 +3,11 @@ 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; @@ -49,6 +52,12 @@ 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/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx index 474934d8..b9966147 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/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts index 40874a2d..bab0bd62 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleAliasResults.ts @@ -29,7 +29,5 @@ export const handleAliasResults = if (hasFitBounds) { searchFitBounds(fitBoundsZoom); } - }).catch(() => { - // TODO to discuss manage state of failure }); }; diff --git a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts index 24332182..bcadc4a1 100644 --- a/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts +++ b/src/components/Map/MapViewer/utils/listeners/mapSingleClick/handleReactionResults.ts @@ -41,7 +41,5 @@ export const handleReactionResults = if (hasFitBounds) { searchFitBounds(fitBoundsZoom); } - }).catch(() => { - // TODO to discuss manage state of failure }); }; diff --git a/src/redux/backgrounds/backgrounds.constants.ts b/src/redux/backgrounds/backgrounds.constants.ts new file mode 100644 index 00000000..0fa103f2 --- /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 9b99161a..4f70938f 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 3741a1c8..18a0c56b 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 3c109ae8..b719afde 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 52062b40..78482731 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.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index 9757ab4b..f2b54f3a 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -55,6 +55,26 @@ 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 () => { diff --git a/src/redux/bioEntity/bioEntity.thunks.ts b/src/redux/bioEntity/bioEntity.thunks.ts index 32185755..20c312f5 100644 --- a/src/redux/bioEntity/bioEntity.thunks.ts +++ b/src/redux/bioEntity/bioEntity.thunks.ts @@ -5,15 +5,21 @@ 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,30 +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<BioEntityContent[]> => { - 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 bioEntityContentsActions = (await Promise.all( - asyncGetBioEntityFunctions, - )) as GetMultiBioEntityActions; + const bioEntityContents = bioEntityContentsActions + .map(bioEntityContentsAction => bioEntityContentsAction.payload || []) + .flat(); - const bioEntityContents = bioEntityContentsActions - .map(bioEntityContentsAction => bioEntityContentsAction.payload || []) - .flat(); + return bioEntityContents; + } catch (error) { + const errorMessage = getErrorMessage({ + error, + prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX, + }); - return bioEntityContents; + return rejectWithValue(errorMessage); + } }, ); diff --git a/src/redux/chemicals/chemicals.constants.ts b/src/redux/chemicals/chemicals.constants.ts new file mode 100644 index 00000000..3311122e --- /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 59a87b20..9b66afe3 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.thunks.test.ts b/src/redux/chemicals/chemicals.thunks.test.ts index 88926792..73cb2d0e 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 0afc94db..5b25951d 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 2bf4d519..cc54322a 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 037eefa2..94d445b8 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 e0d69617..b8143dbe 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 bcf3c906..9a2762d9 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 012e8b1c..8b4db5ea 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 33890a23..bef3fd3d 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.thunks.ts b/src/redux/drawer/drawer.thunks.ts index 7e60536f..f08955f9 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/drugs/drugs.constants.ts b/src/redux/drugs/drugs.constants.ts new file mode 100644 index 00000000..c31cff0f --- /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 3ad034db..f6e6c671 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.thunks.ts b/src/redux/drugs/drugs.thunks.ts index 30074e33..6bd3b532 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 00000000..8f464a4d --- /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 894ee98a..778aca4f 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 3a25cd8c..bf49e0b0 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 27a63ef2..70126c71 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.ts b/src/redux/map/map.thunks.ts index e9c3c7ae..05b675bb 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 00000000..7662714e --- /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 00000000..0ba6a0f7 --- /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 00000000..8855ad36 --- /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 1677afdf..fc6e0af1 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 5880ddcd..2e0fd68d 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 00000000..5b9a636a --- /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 1fed7a97..30222f30 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 dda564cd..fa7efe22 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 f774974f..38b4652f 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 63316421..4b1fa460 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 cdd616c5..812e8503 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 edc59097..885f4212 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 afe657f9..1d69a6cb 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 00000000..46757eb5 --- /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 744f7252..f3c00776 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 649f867d..2ef8de82 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 00000000..2f5ea0a3 --- /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 e95a1d06..f50b611e 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 a7b9f099..c5f51f9d 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 dbbf7fc7..fde44e5d 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 96277846..5c13f6fd 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 05debcd0..078947d3 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 00000000..8b28b57b --- /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 af16b53b..6d620db1 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 df5b6589..1a683a4e 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 2391ea22..3fb15d8b 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 95c567c2..e6241bb6 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/map/triggerSearch/searchByQuery.ts b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts index 4f48c0a7..e29c50f9 100644 --- a/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts +++ b/src/services/pluginsManager/map/triggerSearch/searchByQuery.ts @@ -19,9 +19,6 @@ export const searchByQuery = ( if (hasFitBounds) { searchFitBounds(); } - }) - .catch(() => { - // TODO to discuss manage state of failure }); displaySearchDrawerWithSelectedDefaultTab(searchValues); diff --git a/src/shared/Toast/Toast.component.test.tsx b/src/shared/Toast/Toast.component.test.tsx new file mode 100644 index 00000000..610df96f --- /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 00000000..02e1d965 --- /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 00000000..c28ae189 --- /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 00000000..c513f8db --- /dev/null +++ b/src/types/store.ts @@ -0,0 +1,3 @@ +export type ThunkConfig = { + rejectValue: string; +}; diff --git a/src/utils/getErrorMessage/getErrorMessage.constants.ts b/src/utils/getErrorMessage/getErrorMessage.constants.ts new file mode 100644 index 00000000..00b84d70 --- /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 00000000..2f9f2b7c --- /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 00000000..cb7983e5 --- /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 00000000..20073f02 --- /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 00000000..33e84355 --- /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 00000000..8406dcaf --- /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 00000000..46a46ee4 --- /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 00000000..50ca645d --- /dev/null +++ b/src/utils/getErrorMessage/index.ts @@ -0,0 +1 @@ +export { getErrorMessage } from './getErrorMessage'; diff --git a/src/utils/showToast.test.tsx b/src/utils/showToast.test.tsx new file mode 100644 index 00000000..01f65ade --- /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 00000000..c7ea4329 --- /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} /> + )); +}; -- GitLab