diff --git a/src/models/fixtures/projectsFixture.ts b/src/models/fixtures/projectsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..b845ca59e6a2ab51ba6bc547a3c5baa6f040fd3e --- /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 { z } from 'zod'; +import { projectSchema } from '../projectSchema'; + +export const projectsFixture = createFixture(z.array(projectSchema), { + seed: ZOD_SEED, + array: { min: 1, max: 1 }, +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 88149929bcef8afc5edfcd50700f51822f930206..591ae939da2794784f0e9bc78551a5a340d1478f 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -50,6 +50,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/', 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..df5a0041e146d81d6312d999f95f0d1f80eb0bb6 --- /dev/null +++ b/src/redux/projects/projects.reducers.test.ts @@ -0,0 +1,74 @@ +import { projectsFixture } 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, projectsFixture); + + 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(projectsFixture); + }); + + 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('Request failed with status code 404'); + 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, projectsFixture); + + 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(projectsFixture); + 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.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..c0853db1ead234138200ba753b9b35fe5cfe7cd2 --- /dev/null +++ b/src/redux/projects/projects.thunks.ts @@ -0,0 +1,23 @@ +import { axiosInstanceNewAPI } from '@/services/api/utils/axiosInstance'; +import { projectSchema } from '@/models/projectSchema'; +import { Project } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { ThunkConfig } from '@/types/store'; +import { z } from 'zod'; +import { apiPath } from '../apiPath'; + +export const getProjects = createAsyncThunk<Project[], void, ThunkConfig>( + 'project/getProjects', + async () => { + try { + const response = await axiosInstanceNewAPI.get<Project[]>(apiPath.getProjects()); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(projectSchema)); + + return isDataValid ? response.data : []; + } catch (error) { + return Promise.reject(error); + } + }, +); 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 feaaaedffe924b2b295a0e3d2d9590e85e137df0..285a3ba3e403d4d608a3e6381fd0a1f926836338 100644 --- a/src/redux/root/root.fixtures.ts +++ b/src/redux/root/root.fixtures.ts @@ -1,4 +1,5 @@ import { CONSTANT_INITIAL_STATE } from '@/redux/constant/constant.adapter'; +import { PROJECTS_STATE_INITIAL_MOCK } from '@/redux/projects/projects.mock'; import { BACKGROUND_INITIAL_STATE_MOCK } from '../backgrounds/background.mock'; import { BIOENTITY_INITIAL_STATE_MOCK } from '../bioEntity/bioEntity.mock'; import { CHEMICALS_INITIAL_STATE_MOCK } from '../chemicals/chemicals.mock'; @@ -29,6 +30,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, diff --git a/src/redux/store.ts b/src/redux/store.ts index 891c7cfec500361e496e7d2d53b534f4a79f1bfe..8d7d04c19cbf09d40266eed72305f60fc37730de 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -13,6 +13,7 @@ import modelsReducer from '@/redux/models/models.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'; @@ -37,6 +38,7 @@ import statisticsReducer from './statistics/statistics.slice'; export const reducers = { search: searchReducer, project: projectReducer, + projects: projectsReducer, drugs: drugsReducer, chemicals: chemicalsReducer, bioEntity: bioEntityReducer,