diff --git a/src/assets/images/orcid.png b/src/assets/images/orcid.png new file mode 100644 index 0000000000000000000000000000000000000000..2db382dfc069b8a098c876a8d38add9977bc406a Binary files /dev/null and b/src/assets/images/orcid.png differ diff --git a/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx b/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2cd0b46b08fd232978af3fe8db18240334b1ec0e --- /dev/null +++ b/src/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component.tsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useAppSelector } from '@/redux/hooks/useAppSelector'; +import { loginUserSelector } from '@/redux/user/user.selectors'; +import { openLoginModal } from '@/redux/modal/modal.slice'; +import { MINUS_ONE, ZERO } from '@/constants/common'; +import { Button } from '@/shared/Button'; +import { adminEmailValSelector } from '@/redux/configuration/configuration.selectors'; +import { projectsSelector } from '@/redux/projects/projects.selectors'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; + +export const AccessDeniedModal: React.FC = () => { + const dispatch = useAppDispatch(); + const login = useAppSelector(loginUserSelector); + const projects = useAppSelector(projectsSelector); + + const adminEmail = useAppSelector(adminEmailValSelector); + + const isAnonymousLogin = !login; + + const isProjectsAvailable = projects.length > ZERO; + + const isAdminEmail = adminEmail !== '' && adminEmail !== undefined; + + const handleGoBack = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => { + e.preventDefault(); + window.history.go(MINUS_ONE); + }; + + const handleLogin = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => { + e.preventDefault(); + dispatch(getOAuth()); + dispatch(openLoginModal()); + }; + + const handleContactAmin = async (e: React.FormEvent<HTMLButtonElement>): Promise<void> => { + e.preventDefault(); + window.location.href = `mailto:${adminEmail}`; + }; + + const openProject = (e: React.FormEvent<HTMLButtonElement>): void => { + window.location.href = `?id=${e.currentTarget.value}`; + }; + + return ( + <div className="w-[400px] border border-t-[#E1E0E6] bg-white p-[24px]"> + {isAnonymousLogin && ( + <div className="grid grid-cols-2 gap-2"> + <div> + <Button + className="ring-transparent hover:ring-transparent" + variantStyles="secondary" + onClick={handleGoBack} + > + Go back to previous page + </Button> + </div> + <div className="text-center"> + <Button className="block w-full" onClick={handleLogin}> + Login to your account + </Button> + </div> + </div> + )} + {isProjectsAvailable && ( + <div> + <div className="mb-1 mt-5 text-sm">Switch to another map</div> + <div className="grid grid-cols-3 gap-2"> + {projects.map(project => ( + <Button + key={project.projectId} + value={project.projectId} + variantStyles="ghost" + className="text-center text-gray-500" + onClick={openProject} + > + {project.name} ({project.projectId}) + </Button> + ))} + </div> + </div> + )} + {isAdminEmail && ( + <div className="mt-1 text-center"> + <Button + className="block w-full ring-transparent hover:ring-transparent" + variantStyles="secondary" + onClick={handleContactAmin} + > + Contact admin + </Button> + </div> + )} + </div> + ); +}; diff --git a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx index 3fa89a01282b34bd7a1dec3b1b3698f260ea16c8..58e6e2f672b5120cb8a691f118352b34c12e6307 100644 --- a/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx +++ b/src/components/FunctionalArea/Modal/LoginModal/LoginModal.component.tsx @@ -7,6 +7,10 @@ import { Button } from '@/shared/Button'; import { Input } from '@/shared/Input'; import Link from 'next/link'; import React from 'react'; +import { BASE_API_URL } from '@/constants'; +import { orcidEndpointSelector } from '@/redux/oauth/oauth.selectors'; +import Image from 'next/image'; +import orcidLogoImg from '@/assets/images/orcid.png'; export const LoginModal: React.FC = () => { const dispatch = useAppDispatch(); @@ -14,11 +18,19 @@ export const LoginModal: React.FC = () => { const isPending = loadingUser === 'pending'; const [credentials, setCredentials] = React.useState({ login: '', password: '' }); + const orcidEndpoint = useAppSelector(orcidEndpointSelector); + + const isOrcidAvailable = orcidEndpoint !== undefined; + const handleChange = (e: React.ChangeEvent<HTMLInputElement>): void => { const { name, value } = e.target; setCredentials(prevCredentials => ({ ...prevCredentials, [name]: value })); }; + const handleLoginViaOrcid = (): void => { + window.location.href = `${BASE_API_URL}/..${orcidEndpoint}`; + }; + const handleSubmit = async (e: React.FormEvent<HTMLFormElement>): Promise<void> => { e.preventDefault(); await dispatch(login(credentials)); @@ -57,6 +69,16 @@ export const LoginModal: React.FC = () => { Forgot password? </Link> </div> + {isOrcidAvailable && ( + <Button + variantStyles="quiet" + className="mb-1 w-full justify-center text-base font-medium" + onClick={handleLoginViaOrcid} + > + <Image src={orcidLogoImg} alt="orcid logo" height={32} width={32} className="mr-1.5" /> + Sign in with Orcid + </Button> + )} <Button type="submit" className="w-full justify-center text-base font-medium" diff --git a/src/components/FunctionalArea/Modal/Modal.component.tsx b/src/components/FunctionalArea/Modal/Modal.component.tsx index 1fa31af6a7f052d1005bf4d6155f06352292d16c..8e2a02b528cf172eebb868fd183008da565dde12 100644 --- a/src/components/FunctionalArea/Modal/Modal.component.tsx +++ b/src/components/FunctionalArea/Modal/Modal.component.tsx @@ -1,6 +1,7 @@ import { useAppSelector } from '@/redux/hooks/useAppSelector'; import { modalSelector } from '@/redux/modal/modal.selector'; import dynamic from 'next/dynamic'; +import { AccessDeniedModal } from '@/components/FunctionalArea/Modal/AccessDeniedModal/AccessDeniedModal.component'; import { AddCommentModal } from '@/components/FunctionalArea/Modal/AddCommentModal/AddCommentModal.component'; import { EditOverlayModal } from './EditOverlayModal'; import { LoginModal } from './LoginModal'; @@ -51,6 +52,11 @@ export const Modal = (): React.ReactNode => { <LoggedInMenuModal /> </ModalLayout> )} + {isOpen && modalName === 'access-denied' && ( + <ModalLayout> + <AccessDeniedModal /> + </ModalLayout> + )} {isOpen && modalName === 'add-comment' && ( <ModalLayout> <AddCommentModal /> diff --git a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx index 392a673a0a05cef759abe4e2918d7246b8a00cf0..3afcf1f8cfc442b64f8ced8934545a33337b4d2c 100644 --- a/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx +++ b/src/components/FunctionalArea/Modal/ModalLayout/ModalLayout.component.tsx @@ -28,19 +28,22 @@ 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 === 'access-denied' && 'h-auto w-[400px]', modalName === 'add-comment' && '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"> - {modalName === 'error-report' && ( + {(modalName === 'error-report' || modalName === 'access-denied') && ( <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 !== 'error-report' && modalName !== 'access-denied' && ( + <div> {modalTitle} </div> + )} {modalName !== 'logged-in-menu' && ( <button type="button" onClick={handleCloseModal} aria-label="close button"> diff --git a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx index 6198869f43d302eedd7269fe183e1622eed445a3..707e0f5dac4dac4709f7d47e1eef9e5ee3e226ba 100644 --- a/src/components/FunctionalArea/TopBar/User/User.component.test.tsx +++ b/src/components/FunctionalArea/TopBar/User/User.component.test.tsx @@ -8,6 +8,7 @@ import { apiPath } from '@/redux/apiPath'; import { HttpStatusCode } from 'axios'; import mockRouter from 'next-router-mock'; import { projectFixture } from '@/models/fixtures/projectFixture'; +import { oauthFixture } from '@/models/fixtures/oauthFixture'; import { User } from './User.component'; const mockedAxiosClient = mockNetworkResponse(); @@ -131,6 +132,8 @@ describe('AuthenticatedUser component', () => { }); it('should display login modal if switch account is pressed', async () => { + mockedAxiosClient.onGet(apiPath.getOauthProviders()).reply(HttpStatusCode.Ok, oauthFixture); + const { store } = renderComponent({ user: { ...USER_INITIAL_STATE_MOCK, diff --git a/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts b/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts index dd5b22725de0bb9da3a296a43d2597c62e7e4c4a..f1c0f845b995fe380cd750a38acbf3d7b874631b 100644 --- a/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts +++ b/src/components/FunctionalArea/TopBar/User/hooks/useUserActions.ts @@ -8,6 +8,7 @@ import { useRouter } from 'next/router'; import { USER_ROLE } from '@/constants/user'; import { useMemo } from 'react'; import { CURRENT_PROJECT_ADMIN_PANEL_URL } from '@/constants'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; import { ADMIN_CURATOR_ACTIONS, BASE_ACTIONS } from '../User.constants'; type UseUserActionsReturnType = { @@ -33,6 +34,7 @@ export const useUserActions = (): UseUserActionsReturnType => { }, [userRole]); const openModalLogin = (): void => { + dispatch(getOAuth()); dispatch(openLoginModal()); }; diff --git a/src/constants/common.ts b/src/constants/common.ts index c3a1b1d09a4c675a5cca281cd1a02f33515bb2bd..9bdcd6487cb3cd51ff146c5a2d60796ddccb4c99 100644 --- a/src/constants/common.ts +++ b/src/constants/common.ts @@ -2,6 +2,8 @@ export const SIZE_OF_EMPTY_ARRAY = 0; export const SIZE_OF_ARRAY_WITH_FOUR_ELEMENTS = 4; export const SIZE_OF_ARRAY_WITH_ONE_ELEMENT = 1; +export const MINUS_ONE = -1; + export const ZERO = 0; export const FIRST_ARRAY_ELEMENT = 0; diff --git a/src/models/fixtures/oauthFixture.ts b/src/models/fixtures/oauthFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..753b8da35f060089663aadb518d34b7bee7b1b4e --- /dev/null +++ b/src/models/fixtures/oauthFixture.ts @@ -0,0 +1,8 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { oauthSchema } from '@/models/oauthSchema'; + +export const oauthFixture = createFixture(oauthSchema, { + seed: ZOD_SEED, +}); diff --git a/src/models/fixtures/projectsFixture.ts b/src/models/fixtures/projectsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..c625481f22a4209cead2372ea09a0d98aa94c85a --- /dev/null +++ b/src/models/fixtures/projectsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { pageableSchema } from '@/models/pageableSchema'; +import { projectSchema } from '../projectSchema'; + +export const projectPageFixture = createFixture(pageableSchema(projectSchema), { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/models/oauthSchema.ts b/src/models/oauthSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..a9370c9d2876ad0aa7fff55142461c9b9b7dcea7 --- /dev/null +++ b/src/models/oauthSchema.ts @@ -0,0 +1,5 @@ +import { z } from 'zod'; + +export const oauthSchema = z.object({ + Orcid: z.string().optional(), +}); diff --git a/src/models/pageableSchema.ts b/src/models/pageableSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..94c544ee3909ae23cd73b48f3ddb4e9da1383820 --- /dev/null +++ b/src/models/pageableSchema.ts @@ -0,0 +1,12 @@ +import { z } from 'zod'; +import { ZodTypeAny } from 'zod/lib/types'; + +export const pageableSchema = <T extends ZodTypeAny>(type: T): ZodTypeAny => + z.object({ + totalPages: z.number().nonnegative(), + totalElements: z.number().nonnegative(), + numberOfElements: z.number().nonnegative(), + size: z.number().positive(), + number: z.number().nonnegative(), + content: z.array(type), + }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index ae21303c3c760f0da9efe1878d2c01b7a0f6615f..8227bfec287407fd9cc09415b7176beb6ed914bb 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -54,6 +54,7 @@ export const apiPath = { getAllBackgroundsByProjectIdQuery: (projectId: string): string => `projects/${projectId}/backgrounds/`, getProjectById: (projectId: string): string => `projects/${projectId}`, + getProjects: (): string => `projects/`, getSessionValid: (): string => `users/isSessionValid`, postLogin: (): string => `doLogin`, getConfigurationOptions: (): string => 'configuration/options/', @@ -100,6 +101,7 @@ export const apiPath = { user: (login: string): string => `users/${login}`, getStacktrace: (code: string): string => `stacktrace/${code}`, submitError: (): string => `minervanet/submitError`, + getOauthProviders: (): string => `oauth/providers/`, userPrivileges: (login: string): string => `users/${login}?columns=privileges`, getComments: (): string => `projects/${PROJECT_ID}/comments/models/*/`, addComment: (modelId: number, x: number, y: number): string => diff --git a/src/redux/configuration/configuration.constants.ts b/src/redux/configuration/configuration.constants.ts index a03edc92e3b60554196a00115bc246453a7eef83..59ef10da2e7defa1753613d5b185ad0d6d454439 100644 --- a/src/redux/configuration/configuration.constants.ts +++ b/src/redux/configuration/configuration.constants.ts @@ -4,6 +4,7 @@ export const SIMPLE_COLOR_VAL_NAME_ID = 'SIMPLE_COLOR_VAL'; export const NEUTRAL_COLOR_VAL_NAME_ID = 'NEUTRAL_COLOR_VAL'; export const OVERLAY_OPACITY_NAME_ID = 'OVERLAY_OPACITY'; export const SEARCH_DISTANCE_NAME_ID = 'SEARCH_DISTANCE'; +export const REQUEST_ACCOUNT_EMAIL = 'REQUEST_ACCOUNT_EMAIL'; export const LEGEND_FILE_NAMES_IDS = [ 'LEGEND_FILE_1', diff --git a/src/redux/configuration/configuration.selectors.ts b/src/redux/configuration/configuration.selectors.ts index 00683ccb082b98e28df71b0fc95a3e4f88ef5518..ae5b97b7bc077a3f8316f26faf57e32ce2f8a5bf 100644 --- a/src/redux/configuration/configuration.selectors.ts +++ b/src/redux/configuration/configuration.selectors.ts @@ -17,7 +17,9 @@ import { SIMPLE_COLOR_VAL_NAME_ID, SVG_IMAGE_HANDLER_NAME_ID, SEARCH_DISTANCE_NAME_ID, + REQUEST_ACCOUNT_EMAIL, } from './configuration.constants'; + import { ConfigurationHandlersIds, ConfigurationImageHandlersIds } from './configuration.types'; const configurationSelector = createSelector(rootSelector, state => state.configuration); @@ -56,6 +58,11 @@ export const searchDistanceValSelector = createSelector( state => configurationAdapterSelectors.selectById(state, SEARCH_DISTANCE_NAME_ID)?.value, ); +export const adminEmailValSelector = createSelector( + configurationOptionsSelector, + state => configurationAdapterSelectors.selectById(state, REQUEST_ACCOUNT_EMAIL)?.value, +); + export const defaultLegendImagesSelector = createSelector(configurationOptionsSelector, state => LEGEND_FILE_NAMES_IDS.map( legendNameId => configurationAdapterSelectors.selectById(state, legendNameId)?.value, diff --git a/src/redux/middlewares/error.middleware.test.ts b/src/redux/middlewares/error.middleware.test.ts index 6c34711b000f25d6642c79fdf7068a4b16f47913..f8f7de9c01c87bcb615f05192f22ee758845377e 100644 --- a/src/redux/middlewares/error.middleware.test.ts +++ b/src/redux/middlewares/error.middleware.test.ts @@ -1,5 +1,5 @@ import { store } from '@/redux/store'; -import { showToast } from '@/utils/showToast'; + import { errorMiddlewareListener } from './error.middleware'; jest.mock('../../utils/showToast'); @@ -132,7 +132,7 @@ describe('errorMiddlewareListener', () => { ); }); - it('should toast on access denied', async () => { + it('should show modal on access denied', async () => { const action = { type: 'action/rejected', payload: null, @@ -149,9 +149,10 @@ describe('errorMiddlewareListener', () => { // 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', - }); + expect(dispatchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'modal/openAccessDeniedModal', + }), + ); }); }); diff --git a/src/redux/middlewares/error.middleware.ts b/src/redux/middlewares/error.middleware.ts index 8c6bd8160be1d71fd632d1ec542b66c48c9017fc..56ba18c0f5cfcef3435ac3d6b86aa2e8db452f45 100644 --- a/src/redux/middlewares/error.middleware.ts +++ b/src/redux/middlewares/error.middleware.ts @@ -1,8 +1,8 @@ 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 { openAccessDeniedModal, openErrorReportModal } from '@/redux/modal/modal.slice'; +import { getProjects } from '@/redux/projects/projects.thunks'; export const errorListenerMiddleware = createListenerMiddleware(); @@ -14,10 +14,8 @@ export const errorMiddlewareListener = async ( ): Promise<void> => { if (isRejected(action) && action.type !== 'user/getSessionValid/rejected') { if (action.error.code === '403') { - showToast({ - type: 'error', - message: 'Access denied.', - }); + dispatch(getProjects()); + dispatch(openAccessDeniedModal()); } else { const errorData = await createErrorData(action.error, getState()); dispatch(openErrorReportModal(errorData)); diff --git a/src/redux/modal/modal.reducers.ts b/src/redux/modal/modal.reducers.ts index c52c9cebc2596b2d31228519c88b5583d41311f9..f9d88800610efc49275e3cdad86a19a2396a4d97 100644 --- a/src/redux/modal/modal.reducers.ts +++ b/src/redux/modal/modal.reducers.ts @@ -67,6 +67,12 @@ export const openErrorReportModalReducer = ( }; }; +export const openAccessDeniedModalReducer = (state: ModalState): void => { + state.isOpen = true; + state.modalName = 'access-denied'; + state.modalTitle = 'Access denied!'; +}; + export const setOverviewImageIdReducer = ( state: ModalState, action: PayloadAction<number>, diff --git a/src/redux/modal/modal.slice.ts b/src/redux/modal/modal.slice.ts index 4b7fc45a1227026975d411deb49700324ceb9455..57d852cd19596f39ccb2476e0aae599d4927be4f 100644 --- a/src/redux/modal/modal.slice.ts +++ b/src/redux/modal/modal.slice.ts @@ -12,6 +12,7 @@ import { openLoggedInMenuModalReducer, openAddCommentModalReducer, openErrorReportModalReducer, + openAccessDeniedModalReducer, } from './modal.reducers'; const modalSlice = createSlice({ @@ -29,6 +30,7 @@ const modalSlice = createSlice({ openEditOverlayModal: openEditOverlayModalReducer, openLoggedInMenuModal: openLoggedInMenuModalReducer, openErrorReportModal: openErrorReportModalReducer, + openAccessDeniedModal: openAccessDeniedModalReducer, }, }); @@ -44,6 +46,7 @@ export const { openEditOverlayModal, openLoggedInMenuModal, openErrorReportModal, + openAccessDeniedModal, } = modalSlice.actions; export default modalSlice.reducer; diff --git a/src/redux/oauth/oauth.constants.ts b/src/redux/oauth/oauth.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..fc92f1da5ab570919841515581dd6be8a4988ea2 --- /dev/null +++ b/src/redux/oauth/oauth.constants.ts @@ -0,0 +1 @@ +export const OAUTH_FETCHING_ERROR_PREFIX = 'Failed to fetch oauth providers'; diff --git a/src/redux/oauth/oauth.fixtures.ts b/src/redux/oauth/oauth.fixtures.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b8212184250c69c348ce5fae7c8ac8392a914b8 --- /dev/null +++ b/src/redux/oauth/oauth.fixtures.ts @@ -0,0 +1,5 @@ +import { OAuth } from '@/types/models'; + +export const initialOAuthFixture: OAuth = { + Orcid: undefined, +}; diff --git a/src/redux/oauth/oauth.mock.ts b/src/redux/oauth/oauth.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1b18f8f02b1e79c503f6729793a82721cbf956e --- /dev/null +++ b/src/redux/oauth/oauth.mock.ts @@ -0,0 +1,10 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { initialOAuthFixture } from '@/redux/oauth/oauth.fixtures'; + +export const OAUTH_INITIAL_STATE_MOCK: OauthState = { + data: initialOAuthFixture, + loading: 'idle', + error: DEFAULT_ERROR, + orcidEndpoint: undefined, +}; diff --git a/src/redux/oauth/oauth.reducers.ts b/src/redux/oauth/oauth.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..9e5cce6516930ce2c66cfc37c86b864a6ff8f740 --- /dev/null +++ b/src/redux/oauth/oauth.reducers.ts @@ -0,0 +1,20 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { getOAuth } from '@/redux/oauth/oauth.thunks'; + +export const getOauthReducer = (builder: ActionReducerMapBuilder<OauthState>): void => { + builder.addCase(getOAuth.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getOAuth.fulfilled, (state, action) => { + state.data = action.payload || undefined; + if (action.payload && action.payload.Orcid) { + state.orcidEndpoint = action.payload.Orcid; + } + state.loading = 'succeeded'; + }); + builder.addCase(getOAuth.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/oauth/oauth.selectors.ts b/src/redux/oauth/oauth.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..8bda1780c56eb0804cfbc0e53935a6d6e492fc0d --- /dev/null +++ b/src/redux/oauth/oauth.selectors.ts @@ -0,0 +1,6 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { rootSelector } from '../root/root.selectors'; + +export const oauthSelector = createSelector(rootSelector, state => state.oauth); + +export const orcidEndpointSelector = createSelector(oauthSelector, oauth => oauth?.orcidEndpoint); diff --git a/src/redux/oauth/oauth.slice.ts b/src/redux/oauth/oauth.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..8abb3ba8feb2a9b9414d529710cdbfc367f402ca --- /dev/null +++ b/src/redux/oauth/oauth.slice.ts @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { OauthState } from '@/redux/oauth/oauth.types'; +import { getOauthReducer } from '@/redux/oauth/oauth.reducers'; + +const initialState: OauthState = { + data: undefined, + loading: 'idle', + error: { name: '', message: '' }, + orcidEndpoint: undefined, +}; + +const oauthSlice = createSlice({ + name: 'oauth', + initialState, + reducers: {}, + extraReducers: builder => { + getOauthReducer(builder); + }, +}); + +export default oauthSlice.reducer; diff --git a/src/redux/oauth/oauth.thunks.ts b/src/redux/oauth/oauth.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..42907946419bc86ca8befe0794a16661aec26da7 --- /dev/null +++ b/src/redux/oauth/oauth.thunks.ts @@ -0,0 +1,24 @@ +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { OAuth } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { oauthSchema } from '@/models/oauthSchema'; +import { OAUTH_FETCHING_ERROR_PREFIX } from '@/redux/oauth/oauth.constants'; +import { apiPath } from '../apiPath'; + +export const getOAuth = createAsyncThunk<OAuth | undefined, void, ThunkConfig>( + 'oauth/getProviders', + async () => { + try { + const response = await axiosInstance.get<OAuth>(apiPath.getOauthProviders()); + + const isDataValid = validateDataUsingZodSchema(response.data, oauthSchema); + + return isDataValid ? response.data : undefined; + } catch (error) { + return Promise.reject(getError({ error, prefix: OAUTH_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/oauth/oauth.types.ts b/src/redux/oauth/oauth.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..2992b701903eca04a3792cfed87a85b5388ca170 --- /dev/null +++ b/src/redux/oauth/oauth.types.ts @@ -0,0 +1,9 @@ +import { OAuth } from '@/types/models'; +import { Loading } from '@/types/loadingState'; + +export type OauthState = { + orcidEndpoint: string | undefined; + data: OAuth | undefined; + loading: Loading; + error: Error; +}; diff --git a/src/redux/projects/projects.constants.ts b/src/redux/projects/projects.constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..a7fb0f8bc731b88c03942145baf6c4f4413b8df4 --- /dev/null +++ b/src/redux/projects/projects.constants.ts @@ -0,0 +1 @@ +export const PROJECTS_FETCHING_ERROR_PREFIX = 'Failed to fetch projects'; diff --git a/src/redux/projects/projects.mock.ts b/src/redux/projects/projects.mock.ts new file mode 100644 index 0000000000000000000000000000000000000000..e5b9c93da402d13feee78fcce2e02792fc80216c --- /dev/null +++ b/src/redux/projects/projects.mock.ts @@ -0,0 +1,8 @@ +import { DEFAULT_ERROR } from '@/constants/errors'; +import { ProjectsState } from '@/redux/projects/projects.types'; + +export const PROJECTS_STATE_INITIAL_MOCK: ProjectsState = { + data: [], + loading: 'idle', + error: DEFAULT_ERROR, +}; diff --git a/src/redux/projects/projects.reducers.test.ts b/src/redux/projects/projects.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2912689e2e33d2fd4b8c30e2ad2b858e2def6a28 --- /dev/null +++ b/src/redux/projects/projects.reducers.test.ts @@ -0,0 +1,76 @@ +import { projectPageFixture } from '@/models/fixtures/projectsFixture'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkNewAPIResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import { unwrapResult } from '@reduxjs/toolkit'; +import { ProjectsState } from '@/redux/projects/projects.types'; +import { getProjects } from '@/redux/projects/projects.thunks'; +import { apiPath } from '../apiPath'; +import projectsReducer from './projects.slice'; + +const mockedAxiosClient = mockNetworkNewAPIResponse(); + +const INITIAL_STATE: ProjectsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('projects reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ProjectsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('projects', projectsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(projectsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after successfull getProjects query', async () => { + mockedAxiosClient.onGet(apiPath.getProjects()).reply(HttpStatusCode.Ok, projectPageFixture); + + const { type } = await store.dispatch(getProjects()); + const { data, loading, error } = store.getState().projects; + + expect(type).toBe('project/getProjects/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(projectPageFixture.content); + }); + + it('should update store after failed getProjects query', async () => { + mockedAxiosClient.onGet(apiPath.getProjects()).reply(HttpStatusCode.NotFound, []); + + const action = await store.dispatch(getProjects()); + const { data, loading, error } = store.getState().projects; + + expect(action.type).toBe('project/getProjects/rejected'); + expect(() => unwrapResult(action)).toThrow( + "Failed to fetch projects: The page you're looking for doesn't exist. Please verify the URL and try again.", + ); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getProjects query', async () => { + mockedAxiosClient.onGet(apiPath.getProjects()).reply(HttpStatusCode.Ok, projectPageFixture); + + const actionPromise = store.dispatch(getProjects()); + + const { data, loading } = store.getState().projects; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + actionPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().projects; + + expect(dataPromiseFulfilled).toEqual(projectPageFixture.content); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/projects/projects.reducers.ts b/src/redux/projects/projects.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..49c8a58b0fec82c7fb7b7db5d201ecfc2e3a75bb --- /dev/null +++ b/src/redux/projects/projects.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { ProjectsState } from '@/redux/projects/projects.types'; +import { getProjects } from '@/redux/projects/projects.thunks'; + +export const getProjectsReducer = (builder: ActionReducerMapBuilder<ProjectsState>): void => { + builder.addCase(getProjects.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getProjects.fulfilled, (state, action) => { + state.data = action.payload || undefined; + state.loading = 'succeeded'; + }); + builder.addCase(getProjects.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/projects/projects.selectors.ts b/src/redux/projects/projects.selectors.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb4ed5c4ec056e07bf3d242f96da94549def1f44 --- /dev/null +++ b/src/redux/projects/projects.selectors.ts @@ -0,0 +1,5 @@ +import { rootSelector } from '@/redux/root/root.selectors'; +import { createSelector } from '@reduxjs/toolkit'; + +export const projectsRootSelector = createSelector(rootSelector, state => state.projects); +export const projectsSelector = createSelector(projectsRootSelector, state => state.data); diff --git a/src/redux/projects/projects.slice.ts b/src/redux/projects/projects.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..3da9cb6525f5dcc9e89d532a441438a4b2adbfde --- /dev/null +++ b/src/redux/projects/projects.slice.ts @@ -0,0 +1,21 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { ProjectsState } from '@/redux/projects/projects.types'; + +import { getProjectsReducer } from '@/redux/projects/projects.reducers'; + +const initialState: ProjectsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +const projectsSlice = createSlice({ + name: 'project', + initialState, + reducers: {}, + extraReducers: builder => { + getProjectsReducer(builder); + }, +}); + +export default projectsSlice.reducer; diff --git a/src/redux/projects/projects.thunks.ts b/src/redux/projects/projects.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..b3715ea773daa96201d55d43f6c4be53fd9da86c --- /dev/null +++ b/src/redux/projects/projects.thunks.ts @@ -0,0 +1,25 @@ +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { projectSchema } from '@/models/projectSchema'; +import { PageOf, Project } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { getError } from '@/utils/error-report/getError'; +import { PROJECTS_FETCHING_ERROR_PREFIX } from '@/redux/projects/projects.constants'; +import { pageableSchema } from '@/models/pageableSchema'; +import { apiPath } from '../apiPath'; + +export const getProjects = createAsyncThunk<Project[], void, ThunkConfig>( + 'project/getProjects', + async () => { + try { + const response = await axiosInstanceNewAPI.get<PageOf<Project>>(apiPath.getProjects()); + + const isDataValid = validateDataUsingZodSchema(response.data, pageableSchema(projectSchema)); + + return isDataValid ? response.data.content : []; + } catch (error) { + return Promise.reject(getError({ error, prefix: PROJECTS_FETCHING_ERROR_PREFIX })); + } + }, +); diff --git a/src/redux/projects/projects.types.ts b/src/redux/projects/projects.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..7448a70129808f96ab110e4ee3d68875b55c7939 --- /dev/null +++ b/src/redux/projects/projects.types.ts @@ -0,0 +1,4 @@ +import { Project } from '@/types/models'; +import { FetchDataState } from '@/types/fetchDataState'; + +export type ProjectsState = FetchDataState<Project[], []>; diff --git a/src/redux/root/root.fixtures.ts b/src/redux/root/root.fixtures.ts index 9c5e6df4433c43af3513df7ed427b22681695bc5..421151bd9d870052cc5137a3f66f7c3451b494e8 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -1,4 +1,6 @@ import { CONSTANT_INITIAL_STATE } from '@/redux/constant/constant.adapter'; +import { PROJECTS_STATE_INITIAL_MOCK } from '@/redux/projects/projects.mock'; +import { OAUTH_INITIAL_STATE_MOCK } from '@/redux/oauth/oauth.mock'; import { COMMENT_INITIAL_STATE_MOCK } from '@/redux/comment/comment.mock'; import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; @@ -30,6 +32,7 @@ import { USER_INITIAL_STATE_MOCK } from '../user/user.mock'; export const INITIAL_STORE_STATE_MOCK: RootState = { search: SEARCH_STATE_INITIAL_MOCK, project: PROJECT_STATE_INITIAL_MOCK, + projects: PROJECTS_STATE_INITIAL_MOCK, drugs: DRUGS_INITIAL_STATE_MOCK, chemicals: CHEMICALS_INITIAL_STATE_MOCK, models: MODELS_INITIAL_STATE_MOCK, @@ -37,6 +40,7 @@ export const INITIAL_STORE_STATE_MOCK: RootState = { backgrounds: BACKGROUND_INITIAL_STATE_MOCK, drawer: drawerInitialStateMock, map: initialMapStateFixture, + oauth: OAUTH_INITIAL_STATE_MOCK, overlays: OVERLAYS_INITIAL_STATE_MOCK, reactions: REACTIONS_STATE_INITIAL_MOCK, configuration: CONFIGURATION_INITIAL_STATE, diff --git a/src/redux/store.ts b/src/redux/store.ts index 3e957717d60ad91e097b46beca519bbc8f3dab33..ebc65642cab5db9e0a830989779bdaaceec80450 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -10,9 +10,11 @@ import drugsReducer from '@/redux/drugs/drugs.slice'; import mapReducer from '@/redux/map/map.slice'; import modalReducer from '@/redux/modal/modal.slice'; import modelsReducer from '@/redux/models/models.slice'; +import oauthReducer from '@/redux/oauth/oauth.slice'; import overlayBioEntityReducer from '@/redux/overlayBioEntity/overlayBioEntity.slice'; import overlaysReducer from '@/redux/overlays/overlays.slice'; import projectReducer from '@/redux/project/project.slice'; +import projectsReducer from '@/redux/projects/projects.slice'; import reactionsReducer from '@/redux/reactions/reactions.slice'; import searchReducer from '@/redux/search/search.slice'; import userReducer from '@/redux/user/user.slice'; @@ -38,6 +40,7 @@ import statisticsReducer from './statistics/statistics.slice'; export const reducers = { search: searchReducer, project: projectReducer, + projects: projectsReducer, drugs: drugsReducer, chemicals: chemicalsReducer, bioEntity: bioEntityReducer, @@ -63,6 +66,7 @@ export const reducers = { plugins: pluginsReducer, markers: markersReducer, entityNumber: entityNumberReducer, + oauth: oauthReducer, }; export const middlewares = [mapListenerMiddleware.middleware, errorListenerMiddleware.middleware]; diff --git a/src/types/modal.ts b/src/types/modal.ts index 768f836f968f325dacf5ed073d2d0dc5d88008fb..90e7c20d501aa6c7cb50642df1eea295c6406816 100644 --- a/src/types/modal.ts +++ b/src/types/modal.ts @@ -7,4 +7,5 @@ export type ModalName = | 'publications' | 'edit-overlay' | 'error-report' + | 'access-denied' | 'logged-in-menu'; diff --git a/src/types/models.ts b/src/types/models.ts index 53d477a7f88a2dcb40b451db206236aa3e9fd86d..c55955ac291dc9571cd2879f730777ebb663682e 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -64,6 +64,7 @@ import { z } from 'zod'; import { commentSchema } from '@/models/commentSchema'; import { userSchema } from '@/models/userSchema'; import { javaStacktraceSchema } from '@/models/javaStacktraceSchema'; +import { oauthSchema } from '@/models/oauthSchema'; export type Project = z.infer<typeof projectSchema>; export type OverviewImageView = z.infer<typeof overviewImageView>; @@ -124,3 +125,14 @@ 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>; + +export type PageOf<T> = { + totalPages: number; + totalElements: number; + numberOfElements: number; + size: number; + number: number; + content: T[]; +}; + +export type OAuth = z.infer<typeof oauthSchema>; diff --git a/src/utils/validateDataUsingZodSchema.ts b/src/utils/validateDataUsingZodSchema.ts index 673e253dcbbd2f25ca32612d2e67393adf729e26..5171a271449cfe39e46b6a2449d56b7cde4a1e95 100644 --- a/src/utils/validateDataUsingZodSchema.ts +++ b/src/utils/validateDataUsingZodSchema.ts @@ -6,9 +6,11 @@ export const validateDataUsingZodSchema: IsApiResponseValid = (data, schema: Zod const validationResults = schema.safeParse(data); if (validationResults.success === false) { - // TODO - probably need to rething way of handling parsing errors, for now let's leave it to console.log + // TODO - probably need to rethink way of handling parsing errors, for now let's leave it to console.log // eslint-disable-next-line no-console - console.error('Error on parsing data', validationResults.error.message); + console.error('Error on parsing data', validationResults.error); + // eslint-disable-next-line no-console + console.error(validationResults.error.message); } return validationResults.success;