From 1aaf610baab0d965576a9e52de5034983460329d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tadeusz=20Miesi=C4=85c?= <tadeusz.miesiac@gmail.com> Date: Fri, 29 Sep 2023 00:56:17 +0200 Subject: [PATCH] feat(fetch drugs data): fetch drugs data and save to store --- jest.config.ts | 1 + package-lock.json | 100 ++++++++++++++++++++++++- package.json | 5 +- pages/redux-api-poc.tsx | 8 +- src/constants/httpResponses.ts | 2 + src/constants/mapId.ts | 1 + src/constants/zodSeed.ts | 1 + src/models/fixtures/drugFixtures.ts | 10 +++ src/models/referenceSchema.ts | 20 ++--- src/redux/drugs/drugs.reducers.test.ts | 85 +++++++++++++++++++++ src/redux/drugs/drugs.reducers.ts | 17 +++++ src/redux/drugs/drugs.slice.ts | 20 +++++ src/redux/drugs/drugs.thunks.ts | 20 +++++ src/redux/drugs/drugs.types.ts | 8 ++ src/redux/project/project.thunks.ts | 2 +- src/redux/project/project.types.ts | 5 +- src/redux/store.ts | 2 + src/types/api.ts | 9 --- src/types/loadingState.ts | 1 + src/types/models.ts | 10 +++ src/utils/mockNetworkResponse.ts | 8 ++ 21 files changed, 306 insertions(+), 29 deletions(-) create mode 100644 src/constants/httpResponses.ts create mode 100644 src/constants/mapId.ts create mode 100644 src/constants/zodSeed.ts create mode 100644 src/models/fixtures/drugFixtures.ts create mode 100644 src/redux/drugs/drugs.reducers.test.ts create mode 100644 src/redux/drugs/drugs.reducers.ts create mode 100644 src/redux/drugs/drugs.slice.ts create mode 100644 src/redux/drugs/drugs.thunks.ts create mode 100644 src/redux/drugs/drugs.types.ts create mode 100644 src/types/loadingState.ts create mode 100644 src/types/models.ts create mode 100644 src/utils/mockNetworkResponse.ts diff --git a/jest.config.ts b/jest.config.ts index 7e7dad84..610effde 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -25,6 +25,7 @@ const config = { ], coverageReporters: ['html', 'text', 'text-summary', 'cobertura'], setupFilesAfterEnv: ['<rootDir>/setupTests.ts'], + prettierPath: require.resolve('prettier-2'), }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/package-lock.json b/package-lock.json index 1a168c99..9ae79cb7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "@types/react-redux": "^7.1.26", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", + "axios-mock-adapter": "^1.22.0", "cypress": "^13.2.0", "cz-conventional-changelog": "^3.3.0", "eslint": "^8.49.0", @@ -54,7 +55,9 @@ "jest-junit": "^16.0.0", "lint-staged": "^14.0.1", "prettier": "^3.0.3", - "typescript": "^5.2.2" + "prettier-2": "npm:prettier@^2", + "typescript": "^5.2.2", + "zod-fixture": "^2.5.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -3256,6 +3259,19 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/axios-mock-adapter": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/axios-mock-adapter/-/axios-mock-adapter-1.22.0.tgz", + "integrity": "sha512-dmI0KbkyAhntUR05YY96qg2H6gg0XMl2+qTW0xmYg6Up+BFBAJYRLROMXRdDEL06/Wqwa0TJThAYvFtSFdRCZw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "is-buffer": "^2.0.5" + }, + "peerDependencies": { + "axios": ">= 0.17.0" + } + }, "node_modules/axios/node_modules/proxy-from-env": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", @@ -5099,6 +5115,15 @@ "node": ">=8" } }, + "node_modules/drange": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/drange/-/drange-1.1.1.tgz", + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -7357,6 +7382,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "engines": { + "node": ">=4" + } + }, "node_modules/is-callable": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", @@ -10484,6 +10532,22 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-2": { + "name": "prettier", + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -10655,6 +10719,19 @@ "node": ">=8" } }, + "node_modules/randexp": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.5.3.tgz", + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dev": true, + "dependencies": { + "drange": "^1.0.2", + "ret": "^0.2.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/react": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react/-/react-18.2.0.tgz", @@ -11122,6 +11199,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.2.2.tgz", + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/reusify": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", @@ -12805,6 +12891,18 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-fixture": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/zod-fixture/-/zod-fixture-2.5.0.tgz", + "integrity": "sha512-lMQcUI5RC9zdkU26RUaOTDsUo1xWHEHtkVrERn6Cs/sRe+s/0qzltHQlRZMwYuvGh5SGPRI3Xy4eOToVJCj9sQ==", + "dev": true, + "dependencies": { + "randexp": "^0.5.3" + }, + "peerDependencies": { + "zod": ">=3.0.0" + } } } } diff --git a/package.json b/package.json index 30e182e0..ce0430aa 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,7 @@ "@types/react-redux": "^7.1.26", "@typescript-eslint/eslint-plugin": "^6.7.0", "@typescript-eslint/parser": "^6.7.0", + "axios-mock-adapter": "^1.22.0", "cypress": "^13.2.0", "cz-conventional-changelog": "^3.3.0", "eslint": "^8.49.0", @@ -72,7 +73,9 @@ "jest-junit": "^16.0.0", "lint-staged": "^14.0.1", "prettier": "^3.0.3", - "typescript": "^5.2.2" + "prettier-2": "npm:prettier@^2", + "typescript": "^5.2.2", + "zod-fixture": "^2.5.0" }, "config": { "commitizen": { diff --git a/pages/redux-api-poc.tsx b/pages/redux-api-poc.tsx index 4a195e6e..7e51bc3b 100644 --- a/pages/redux-api-poc.tsx +++ b/pages/redux-api-poc.tsx @@ -1,18 +1,14 @@ import { useSelector } from 'react-redux'; import { selectSearchValue } from '@/redux/search/search.selectors'; -import { setSearchValue } from '@/redux/search/search.slice'; import { useAppDispatch } from '@/redux/hooks/useAppDispatch'; -import { getProjectById } from '@/redux/project/project.thunks'; +import { getDrugs } from '@/redux/drugs/drugs.thunks'; const ReduxPage = (): JSX.Element => { const dispatch = useAppDispatch(); const searchValue = useSelector(selectSearchValue); const triggerSyncUpdate = (): void => { - // eslint-disable-next-line prefer-template - const newValue = searchValue + 'test'; - dispatch(setSearchValue(newValue)); - dispatch(getProjectById('pd_map_winter_23')); + dispatch(getDrugs('aspirin')); }; return ( diff --git a/src/constants/httpResponses.ts b/src/constants/httpResponses.ts new file mode 100644 index 00000000..e017a787 --- /dev/null +++ b/src/constants/httpResponses.ts @@ -0,0 +1,2 @@ +export const HTTP_OK = 200; +export const HTTP_NOT_FOUND = 404; diff --git a/src/constants/mapId.ts b/src/constants/mapId.ts new file mode 100644 index 00000000..6c76bb70 --- /dev/null +++ b/src/constants/mapId.ts @@ -0,0 +1 @@ +export const PROJECT_ID = 'pd_map_winter_23'; diff --git a/src/constants/zodSeed.ts b/src/constants/zodSeed.ts new file mode 100644 index 00000000..1f6c322d --- /dev/null +++ b/src/constants/zodSeed.ts @@ -0,0 +1 @@ +export const ZOD_SEED = 997; diff --git a/src/models/fixtures/drugFixtures.ts b/src/models/fixtures/drugFixtures.ts new file mode 100644 index 00000000..76c0b54c --- /dev/null +++ b/src/models/fixtures/drugFixtures.ts @@ -0,0 +1,10 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import { createFixture } from 'zod-fixture'; +import { z } from 'zod'; +import { drugSchema } from '@/models/drugSchema'; +import { ZOD_SEED } from '@/constants/zodSeed'; + +export const drugsFixture = createFixture(z.array(drugSchema), { + seed: ZOD_SEED, + array: { min: 2, max: 2 }, +}); diff --git a/src/models/referenceSchema.ts b/src/models/referenceSchema.ts index 029c6ecb..ed8f782f 100644 --- a/src/models/referenceSchema.ts +++ b/src/models/referenceSchema.ts @@ -2,15 +2,17 @@ import { z } from 'zod'; export const referenceSchema = z.object({ link: z.string(), - article: z.object({ - title: z.string(), - authors: z.array(z.string()), - journal: z.string(), - year: z.number(), - link: z.string(), - pubmedId: z.string(), - citationCount: z.number(), - }), + article: z + .object({ + title: z.string(), + authors: z.array(z.string()), + journal: z.string(), + year: z.number(), + link: z.string(), + pubmedId: z.string(), + citationCount: z.number(), + }) + .optional(), type: z.string(), resource: z.string(), id: z.number(), diff --git a/src/redux/drugs/drugs.reducers.test.ts b/src/redux/drugs/drugs.reducers.test.ts new file mode 100644 index 00000000..73053159 --- /dev/null +++ b/src/redux/drugs/drugs.reducers.test.ts @@ -0,0 +1,85 @@ +import { AnyAction, ThunkMiddleware } from '@reduxjs/toolkit'; +import { ToolkitStore, configureStore } from '@reduxjs/toolkit/dist/configureStore'; +import { PROJECT_ID } from '@/constants/mapId'; +import { drugsFixture } from '@/models/fixtures/drugFixtures'; +import { HTTP_NOT_FOUND, HTTP_OK } from '@/constants/httpResponses'; +import { mockNetworkResponse } from '@/utils/mockNetworkResponse'; +import { getDrugs } from './drugs.thunks'; +import drugsReducer from './drugs.slice'; +import { DrugsState } from './drugs.types'; + +const mockedAxiosClient = mockNetworkResponse(); +const SEARCH_QUERY = 'aspirin'; + +const INITIAL_STATE: DrugsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +type SliceReducerType = ToolkitStore< + { + drugs: DrugsState; + }, + AnyAction, + [ + ThunkMiddleware< + { + drugs: DrugsState; + }, + AnyAction + >, + ] +>; + +const createStoreInstanceUsingSliceReducer = (): SliceReducerType => + configureStore({ + reducer: { + drugs: drugsReducer, + }, + }); + +describe('drugs reducer', () => { + let store = {} as SliceReducerType; + + beforeEach(() => { + store = createStoreInstanceUsingSliceReducer(); + }); + + it('should match initial state', () => { + const action = { type: 'unknown' }; + + expect(drugsReducer(undefined, action)).toEqual(INITIAL_STATE); + }); + it('should update store after succesfull getDrugs query', async () => { + mockedAxiosClient + .onGet(`projects/${PROJECT_ID}/drugs:search?query=${SEARCH_QUERY}`) + .reply(HTTP_OK, drugsFixture); + + const { type } = await store.dispatch(getDrugs(SEARCH_QUERY)); + const { data, loading, error } = store.getState().drugs; + + expect(type).toBe('project/getDrugs/fulfilled'); + expect(loading).toEqual('succeeded'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual(drugsFixture); + }); + + it('should update store after failed getDrugs query', async () => { + mockedAxiosClient + .onGet(`projects/${PROJECT_ID}/drugs:search?query=${SEARCH_QUERY}`) + .reply(HTTP_NOT_FOUND, []); + + const { type } = await store.dispatch(getDrugs(SEARCH_QUERY)); + const { data, loading, error } = store.getState().drugs; + + expect(type).toBe('project/getDrugs/rejected'); + expect(loading).toEqual('failed'); + expect(error).toEqual({ message: '', name: '' }); + expect(data).toEqual([]); + }); + + it.skip('should update store on loading getDrugs query', () => { + // TODO + }); +}); diff --git a/src/redux/drugs/drugs.reducers.ts b/src/redux/drugs/drugs.reducers.ts new file mode 100644 index 00000000..8ca4b350 --- /dev/null +++ b/src/redux/drugs/drugs.reducers.ts @@ -0,0 +1,17 @@ +import { ActionReducerMapBuilder } from '@reduxjs/toolkit'; +import { DrugsState } from './drugs.types'; +import { getDrugs } from './drugs.thunks'; + +export const getDrugsReducer = (builder: ActionReducerMapBuilder<DrugsState>): void => { + builder.addCase(getDrugs.pending, state => { + state.loading = 'pending'; + }); + builder.addCase(getDrugs.fulfilled, (state, action) => { + state.data = action.payload; + state.loading = 'succeeded'; + }); + builder.addCase(getDrugs.rejected, state => { + state.loading = 'failed'; + // TODO to discuss manage state of failure + }); +}; diff --git a/src/redux/drugs/drugs.slice.ts b/src/redux/drugs/drugs.slice.ts new file mode 100644 index 00000000..651c68ae --- /dev/null +++ b/src/redux/drugs/drugs.slice.ts @@ -0,0 +1,20 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { DrugsState } from '@/redux/drugs/drugs.types'; +import { getDrugsReducer } from './drugs.reducers'; + +const initialState: DrugsState = { + data: [], + loading: 'idle', + error: { name: '', message: '' }, +}; + +export const drugsSlice = createSlice({ + name: 'drugs', + initialState, + reducers: {}, + extraReducers: builder => { + getDrugsReducer(builder); + }, +}); + +export default drugsSlice.reducer; diff --git a/src/redux/drugs/drugs.thunks.ts b/src/redux/drugs/drugs.thunks.ts new file mode 100644 index 00000000..c1726ee6 --- /dev/null +++ b/src/redux/drugs/drugs.thunks.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; +import { PROJECT_ID } from '@/constants/mapId'; +import { Drug } from '@/types/models'; +import { drugSchema } from '@/models/drugSchema'; +import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; + +export const getDrugs = createAsyncThunk( + 'project/getDrugs', + async (searchQuery: string): Promise<Drug[] | undefined> => { + const response = await axiosInstance.get<Drug[]>( + `projects/${PROJECT_ID}/drugs:search?query=${searchQuery}`, + ); + + const isDataValid = validateDataUsingZodSchema(response.data, z.array(drugSchema)); + + return isDataValid ? response.data : undefined; + }, +); diff --git a/src/redux/drugs/drugs.types.ts b/src/redux/drugs/drugs.types.ts new file mode 100644 index 00000000..fb38d5e0 --- /dev/null +++ b/src/redux/drugs/drugs.types.ts @@ -0,0 +1,8 @@ +import { Loading } from '@/types/loadingState'; +import { Drug } from '@/types/models'; + +export type DrugsState = { + data: Drug[] | undefined; + loading: Loading; + error: Error; +}; diff --git a/src/redux/project/project.thunks.ts b/src/redux/project/project.thunks.ts index d26bbdda..944895cf 100644 --- a/src/redux/project/project.thunks.ts +++ b/src/redux/project/project.thunks.ts @@ -1,6 +1,6 @@ import { createAsyncThunk } from '@reduxjs/toolkit'; import { axiosInstance } from '@/services/api/utils/axiosInstance'; -import { Project } from '@/types/api'; +import { Project } from '@/types/models'; import { validateDataUsingZodSchema } from '@/utils/validateDataUsingZodSchema'; import { projectSchema } from '@/models/project'; diff --git a/src/redux/project/project.types.ts b/src/redux/project/project.types.ts index b5ace9d7..f88c4b6b 100644 --- a/src/redux/project/project.types.ts +++ b/src/redux/project/project.types.ts @@ -1,7 +1,8 @@ -import { Project } from '@/types/api'; +import { Project } from '@/types/models'; +import { Loading } from '@/types/loadingState'; export type ProjectState = { data: Project | undefined | []; - loading: 'idle' | 'pending' | 'succeeded' | 'failed'; + loading: Loading; error: Error; }; diff --git a/src/redux/store.ts b/src/redux/store.ts index d1335018..4996b2a3 100644 --- a/src/redux/store.ts +++ b/src/redux/store.ts @@ -1,11 +1,13 @@ import { configureStore } from '@reduxjs/toolkit'; import searchReducer from '@/redux/search/search.slice'; import projectSlice from '@/redux/project/project.slice'; +import drugsReducer from '@/redux/drugs/drugs.slice'; export const store = configureStore({ reducer: { search: searchReducer, project: projectSlice, + drugs: drugsReducer, }, devTools: true, }); diff --git a/src/types/api.ts b/src/types/api.ts index 98a13076..84702d16 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,8 +1,3 @@ -import { z } from 'zod'; -import { disease } from '@/models/disease'; -import { organism } from '@/models/organism'; -import { projectSchema } from '@/models/project'; - export interface QueryOptions<Response> { method: 'GET' | 'POST'; path: string; @@ -12,7 +7,3 @@ export interface QueryOptions<Response> { export interface Query<Params, Response> { (params: Params): QueryOptions<Response>; } - -export type Project = z.infer<typeof projectSchema>; -export type Organism = z.infer<typeof organism>; -export type Disease = z.infer<typeof disease>; diff --git a/src/types/loadingState.ts b/src/types/loadingState.ts new file mode 100644 index 00000000..12859c95 --- /dev/null +++ b/src/types/loadingState.ts @@ -0,0 +1 @@ +export type Loading = 'idle' | 'pending' | 'succeeded' | 'failed'; diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 00000000..a7bc1e1e --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,10 @@ +import { disease } from '@/models/disease'; +import { drugSchema } from '@/models/drugSchema'; +import { organism } from '@/models/organism'; +import { projectSchema } from '@/models/project'; +import { z } from 'zod'; + +export type Project = z.infer<typeof projectSchema>; +export type Organism = z.infer<typeof organism>; +export type Disease = z.infer<typeof disease>; +export type Drug = z.infer<typeof drugSchema>; diff --git a/src/utils/mockNetworkResponse.ts b/src/utils/mockNetworkResponse.ts new file mode 100644 index 00000000..4f7bd109 --- /dev/null +++ b/src/utils/mockNetworkResponse.ts @@ -0,0 +1,8 @@ +// eslint-disable-next-line import/no-extraneous-dependencies +import MockAdapter from 'axios-mock-adapter'; +import { axiosInstance } from '@/services/api/utils/axiosInstance'; + +export const mockNetworkResponse = (): MockAdapter => { + const mock = new MockAdapter(axiosInstance); + return mock; +}; -- GitLab