diff --git a/src/components/SPA/MinervaSPA.component.tsx b/src/components/SPA/MinervaSPA.component.tsx index c4b61c47c268f3421b3e2fa908b7853e2b6ff8e6..c9b5381584b0a04df6a647779e15e82e4bff4962 100644 --- a/src/components/SPA/MinervaSPA.component.tsx +++ b/src/components/SPA/MinervaSPA.component.tsx @@ -2,6 +2,9 @@ import { Manrope } from '@next/font/google'; import { twMerge } from 'tailwind-merge'; import { Map } from '@/components/Map'; import { FunctionalArea } from '@/components/FunctionalArea'; +import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; +import { useEffect } from 'react'; +import { getModels } from '@/redux/models/models.thunks'; const manrope = Manrope({ variable: '--font-manrope', @@ -10,9 +13,17 @@ const manrope = Manrope({ subsets: ['latin'], }); -export const MinervaSPA = (): JSX.Element => ( - <div className={twMerge('relative', manrope.variable)}> - <FunctionalArea /> - <Map /> - </div> -); +export const MinervaSPA = (): JSX.Element => { + const dispatch = useAppDispatch(); + + useEffect(() => { + dispatch(getModels()); + }, [dispatch]); + + return ( + <div className={twMerge('relative', manrope.variable)}> + <FunctionalArea /> + <Map /> + </div> + ); +}; diff --git a/src/models/authorSchema.ts b/src/models/authorSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dfa82ec918b797f2a3b1cd8816e197274cd2f1c --- /dev/null +++ b/src/models/authorSchema.ts @@ -0,0 +1,3 @@ +import { z } from 'zod'; + +export const authorSchema = z.string(); diff --git a/src/models/fixtures/modelsFixture.ts b/src/models/fixtures/modelsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d4e39e35e9ec6500ecf7759da7c3fa028f54c09 --- /dev/null +++ b/src/models/fixtures/modelsFixture.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { z } from 'zod'; +import { ZOD_SEED } from '@/constants'; +import { modelSchema } from '@/models/modelSchema'; + +export const modelsFixture = createFixture(z.array(modelSchema), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/modelSchema.ts b/src/models/modelSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..64752c8c1bd4043d1ff24cc0de26022f5f22daa2 --- /dev/null +++ b/src/models/modelSchema.ts @@ -0,0 +1,31 @@ +import { z } from 'zod'; +import { referenceSchema } from './referenceSchema'; +import { authorSchema } from './authorSchema'; + +export const modelSchema = z.object({ + /** name of the map */ + name: z.string(), + description: z.string(), + /** map id */ + idObject: z.number(), + /** map width */ + width: z.number(), + /** map height */ + height: z.number(), + /** size of the png tile used to visualize in frontend */ + tileSize: z.number(), + /** default x center used in frontend visualization */ + defaultCenterX: z.union([z.number(), z.null()]), + /** default y center used in frontend visualization */ + defaultCenterY: z.union([z.number(), z.null()]), + /** default zoom level used in frontend visualization */ + defaultZoomLevel: z.union([z.number(), z.null()]), + /** minimum zoom level availbale for the map */ + minZoom: z.number(), + /** maximum zoom level available for the map */ + maxZoom: z.number(), + authors: z.array(authorSchema), + references: z.array(referenceSchema), + creationDate: z.union([z.string(), z.null()]), + modificationDates: z.array(z.string()), +}); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index 8469a3d16b0f7eb0e9144339ff69da900564e86c..11ce6f11c1e59f6e8f6a4bf38037ac62e2e501a2 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -7,6 +7,7 @@ export const apiPath = { `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, getMirnasStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/miRnas:search?query=${searchQuery}`, + getModelsString: (): string => `projects/${PROJECT_ID}/models/`, getChemicalsStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`, }; diff --git a/src/redux/models/models.reducers.test.ts b/src/redux/models/models.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..c130ae0ee5a782380554a87b39e9a77f31ec3864 --- /dev/null +++ b/src/redux/models/models.reducers.test.ts @@ -0,0 +1,72 @@ +import { HttpStatusCode } from 'axios'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { apiPath } from '@/redux/apiPath'; +import { getModels } from './models.thunks'; +import modelsReducer from './models.slice'; +import { ModelsState } from './models.types'; + +const mockedAxiosClient = mockNetworkResponse(); + +const INITIAL_STATE: ModelsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('models reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('models', modelsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(modelsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const { type } = await store.dispatch(getModels()); + const { data, loading, error } = store.getState().models; + + expect(type).toBe('project/getModels/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(modelsFixture); + }); + + it('should update store after failed getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.NotFound, []); + + const { type } = await store.dispatch(getModels()); + const { data, loading, error } = store.getState().models; + + expect(type).toBe('project/getModels/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getModels query', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const modelsPromise = store.dispatch(getModels()); + + const { data, loading } = store.getState().models; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + modelsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().models; + + expect(dataPromiseFulfilled).toEqual(modelsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/models/models.reducers.ts b/src/redux/models/models.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..28f71efbe06a26fde0b4f5cdb8b11599965f5713 --- /dev/null +++ b/src/redux/models/models.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { ModelsState } from './models.types'; +import { getModels } from './models.thunks'; + +export const getModelsReducer = (builder: ActionReducerMapBuilder<ModelsState>): void => { + builder.addCase(getModels.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getModels.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getModels.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/models/models.slice.ts b/src/redux/models/models.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..61c765b6b8668424f2b518eb50f463c8629d769f --- /dev/null +++ b/src/redux/models/models.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { ModelsState } from '@/redux/models/models.types'; +import { getModelsReducer } from './models.reducers'; + +const initialState: ModelsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const modelsSlice = createSlice({ + name: 'models', + initialState, + reducers: {}, + extraReducers: builder => { + getModelsReducer(builder); + }, +}); + +export default modelsSlice.reducer; diff --git a/src/redux/models/models.thunks.test.ts b/src/redux/models/models.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3d85a15e274e0d0ee6352353d25e504de8097259 --- /dev/null +++ b/src/redux/models/models.thunks.test.ts @@ -0,0 +1,36 @@ +import { HttpStatusCode } from 'axios'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { ModelsState } from '@/redux/models/models.types'; +import { apiPath } from '@/redux/apiPath'; +import { modelsFixture } from '@/models/fixtures/modelsFixture'; +import modelsReducer from './models.slice'; +import { getModels } from './models.thunks'; + +const mockedAxiosClient = mockNetworkResponse(); + +describe('models thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ModelsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('models', modelsReducer); + }); + describe('getModels', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient.onGet(apiPath.getModelsString()).reply(HttpStatusCode.Ok, modelsFixture); + + const { payload } = await store.dispatch(getModels()); + expect(payload).toEqual(modelsFixture); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getModelsString()) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getModels()); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/models/models.thunks.ts b/src/redux/models/models.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..430bafb0e14acf41302102c42eddcc54fe5ca72c --- /dev/null +++ b/src/redux/models/models.thunks.ts @@ -0,0 +1,18 @@ +import { z } from 'zod'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Model } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { apiPath } from '@/redux/apiPath'; +import { modelSchema } from '@/models/modelSchema'; + +export const getModels = createAsyncThunk( + 'project/getModels', + async (): Promise<Model[] | undefined> => { + const response = await axiosInstance.get<Model[]>(apiPath.getModelsString()); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(modelSchema)); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/models/models.types.ts b/src/redux/models/models.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..c3a6b9824eb8abc7a30d10137a1ae0f87ce4f5e7 --- /dev/null +++ b/src/redux/models/models.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Model } from '@/types/models'; + +export type ModelsState = FetchDataState<Model[]>; diff --git a/src/redux/store.ts b/src/redux/store.ts index a095912bdda990fb9677b2b8814e0cd848014d6a..1999d33f1373323b288bed176473fca135a77067 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,6 +1,7 @@ import bioEntityContentsReducer from '@/redux/bioEntityContents/bioEntityContents.slice'; import chemicalsReducer from '@/redux/chemicals/chemicals.slice'; import drawerReducer from '@/redux/drawer/drawer.slice'; +import modelsReducer from '@/redux/models/models.slice'; import drugsReducer from '@/redux/drugs/drugs.slice'; import mirnasReducer from '@/redux/mirnas/mirnas.slice'; import projectSlice from '@/redux/project/project.slice'; @@ -16,6 +17,7 @@ export const store = configureStore({ chemicals: chemicalsReducer, bioEntityContents: bioEntityContentsReducer, drawer: drawerReducer, + models: modelsReducer, }, devTools: true, }); diff --git a/src/types/models.ts b/src/types/models.ts index abadf02ac342719044fbc94260c65ed3704d8b71..8902849349a999a58661d14d73d5734574f96130 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -6,6 +6,7 @@ import { mirnaSchema } from '@/models/mirnaSchema'; import { organism } from '@/models/organism'; import { projectSchema } from '@/models/project'; import { z } from 'zod'; +import { modelSchema } from '@/models/modelSchema'; export type Project = z.infer<typeof projectSchema>; export type Organism = z.infer<typeof organism>; @@ -13,4 +14,5 @@ export type Disease = z.infer<typeof disease>; export type Drug = z.infer<typeof drugSchema>; export type Mirna = z.infer<typeof mirnaSchema>; export type BioEntityContent = z.infer<typeof bioEntityContentSchema>; +export type Model = z.infer<typeof modelSchema>; export type Chemical = z.infer<typeof chemicalSchema>;