diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx index aaf74bf636298beca86281a2d6c9cb9e657eecb4..b8015a4bda66ceedeb92c00b3c2f3276d48bb871 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/EditOverlayModal.component.test.tsx @@ -46,6 +46,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -63,6 +64,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -83,6 +85,7 @@ describe('EditOverlayModal - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -91,6 +94,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -116,6 +120,7 @@ describe('EditOverlayModal - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -124,6 +129,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -150,6 +156,7 @@ describe('EditOverlayModal - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -158,6 +165,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -183,6 +191,7 @@ describe('EditOverlayModal - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -191,6 +200,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -219,6 +229,7 @@ describe('EditOverlayModal - component', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); diff --git a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts index 8109120a0830f36538a1e86e4b83459f43c3a0dc..172a10260690e968f93774434a5223a1d57c0ccc 100644 --- a/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts +++ b/src/components/FunctionalArea/Modal/EditOverlayModal/hooks/useEditOverlay.test.ts @@ -14,6 +14,7 @@ describe('useEditOverlay', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -22,6 +23,7 @@ describe('useEditOverlay', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -48,6 +50,7 @@ describe('useEditOverlay', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -56,6 +59,7 @@ describe('useEditOverlay', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -85,6 +89,7 @@ describe('useEditOverlay', () => { error: DEFAULT_ERROR, login: null, role: 'user', + userData: null, }, modal: { isOpen: true, @@ -93,6 +98,7 @@ describe('useEditOverlay', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -118,6 +124,7 @@ describe('useEditOverlay', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, modal: { isOpen: true, @@ -126,6 +133,7 @@ describe('useEditOverlay', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); @@ -152,6 +160,7 @@ describe('useEditOverlay', () => { error: DEFAULT_ERROR, login: null, role: 'user', + userData: null, }, modal: { isOpen: true, @@ -160,6 +169,7 @@ describe('useEditOverlay', () => { editOverlayState: overlayFixture, molArtState: {}, overviewImagesState: {}, + errorReportState: {}, }, }); diff --git a/src/components/FunctionalArea/Modal/ErrorReportModal/ErroReportModal.component.tsx b/src/components/FunctionalArea/Modal/ErrorReportModal/ErroReportModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bfc5c1bb1d40793ea528fb920f882e3a2976f2dc --- /dev/null +++ b/src/components/FunctionalArea/Modal/ErrorReportModal/ErroReportModal.component.tsx @@ -0,0 +1,170 @@ +/* eslint-disable jsx-a11y/label-has-associated-control */ +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { Button } from '@/shared/Button'; +import { Input } from '@/shared/Input'; +import React from 'react'; +import { currentErrorDataSelector } from '@/redux/modal/modal.selector'; +import { sendReport } from '@/utils/error-report/sendErrorReport'; +import { ONE_THOUSAND } from '@/constants/common'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { closeModal } from '@/redux/modal/modal.slice'; + +export const ErrorReportModal: React.FC = () => { + const dispatch = useAppDispatch(); + const errorData = useAppSelector(currentErrorDataSelector); + + function getValue(nullableVale: string | null | undefined): string { + if (!nullableVale) { + return ''; + } + return nullableVale; + } + + const url = getValue(errorData?.url); + const browser = getValue(errorData?.browser); + const comment = getValue(errorData?.comment); + const login = getValue(errorData?.login); + const email = getValue(errorData?.email); + const javaStacktrace = getValue(errorData?.javaStacktrace); + const stacktrace = getValue(errorData?.stacktrace); + const version = getValue(errorData?.version); + const message = getValue(errorData?.message); + const timestamp = errorData ? errorData.timestamp : Math.floor(+new Date() / ONE_THOUSAND); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [errorDataToSend, setValue] = React.useState({ + url, + browser, + comment, + login, + email, + javaStacktrace, + stacktrace, + version, + timestamp, + message, + }); + + const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { + const { name, checked } = e.target; + let { value } = e.target; + setValue(errorData2 => { + if (name === 'login') { + const loginValue = checked ? login : ''; + const emailValue = checked ? email : ''; + + return { ...errorData2, login: loginValue, email: emailValue }; + } + if (name === 'url') { + value = checked ? url : ''; + } + if (name === 'browser') { + value = checked ? browser : ''; + } + if (name === 'version') { + value = checked ? version : ''; + } + return { ...errorData2, [name]: value }; + }); + }; + + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { + e.preventDefault(); + await dispatch(sendReport(errorDataToSend)); + dispatch(closeModal()); + }; + + return ( + <div className="w-[800px] border border-t-[#E1E0E6] bg-white"> + <form onSubmit={handleSubmit} className="p-4"> + <p className="my-4 font-bold"> {errorDataToSend.message}</p> + <p className="my-4"> + If you agree to submit the following information to the minerva maintainers please uncheck + all boxes that might contain sensitive data. + </p> + <label className="mb-1 mt-4 block text-sm" htmlFor="comment"> + <span className="font-semibold">Add comment</span> (max 1000 characters): + </label> + <Input + type="textarea" + name="comment" + id="comment" + onChange={handleChange} + value={errorDataToSend.comment} + className="mb-4 text-sm font-medium text-font-400" + /> + <div className="grid grid-cols-[15px_743px] gap-2"> + <label className="flex-1 text-sm font-semibold" htmlFor="url"> + <Input + styleVariant="primaryWithoutFull" + id="url" + name="url" + type="checkbox" + onChange={handleChange} + checked={errorDataToSend.url !== ''} + className="flex-1 align-bottom text-sm font-semibold" + /> + </label> + <label className="block border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + {url} + </label> + <label className="block text-sm font-semibold"> + <Input + styleVariant="primaryWithoutFull" + id="browser" + name="browser" + type="checkbox" + onChange={handleChange} + checked={errorDataToSend.browser !== ''} + className="flex-1 align-bottom text-sm font-semibold" + /> + </label> + <label className="block border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + {browser} + </label> + <label className="block text-sm font-semibold"> + <Input + id="login" + name="login" + type="checkbox" + onChange={handleChange} + checked={errorDataToSend.login !== ''} + styleVariant="primaryWithoutFull" + className="flex-1 align-bottom text-sm font-semibold" + /> + </label> + <label className="block border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + {login} (email: {email}) + </label> + <label className="block text-sm font-semibold"> + <Input + styleVariant="primaryWithoutFull" + id="version" + name="version" + type="checkbox" + onChange={handleChange} + checked={errorDataToSend.version !== ''} + className="flex-1 align-bottom text-sm font-semibold" + /> + </label> + <label className="block border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + Minerva {version} + </label> + </div> + + <div className="my-4 block max-h-20 overflow-auto border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + Stacktrace: + <pre>{errorDataToSend.stacktrace}</pre> + </div> + <div className="my-4 block max-h-20 overflow-auto border border-transparent bg-cultured px-2 py-2.5 text-sm font-medium outline-none hover:border-greyscale-600 focus:border-greyscale-600"> + Backend stacktrace: + <pre>{errorDataToSend.javaStacktrace}</pre> + </div> + + <Button type="submit" className="w-full justify-center text-base font-medium"> + Submit + </Button> + </form> + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/ErrorReportModal/index.ts b/src/components/FunctionalArea/Modal/ErrorReportModal/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..016741c087dae51bd3f16b61b5acfc5d07ef564d --- /dev/null +++ b/src/components/FunctionalArea/Modal/ErrorReportModal/index.ts @@ -0,0 +1 @@ +export { ErrorReportModal } from './ErroReportModal.component'; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx index ca8aef6cc2e75352bf0538dce2681bf63a7bb06a..3887053892dda1e0ff1344fbb2705fb8662cf58b 100644 --- a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.test.tsx @@ -10,8 +10,8 @@ import { apiPath } from '@/redux/apiPath'; import { HttpStatusCode } from 'axios'; import { loginFixture } from '@/models/fixtures/loginFixture'; import { overlaysFixture } from '@/models/fixtures/overlaysFixture'; -import { userPrivilegesFixture } from '@/models/fixtures/userPrivilegesFixture'; import { MODAL_INITIAL_STATE_MOCK } from '@/redux/modal/modal.mock'; +import { userFixture } from '@/models/fixtures/userFixture'; import { LoginModal } from './LoginModal.component'; const mockedAxiosClient = mockNetworkResponse(); @@ -71,9 +71,7 @@ describe('LoginModal - component', () => { }); it('should fetch user overlays when login is successful', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); - mockedAxiosClient - .onGet(apiPath.userPrivileges(loginFixture.login)) - .reply(HttpStatusCode.Ok, userPrivilegesFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); mockedAxiosClient .onGet( apiPath.getAllUserOverlaysByCreatorQuery({ @@ -103,8 +101,8 @@ describe('LoginModal - component', () => { }); it('should display loggedInMenuModal after successful login as admin', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); - mockedAxiosClient.onGet(apiPath.userPrivileges(loginFixture.login)).reply(HttpStatusCode.Ok, { - ...userPrivilegesFixture, + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, { + ...userFixture, privileges: [ { privilegeType: 'IS_ADMIN', @@ -142,8 +140,8 @@ describe('LoginModal - component', () => { }); it('should display loggedInMenuModal after successful login as curator', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); - mockedAxiosClient.onGet(apiPath.userPrivileges(loginFixture.login)).reply(HttpStatusCode.Ok, { - ...userPrivilegesFixture, + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, { + ...userFixture, privileges: [ { privilegeType: 'IS_CURATOR', @@ -181,9 +179,7 @@ describe('LoginModal - component', () => { }); it('should close modal after successful login as user', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); - mockedAxiosClient - .onGet(apiPath.userPrivileges(loginFixture.login)) - .reply(HttpStatusCode.Ok, userPrivilegesFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); mockedAxiosClient .onGet( apiPath.getAllUserOverlaysByCreatorQuery({ diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 3fb3fb373e77be1919afda00992f59caa825a57f..d913abb8ec51661208b50417a1fa090f1f3fcded 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -3,6 +3,7 @@ import { modalSelector } from '@/redux/modal/modal.selector'; import dynamic from 'next/dynamic'; import { EditOverlayModal } from './EditOverlayModal'; import { LoginModal } from './LoginModal'; +import { ErrorReportModal } from './ErrorReportModal'; import { ModalLayout } from './ModalLayout'; import { OverviewImagesModal } from './OverviewImagesModal'; import { PublicationsModal } from './PublicationsModal'; @@ -33,6 +34,11 @@ export const Modal = (): React.ReactNode => { <LoginModal /> </ModalLayout> )} + {isOpen && modalName === 'error-report' && ( + <ModalLayout> + <ErrorReportModal /> + </ModalLayout> + )} {isOpen && modalName === 'publications' && <PublicationsModal />} {isOpen && modalName === 'edit-overlay' && ( <ModalLayout> diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index d5979910d7b068b1dfdea7f79807f9083176c361..b834d91c802d23bc93587e5c91f93cff6f481832 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -28,11 +28,19 @@ export const ModalLayout = ({ children }: ModalLayoutProps): JSX.Element => { className={twMerge( 'flex h-5/6 w-10/12 flex-col overflow-hidden rounded-lg', modalName === 'login' && 'h-auto w-[400px]', + modalName === 'error-report' && 'h-auto w-[800px]', ['edit-overlay', 'logged-in-menu'].includes(modalName) && 'h-auto w-[432px]', )} > <div className="flex items-center justify-between bg-white p-[24px] text-xl"> - <div>{modalTitle}</div> + {modalName === 'error-report' && ( + <div className="font-bold text-red-500"> + <Icon name="info" className={twMerge('mr-4 fill-red-500')} /> + {modalTitle} + </div> + )} + {modalName !== 'error-report' && <div> {modalTitle} </div>} + {modalName !== 'logged-in-menu' && ( <button type="button" onClick={handleCloseModal} aria-label="close button"> <Icon name="close" className="fill-font-500" /> diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts index eb9a4b10c26bcb879b2e9066173d1e728f355e1c..ffa8191ae9167d7dd8bf2cdae4d0e01e8cc9ac53 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.test.ts @@ -1,8 +1,8 @@ import { apiPath } from '@/redux/apiPath'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { showToast } from '@/utils/showToast'; import { PUBLICATIONS_DEFAULT_SEARCH_FIRST_10_MOCK } from '../../../../../../models/mocks/publicationsResponseMock'; -import { showToast } from '../../../../../../utils/showToast'; import { getBasePublications } from './getBasePublications'; const mockedAxiosClient = mockNetworkResponse(); diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts index 7725df112b3f7d8f4d5464a52ba0caee6e29557c..d149d03191849ea5a288b2c2c9cde031bed79ed7 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/PublicationsModalLayout/utils/getBasePublications.ts @@ -3,9 +3,9 @@ import { apiPath } from '@/redux/apiPath'; import { PUBLICATIONS_FETCHING_ERROR_PREFIX } from '@/redux/publications/publications.constatns'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { Publication, PublicationsResponse } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { getErrorMessage } from '@/utils/getErrorMessage'; import { showToast } from '@/utils/showToast'; -import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; interface Args { length: number; @@ -26,7 +26,6 @@ export const getBasePublications = async ({ length }: Args): Promise<Publication type: 'error', message: errorMessage, }); - return []; } }; diff --git a/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts index ceaf1b8a355e43d7c29c4c54eb8635c1f1f3233c..d7b14ea9e150beb61b66980c2f8f6f516603aea0 100644 --- a/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts +++ b/src/components/FunctionalArea/Modal/PublicationsModal/utils/fetchElementData.ts @@ -4,9 +4,9 @@ import { apiPath } from '@/redux/apiPath'; import { BIO_ENTITY_FETCHING_ERROR_PREFIX } from '@/redux/bioEntity/bioEntity.constants'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse } from '@/types/models'; -import { getErrorMessage } from '@/utils/getErrorMessage'; -import { showToast } from '@/utils/showToast'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { showToast } from '@/utils/showToast'; +import { getErrorMessage } from '@/utils/getErrorMessage'; export const fetchElementData = async ( searchQuery: string, diff --git a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx index 979254a81f0e7e10adbf1e92001d89da096a3c21..6198869f43d302eedd7269fe183e1622eed445a3 100644 --- a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx @@ -126,6 +126,7 @@ describe('AuthenticatedUser component', () => { loading: 'succeeded', login: null, role: null, + userData: null, }); }); 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 208136f0a71e569aad40d317399ece1a53c2b4f8..162b45451915d0466af4a83a2d768ae9daffc7a9 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/LoadPluginFromUrl.component.test.tsx @@ -123,6 +123,7 @@ describe('LoadPluginFromUrl - component', () => { global.URL.canParse = jest.fn().mockReturnValue(true); renderComponent(); + const input = screen.getByTestId('load-plugin-input-url'); expect(input).toBeVisible(); diff --git a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts index 54276c16149fd733bf0dddae65a8db45f21624a6..5616dca84f7348306ded4a590f3de408146666f4 100644 --- a/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts +++ b/src/components/Map/Drawer/AvailablePluginsDrawer/LoadPluginFromUrl/hooks/useLoadPluginFromUrl.ts @@ -1,9 +1,9 @@ import { PluginsManager } from '@/services/pluginsManager'; -import { showToast } from '@/utils/showToast'; import axios from 'axios'; import { ChangeEvent, useMemo, useState, KeyboardEvent } from 'react'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { ENTER_KEY_CODE } from '@/constants/common'; +import { showToast } from '@/utils/showToast'; +import { getErrorMessage } from '@/utils/getErrorMessage'; import { PLUGIN_LOADING_ERROR_PREFIX } from '../../AvailablePluginsDrawer.constants'; type UseLoadPluginReturnType = { 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 9f9e6faab7f10ace0507ceafdeea51385424a484..5248abdf5b3c4fb6247b99aeca83ce6f81876517 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlayForm/UserOverlayForm.component.test.tsx @@ -224,6 +224,7 @@ describe('UserOverlayForm - Component', () => { login: 'test', loading: 'succeeded', role: 'user', + userData: null, }, project: { data: projectFixture, @@ -284,6 +285,7 @@ describe('UserOverlayForm - Component', () => { login: 'test', loading: 'succeeded', role: 'user', + userData: null, }, project: { data: projectFixture, diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx index 360c48a5e3e1fed4fdb8713a7be878c2fd9b5bd5..82c38758c84f11afeded8f09960e37938defc335 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlays.component.test.tsx @@ -29,6 +29,7 @@ describe('UserOverlays component', () => { error: { name: '', message: '' }, login: null, role: 'user', + userData: null, }, }); @@ -43,6 +44,7 @@ describe('UserOverlays component', () => { error: { name: '', message: '' }, login: 'test', role: 'user', + userData: null, }, }); @@ -56,6 +58,7 @@ describe('UserOverlays component', () => { error: { name: '', message: '' }, login: 'test', role: 'user', + userData: null, }, }); diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx index 4dc6b072685ac22f53e7585e86477c9c08f40a5c..3e3df316d994e237f0f3ed6be3010e332467816e 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/UserOverlaysWithoutGroup.component.test.tsx @@ -45,6 +45,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -82,6 +83,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -104,6 +106,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -148,6 +151,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -186,6 +190,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -237,6 +242,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -279,6 +285,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -328,6 +335,7 @@ describe('UserOverlaysWithoutGroup - component', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, diff --git a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts index 2f1b15654e86f62f369d013710bc9dcebad3adaf..e7b61b7bb654d8ccc8074d56a5f213612ec13399 100644 --- a/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts +++ b/src/components/Map/Drawer/OverlaysDrawer/UserOverlays/UserOverlaysWithoutGroup/hooks/useUserOverlays.test.ts @@ -21,6 +21,7 @@ describe('useUserOverlays', () => { error: DEFAULT_ERROR, login: null, role: 'user', + userData: null, }, overlays: OVERLAYS_INITIAL_STATE_MOCK, }); @@ -56,6 +57,7 @@ describe('useUserOverlays', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -96,6 +98,7 @@ describe('useUserOverlays', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, @@ -140,6 +143,7 @@ describe('useUserOverlays', () => { error: DEFAULT_ERROR, login: 'test', role: 'user', + userData: null, }, overlays: { ...OVERLAYS_INITIAL_STATE_MOCK, diff --git a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts index a636c57f4210f5ea5564bc93baa8e3bc37e0aecd..d98b480fe8c9e2494e841aad28dd37702c1b9c8b 100644 --- a/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts +++ b/src/components/Map/MapViewer/utils/config/useOlMapTileLayer.ts @@ -46,7 +46,7 @@ export const useOlMapTileLayer = (): BaseLayer => { url: sourceUrl, maxZoom: mapSize.maxZoom, minZoom: mapSize.minZoom, - tileSize: mapSize.tileSize, + tileSize: Math.max(mapSize.tileSize, 1), wrapX: OPTIONS.wrapXInTileLayer, }), [sourceUrl, mapSize.maxZoom, mapSize.minZoom, mapSize.tileSize], diff --git a/src/constants/common.ts b/src/constants/common.ts index 33920fa680973ef0fb027bef64903a93af288750..c3a1b1d09a4c675a5cca281cd1a02f33515bb2bd 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -16,6 +16,7 @@ export const NOOP = (): void => {}; export const ONE_DECIMAL = 0.1; export const ONE_HUNDRED = 100; +export const ONE_THOUSAND = 1000; export const EMPTY_ARRAY_STRING = '[]'; export const ZOOM_FACTOR = 2.0; // Zoom factor indicating doubling the distance for each zoom level diff --git a/src/models/fixtures/javaStacktraceFixture.ts b/src/models/fixtures/javaStacktraceFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..71ed655ec26ffdb1e62b2b7e128d65a6f50a9a4a --- /dev/null +++ b/src/models/fixtures/javaStacktraceFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; + +export const javaStacktraceFixture = createFixture(javaStacktraceSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/userPrivilegesFixture.ts b/src/models/fixtures/userFixture.ts similarity index 58% rename from src/models/fixtures/userPrivilegesFixture.ts rename to src/models/fixtures/userFixture.ts index fcddc70ca1ea84b9a495a447d8760f6606529c8d..6a44bb93a6f6915a8989c622a5c635fb81b923b2 100644 --- a/src/models/fixtures/userPrivilegesFixture.ts +++ b/src/models/fixtures/userFixture.ts @@ -1,9 +1,9 @@ import { ZOD_SEED } from '@/constants'; // eslint-disable-next-line import/no-extraneous-dependencies import { createFixture } from 'zod-fixture'; -import { userPrivilegesSchema } from '../userPrivilegesSchema'; +import { userSchema } from '@/models/userSchema'; -export const userPrivilegesFixture = createFixture(userPrivilegesSchema, { +export const userFixture = createFixture(userSchema, { seed: ZOD_SEED, array: { min: 2, max: 2 }, }); diff --git a/src/models/javaStacktraceSchema.ts b/src/models/javaStacktraceSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f9bb8c4db545a90b59c11070f745c4a4b92c70e --- /dev/null +++ b/src/models/javaStacktraceSchema.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +export const javaStacktraceSchema = z.object({ + id: z.string(), + content: z.string(), + createdAt: z.string(), +}); diff --git a/src/models/userPrivilegesSchema.ts b/src/models/userPrivilegesSchema.ts index 28115b1a4c9ff883ac86d0f9683cc5ef139e5949..06a491461d1e4d5f2463ab5b0b13e664064b0510 100644 --- a/src/models/userPrivilegesSchema.ts +++ b/src/models/userPrivilegesSchema.ts @@ -1,10 +1,6 @@ import { z } from 'zod'; -const userPrivilege = z.object({ +export const userPrivilegeSchema = z.object({ privilegeType: z.string(), objectId: z.string().nullable(), }); - -export const userPrivilegesSchema = z.object({ - privileges: z.array(userPrivilege), -}); diff --git a/src/models/userSchema.ts b/src/models/userSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..aaf1ffd817abb3d5e3ff80bf328e37c45595e3da --- /dev/null +++ b/src/models/userSchema.ts @@ -0,0 +1,22 @@ +import { z } from 'zod'; +import { userPrivilegeSchema } from '@/models/userPrivilegesSchema'; +import { ZERO } from '@/constants/common'; + +export const userSchema = z.object({ + id: z.number().int().gt(ZERO), + login: z.string(), + name: z.string(), + surname: z.string(), + email: z.string().email().nullable(), + orcidId: z.string().nullable(), + minColor: z.string().nullable(), + neutralColor: z.string().nullable(), + simpleColor: z.string().nullable(), + removed: z.boolean(), + termsOfUseConsent: z.boolean(), + privileges: z.array(userPrivilegeSchema), + active: z.boolean(), + confirmed: z.boolean(), + ldapAccountAvailable: z.boolean(), + lastActive: z.string().nullable(), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index d5a42e1ccc9f53ad68f2f0019108fb123d20d571..b1b26ff3c98c97eede628a3442c1659bb3775837 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -97,6 +97,9 @@ export const apiPath = { getAllPlugins: (): string => `/plugins/`, getSubmapConnections: (): string => `projects/${PROJECT_ID}/submapConnections/`, logout: (): string => `doLogout`, + user: (login: string): string => `users/${login}`, + getStacktrace: (code: string): string => `stacktrace/${code}`, + submitError: (): string => `minervanet/submitError`, userPrivileges: (login: string): string => `users/${login}?columns=privileges`, getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`, }; diff --git a/src/redux/backgrounds/backgrounds.reducers.test.ts b/src/redux/backgrounds/backgrounds.reducers.test.ts index 4f70938fa505c0da1813ec8df6cf16e2f9fe4ad8..dd13a7711f9d39428ffa0ea0d3b22f90a448a962 100644 --- a/src/redux/backgrounds/backgrounds.reducers.test.ts +++ b/src/redux/backgrounds/backgrounds.reducers.test.ts @@ -6,6 +6,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import { apiPath } from '../apiPath'; import backgroundsReducer from './backgrounds.slice'; import { getAllBackgroundsByProjectId } from './backgrounds.thunks'; @@ -51,14 +52,14 @@ describe('backgrounds reducer', () => { .onGet(apiPath.getAllBackgroundsByProjectIdQuery(PROJECT_ID)) .reply(HttpStatusCode.NotFound, []); - const { type, payload } = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); + const action = await store.dispatch(getAllBackgroundsByProjectId(PROJECT_ID)); const { data, loading, error } = store.getState().backgrounds; - expect(type).toBe('backgrounds/getAllBackgroundsByProjectId/rejected'); + expect(action.type).toBe('backgrounds/getAllBackgroundsByProjectId/rejected'); expect(loading).toEqual('failed'); expect(error).toEqual({ message: '', name: '' }); expect(data).toEqual([]); - expect(payload).toBe( + expect(() => unwrapResult(action)).toThrow( "Failed to fetch backgrounds: The page you're looking for doesn't exist. Please verify the URL and try again.", ); }); diff --git a/src/redux/backgrounds/backgrounds.thunks.ts b/src/redux/backgrounds/backgrounds.thunks.ts index 18a0c56bcfed6861ffabd3fb944378930dad13c4..d2ce1949ef25fd4f9768f23e63e556925be48b2b 100644 --- a/src/redux/backgrounds/backgrounds.thunks.ts +++ b/src/redux/backgrounds/backgrounds.thunks.ts @@ -4,14 +4,14 @@ 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 { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { BACKGROUNDS_FETCHING_ERROR_PREFIX } from './backgrounds.constants'; export const getAllBackgroundsByProjectId = createAsyncThunk<MapBackground[], string, ThunkConfig>( 'backgrounds/getAllBackgroundsByProjectId', - async (projectId: string, { rejectWithValue }) => { + async (projectId: string) => { try { const response = await axiosInstance.get<MapBackground[]>( apiPath.getAllBackgroundsByProjectIdQuery(projectId), @@ -21,9 +21,7 @@ export const getAllBackgroundsByProjectId = createAsyncThunk<MapBackground[], st return isDataValid ? response.data : []; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: BACKGROUNDS_FETCHING_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: BACKGROUNDS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/bioEntity/bioEntity.reducers.test.ts b/src/redux/bioEntity/bioEntity.reducers.test.ts index d48998d83951e3289f1da610ff3f62915a5c3281..07af4ebe2cc8d18f49b353fe759039125617c1cf 100644 --- a/src/redux/bioEntity/bioEntity.reducers.test.ts +++ b/src/redux/bioEntity/bioEntity.reducers.test.ts @@ -7,6 +7,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import bioEntityContentsReducer from './bioEntity.slice'; import { getBioEntity } from './bioEntity.thunks'; import { BioEntityContentsState } from './bioEntity.types'; @@ -14,6 +15,8 @@ import { BioEntityContentsState } from './bioEntity.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'park7'; +jest.mock('../../utils/error-report/errorReporting'); + const INITIAL_STATE: BioEntityContentsState = { data: [], loading: 'idle', @@ -77,7 +80,7 @@ describe('bioEntity reducer', () => { ) .reply(HttpStatusCode.NotFound, bioEntityResponseFixture); - const { type, payload } = await store.dispatch( + const action = await store.dispatch( getBioEntity({ searchQuery: SEARCH_QUERY, isPerfectMatch: false, @@ -85,14 +88,14 @@ describe('bioEntity reducer', () => { ); const { data } = store.getState().bioEntity; - const bioEnityWithSearchElement = data.find( + const bioEntityWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); - expect(type).toBe('project/getBioEntityContents/rejected'); - expect(payload).toBe( + expect(action.type).toBe('project/getBioEntityContents/rejected'); + expect(() => unwrapResult(action)).toThrow( "Failed to fetch bio entity: The page you're looking for doesn't exist. Please verify the URL and try again.", ); - expect(bioEnityWithSearchElement).toEqual({ + expect(bioEntityWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, data: undefined, loading: 'failed', @@ -118,11 +121,11 @@ describe('bioEntity reducer', () => { ); const { data } = store.getState().bioEntity; - const bioEnityWithSearchElement = data.find( + const bioEntityWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); - expect(bioEnityWithSearchElement).toEqual({ + expect(bioEntityWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, data: undefined, loading: 'pending', @@ -132,11 +135,11 @@ describe('bioEntity reducer', () => { bioEntityContentsPromise.then(() => { const { data: dataPromiseFulfilled } = store.getState().bioEntity; - const bioEnityWithSearchElementFulfilled = dataPromiseFulfilled.find( + const bioEntityWithSearchElementFulfilled = dataPromiseFulfilled.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); - expect(bioEnityWithSearchElementFulfilled).toEqual({ + expect(bioEntityWithSearchElementFulfilled).toEqual({ searchQueryElement: SEARCH_QUERY, data: bioEntityResponseFixture.content, loading: 'succeeded', diff --git a/src/redux/bioEntity/bioEntity.thunks.test.ts b/src/redux/bioEntity/bioEntity.thunks.test.ts index f2b54f3abdd5a5e258c51bfc8d224a3877d02fdf..fb433fb0fb0b18033b883c6fa8213432f5f3260b 100644 --- a/src/redux/bioEntity/bioEntity.thunks.test.ts +++ b/src/redux/bioEntity/bioEntity.thunks.test.ts @@ -6,10 +6,13 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import contentsReducer from './bioEntity.slice'; import { getBioEntity, getMultiBioEntity } from './bioEntity.thunks'; import { BioEntityContentsState } from './bioEntity.types'; +jest.mock('../../utils/error-report/errorReporting'); + const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'park7'; @@ -65,13 +68,13 @@ describe('bioEntityContents thunks', () => { ) .reply(HttpStatusCode.NotFound, null); - const { payload } = await store.dispatch( + const action = await store.dispatch( getBioEntity({ searchQuery: SEARCH_QUERY, isPerfectMatch: false, }), ); - expect(payload).toEqual( + expect(() => unwrapResult(action)).toThrow( "Failed to fetch bio entity: The page you're looking for doesn't exist. Please verify the URL and try again.", ); }); diff --git a/src/redux/bioEntity/thunks/getBioEntity.ts b/src/redux/bioEntity/thunks/getBioEntity.ts index f8a302ee49a3d97ea240da6bbad3f282b0bc0355..bf54870a8db007d3785bb07606516fa293044a6f 100644 --- a/src/redux/bioEntity/thunks/getBioEntity.ts +++ b/src/redux/bioEntity/thunks/getBioEntity.ts @@ -4,9 +4,9 @@ import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse } from '@/types/models'; import { PerfectSearchParams } from '@/types/search'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getError } from '@/utils/error-report/getError'; import { addNumbersToEntityNumberData } from '../../entityNumber/entityNumber.slice'; import { BIO_ENTITY_FETCHING_ERROR_PREFIX } from '../bioEntity.constants'; @@ -18,10 +18,7 @@ export const getBioEntity = createAsyncThunk< ThunkConfig >( 'project/getBioEntityContents', - async ( - { searchQuery, isPerfectMatch, addNumbersToEntityNumber = true }, - { rejectWithValue, dispatch }, - ) => { + async ({ searchQuery, isPerfectMatch, addNumbersToEntityNumber = true }, { dispatch }) => { try { const response = await axiosInstanceNewAPI.get<BioEntityResponse>( apiPath.getBioEntityContentsStringWithQuery({ searchQuery, isPerfectMatch }), @@ -39,11 +36,7 @@ export const getBioEntity = createAsyncThunk< return isDataValidBioEnity ? response.data.content : undefined; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: BIO_ENTITY_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/bioEntity/thunks/getMultiBioEntity.ts b/src/redux/bioEntity/thunks/getMultiBioEntity.ts index c8a38ef3aa39a081bf0351fa166c074b509d5eaf..948156156324bc4cb4d9ff0e846f7dfc97fb8284 100644 --- a/src/redux/bioEntity/thunks/getMultiBioEntity.ts +++ b/src/redux/bioEntity/thunks/getMultiBioEntity.ts @@ -3,8 +3,8 @@ import type { AppDispatch, store } from '@/redux/store'; import { BioEntityContent } from '@/types/models'; import { PerfectMultiSearchParams } from '@/types/search'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; +import { getError } from '@/utils/error-report/getError'; import { addNumbersToEntityNumberData } from '../../entityNumber/entityNumber.slice'; import { MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX } from '../bioEntity.constants'; import { getBioEntity } from './getBioEntity'; @@ -20,7 +20,7 @@ export const getMultiBioEntity = createAsyncThunk< >( 'project/getMultiBioEntity', // eslint-disable-next-line consistent-return - async ({ searchQueries, isPerfectMatch }, { dispatch, rejectWithValue, getState }) => { + async ({ searchQueries, isPerfectMatch }, { dispatch, getState }) => { try { const asyncGetBioEntityFunctions = searchQueries.map(searchQuery => dispatch(getBioEntity({ searchQuery, isPerfectMatch, addNumbersToEntityNumber: false })), @@ -50,12 +50,7 @@ export const getMultiBioEntity = createAsyncThunk< return bioEntityContents; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/bioEntity/thunks/getSubmapConnectionsBioEntity.ts b/src/redux/bioEntity/thunks/getSubmapConnectionsBioEntity.ts index bfda62b8ed94d82ddb73c8991053aac611b5f725..e50189938bbe9b45e5fa38601d799c327785df2f 100644 --- a/src/redux/bioEntity/thunks/getSubmapConnectionsBioEntity.ts +++ b/src/redux/bioEntity/thunks/getSubmapConnectionsBioEntity.ts @@ -3,16 +3,15 @@ import { submapConnection } from '@/models/submapConnection'; import { apiPath } from '@/redux/apiPath'; import { axiosInstance, axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { BioEntityContent, BioEntityResponse, SubmapConnection } from '@/types/models'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { getError } from '@/utils/error-report/getError'; import { MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX } from '../bioEntity.constants'; export const getSubmapConnectionsBioEntity = createAsyncThunk<BioEntityContent[]>( 'project/getSubmapConnectionsBioEntity', - // eslint-disable-next-line consistent-return - async (_, { rejectWithValue }) => { + async () => { try { const response = await axiosInstance.get<SubmapConnection[]>(apiPath.getSubmapConnections()); @@ -39,12 +38,7 @@ export const getSubmapConnectionsBioEntity = createAsyncThunk<BioEntityContent[] return bioEntityContents; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: MULTI_BIO_ENTITY_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/chemicals/chemicals.reducers.test.ts b/src/redux/chemicals/chemicals.reducers.test.ts index 9b66afe34421293ef0e89ec2b02b45767c5c151c..a877f38ea214c68ba8609f4645d53c417c99c5c6 100644 --- a/src/redux/chemicals/chemicals.reducers.test.ts +++ b/src/redux/chemicals/chemicals.reducers.test.ts @@ -7,6 +7,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import chemicalsReducer from './chemicals.slice'; import { getChemicals } from './chemicals.thunks'; import { ChemicalsState } from './chemicals.types'; @@ -57,15 +58,15 @@ describe('chemicals reducer', () => { .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.NotFound, chemicalsFixture); - const { type, payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const action = await store.dispatch(getChemicals(SEARCH_QUERY)); const { data } = store.getState().chemicals; const chemicalsWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); - expect(type).toBe('project/getChemicals/rejected'); - expect(payload).toBe( + expect(action.type).toBe('project/getChemicals/rejected'); + expect(() => unwrapResult(action)).toThrow( "Failed to fetch chemicals: The page you're looking for doesn't exist. Please verify the URL and try again.", ); expect(chemicalsWithSearchElement).toEqual({ diff --git a/src/redux/chemicals/chemicals.thunks.test.ts b/src/redux/chemicals/chemicals.thunks.test.ts index 73cb2d0ef46659c702a4cda23ea6eda640bb871e..7a93ec084524cd7a3dd6ea61675088377a08b789 100644 --- a/src/redux/chemicals/chemicals.thunks.test.ts +++ b/src/redux/chemicals/chemicals.thunks.test.ts @@ -6,6 +6,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import chemicalsReducer from './chemicals.slice'; import { getChemicals } from './chemicals.thunks'; import { ChemicalsState } from './chemicals.types'; @@ -13,12 +14,14 @@ import { ChemicalsState } from './chemicals.types'; const mockedAxiosClient = mockNetworkNewAPIResponse(); const SEARCH_QUERY = 'Corticosterone'; +jest.mock('../../utils/error-report/errorReporting'); + describe('chemicals thunks', () => { let store = {} as ToolkitStoreWithSingleSlice<ChemicalsState>; beforeEach(() => { store = createStoreInstanceUsingSliceReducer('chemicals', chemicalsReducer); }); - describe('getChemiclas', () => { + describe('getChemicals', () => { it('should return data when data response from API is valid', async () => { mockedAxiosClient .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) @@ -35,13 +38,13 @@ describe('chemicals thunks', () => { const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); expect(payload).toEqual(undefined); }); - it('should handle error message when getChemiclas failed ', async () => { + it('should handle error message when getChemicals failed ', async () => { mockedAxiosClient .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.Forbidden, { randomProperty: 'randomValue' }); - const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); - expect(payload).toEqual( + const action = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(() => unwrapResult(action)).toThrow( "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 8a2f9878eb66b14c781d3ad69e484cf83efe6c6d..8553d6a4f3d06bbc8fbf50073c4c18ee53e70b63 100644 --- a/src/redux/chemicals/chemicals.thunks.ts +++ b/src/redux/chemicals/chemicals.thunks.ts @@ -3,10 +3,10 @@ import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Chemical } from '@/types/models'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { getError } from '@/utils/error-report/getError'; import { addNumbersToEntityNumberData } from '../entityNumber/entityNumber.slice'; import { CHEMICALS_FETCHING_ERROR_PREFIX, @@ -15,7 +15,7 @@ import { export const getChemicals = createAsyncThunk<Chemical[] | undefined, string, ThunkConfig>( 'project/getChemicals', - async (searchQuery, { rejectWithValue }) => { + async searchQuery => { try { const response = await axiosInstanceNewAPI.get<Chemical[]>( apiPath.getChemicalsStringWithQuery(searchQuery), @@ -25,8 +25,7 @@ export const getChemicals = createAsyncThunk<Chemical[] | undefined, string, Thu return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: CHEMICALS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: CHEMICALS_FETCHING_ERROR_PREFIX })); } }, ); @@ -34,7 +33,7 @@ export const getChemicals = createAsyncThunk<Chemical[] | undefined, string, Thu export const getMultiChemicals = createAsyncThunk<void, string[], ThunkConfig>( 'project/getMultChemicals', // eslint-disable-next-line consistent-return - async (searchQueries, { dispatch, rejectWithValue }) => { + async (searchQueries, { dispatch }) => { try { const asyncGetChemicalsFunctions = searchQueries.map(searchQuery => dispatch(getChemicals(searchQuery)), @@ -55,12 +54,7 @@ export const getMultiChemicals = createAsyncThunk<void, string[], ThunkConfig>( const chemicalsIds = chemicalsTargetsData.map(d => d.elementId); dispatch(addNumbersToEntityNumberData(chemicalsIds)); } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: MULTI_CHEMICALS_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: MULTI_CHEMICALS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts index 94d445b845aa63f1c45fef92ea66a5a75a05fd00..1df36392927a3d24ea6fd7b64d7e11043c11a205 100644 --- a/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts +++ b/src/redux/compartmentPathways/compartmentPathways.reducers.test.ts @@ -11,6 +11,7 @@ import { compartmentPathwaysOverLimitFixture, } from '@/models/fixtures/compartmentPathways'; import { getModelsIds } from '@/components/Map/Drawer/ExportDrawer/ExportDrawer.component.utils'; +import { unwrapResult } from '@reduxjs/toolkit'; import { apiPath } from '../apiPath'; import compartmentPathwaysReducer from './compartmentPathways.slice'; import { CompartmentPathwaysState } from './compartmentPathways.types'; @@ -114,7 +115,7 @@ describe('compartmentPathways reducer', () => { const dispatchData = await compartmentPathwaysPromise; - expect(dispatchData.payload).toBe( + expect(() => unwrapResult(dispatchData)).toThrow( "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; diff --git a/src/redux/compartmentPathways/compartmentPathways.thunks.ts b/src/redux/compartmentPathways/compartmentPathways.thunks.ts index b8143dbe2627fe7239a9eadf8546497e70b5bbfe..8f1bd216b8ecbff5f0cd4e608a7ee8737b9e9ad8 100644 --- a/src/redux/compartmentPathways/compartmentPathways.thunks.ts +++ b/src/redux/compartmentPathways/compartmentPathways.thunks.ts @@ -8,7 +8,7 @@ import { compartmentPathwaySchema, } from '@/models/compartmentPathwaySchema'; import { z } from 'zod'; -import { getErrorMessage } from '@/utils/getErrorMessage'; +import { getError } from '@/utils/error-report/getError'; import { COMPARMENT_PATHWAYS_FETCHING_ERROR_PREFIX, MAX_NUMBER_OF_IDS_IN_GET_QUERY, @@ -116,18 +116,14 @@ export const fetchCompartmentPathways = async ( export const getCompartmentPathways = createAsyncThunk( 'compartmentPathways/getCompartmentPathways', - async (modelsIds: number[] | undefined, { rejectWithValue }) => { + async (modelsIds: number[] | undefined) => { 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); + return Promise.reject(getError({ error, prefix: COMPARMENT_PATHWAYS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/configuration/configuration.thunks.ts b/src/redux/configuration/configuration.thunks.ts index 8b4db5ea19083cc0f044966eb567cc12610aa66f..6b03cfa68a9fa4484528c02bc276a7f72d866f34 100644 --- a/src/redux/configuration/configuration.thunks.ts +++ b/src/redux/configuration/configuration.thunks.ts @@ -5,8 +5,8 @@ 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 { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { CONFIGURATION_FETCHING_ERROR_PREFIX, @@ -17,7 +17,7 @@ export const getConfigurationOptions = createAsyncThunk< ConfigurationOption[] | undefined, void, ThunkConfig ->('configuration/getConfigurationOptions', async (_, { rejectWithValue }) => { +>('configuration/getConfigurationOptions', async () => { try { const response = await axiosInstance.get<ConfigurationOption[]>( apiPath.getConfigurationOptions(), @@ -30,17 +30,13 @@ export const getConfigurationOptions = createAsyncThunk< return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: CONFIGURATION_OPTIONS_FETCHING_ERROR_PREFIX, - }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: CONFIGURATION_OPTIONS_FETCHING_ERROR_PREFIX })); } }); export const getConfiguration = createAsyncThunk<Configuration | undefined, void, ThunkConfig>( 'configuration/getConfiguration', - async (_, { rejectWithValue }) => { + async () => { try { const response = await axiosInstance.get<Configuration>(apiPath.getConfiguration()); @@ -48,8 +44,7 @@ export const getConfiguration = createAsyncThunk<Configuration | undefined, void return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: CONFIGURATION_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: CONFIGURATION_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/constant/constant.thunks.ts b/src/redux/constant/constant.thunks.ts index c939e0812f6bc36ecc195e51dd4226515b435408..9fb31a768864e749ee437b078c462e74a6390411 100644 --- a/src/redux/constant/constant.thunks.ts +++ b/src/redux/constant/constant.thunks.ts @@ -1,12 +1,12 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { ThunkConfig } from '@/types/store'; import { ConstantType } from '@/redux/constant/constant.types'; import { getConfigValue } from '@/constants/index.utils'; +import { getError } from '@/utils/error-report/getError'; export const getConstant = createAsyncThunk<ConstantType | undefined, void, ThunkConfig>( 'constant/getConstant', - async (_, { rejectWithValue }) => { + async () => { try { const apiBaseUrl = getConfigValue('BASE_API_URL'); const result: ConstantType = { @@ -15,8 +15,7 @@ export const getConstant = createAsyncThunk<ConstantType | undefined, void, Thun }; return result; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: 'Failed to build constants' }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: 'Failed to build constants' })); } }, ); diff --git a/src/redux/drawer/drawer.thunks.ts b/src/redux/drawer/drawer.thunks.ts index f08955f9c5aafd0d79a7b960fe584abf7b688ac1..a590ad90d775be513a15646b8f8f523cfed59aa2 100644 --- a/src/redux/drawer/drawer.thunks.ts +++ b/src/redux/drawer/drawer.thunks.ts @@ -6,7 +6,7 @@ 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 { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, @@ -36,7 +36,7 @@ const getDrugsByName = async (drugName: string): Promise<Drug[]> => { export const getDrugsForBioEntityDrawerTarget = createAsyncThunk<Drug[], string, ThunkConfig>( 'drawer/getDrugsForBioEntityDrawerTarget', - async (target, { rejectWithValue }) => { + async target => { try { const drugsNames = await getDrugsNamesForTarget(target); const drugsArrays = await Promise.all( @@ -46,12 +46,9 @@ export const getDrugsForBioEntityDrawerTarget = createAsyncThunk<Drug[], string, return drugs; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: DRUGS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject( + getError({ error, prefix: DRUGS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX }), + ); } }, ); @@ -82,7 +79,7 @@ export const getChemicalsForBioEntityDrawerTarget = createAsyncThunk< Chemical[], string, ThunkConfig ->('drawer/getChemicalsForBioEntityDrawerTarget', async (target, { rejectWithValue }) => { +>('drawer/getChemicalsForBioEntityDrawerTarget', async target => { try { const chemicalsNames = await getChemicalsNamesForTarget(target); const chemicalsArrays = await Promise.all( @@ -92,11 +89,8 @@ export const getChemicalsForBioEntityDrawerTarget = createAsyncThunk< return chemicals; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject( + getError({ error, prefix: CHEMICALS_FOR_BIO_ENTITY_FETCHING_ERROR_PREFIX }), + ); } }); diff --git a/src/redux/drugs/drugs.reducers.test.ts b/src/redux/drugs/drugs.reducers.test.ts index f6e6c671bc29975194a163390e717a5bc91275e3..69d29267ef92cdddead5846ce3655c0785b3c2d7 100644 --- a/src/redux/drugs/drugs.reducers.test.ts +++ b/src/redux/drugs/drugs.reducers.test.ts @@ -7,6 +7,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import drugsReducer from './drugs.slice'; import { getDrugs } from './drugs.thunks'; import { DrugsState } from './drugs.types'; @@ -56,16 +57,16 @@ describe('drugs reducer', () => { .onGet(apiPath.getDrugsStringWithQuery(SEARCH_QUERY)) .reply(HttpStatusCode.NotFound, []); - const { type, payload } = await store.dispatch(getDrugs(SEARCH_QUERY)); + const action = await store.dispatch(getDrugs(SEARCH_QUERY)); const { data } = store.getState().drugs; const drugsWithSearchElement = data.find( bioEntity => bioEntity.searchQueryElement === SEARCH_QUERY, ); - expect(payload).toBe( + expect(() => unwrapResult(action)).toThrow( "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(action.type).toBe('project/getDrugs/rejected'); expect(drugsWithSearchElement).toEqual({ searchQueryElement: SEARCH_QUERY, data: undefined, diff --git a/src/redux/drugs/drugs.thunks.ts b/src/redux/drugs/drugs.thunks.ts index f837f49d662ef6bfe3b7fef5558a43d0caf3434a..c327e5cd80c970b41c3318451e26c599ac655d4a 100644 --- a/src/redux/drugs/drugs.thunks.ts +++ b/src/redux/drugs/drugs.thunks.ts @@ -3,16 +3,16 @@ import { apiPath } from '@/redux/apiPath'; import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; import { Drug } from '@/types/models'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { getError } from '@/utils/error-report/getError'; import { addNumbersToEntityNumberData } from '../entityNumber/entityNumber.slice'; import { DRUGS_FETCHING_ERROR_PREFIX, MULTI_DRUGS_FETCHING_ERROR_PREFIX } from './drugs.constants'; export const getDrugs = createAsyncThunk<Drug[] | undefined, string, ThunkConfig>( 'project/getDrugs', - async (searchQuery: string, { rejectWithValue }) => { + async (searchQuery: string) => { try { const response = await axiosInstanceNewAPI.get<Drug[]>( apiPath.getDrugsStringWithQuery(searchQuery), @@ -22,8 +22,7 @@ export const getDrugs = createAsyncThunk<Drug[] | undefined, string, ThunkConfig return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: DRUGS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: DRUGS_FETCHING_ERROR_PREFIX })); } }, ); @@ -31,7 +30,7 @@ export const getDrugs = createAsyncThunk<Drug[] | undefined, string, ThunkConfig export const getMultiDrugs = createAsyncThunk<void, string[], ThunkConfig>( 'project/getMultiDrugs', // eslint-disable-next-line consistent-return - async (searchQueries, { dispatch, rejectWithValue }) => { + async (searchQueries, { dispatch }) => { try { const asyncGetDrugsFunctions = searchQueries.map(searchQuery => dispatch(getDrugs(searchQuery)), @@ -52,9 +51,7 @@ export const getMultiDrugs = createAsyncThunk<void, string[], ThunkConfig>( const drugsIds = drugsTargetsData.map(d => d.elementId); dispatch(addNumbersToEntityNumberData(drugsIds)); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: MULTI_DRUGS_FETCHING_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: MULTI_DRUGS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/export/export.reducers.test.ts b/src/redux/export/export.reducers.test.ts index 778aca4f95a58b094b126f449643804fcab85287..40e92e5cce3ba3acebf5ad28dbc7b4c1f5afecfe 100644 --- a/src/redux/export/export.reducers.test.ts +++ b/src/redux/export/export.reducers.test.ts @@ -4,6 +4,7 @@ import { createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import { ExportState } from './export.types'; import exportReducer from './export.slice'; import { apiPath } from '../apiPath'; @@ -76,7 +77,7 @@ describe('export reducer', () => { mockedAxiosClient .onPost(apiPath.downloadNetworkCsv()) .reply(HttpStatusCode.NotFound, undefined); - const { payload, type } = await store.dispatch( + const action = await store.dispatch( downloadNetwork({ annotations: [], columns: [], @@ -85,8 +86,8 @@ describe('export reducer', () => { submaps: [], }), ); - expect(type).toBe('export/downloadNetwork/rejected'); - expect(payload).toBe( + expect(action.type).toBe('export/downloadNetwork/rejected'); + expect(() => unwrapResult(action)).toThrow( "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; @@ -135,7 +136,7 @@ describe('export reducer', () => { mockedAxiosClient .onPost(apiPath.downloadElementsCsv()) .reply(HttpStatusCode.NotFound, undefined); - const { payload } = await store.dispatch( + const action = await store.dispatch( downloadElements({ annotations: [], columns: [], @@ -147,7 +148,7 @@ describe('export reducer', () => { const { loading } = store.getState().export.downloadElements; expect(loading).toEqual('failed'); - expect(payload).toEqual( + expect(() => unwrapResult(action)).toThrow( "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 bf49e0b04e79e1eea61cac05f2eb79810b23ce55..ccb5f74d412d0963fbd420b3a662ab1ee8c89f5e 100644 --- a/src/redux/export/export.thunks.ts +++ b/src/redux/export/export.thunks.ts @@ -5,8 +5,8 @@ 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 { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { downloadFileFromBlob } from './export.utils'; import { ELEMENTS_DOWNLOAD_ERROR_PREFIX, NETWORK_DOWNLOAD_ERROR_PREFIX } from './export.constants'; @@ -24,7 +24,7 @@ export const downloadElements = createAsyncThunk< DownloadElementsBodyRequest, ThunkConfig // eslint-disable-next-line consistent-return ->('export/downloadElements', async (data, { rejectWithValue }) => { +>('export/downloadElements', async data => { try { const response = await axiosInstanceNewAPI.post<ExportElements>( apiPath.downloadElementsCsv(), @@ -40,9 +40,7 @@ export const downloadElements = createAsyncThunk< downloadFileFromBlob(response.data, `${PROJECT_ID}-elementExport.csv`); } } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: ELEMENTS_DOWNLOAD_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: ELEMENTS_DOWNLOAD_ERROR_PREFIX })); } }); @@ -57,7 +55,7 @@ type DownloadNetworkBodyRequest = { export const downloadNetwork = createAsyncThunk<undefined, DownloadNetworkBodyRequest, ThunkConfig>( 'export/downloadNetwork', // eslint-disable-next-line consistent-return - async (data, { rejectWithValue }) => { + async data => { try { const response = await axiosInstanceNewAPI.post<ExportNetwork>( apiPath.downloadNetworkCsv(), @@ -73,9 +71,7 @@ export const downloadNetwork = createAsyncThunk<undefined, DownloadNetworkBodyRe downloadFileFromBlob(response.data, `${PROJECT_ID}-networkExport.csv`); } } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: NETWORK_DOWNLOAD_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: NETWORK_DOWNLOAD_ERROR_PREFIX })); } }, ); diff --git a/src/redux/map/map.thunks.ts b/src/redux/map/map.thunks.ts index 05b675bba64d223d63cab0865e8692f5f9620da4..0ea2c2ea5766dcee135c80d56ab3122a3cc6217e 100644 --- a/src/redux/map/map.thunks.ts +++ b/src/redux/map/map.thunks.ts @@ -5,8 +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 { getError } from '@/utils/error-report/getError'; import type { AppDispatch, RootState } from '../store'; import { InitMapBackgroundActionPayload, @@ -145,15 +145,13 @@ export const initMapSizeAndModelId = createAsyncThunk< InitMapSizeAndModelIdActionPayload, InitMapSizeAndModelIdParams, { dispatch: AppDispatch; state: RootState } & ThunkConfig ->('map/initMapSizeAndModelId', async ({ queryData }, { getState, rejectWithValue }) => { +>('map/initMapSizeAndModelId', async ({ queryData }, { getState }) => { 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); + return Promise.reject(getError({ error, prefix: INIT_MAP_SIZE_MODEL_ID_ERROR_PREFIX })); } }); @@ -161,15 +159,13 @@ export const initMapPosition = createAsyncThunk< InitMapPositionActionPayload, InitMapPositionParams, { dispatch: AppDispatch; state: RootState } & ThunkConfig ->('map/initMapPosition', async ({ queryData }, { getState, rejectWithValue }) => { +>('map/initMapPosition', async ({ queryData }, { getState }) => { try { const state = getState(); return getInitMapPosition(state, queryData); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: INIT_MAP_POSITION_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: INIT_MAP_POSITION_ERROR_PREFIX })); } }); @@ -177,14 +173,12 @@ export const initMapBackground = createAsyncThunk< InitMapBackgroundActionPayload, InitMapBackgroundParams, { dispatch: AppDispatch; state: RootState } & ThunkConfig ->('map/initMapBackground', async ({ queryData }, { getState, rejectWithValue }) => { +>('map/initMapBackground', async ({ queryData }, { getState }) => { try { const state = getState(); return getBackgroundId(state, queryData); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: INIT_MAP_BACKGROUND_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: INIT_MAP_BACKGROUND_ERROR_PREFIX })); } }); @@ -192,14 +186,12 @@ export const initOpenedMaps = createAsyncThunk< InitOpenedMapsActionPayload, InitOpenedMapsProps, { dispatch: AppDispatch; state: RootState } & ThunkConfig ->('appInit/initOpenedMaps', async ({ queryData }, { getState, rejectWithValue }) => { +>('appInit/initOpenedMaps', async ({ queryData }, { getState }) => { try { const state = getState(); return getOpenedMaps(state, queryData); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: INIT_OPENED_MAPS_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: INIT_OPENED_MAPS_ERROR_PREFIX })); } }); diff --git a/src/redux/middlewares/error.middleware.test.ts b/src/redux/middlewares/error.middleware.test.ts index 7662714e2f08a2dccd3a6329db08716d0103bc2d..6c34711b000f25d6642c79fdf7068a4b16f47913 100644 --- a/src/redux/middlewares/error.middleware.test.ts +++ b/src/redux/middlewares/error.middleware.test.ts @@ -1,16 +1,17 @@ +import { store } from '@/redux/store'; import { showToast } from '@/utils/showToast'; import { errorMiddlewareListener } from './error.middleware'; -jest.mock('../../utils/showToast', () => ({ - showToast: jest.fn(), -})); +jest.mock('../../utils/showToast'); describe('errorMiddlewareListener', () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + beforeEach(() => { jest.clearAllMocks(); }); - it('should show toast with error message when action is rejected with value', async () => { + it('should handle error with message when action is rejected', async () => { const action = { type: 'action/rejected', payload: 'Error message', @@ -19,12 +20,23 @@ describe('errorMiddlewareListener', () => { rejectedWithValue: true, requestStatus: 'rejected', }, + error: { + code: 'Error 2', + }, }; - await errorMiddlewareListener(action); - expect(showToast).toHaveBeenCalledWith({ type: 'error', message: 'Error message' }); + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal/openErrorReportModal', + payload: expect.anything(), + }), + ); }); - it('should show toast with unknown error when action is rejected without value', async () => { + it('should handle error without message when action is rejected', async () => { const action = { type: 'action/rejected', payload: null, @@ -33,15 +45,23 @@ describe('errorMiddlewareListener', () => { rejectedWithValue: true, requestStatus: 'rejected', }, + error: { + code: 'Error 3', + }, }; - await errorMiddlewareListener(action); - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An unknown error occurred. Please try again later.', - }); + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal/openErrorReportModal', + payload: expect.anything(), + }), + ); }); - it('should not show toast when action is not rejected', async () => { + it('should not handle error when action is not rejected', async () => { const action = { type: 'action/loading', payload: null, @@ -50,11 +70,14 @@ describe('errorMiddlewareListener', () => { requestStatus: 'fulfilled', }, }; - await errorMiddlewareListener(action); - expect(showToast).not.toHaveBeenCalled(); + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(dispatchSpy).not.toHaveBeenCalled(); }); - it('should show toast with unknown error when action payload is not a string', async () => { + it('should handle error with unknown error message when action payload is not a string', async () => { const action = { type: 'action/rejected', payload: {}, @@ -63,15 +86,24 @@ describe('errorMiddlewareListener', () => { rejectedWithValue: true, requestStatus: 'rejected', }, + error: { + code: 'ERROR', + message: 'Error message', + }, }; - await errorMiddlewareListener(action); - expect(showToast).toHaveBeenCalledWith({ - type: 'error', - message: 'An unknown error occurred. Please try again later.', - }); + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal/openErrorReportModal', + payload: expect.anything(), + }), + ); }); - it('should show toast with custom message when action payload is a string', async () => { + it('should handle error with custom message when action payload is a string', async () => { const action = { type: 'action/rejected', payload: 'Failed to fetch', @@ -80,8 +112,46 @@ describe('errorMiddlewareListener', () => { rejectedWithValue: true, requestStatus: 'rejected', }, + error: { + code: 'ERROR', + message: 'xyz', + stack: 'stack', + }, + }; + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal/openErrorReportModal', + payload: expect.objectContaining({ + stacktrace: 'stack', + }), + }), + ); + }); + + it('should toast on access denied', async () => { + const action = { + type: 'action/rejected', + payload: null, + meta: { + requestId: '421', + rejectedWithValue: true, + requestStatus: 'rejected', + }, + error: { + code: '403', + }, }; - await errorMiddlewareListener(action); - expect(showToast).toHaveBeenCalledWith({ type: 'error', message: 'Failed to fetch' }); + const { getState, dispatch } = store; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + await errorMiddlewareListener(action, { getState, dispatch }); + expect(showToast).toHaveBeenCalledWith({ + message: 'Access denied.', + type: 'error', + }); }); }); diff --git a/src/redux/middlewares/error.middleware.ts b/src/redux/middlewares/error.middleware.ts index 0ba6a0f75452822908c449ba4d3dcb0ac59dddd9..8c6bd8160be1d71fd632d1ec542b66c48c9017fc 100644 --- a/src/redux/middlewares/error.middleware.ts +++ b/src/redux/middlewares/error.middleware.ts @@ -1,30 +1,27 @@ -import type { AppStartListening } from '@/redux/store'; -import { UNKNOWN_ERROR } from '@/utils/getErrorMessage/getErrorMessage.constants'; +import type { AppListenerEffectAPI, AppStartListening } from '@/redux/store'; +import { Action, createListenerMiddleware, isRejected } from '@reduxjs/toolkit'; +import { createErrorData } from '@/utils/error-report/errorReporting'; +import { openErrorReportModal } from '@/redux/modal/modal.slice'; 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; +export const errorMiddlewareListener = async ( + action: Action, + { getState, dispatch }: AppListenerEffectAPI, +): Promise<void> => { + if (isRejected(action) && action.type !== 'user/getSessionValid/rejected') { + if (action.error.code === '403') { + showToast({ + type: 'error', + message: 'Access denied.', + }); } else { - message = UNKNOWN_ERROR; + const errorData = await createErrorData(action.error, getState()); + dispatch(openErrorReportModal(errorData)); } - - showToast({ - type: 'error', - message, - }); } }; diff --git a/src/redux/modal/modal.constants.ts b/src/redux/modal/modal.constants.ts index f0df9964801af60a083ceb6a773b4a7c8375c40f..d7dea45c423398a99180fcd4151255ad93489fdf 100644 --- a/src/redux/modal/modal.constants.ts +++ b/src/redux/modal/modal.constants.ts @@ -12,4 +12,5 @@ export const MODAL_INITIAL_STATE: ModalState = { uniprotId: MOL_ART_UNIPROT_ID_DEFAULT, }, editOverlayState: null, + errorReportState: {}, }; diff --git a/src/redux/modal/modal.mock.ts b/src/redux/modal/modal.mock.ts index 22b833031510bdea484a479d1fc43faa52c66c74..cde5fab5cc156a2783af5987006fc87e499f7477 100644 --- a/src/redux/modal/modal.mock.ts +++ b/src/redux/modal/modal.mock.ts @@ -12,4 +12,5 @@ export const MODAL_INITIAL_STATE_MOCK: ModalState = { uniprotId: MOL_ART_UNIPROT_ID_DEFAULT, }, editOverlayState: null, + errorReportState: {}, }; diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index 4871a245229a6dc20149d9599e86e2258a1eb087..24ae505605ac7a2d4ed377f2069d4aa918e6baf9 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -1,5 +1,6 @@ import { ModalName } from '@/types/modal'; import { PayloadAction } from '@reduxjs/toolkit'; +import { ErrorData } from '@/utils/error-report/ErrorData'; import { ModalState, OpenEditOverlayModalAction } from './modal.types'; export const openModalReducer = (state: ModalState, action: PayloadAction<ModalName>): void => { @@ -48,6 +49,18 @@ export const openLoggedInMenuModalReducer = (state: ModalState): void => { state.modalTitle = 'Select'; }; +export const openErrorReportModalReducer = ( + state: ModalState, + action: PayloadAction<ErrorData | undefined>, +): void => { + state.isOpen = true; + state.modalName = 'error-report'; + state.modalTitle = 'An error occurred!'; + state.errorReportState = { + errorData: action.payload, + }; +}; + export const setOverviewImageIdReducer = ( state: ModalState, action: PayloadAction<number>, diff --git a/src/redux/modal/modal.selector.ts b/src/redux/modal/modal.selector.ts index c77223ea30f739d335170d382c9ecb524b6b6a49..654dfb7aeac4b0b43a89cb676a92b9bf7d09922d 100644 --- a/src/redux/modal/modal.selector.ts +++ b/src/redux/modal/modal.selector.ts @@ -20,3 +20,8 @@ export const currentEditedOverlaySelector = createSelector( modalSelector, modal => modal.editOverlayState, ); + +export const currentErrorDataSelector = createSelector( + modalSelector, + modal => modal?.errorReportState.errorData || undefined, +); diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index c1fe7b36a49f38f8ed81fbf0a2c32a01b54ceb76..40f7ed6566b9b89c6127e67effb65b5daff38b9d 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -10,6 +10,7 @@ import { openPublicationsModalReducer, openEditOverlayModalReducer, openLoggedInMenuModalReducer, + openErrorReportModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -25,6 +26,7 @@ const modalSlice = createSlice({ openPublicationsModal: openPublicationsModalReducer, openEditOverlayModal: openEditOverlayModalReducer, openLoggedInMenuModal: openLoggedInMenuModalReducer, + openErrorReportModal: openErrorReportModalReducer, }, }); @@ -38,6 +40,7 @@ export const { openPublicationsModal, openEditOverlayModal, openLoggedInMenuModal, + openErrorReportModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/modal/modal.types.ts b/src/redux/modal/modal.types.ts index dfb5ca5d5c5a1f7b020b766bed4b7602e9cc2526..ea77209610093378c152d5d114f41bc475bd97fb 100644 --- a/src/redux/modal/modal.types.ts +++ b/src/redux/modal/modal.types.ts @@ -1,6 +1,7 @@ import { ModalName } from '@/types/modal'; import { MapOverlay } from '@/types/models'; import { PayloadAction } from '@reduxjs/toolkit'; +import { ErrorData } from '@/utils/error-report/ErrorData'; export type OverviewImagesModalState = { imageId?: number; @@ -10,6 +11,10 @@ export type MolArtModalState = { uniprotId?: string | undefined; }; +export type ErrorRepostState = { + errorData?: ErrorData | undefined; +}; + export type EditOverlayState = MapOverlay | null; export interface ModalState { @@ -18,6 +23,7 @@ export interface ModalState { modalTitle: string; overviewImagesState: OverviewImagesModalState; molArtState: MolArtModalState; + errorReportState: ErrorRepostState; editOverlayState: EditOverlayState; } diff --git a/src/redux/models/models.reducers.test.ts b/src/redux/models/models.reducers.test.ts index fc6e0af1e28e47a3bb0e2ed3a38186549c053907..c25cd72d4bff9e4510cbb0b6b596201c13831ec1 100644 --- a/src/redux/models/models.reducers.test.ts +++ b/src/redux/models/models.reducers.test.ts @@ -6,6 +6,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import modelsReducer from './models.slice'; import { getModels } from './models.thunks'; import { ModelsState } from './models.types'; @@ -44,11 +45,11 @@ describe('models reducer', () => { it('should update store after failed getModels query', async () => { mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.NotFound, []); - const { type, payload } = await store.dispatch(getModels()); + const action = await store.dispatch(getModels()); const { data, loading, error } = store.getState().models; - expect(type).toBe('project/getModels/rejected'); - expect(payload).toBe( + expect(action.type).toBe('project/getModels/rejected'); + expect(() => unwrapResult(action)).toThrow( "Failed to fetch models: The page you're looking for doesn't exist. Please verify the URL and try again.", ); expect(loading).toEqual('failed'); diff --git a/src/redux/models/models.thunks.ts b/src/redux/models/models.thunks.ts index 2e0fd68d89e09cff7b66e6c5e1aefff3acc15486..d81096c9dd853bf571c31de25006583c1b60c36a 100644 --- a/src/redux/models/models.thunks.ts +++ b/src/redux/models/models.thunks.ts @@ -2,16 +2,16 @@ 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 { getError } from '@/utils/error-report/getError'; import { MODELS_FETCHING_ERROR_PREFIX } from './models.constants'; export const getModels = createAsyncThunk<MapModel[] | undefined, void, ThunkConfig>( 'project/getModels', - async (_, { rejectWithValue }) => { + async () => { try { const response = await axiosInstance.get<MapModel[]>(apiPath.getModelsString()); @@ -19,9 +19,7 @@ export const getModels = createAsyncThunk<MapModel[] | undefined, void, ThunkCon return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: MODELS_FETCHING_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: MODELS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts index 956b6751b59b785cf082e89b5630f65d8acaf08f..f726bd8fff76200318f7e4f0b40c64bfb985eb3e 100644 --- a/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts +++ b/src/redux/overlayBioEntity/overlayBioEntity.thunk.ts @@ -3,8 +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 { getError } from '@/utils/error-report/getError'; import { getValidOverlayBioEntities, parseOverlayBioEntityToOlRenderingFormat, @@ -30,7 +30,7 @@ export const getOverlayBioEntity = createAsyncThunk< OverlayBioEntityRender[] | undefined, GetOverlayBioEntityThunkProps, ThunkConfig ->('overlayBioEntity/getOverlayBioEntity', async ({ overlayId, modelId }, { rejectWithValue }) => { +>('overlayBioEntity/getOverlayBioEntity', async ({ overlayId, modelId }) => { try { const response = await axiosInstanceNewAPI.get<OverlayBioEntity[]>( apiPath.getOverlayBioEntity({ overlayId, modelId }), @@ -47,12 +47,7 @@ export const getOverlayBioEntity = createAsyncThunk< return undefined; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: OVERLAY_BIO_ENTITY_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: OVERLAY_BIO_ENTITY_FETCHING_ERROR_PREFIX })); } }); @@ -65,7 +60,7 @@ export const getOverlayBioEntityForAllModels = createAsyncThunk< >( 'overlayBioEntity/getOverlayBioEntityForAllModels', // eslint-disable-next-line consistent-return - async ({ overlayId }, { dispatch, getState, rejectWithValue }) => { + async ({ overlayId }, { dispatch, getState }) => { try { const state = getState(); const modelsIds = modelsIdsSelector(state); @@ -76,12 +71,9 @@ export const getOverlayBioEntityForAllModels = createAsyncThunk< await Promise.all(asyncGetOverlayBioEntityFunctions); } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: OVERLAY_BIO_ENTITY_ALL_MODELS_FETCHING_ERROR_PREFIX, - }); - - return rejectWithValue(errorMessage); + return Promise.reject( + getError({ error, prefix: OVERLAY_BIO_ENTITY_ALL_MODELS_FETCHING_ERROR_PREFIX }), + ); } }, ); @@ -93,7 +85,7 @@ export const getInitOverlays = createAsyncThunk< GetInitOverlaysProps, { state: RootState } & ThunkConfig // eslint-disable-next-line consistent-return ->('appInit/getInitOverlays', async ({ overlaysId }, { dispatch, getState, rejectWithValue }) => { +>('appInit/getInitOverlays', async ({ overlaysId }, { dispatch, getState }) => { try { const state = getState(); @@ -114,8 +106,6 @@ export const getInitOverlays = createAsyncThunk< dispatch(getOverlayBioEntityForAllModels({ overlayId: id })); }); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: INIT_OVERLAYS_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: INIT_OVERLAYS_ERROR_PREFIX })); } }); diff --git a/src/redux/overlays/overlays.reducers.test.ts b/src/redux/overlays/overlays.reducers.test.ts index 38b4652f0f0c29e09c3917d9de1160f9dc27fc4a..1c4cada35a94834d7b670696893c106d237f9106 100644 --- a/src/redux/overlays/overlays.reducers.test.ts +++ b/src/redux/overlays/overlays.reducers.test.ts @@ -14,6 +14,7 @@ import { import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { waitFor } from '@testing-library/react'; +import { unwrapResult } from '@reduxjs/toolkit'; import { apiPath } from '../apiPath'; import overlaysReducer from './overlays.slice'; import { @@ -80,11 +81,11 @@ describe('overlays reducer', () => { .onGet(apiPath.getAllOverlaysByProjectIdQuery(PROJECT_ID, { publicOverlay: true })) .reply(HttpStatusCode.NotFound, []); - const { type, payload } = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); + const action = await store.dispatch(getAllPublicOverlaysByProjectId(PROJECT_ID)); const { data, loading, error } = store.getState().overlays; - expect(type).toBe('overlays/getAllPublicOverlaysByProjectId/rejected'); - expect(payload).toBe( + expect(action.type).toBe('overlays/getAllPublicOverlaysByProjectId/rejected'); + expect(() => unwrapResult(action)).toThrow( "Failed to fetch overlays: The page you're looking for doesn't exist. Please verify the URL and try again.", ); expect(loading).toEqual('failed'); diff --git a/src/redux/overlays/overlays.thunks.ts b/src/redux/overlays/overlays.thunks.ts index 67fd7b7d20e59979c665bccb5e5977fbd58f2ade..700eeb58024c66facd3888536301f49959165498 100644 --- a/src/redux/overlays/overlays.thunks.ts +++ b/src/redux/overlays/overlays.thunks.ts @@ -12,9 +12,9 @@ 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 { BASE_API_URL } from '@/constants'; +import { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { CHUNK_SIZE, @@ -32,7 +32,7 @@ import type { RootState } from '../store'; export const getAllPublicOverlaysByProjectId = createAsyncThunk<MapOverlay[], string, ThunkConfig>( 'overlays/getAllPublicOverlaysByProjectId', - async (projectId: string, { rejectWithValue }) => { + async (projectId: string) => { try { const response = await axiosInstance.get<MapOverlay[]>( apiPath.getAllOverlaysByProjectIdQuery(projectId, { publicOverlay: true }), @@ -42,16 +42,14 @@ export const getAllPublicOverlaysByProjectId = createAsyncThunk<MapOverlay[], st return isDataValid ? response.data : []; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: OVERLAYS_FETCHING_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: OVERLAYS_FETCHING_ERROR_PREFIX })); } }, ); export const getAllUserOverlaysByCreator = createAsyncThunk<MapOverlay[], void, ThunkConfig>( 'overlays/getAllUserOverlaysByCreator', - async (_, { getState, rejectWithValue }) => { + async (_, { getState }) => { try { const state = getState() as RootState; const creator = state.user.login; @@ -78,8 +76,7 @@ export const getAllUserOverlaysByCreator = createAsyncThunk<MapOverlay[], void, return isDataValid ? sortedUserOverlays : []; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAYS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: USER_OVERLAYS_FETCHING_ERROR_PREFIX })); } }, ); @@ -197,7 +194,7 @@ export const addOverlay = createAsyncThunk<undefined, AddOverlayArgs, ThunkConfi 'overlays/addOverlay', async ( { filename, content, description, name, type, projectId }, - { rejectWithValue, dispatch }, + { dispatch }, // eslint-disable-next-line consistent-return ) => { try { @@ -223,8 +220,7 @@ export const addOverlay = createAsyncThunk<undefined, AddOverlayArgs, ThunkConfi showToast({ type: 'success', message: USER_OVERLAY_ADD_SUCCESS_MESSAGE }); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_ADD_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: USER_OVERLAY_ADD_ERROR_PREFIX })); } }, ); @@ -232,7 +228,7 @@ export const addOverlay = createAsyncThunk<undefined, AddOverlayArgs, ThunkConfi export const updateOverlays = createAsyncThunk<undefined, MapOverlay[], ThunkConfig>( 'overlays/updateOverlays', // eslint-disable-next-line consistent-return - async (userOverlays, { rejectWithValue }) => { + async userOverlays => { try { const userOverlaysPromises = userOverlays.map(userOverlay => axiosInstance.patch<MapOverlay>( @@ -256,9 +252,7 @@ export const updateOverlays = createAsyncThunk<undefined, MapOverlay[], ThunkCon showToast({ type: 'success', message: USER_OVERLAY_UPDATE_SUCCESS_MESSAGE }); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_UPDATE_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: USER_OVERLAY_UPDATE_ERROR_PREFIX })); } }, ); @@ -266,7 +260,7 @@ export const updateOverlays = createAsyncThunk<undefined, MapOverlay[], ThunkCon export const removeOverlay = createAsyncThunk<undefined, { overlayId: number }, ThunkConfig>( 'overlays/removeOverlay', // eslint-disable-next-line consistent-return - async ({ overlayId }, { dispatch, rejectWithValue }) => { + async ({ overlayId }, { dispatch }) => { try { await axiosInstance.delete(apiPath.removeOverlay(overlayId), { withCredentials: true, @@ -278,8 +272,7 @@ export const removeOverlay = createAsyncThunk<undefined, { overlayId: number }, showToast({ type: 'success', message: USER_OVERLAY_REMOVE_SUCCESS_MESSAGE }); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: USER_OVERLAY_REMOVE_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: USER_OVERLAY_REMOVE_ERROR_PREFIX })); } }, ); diff --git a/src/redux/plugins/plugins.reducers.test.ts b/src/redux/plugins/plugins.reducers.test.ts index ecbedd35721534bef5f6d274192873088206a28f..d5a7f0103f696eb9654a7edb482114fd2dbc4c42 100644 --- a/src/redux/plugins/plugins.reducers.test.ts +++ b/src/redux/plugins/plugins.reducers.test.ts @@ -6,6 +6,7 @@ import { import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { pluginFixture } from '@/models/fixtures/pluginFixture'; +import { unwrapResult } from '@reduxjs/toolkit'; import { apiPath } from '../apiPath'; import { PluginsState } from './plugins.types'; import pluginsReducer, { removePlugin } from './plugins.slice'; @@ -59,7 +60,7 @@ describe('plugins reducer', () => { it('should update store after failed registerPlugin query', async () => { mockedAxiosClient.onPost(apiPath.registerPluign()).reply(HttpStatusCode.NotFound, undefined); - const { type, payload } = await store.dispatch( + const action = await store.dispatch( registerPlugin({ hash: pluginFixture.hash, isPublic: pluginFixture.isPublic, @@ -70,8 +71,8 @@ describe('plugins reducer', () => { }), ); - expect(type).toBe('plugins/registerPlugin/rejected'); - expect(payload).toEqual( + expect(action.type).toBe('plugins/registerPlugin/rejected'); + expect(() => unwrapResult(action)).toThrow( "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; diff --git a/src/redux/plugins/plugins.thunks.ts b/src/redux/plugins/plugins.thunks.ts index 9f2108c4973f882ae17268ea73d2ca0e11da30c9..19dd2592d828443d2dd5ff52e41147d3ccc231a8 100644 --- a/src/redux/plugins/plugins.thunks.ts +++ b/src/redux/plugins/plugins.thunks.ts @@ -6,9 +6,9 @@ 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 { getPluginHashWithoutPrefix } from '@/utils/plugins/getPluginHashWithoutPrefix'; +import { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { PLUGIN_FETCHING_ALL_ERROR_PREFIX, @@ -31,10 +31,7 @@ export const registerPlugin = createAsyncThunk< ThunkConfig >( 'plugins/registerPlugin', - async ( - { hash, isPublic, pluginName, pluginUrl, pluginVersion, extendedPluginName }, - { rejectWithValue }, - ) => { + async ({ hash, isPublic, pluginName, pluginUrl, pluginVersion, extendedPluginName }) => { try { const hashWihtoutPrefix = getPluginHashWithoutPrefix(hash); @@ -66,8 +63,7 @@ export const registerPlugin = createAsyncThunk< return undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: PLUGIN_REGISTER_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: PLUGIN_REGISTER_ERROR_PREFIX })); } }, ); @@ -86,7 +82,7 @@ type GetInitPluginsProps = { export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps, ThunkConfig>( 'plugins/getInitPlugins', // eslint-disable-next-line consistent-return - async ({ pluginsId, setHashedPlugin }, { rejectWithValue }) => { + async ({ pluginsId, setHashedPlugin }) => { try { /* eslint-disable no-restricted-syntax, no-await-in-loop */ @@ -112,15 +108,14 @@ export const getInitPlugins = createAsyncThunk<void, GetInitPluginsProps, ThunkC } } } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: PLUGIN_INIT_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: PLUGIN_INIT_FETCHING_ERROR_PREFIX })); } }, ); export const getAllPlugins = createAsyncThunk<MinervaPlugin[], void, ThunkConfig>( 'plugins/getAllPlugins', - async (_, { rejectWithValue }) => { + async () => { try { const response = await axiosInstance.get<MinervaPlugin[]>(apiPath.getAllPlugins()); @@ -130,8 +125,7 @@ export const getAllPlugins = createAsyncThunk<MinervaPlugin[], void, ThunkConfig return validPlugins; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: PLUGIN_FETCHING_ALL_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: PLUGIN_FETCHING_ALL_ERROR_PREFIX })); } }, ); diff --git a/src/redux/project/project.reducers.test.ts b/src/redux/project/project.reducers.test.ts index 0521d9c262092e38956e8d239c5a8b82fb337610..6579fe788be4eb12bbebb7284648bf35b065925d 100644 --- a/src/redux/project/project.reducers.test.ts +++ b/src/redux/project/project.reducers.test.ts @@ -6,6 +6,7 @@ import { } from '@/utils/createStoreInstanceUsingSliceReducer'; import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; import { apiPath } from '../apiPath'; import projectReducer from './project.slice'; import { getProjectById } from './project.thunks'; @@ -48,11 +49,11 @@ describe('project reducer', () => { it('should update store after failed getProjectById query', async () => { mockedAxiosClient.onGet(apiPath.getProjectById(PROJECT_ID)).reply(HttpStatusCode.NotFound, []); - const { type, payload } = await store.dispatch(getProjectById(PROJECT_ID)); + const action = await store.dispatch(getProjectById(PROJECT_ID)); const { data, loading, error } = store.getState().project; - expect(type).toBe('project/getProjectById/rejected'); - expect(payload).toBe( + expect(action.type).toBe('project/getProjectById/rejected'); + expect(() => unwrapResult(action)).toThrow( "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'); diff --git a/src/redux/project/project.thunks.ts b/src/redux/project/project.thunks.ts index 23a73a0934aae9f5351d7810beb40666d93f9076..21d990741207aa2beea8d5cfab79a561fb47b1a8 100644 --- a/src/redux/project/project.thunks.ts +++ b/src/redux/project/project.thunks.ts @@ -3,16 +3,16 @@ 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 { DEFAULT_PROJECT_ID } from '@/constants'; +import { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { PROJECT_FETCHING_ERROR_PREFIX } from './project.constants'; import { SetProjectIdParams } from './project.types'; export const getProjectById = createAsyncThunk<Project | undefined, string, ThunkConfig>( 'project/getProjectById', - async (id, { rejectWithValue }) => { + async id => { try { const response = await axiosInstanceNewAPI.get<Project>(apiPath.getProjectById(id)); @@ -20,8 +20,7 @@ export const getProjectById = createAsyncThunk<Project | undefined, string, Thun return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: PROJECT_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: PROJECT_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/publications/publications.thunks.ts b/src/redux/publications/publications.thunks.ts index f50b611eec013b7e09a0b9773775f6fafc9e4cba..4c03def9f0b594ea9450d5733df8034d7bc8cce7 100644 --- a/src/redux/publications/publications.thunks.ts +++ b/src/redux/publications/publications.thunks.ts @@ -3,8 +3,8 @@ 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 { getError } from '@/utils/error-report/getError'; import { GetPublicationsParams } from './publications.types'; import { apiPath } from '../apiPath'; import { PUBLICATIONS_FETCHING_ERROR_PREFIX } from './publications.constatns'; @@ -13,7 +13,7 @@ export const getPublications = createAsyncThunk< PublicationsResponse | undefined, GetPublicationsParams, ThunkConfig ->('publications/getPublications', async (params, { rejectWithValue }) => { +>('publications/getPublications', async params => { try { const response = await axiosInstance.get<PublicationsResponse>(apiPath.getPublications(params)); @@ -21,7 +21,6 @@ export const getPublications = createAsyncThunk< return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: PUBLICATIONS_FETCHING_ERROR_PREFIX })); } }); diff --git a/src/redux/reactions/reactions.thunks.ts b/src/redux/reactions/reactions.thunks.ts index 610a83adfced0672fe526d609df7072842e3c264..5a650ebce706d23eecad1e8c1fdd72a95b2411d2 100644 --- a/src/redux/reactions/reactions.thunks.ts +++ b/src/redux/reactions/reactions.thunks.ts @@ -3,10 +3,10 @@ import { apiPath } from '@/redux/apiPath'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; import { Reaction } from '@/types/models'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { createAsyncThunk } from '@reduxjs/toolkit'; import { z } from 'zod'; +import { getError } from '@/utils/error-report/getError'; import { REACTIONS_FETCHING_ERROR_PREFIX } from './reactions.constants'; type GetReactionsByIdsArgs = @@ -25,7 +25,7 @@ export const getReactionsByIds = createAsyncThunk< GetReactionsByIdsResult | undefined, GetReactionsByIdsArgs, ThunkConfig ->('reactions/getByIds', async (args, { rejectWithValue }) => { +>('reactions/getByIds', async args => { const ids = args instanceof Array ? args : args.ids; const shouldConcat = args instanceof Array ? false : args.shouldConcat || false; @@ -42,7 +42,6 @@ export const getReactionsByIds = createAsyncThunk< shouldConcat, }; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: REACTIONS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: REACTIONS_FETCHING_ERROR_PREFIX })); } }); diff --git a/src/redux/search/search.thunks.ts b/src/redux/search/search.thunks.ts index da0607cd0fc7a73500a42821a574cd65e13cdd9d..5d1e36e8b7c5c7a818be6981636a9935c0ee1680 100644 --- a/src/redux/search/search.thunks.ts +++ b/src/redux/search/search.thunks.ts @@ -3,8 +3,8 @@ import { getMultiChemicals } from '@/redux/chemicals/chemicals.thunks'; import { getMultiDrugs } from '@/redux/drugs/drugs.thunks'; import { PerfectMultiSearchParams } from '@/types/search'; import { ThunkConfig } from '@/types/store'; -import { getErrorMessage } from '@/utils/getErrorMessage'; import { createAsyncThunk } from '@reduxjs/toolkit'; +import { getError } from '@/utils/error-report/getError'; import { resetReactionsData } from '../reactions/reactions.slice'; import type { RootState } from '../store'; import { DATA_SEARCHING_ERROR_PREFIX } from './search.constants'; @@ -20,7 +20,7 @@ export const getSearchData = createAsyncThunk< >( 'project/getSearchData', // eslint-disable-next-line consistent-return - async ({ searchQueries, isPerfectMatch }, { dispatch, getState, rejectWithValue }) => { + async ({ searchQueries, isPerfectMatch }, { dispatch, getState }) => { try { dispatch(resetReactionsData()); @@ -46,9 +46,7 @@ export const getSearchData = createAsyncThunk< dispatchPluginsEvents(searchQueries, getState()); } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: DATA_SEARCHING_ERROR_PREFIX }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: DATA_SEARCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/statistics/statistics.reducers.test.ts b/src/redux/statistics/statistics.reducers.test.ts index 6d620db1720db9fa9f929ed84fe94c1cb32d8b0c..45a0b8430eb9cca2df2a7eae9fcb08c5487cd4a6 100644 --- a/src/redux/statistics/statistics.reducers.test.ts +++ b/src/redux/statistics/statistics.reducers.test.ts @@ -7,6 +7,7 @@ import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { waitFor } from '@testing-library/react'; import { statisticsFixture } from '@/models/fixtures/statisticsFixture'; +import { unwrapResult } from '@reduxjs/toolkit'; import { StatisticsState } from './statistics.types'; import statisticsReducer from './statistics.slice'; import { apiPath } from '../apiPath'; @@ -56,11 +57,12 @@ describe('statistics reducer', () => { .onGet(apiPath.getStatisticsById(PROJECT_ID)) .reply(HttpStatusCode.NotFound, undefined); - const { type, payload } = await store.dispatch(getStatisticsById(PROJECT_ID)); + const action = await store.dispatch(getStatisticsById(PROJECT_ID)); + const { loading } = store.getState().statistics; - expect(type).toBe('statistics/getStatisticsById/rejected'); - expect(payload).toBe( + expect(action.type).toBe('statistics/getStatisticsById/rejected'); + expect(() => unwrapResult(action)).toThrow( "Failed to fetch statistics: The page you're looking for doesn't exist. Please verify the URL and try again.", ); diff --git a/src/redux/statistics/statistics.thunks.ts b/src/redux/statistics/statistics.thunks.ts index 1a683a4e3ea3e50238f1fce85b47dbde93b59b98..dcaf0100818bce4c9d6a5c90f5698fc11adcbfa7 100644 --- a/src/redux/statistics/statistics.thunks.ts +++ b/src/redux/statistics/statistics.thunks.ts @@ -3,14 +3,14 @@ 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 { getError } from '@/utils/error-report/getError'; import { apiPath } from '../apiPath'; import { STATISTICS_FETCHING_ERROR_PREFIX } from './statistics.constants'; export const getStatisticsById = createAsyncThunk<Statistics | undefined, string, ThunkConfig>( 'statistics/getStatisticsById', - async (id, { rejectWithValue }) => { + async id => { try { const response = await axiosInstance.get<Statistics>(apiPath.getStatisticsById(id)); @@ -18,8 +18,7 @@ export const getStatisticsById = createAsyncThunk<Statistics | undefined, string return isDataValid ? response.data : undefined; } catch (error) { - const errorMessage = getErrorMessage({ error, prefix: STATISTICS_FETCHING_ERROR_PREFIX }); - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: STATISTICS_FETCHING_ERROR_PREFIX })); } }, ); diff --git a/src/redux/user/user.mock.ts b/src/redux/user/user.mock.ts index 5d372ee070a0d3d282ade62cfaba0972cef2d462..ac2813cfdadb09548212b8b0f0dd5e80bef14511 100644 --- a/src/redux/user/user.mock.ts +++ b/src/redux/user/user.mock.ts @@ -6,4 +6,5 @@ export const USER_INITIAL_STATE_MOCK: UserState = { error: { name: '', message: '' }, login: null, role: null, + userData: null, }; diff --git a/src/redux/user/user.reducers.test.ts b/src/redux/user/user.reducers.test.ts index 9d0fd4093fb3bbf2619528c8be7bb8fb63756116..3033a51affb22201af934a6d97121115644077b9 100644 --- a/src/redux/user/user.reducers.test.ts +++ b/src/redux/user/user.reducers.test.ts @@ -8,7 +8,7 @@ import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; import { HttpStatusCode } from 'axios'; import { loginFixture } from '@/models/fixtures/loginFixture'; import { sessionFixture } from '@/models/fixtures/sessionFixture'; -import { userPrivilegesFixture } from '@/models/fixtures/userPrivilegesFixture'; +import { userFixture } from '@/models/fixtures/userFixture'; import { apiPath } from '../apiPath'; import userReducer from './user.slice'; @@ -25,6 +25,7 @@ const INITIAL_STATE: UserState = { error: { name: '', message: '' }, login: null, role: null, + userData: null, }; describe('user reducer', () => { @@ -40,9 +41,7 @@ describe('user reducer', () => { it('should update store after successful login query', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); - mockedAxiosClient - .onGet(apiPath.userPrivileges(loginFixture.login)) - .reply(HttpStatusCode.Ok, userPrivilegesFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); await store.dispatch(login(CREDENTIALS)); const { authenticated, loading } = store.getState().user; @@ -52,6 +51,7 @@ describe('user reducer', () => { it('should update store on loading login query', async () => { mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); const loginPromise = store.dispatch(login(CREDENTIALS)); const { authenticated, loading } = store.getState().user; @@ -63,21 +63,26 @@ describe('user reducer', () => { const { authenticated: authenticatedFulfilled, loading: promiseFulfilled } = store.getState().user; - expect(authenticatedFulfilled).toBe(true); expect(promiseFulfilled).toEqual('succeeded'); + expect(authenticatedFulfilled).toBe(true); }); it('should update store after successful getSessionValid query', async () => { mockedAxiosClient.onGet(apiPath.getSessionValid()).reply(HttpStatusCode.Ok, sessionFixture); mockedAxiosClient - .onGet(apiPath.userPrivileges(sessionFixture.login)) - .reply(HttpStatusCode.Ok, userPrivilegesFixture); + .onGet(apiPath.user(sessionFixture.login)) + .reply(HttpStatusCode.Ok, userFixture); + + mockedAxiosClient + .onGet(apiPath.user(sessionFixture.login)) + .reply(HttpStatusCode.Ok, userFixture); await store.dispatch(getSessionValid()); - const { authenticated, loading, login: sessionLogin } = store.getState().user; + const { authenticated, loading, login: sessionLogin, userData } = store.getState().user; - expect(authenticated).toBe(true); expect(loading).toEqual('succeeded'); + expect(authenticated).toBe(true); expect(sessionLogin).toBeDefined(); + expect(userData).toBeDefined(); }); }); diff --git a/src/redux/user/user.reducers.ts b/src/redux/user/user.reducers.ts index 8bad6c7e195adf406eeba3b5d4e85d1afab82706..2cb2002a5ad810bd67698c60aaa5618ec3ab41b3 100644 --- a/src/redux/user/user.reducers.ts +++ b/src/redux/user/user.reducers.ts @@ -12,6 +12,7 @@ export const loginReducer = (builder: ActionReducerMapBuilder<UserState>): void state.loading = 'succeeded'; state.role = action.payload?.role || null; state.login = action.payload?.login || null; + state.userData = action.payload?.userData || null; }) .addCase(login.rejected, state => { state.authenticated = false; @@ -29,6 +30,7 @@ export const getSessionValidReducer = (builder: ActionReducerMapBuilder<UserStat state.loading = 'succeeded'; state.login = action.payload?.login || null; state.role = action.payload?.role || null; + state.userData = action.payload?.userData || null; }) .addCase(getSessionValid.rejected, state => { state.authenticated = false; @@ -47,6 +49,7 @@ export const logoutReducer = (builder: ActionReducerMapBuilder<UserState>): void state.loading = 'succeeded'; state.role = null; state.login = null; + state.userData = null; }) .addCase(logout.rejected, state => { state.loading = 'failed'; diff --git a/src/redux/user/user.slice.ts b/src/redux/user/user.slice.ts index 325d9520aebe83d3c042829ae2e8e93bac9a6ce5..bf9f7f388be72f4f379e258fe78705dd60dcbcf3 100644 --- a/src/redux/user/user.slice.ts +++ b/src/redux/user/user.slice.ts @@ -8,6 +8,7 @@ export const initialState: UserState = { error: { name: '', message: '' }, login: null, role: null, + userData: null, }; export const userSlice = createSlice({ diff --git a/src/redux/user/user.thunks.test.ts b/src/redux/user/user.thunks.test.ts index 982274d9bd4d6190f7eb3fb4f7196f2ff8b38d70..e0baefa9c8765d0ea64723c2e27ffb70f10387dd 100644 --- a/src/redux/user/user.thunks.test.ts +++ b/src/redux/user/user.thunks.test.ts @@ -5,12 +5,15 @@ import { ToolkitStoreWithSingleSlice, createStoreInstanceUsingSliceReducer, } from '@/utils/createStoreInstanceUsingSliceReducer'; +import { showToast } from '@/utils/showToast'; import { apiPath } from '../apiPath'; import { closeModal } from '../modal/modal.slice'; import userReducer from './user.slice'; import { UserState } from './user.types'; import { login } from './user.thunks'; +jest.mock('../../utils/showToast'); + const mockedAxiosClient = mockNetworkResponse(); const CREDENTIALS = { login: 'test', @@ -50,4 +53,15 @@ describe('login thunk', () => { await store.dispatch(login(CREDENTIALS)); }); + + it('dispatch showToast on failed login with invalid data', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Unauthorized, loginFixture); + + await store.dispatch(login(CREDENTIALS)); + + expect(showToast).toHaveBeenCalledWith({ + message: 'Invalid credentials.', + type: 'error', + }); + }); }); diff --git a/src/redux/user/user.thunks.ts b/src/redux/user/user.thunks.ts index 5741e090ceaf830c7abc704a847becf95cee9668..6abb07c34200d96522c4c69c59586f950b44b160 100644 --- a/src/redux/user/user.thunks.ts +++ b/src/redux/user/user.thunks.ts @@ -3,22 +3,16 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { loginSchema } from '@/models/loginSchema'; import { sessionSchemaValid } from '@/models/sessionValidSchema'; -import { Login, SessionValid, UserPrivileges } from '@/types/models'; -import { getErrorMessage } from '@/utils/getErrorMessage'; +import { Login, SessionValid, User, UserPrivilege } from '@/types/models'; import { USER_ROLE } from '@/constants/user'; +import { getError } from '@/utils/error-report/getError'; +import axios, { HttpStatusCode } from 'axios'; +import { showToast } from '@/utils/showToast'; import { apiPath } from '../apiPath'; import { closeModal, openLoggedInMenuModal } from '../modal/modal.slice'; import { hasPrivilege } from './user.utils'; -const getUserRole = async (login: string): Promise<string> => { - const response = await axiosInstance.get<UserPrivileges>(apiPath.userPrivileges(login), { - withCredentials: true, - }); - - const { - data: { privileges }, - } = response; - +const getUserRole = (privileges: UserPrivilege[]): string => { if (hasPrivilege(privileges, 'IS_ADMIN')) { return USER_ROLE.ADMIN; } @@ -29,9 +23,17 @@ const getUserRole = async (login: string): Promise<string> => { return USER_ROLE.USER; }; +const getUserData = async (login: string): Promise<User> => { + const response = await axiosInstance.get<User>(apiPath.user(login), { + withCredentials: true, + }); + + return response.data; +}; + export const login = createAsyncThunk( 'user/login', - async (credentials: { login: string; password: string }, { dispatch, rejectWithValue }) => { + async (credentials: { login: string; password: string }, { dispatch }) => { try { const searchParams = new URLSearchParams(credentials); const response = await axiosInstance.post<Login>(apiPath.postLogin(), searchParams, { @@ -42,7 +44,8 @@ export const login = createAsyncThunk( const loginName = response.data.login; if (isDataValid) { - const role = await getUserRole(loginName); + const userData = await getUserData(loginName); + const role = getUserRole(userData.privileges); if (role !== USER_ROLE.ADMIN && role !== USER_ROLE.CURATOR) { dispatch(closeModal()); @@ -53,17 +56,22 @@ export const login = createAsyncThunk( return { login: loginName, role, + userData, }; } return undefined; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: 'Login', - }); - - return rejectWithValue(errorMessage); + if (axios.isAxiosError(error)) { + if (error?.response?.status === HttpStatusCode.Unauthorized) { + showToast({ + type: 'error', + message: 'Invalid credentials.', + }); + return undefined; + } + } + return Promise.reject(getError({ error, prefix: 'Login' })); } }, ); @@ -79,11 +87,13 @@ export const getSessionValid = createAsyncThunk('user/getSessionValid', async () data: { login: loginName }, } = response; - const role = await getUserRole(loginName); + const userData = await getUserData(loginName); + const role = getUserRole(userData.privileges); if (isDataValid) { return { login: loginName, + userData, role, }; } @@ -91,7 +101,7 @@ export const getSessionValid = createAsyncThunk('user/getSessionValid', async () return undefined; }); -export const logout = createAsyncThunk('user/logout', async (_, { rejectWithValue }) => { +export const logout = createAsyncThunk('user/logout', async () => { try { await axiosInstance.post(apiPath.logout(), null, { withCredentials: true, @@ -99,11 +109,6 @@ export const logout = createAsyncThunk('user/logout', async (_, { rejectWithValu return undefined; } catch (error) { - const errorMessage = getErrorMessage({ - error, - prefix: 'Log out', - }); - - return rejectWithValue(errorMessage); + return Promise.reject(getError({ error, prefix: 'Log out' })); } }); diff --git a/src/redux/user/user.types.ts b/src/redux/user/user.types.ts index 459a86e3861930f8892211fa05d3164a70ea4015..08210c0a3b06dbb700b583fcbf76073ecd838784 100644 --- a/src/redux/user/user.types.ts +++ b/src/redux/user/user.types.ts @@ -1,4 +1,5 @@ import { Loading } from '@/types/loadingState'; +import { User } from '@/types/models'; export type UserState = { loading: Loading; @@ -6,4 +7,5 @@ export type UserState = { authenticated: boolean; login: null | string; role: string | null; + userData: User | null; }; diff --git a/src/redux/user/user.utils.ts b/src/redux/user/user.utils.ts index 89a7afb8ae7e651dd682d7b9d0c2ff15c1cf8c87..ecb9c6239006f351b806b4fbf32d7bcf5cacd652 100644 --- a/src/redux/user/user.utils.ts +++ b/src/redux/user/user.utils.ts @@ -1,8 +1,5 @@ -import { UserPrivileges } from '@/types/models'; +import { UserPrivilege } from '@/types/models'; -export const hasPrivilege = ( - privileges: UserPrivileges['privileges'], - privilegeType: string, -): boolean => { +export const hasPrivilege = (privileges: UserPrivilege[], privilegeType: string): boolean => { return privileges.some(privilege => privilege.privilegeType === privilegeType); }; diff --git a/src/shared/Input/Input.component.tsx b/src/shared/Input/Input.component.tsx index 68cf9097dbb05b652a539d52cb0ccdefdc9901d1..00f3e9240109b21b66e54974b9676daf5ee8c11b 100644 --- a/src/shared/Input/Input.component.tsx +++ b/src/shared/Input/Input.component.tsx @@ -1,7 +1,7 @@ import React, { InputHTMLAttributes } from 'react'; import { twMerge } from 'tailwind-merge'; -type StyleVariant = 'primary'; +type StyleVariant = 'primary' | 'primaryWithoutFull'; type SizeVariant = 'small' | 'medium'; type InputProps = { @@ -13,6 +13,8 @@ type InputProps = { const styleVariants = { primary: 'w-full border border-transparent bg-cultured px-2 py-2.5 font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', + primaryWithoutFull: + 'border border-transparent bg-cultured px-2 py-2.5 font-semibold outline-none hover:border-greyscale-600 focus:border-greyscale-600', } as const; const sizeVariants = { diff --git a/src/types/modal.ts b/src/types/modal.ts index b268bf17ab32baf80f7adda78c5770c0bc482578..865adda9bd489d0d216fdc7f93abc51b1bf36970 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -5,4 +5,5 @@ export type ModalName = | 'login' | 'publications' | 'edit-overlay' + | 'error-report' | 'logged-in-menu'; diff --git a/src/types/models.ts b/src/types/models.ts index 0bebd142f8e74520ca349a519070a3555fdfd386..53d477a7f88a2dcb40b451db206236aa3e9fd86d 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -59,9 +59,11 @@ import { submapConnection } from '@/models/submapConnection'; import { targetElementSchema } from '@/models/targetElementSchema'; import { targetSchema } from '@/models/targetSchema'; import { targetSearchNameResult } from '@/models/targetSearchNameResult'; -import { userPrivilegesSchema } from '@/models/userPrivilegesSchema'; +import { userPrivilegeSchema } from '@/models/userPrivilegesSchema'; import { z } from 'zod'; import { commentSchema } from '@/models/commentSchema'; +import { userSchema } from '@/models/userSchema'; +import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -112,11 +114,13 @@ export type GeneVariant = z.infer<typeof geneVariant>; export type TargetSearchNameResult = z.infer<typeof targetSearchNameResult>; export type TargetElement = z.infer<typeof targetElementSchema>; export type SubmapConnection = z.infer<typeof submapConnection>; -export type UserPrivileges = z.infer<typeof userPrivilegesSchema>; +export type UserPrivilege = z.infer<typeof userPrivilegeSchema>; +export type User = z.infer<typeof userSchema>; export type MarkerType = z.infer<typeof markerTypeSchema>; export type MarkerPin = z.infer<typeof markerPinSchema>; export type MarkerSurface = z.infer<typeof markerSurfaceSchema>; export type MarkerLine = z.infer<typeof markerLineSchema>; export type MarkerWithPosition = z.infer<typeof markerWithPositionSchema>; export type Marker = z.infer<typeof markerSchema>; +export type JavaStacktrace = z.infer<typeof javaStacktraceSchema>; export type Comment = z.infer<typeof commentSchema>; diff --git a/src/utils/error-report/ErrorData.ts b/src/utils/error-report/ErrorData.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d807d9e0f944503ed56c92e0cdd7459d1c77fa2 --- /dev/null +++ b/src/utils/error-report/ErrorData.ts @@ -0,0 +1,12 @@ +export type ErrorData = { + url: string | null; + login: string | null; + email: string | null; + browser: string | null; + timestamp: number | null; + version: string | null; + comment: string | null; + message: string; + stacktrace: string; + javaStacktrace: string | null; +}; diff --git a/src/utils/error-report/errorReporting.test.ts b/src/utils/error-report/errorReporting.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7534585eb016e148acfba0211c8d8501b73eb6de --- /dev/null +++ b/src/utils/error-report/errorReporting.test.ts @@ -0,0 +1,94 @@ +import { createErrorData } from '@/utils/error-report/errorReporting'; +import { apiPath } from '@/redux/apiPath'; +import { HttpStatusCode } from 'axios'; +import { loginFixture } from '@/models/fixtures/loginFixture'; +import { login, logout } from '@/redux/user/user.thunks'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { store } from '@/redux/store'; +import { getConfiguration } from '@/redux/configuration/configuration.thunks'; +import { configurationFixture } from '@/models/fixtures/configurationFixture'; +import { userFixture } from '@/models/fixtures/userFixture'; +import { SerializedError } from '@reduxjs/toolkit'; +import { javaStacktraceFixture } from '@/models/fixtures/javaStacktraceFixture'; + +const mockedAxiosClient = mockNetworkResponse(); + +const CREDENTIALS = { + login: 'test', + password: 'password', +}; + +describe('createErrorData', () => { + it('should add stacktrace', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); + expect(error.stacktrace).not.toEqual(''); + }); + + it('should add url', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); + expect(error.url).not.toBeNull(); + }); + + it('should add browser', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); + expect(error.browser).not.toBeNull(); + }); + + it('should add guest login when not logged', async () => { + const error = await createErrorData(new Error('hello'), store.getState()); + expect(error.login).toBe('anonymous'); + }); + + it('should add login when logged', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); + await store.dispatch(login(CREDENTIALS)); + + const error = await createErrorData(new Error('hello'), store.getState()); + expect(error.login).not.toBe('anonymous'); + expect(error.login).toBe(loginFixture.login); + }); + + it('should add timestamp', async () => { + const error = await createErrorData(new Error(), store.getState()); + expect(error.timestamp).not.toBeNull(); + }); + + it('should add version', async () => { + mockedAxiosClient + .onGet(apiPath.getConfiguration()) + .reply(HttpStatusCode.Ok, configurationFixture); + await store.dispatch(getConfiguration()); + + const error = await createErrorData(new Error(), store.getState()); + expect(error.version).not.toBeNull(); + }); + + it('should add email when logged', async () => { + mockedAxiosClient.onPost(apiPath.postLogin()).reply(HttpStatusCode.Ok, loginFixture); + mockedAxiosClient.onGet(apiPath.user(loginFixture.login)).reply(HttpStatusCode.Ok, userFixture); + await store.dispatch(login(CREDENTIALS)); + + const error = await createErrorData(new Error(), store.getState()); + expect(error.email).toBe(userFixture.email); + }); + + it('email should be empty when not logged', async () => { + mockedAxiosClient.onPost(apiPath.logout()).reply(HttpStatusCode.Ok, {}); + await store.dispatch(logout()); + const error = await createErrorData(new Error(), store.getState()); + expect(error.email).toBeNull(); + }); + + it('java stacktrace should be attached if error report provides info', async () => { + mockedAxiosClient + .onGet(apiPath.getStacktrace('dab932be-1e2e-45d7-b57a-aff30e2629e6')) + .reply(HttpStatusCode.Ok, javaStacktraceFixture); + + const error: SerializedError = { + code: 'dab932be-1e2e-45d7-b57a-aff30e2629e6', + }; + const errorData = await createErrorData(error, store.getState()); + expect(errorData.javaStacktrace).not.toBeNull(); + }); +}); diff --git a/src/utils/error-report/errorReporting.ts b/src/utils/error-report/errorReporting.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bc652535e50df366fad0a6f6cda6afe1f618a02 --- /dev/null +++ b/src/utils/error-report/errorReporting.ts @@ -0,0 +1,77 @@ +import { ErrorData } from '@/utils/error-report/ErrorData'; +import { SerializedError } from '@reduxjs/toolkit'; +import { ONE_THOUSAND } from '@/constants/common'; +import { + GENERIC_AXIOS_ERROR_CODE, + NOT_FOUND_AXIOS_ERROR_CODE, + UNKNOWN_AXIOS_ERROR_CODE, + UNKNOWN_ERROR, +} from '@/utils/getErrorMessage/getErrorMessage.constants'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { JavaStacktrace } from '@/types/models'; +import { apiPath } from '@/redux/apiPath'; +import type { RootState } from '@/redux/store'; + +export const createErrorData = async ( + error: Error | SerializedError | undefined, + state: RootState, +): Promise<ErrorData> => { + let stacktrace = ''; + let message = ''; + if (error !== undefined) { + stacktrace = error.stack !== undefined ? error.stack : ''; + message = error.message !== undefined ? error.message : ''; + } + + let login = null; + let userData = null; + + if (state.user) { + login = state.user.login; + userData = state.user.userData; + } + if (!login) { + login = 'anonymous'; + } + + let email = null; + if (userData) { + email = userData.email; + } + + const configuration = state?.configuration?.main?.data; + const version = configuration ? configuration.version : null; + + let javaStacktrace = null; + if (error !== undefined && 'code' in error) { + const { code } = error; + if ( + code && + code !== UNKNOWN_ERROR && + code !== UNKNOWN_AXIOS_ERROR_CODE && + code !== GENERIC_AXIOS_ERROR_CODE && + code !== NOT_FOUND_AXIOS_ERROR_CODE + ) { + try { + javaStacktrace = (await axiosInstance.get<JavaStacktrace>(apiPath.getStacktrace(code))).data + .content; + } catch (e) { + // eslint-disable-next-line no-console + console.log('Problem with fetching javaStacktrace', e); + } + } + } + + return { + url: window?.location?.href, + login, + browser: navigator.userAgent, + comment: null, + email, + javaStacktrace, + stacktrace, + timestamp: Math.floor(+new Date() / ONE_THOUSAND), + version, + message, + }; +}; diff --git a/src/utils/error-report/getError.ts b/src/utils/error-report/getError.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1da418173ac5d178e1f4fc4e5feee24a4af0ad2 --- /dev/null +++ b/src/utils/error-report/getError.ts @@ -0,0 +1,26 @@ +import { getErrorMessage } from '@/utils/getErrorMessage'; +import { SerializedError } from '@reduxjs/toolkit'; +import { getErrorName } from '@/utils/error-report/getErrorName'; +import { getErrorCode } from '@/utils/error-report/getErrorCode'; +import { getErrorStack } from '@/utils/error-report/getErrorStack'; + +type GetErrorMessageConfig = { + error: unknown; + message?: string; + prefix?: string; +}; + +export const getError = ({ error, message, prefix }: GetErrorMessageConfig): SerializedError => { + const errorMessage = getErrorMessage({ error, message, prefix }); + + const name = getErrorName(error); + const stack = getErrorStack(error); + const code = getErrorCode(error); + + return { + name, + message: errorMessage, + stack, + code, + }; +}; diff --git a/src/utils/error-report/getErrorCode.ts b/src/utils/error-report/getErrorCode.ts new file mode 100644 index 0000000000000000000000000000000000000000..df25ac8e84fcd7fd26e4a27b0b78c2fc4061770e --- /dev/null +++ b/src/utils/error-report/getErrorCode.ts @@ -0,0 +1,27 @@ +import axios from 'axios'; +import { + UNKNOWN_AXIOS_ERROR_CODE, + UNKNOWN_ERROR, +} from '../getErrorMessage/getErrorMessage.constants'; + +export const getErrorCode = (error: unknown): string => { + if (axios.isAxiosError(error)) { + let code = UNKNOWN_AXIOS_ERROR_CODE; + try { + if (error.response) { + if (typeof error.response.data === 'object') { + code = error.response.data['error-id']; + } else if (typeof error.response.data === 'string') { + code = JSON.parse(error.response.data)['error-id']; + } + if (code === undefined || code === null) { + code = `${error.response.status}`; + } + } + } catch (e) { + code = UNKNOWN_AXIOS_ERROR_CODE; + } + return code; + } + return UNKNOWN_ERROR; +}; diff --git a/src/utils/error-report/getErrorName.ts b/src/utils/error-report/getErrorName.ts new file mode 100644 index 0000000000000000000000000000000000000000..163a0d860c9f3a87809a3262d941b30a692dbca6 --- /dev/null +++ b/src/utils/error-report/getErrorName.ts @@ -0,0 +1,12 @@ +import axios from 'axios'; +import { UNKNOWN_ERROR } from '../getErrorMessage/getErrorMessage.constants'; + +export const getErrorName = (error: unknown): string => { + if (axios.isAxiosError(error)) { + return error.name; + } + if (error instanceof Error) { + return error.name; + } + return UNKNOWN_ERROR; +}; diff --git a/src/utils/error-report/getErrorStack.ts b/src/utils/error-report/getErrorStack.ts new file mode 100644 index 0000000000000000000000000000000000000000..e148696f16214a786cb7a48ee1862fa3743bbbe2 --- /dev/null +++ b/src/utils/error-report/getErrorStack.ts @@ -0,0 +1,14 @@ +import axios from 'axios'; +import { getErrorUrl } from '@/utils/error-report/getErrorUrl'; + +export const getErrorStack = (error: unknown): string => { + let stack = null; + if (axios.isAxiosError(error)) { + const url = getErrorUrl(error); + + stack = (url ? `(Request URL: ${url}) ` : '') + error.stack; + } else if (error instanceof Error) { + stack = error.stack; + } + return stack || 'No stack provided'; +}; diff --git a/src/utils/error-report/getErrorUrl.ts b/src/utils/error-report/getErrorUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..53ef46e9f4e5c4bf4bb9541f252594f357c8de8f --- /dev/null +++ b/src/utils/error-report/getErrorUrl.ts @@ -0,0 +1,12 @@ +import axios from 'axios'; + +export const getErrorUrl = (error: unknown): string | null => { + if (axios.isAxiosError(error)) { + if (error.request) { + if (error.request.responseURL) { + return error.request.responseURL; + } + } + } + return null; +}; diff --git a/src/utils/error-report/sendErrorReport.ts b/src/utils/error-report/sendErrorReport.ts new file mode 100644 index 0000000000000000000000000000000000000000..6fa588214425c86af0d98ffeb2d2e86dcd9e182e --- /dev/null +++ b/src/utils/error-report/sendErrorReport.ts @@ -0,0 +1,20 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { Login } from '@/types/models'; +import { ErrorData } from '@/utils/error-report/ErrorData'; +import { apiPath } from '@/redux/apiPath'; +import { showToast } from '@/utils/showToast'; + +export const sendReport = createAsyncThunk('error/report', async (errorData: ErrorData) => { + try { + await axiosInstance.post<Login>(apiPath.submitError(), errorData); + showToast({ type: 'success', message: 'Error report sent successfully.' }); + } catch (error) { + // eslint-disable-next-line no-console + console.log(error); + showToast({ + type: 'error', + message: 'Unexpected error. More information can be found in the console.', + }); + } +}); diff --git a/src/utils/getErrorMessage/getErrorMessage.constants.ts b/src/utils/getErrorMessage/getErrorMessage.constants.ts index 00b84d7087cf177ecf1a129633867153ce228e48..e6d05b2732652bf8ceb927f41c324af05b0526b8 100644 --- a/src/utils/getErrorMessage/getErrorMessage.constants.ts +++ b/src/utils/getErrorMessage/getErrorMessage.constants.ts @@ -1,4 +1,7 @@ export const UNKNOWN_ERROR = 'An unknown error occurred. Please try again later.'; +export const UNKNOWN_AXIOS_ERROR_CODE = 'UNKNOWN_AXIOS_ERROR'; +export const NOT_FOUND_AXIOS_ERROR_CODE = '404'; +export const GENERIC_AXIOS_ERROR_CODE = 'ERR_BAD_REQUEST'; export const HTTP_ERROR_MESSAGES = { 400: "The server couldn't understand your request. Please check your input and try again.",