diff --git a/pages/redux-api-poc.tsx b/pages/redux-api-poc.tsx index 86f369323bd601379d1765822b11e9fd54d178b1..f6add11e9d67d6f2fa1d73b3f6f89ac7a1ed6da2 100644 --- a/pages/redux-api-poc.tsx +++ b/pages/redux-api-poc.tsx @@ -1,4 +1,5 @@ import { getBioEntityContents } from '@/redux/bioEntityContents/bioEntityContents.thunks'; +import { getChemicals } from '@/redux/chemicals/chemicals.thunks'; import { getDrugs } from '@/redux/drugs/drugs.thunks'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; import { getMirnas } from '@/redux/mirnas/mirnas.thunks'; @@ -17,6 +18,7 @@ const ReduxPage = (): JSX.Element => { dispatch(getDrugs('aspirin')); dispatch(getMirnas('hsa-miR-302b-3p')); dispatch(getBioEntityContents('park7')); + dispatch(getChemicals('Corticosterone')); }; return ( diff --git a/src/models/chemicalSchema.ts b/src/models/chemicalSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..eafa7c257b1ce8886772745f73f147043d0a9573 --- /dev/null +++ b/src/models/chemicalSchema.ts @@ -0,0 +1,15 @@ +import { idSchema } from '@/models/idSchema'; +import { z } from 'zod'; +import { referenceSchema } from './referenceSchema'; +import { targetSchema } from './targetSchema'; + +export const chemicalSchema = z.object({ + id: idSchema, + name: z.string(), + description: z.string(), + directEvidence: z.string().nullable(), + directEvidenceReferences: z.array(referenceSchema), + synonyms: z.array(z.string()), + references: z.array(referenceSchema), + targets: z.array(targetSchema), +}); diff --git a/src/models/fixtures/chemicalsFixture.ts b/src/models/fixtures/chemicalsFixture.ts new file mode 100644 index 0000000000000000000000000000000000000000..88e2169170f74600809a1329280c1f9549527e20 --- /dev/null +++ b/src/models/fixtures/chemicalsFixture.ts @@ -0,0 +1,10 @@ +import { ZOD_SEED } from '@/constants'; +import { chemicalSchema } from '@/models/chemicalSchema'; +import { z } from 'zod'; +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; + +export const chemicalsFixture = createFixture(z.array(chemicalSchema), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/idSchema.ts b/src/models/idSchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..2ccc4d780bd3185097e86bdac53ec45abd1c848b --- /dev/null +++ b/src/models/idSchema.ts @@ -0,0 +1,9 @@ +import { z } from 'zod'; + +export const idSchema = z.object({ + annotatorClassName: z.string(), + id: z.number(), + link: z.string(), + resource: z.string(), + type: z.string(), +}); diff --git a/src/redux/apiPath.test.ts b/src/redux/apiPath.test.ts index ca36caaedbba792346899f05ee6bb45010727e3d..e1337f97c6f7f73f141d5739313c1868ab357f30 100644 --- a/src/redux/apiPath.test.ts +++ b/src/redux/apiPath.test.ts @@ -19,4 +19,10 @@ describe('api path', () => { `projects/${PROJECT_ID}/models/*/bioEntities:search?query=park7`, ); }); + + it('should return url string for bio entity content', () => { + expect(apiPath.getChemicalsStringWithQuery('Corticosterone')).toBe( + `projects/${PROJECT_ID}/chemicals:search?query=Corticosterone`, + ); + }); }); diff --git a/src/redux/apiPath.ts b/src/redux/apiPath.ts index d199de1ecbe4405c0af4b766000fff2fb5b84e7f..8469a3d16b0f7eb0e9144339ff69da900564e86c 100644 --- a/src/redux/apiPath.ts +++ b/src/redux/apiPath.ts @@ -7,4 +7,6 @@ export const apiPath = { `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, getMirnasStringWithQuery: (searchQuery: string): string => `projects/${PROJECT_ID}/miRnas:search?query=${searchQuery}`, + getChemicalsStringWithQuery: (searchQuery: string): string => + `projects/${PROJECT_ID}/chemicals:search?query=${searchQuery}`, }; diff --git a/src/redux/chemicals/chemicals.reducers.test.ts b/src/redux/chemicals/chemicals.reducers.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e74d817181ca064e3b36069fcb3ffc398dfce54a --- /dev/null +++ b/src/redux/chemicals/chemicals.reducers.test.ts @@ -0,0 +1,79 @@ +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import chemicalsReducer from './chemicals.slice'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +const mockedAxiosClient = mockNetworkResponse(); +const SEARCH_QUERY = 'Corticosterone'; + +const INITIAL_STATE: ChemicalsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +describe('chemicals reducer', () => { + let store = {} as ToolkitStoreWithSingleSlice<ChemicalsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('chemicals', chemicalsReducer); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(chemicalsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const { type } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const { data, loading, error } = store.getState().chemicals; + + expect(type).toBe('project/getChemicals/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(chemicalsFixture); + }); + + it('should update store after failed getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.NotFound, chemicalsFixture); + + const { type } = await store.dispatch(getChemicals(SEARCH_QUERY)); + const { data, loading, error } = store.getState().chemicals; + + expect(type).toBe('project/getChemicals/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it('should update store on loading getChemicals query', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const chemicalsPromise = store.dispatch(getChemicals(SEARCH_QUERY)); + + const { data, loading } = store.getState().chemicals; + expect(data).toEqual([]); + expect(loading).toEqual('pending'); + + chemicalsPromise.then(() => { + const { data: dataPromiseFulfilled, loading: promiseFulfilled } = store.getState().chemicals; + + expect(dataPromiseFulfilled).toEqual(chemicalsFixture); + expect(promiseFulfilled).toEqual('succeeded'); + }); + }); +}); diff --git a/src/redux/chemicals/chemicals.reducers.ts b/src/redux/chemicals/chemicals.reducers.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ca1b96f7034944c01b3743544220faa9e563090 --- /dev/null +++ b/src/redux/chemicals/chemicals.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +export const getChemicalsReducer = (builder: ActionReducerMapBuilder<ChemicalsState>): void => { + builder.addCase(getChemicals.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getChemicals.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getChemicals.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/chemicals/chemicals.slice.ts b/src/redux/chemicals/chemicals.slice.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8dd8e593bfca43771ec553bac8face485b88a2d --- /dev/null +++ b/src/redux/chemicals/chemicals.slice.ts @@ -0,0 +1,20 @@ +import { ChemicalsState } from '@/redux/chemicals/chemicals.types'; +import { createSlice } from '@reduxjs/toolkit'; +import { getChemicalsReducer } from './chemicals.reducers'; + +const initialState: ChemicalsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const chemicalsSlice = createSlice({ + name: 'chemicals', + initialState, + reducers: {}, + extraReducers: builder => { + getChemicalsReducer(builder); + }, +}); + +export default chemicalsSlice.reducer; diff --git a/src/redux/chemicals/chemicals.thunks.test.ts b/src/redux/chemicals/chemicals.thunks.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..93945477c94dd25afcf561a04ac592ce57d12f2e --- /dev/null +++ b/src/redux/chemicals/chemicals.thunks.test.ts @@ -0,0 +1,39 @@ +import { chemicalsFixture } from '@/models/fixtures/chemicalsFixture'; +import { apiPath } from '@/redux/apiPath'; +import { + ToolkitStoreWithSingleSlice, + createStoreInstanceUsingSliceReducer, +} from '@/utils/createStoreInstanceUsingSliceReducer'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { HttpStatusCode } from 'axios'; +import chemicalsReducer from './chemicals.slice'; +import { getChemicals } from './chemicals.thunks'; +import { ChemicalsState } from './chemicals.types'; + +const mockedAxiosClient = mockNetworkResponse(); +const SEARCH_QUERY = 'Corticosterone'; + +describe('chemicals thunks', () => { + let store = {} as ToolkitStoreWithSingleSlice<ChemicalsState>; + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer('chemicals', chemicalsReducer); + }); + describe('getChemiclas', () => { + it('should return data when data response from API is valid', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, chemicalsFixture); + + const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(payload).toEqual(chemicalsFixture); + }); + it('should return undefined when data response from API is not valid ', async () => { + mockedAxiosClient + .onGet(apiPath.getChemicalsStringWithQuery(SEARCH_QUERY)) + .reply(HttpStatusCode.Ok, { randomProperty: 'randomValue' }); + + const { payload } = await store.dispatch(getChemicals(SEARCH_QUERY)); + expect(payload).toEqual(undefined); + }); + }); +}); diff --git a/src/redux/chemicals/chemicals.thunks.ts b/src/redux/chemicals/chemicals.thunks.ts new file mode 100644 index 0000000000000000000000000000000000000000..df0f9dec0db3cf06752fb5bcb80568990112ea32 --- /dev/null +++ b/src/redux/chemicals/chemicals.thunks.ts @@ -0,0 +1,20 @@ +import { chemicalSchema } from '@/models/chemicalSchema'; +import { apiPath } from '@/redux/apiPath'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { Chemical } from '@/types/models'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { z } from 'zod'; + +export const getChemicals = createAsyncThunk( + 'project/getChemicals', + async (searchQuery: string): Promise<Chemical[] | undefined> => { + const response = await axiosInstance.get<Chemical[]>( + apiPath.getChemicalsStringWithQuery(searchQuery), + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(chemicalSchema)); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/chemicals/chemicals.types.ts b/src/redux/chemicals/chemicals.types.ts new file mode 100644 index 0000000000000000000000000000000000000000..653e910ab23427b38d7a24d6c6caecc588d46af2 --- /dev/null +++ b/src/redux/chemicals/chemicals.types.ts @@ -0,0 +1,4 @@ +import { FetchDataState } from '@/types/fetchDataState'; +import { Chemical } from '@/types/models'; + +export type ChemicalsState = FetchDataState<Chemical[]>; diff --git a/src/types/models.ts b/src/types/models.ts index 52aca9ac48e7ec7903544dacf3442d6a28ca911e..abadf02ac342719044fbc94260c65ed3704d8b71 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -1,9 +1,10 @@ +import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; +import { chemicalSchema } from '@/models/chemicalSchema'; import { disease } from '@/models/disease'; import { drugSchema } from '@/models/drugSchema'; +import { mirnaSchema } from '@/models/mirnaSchema'; import { organism } from '@/models/organism'; import { projectSchema } from '@/models/project'; -import { mirnaSchema } from '@/models/mirnaSchema'; -import { bioEntityContentSchema } from '@/models/bioEntityContentSchema'; import { z } from 'zod'; export type Project = z.infer<typeof projectSchema>; @@ -12,3 +13,4 @@ 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 Chemical = z.infer<typeof chemicalSchema>;