diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 553933c1..ae1a6ba0 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,7 +7,6 @@ import domainVisitsReducer from '../visits/reducers/domainVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import { settingsReducer } from '../settings/reducers/settings'; -import visitsOverviewReducer from '../visits/reducers/visitsOverview'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { sidebarReducer } from '../common/reducers/sidebar'; import { ShlinkState } from '../container/types'; @@ -31,7 +30,7 @@ export default (container: IContainer) => combineReducers({ mercureInfo: container.mercureInfoReducer, settings: settingsReducer, domainsList: container.domainsListReducer, - visitsOverview: visitsOverviewReducer, + visitsOverview: container.visitsOverviewReducer, appUpdated: appUpdatesReducer, sidebar: sidebarReducer, }); diff --git a/src/visits/reducers/visitsOverview.ts b/src/visits/reducers/visitsOverview.ts index 824d7f5f..105bfd2b 100644 --- a/src/visits/reducers/visitsOverview.ts +++ b/src/visits/reducers/visitsOverview.ts @@ -1,14 +1,11 @@ -import { Action, Dispatch } from 'redux'; +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { ShlinkVisitsOverview } from '../../api/types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createAsyncThunk } from '../../utils/helpers/redux'; import { groupNewVisitsByType } from '../types/helpers'; -import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { createNewVisits } from './visitCreation'; -export const GET_OVERVIEW_START = 'shlink/visitsOverview/GET_OVERVIEW_START'; -export const GET_OVERVIEW_ERROR = 'shlink/visitsOverview/GET_OVERVIEW_ERROR'; -export const GET_OVERVIEW = 'shlink/visitsOverview/GET_OVERVIEW'; +const REDUCER_PREFIX = 'shlink/visitsOverview'; export interface VisitsOverview { visitsCount: number; @@ -17,7 +14,7 @@ export interface VisitsOverview { error: boolean; } -export type GetVisitsOverviewAction = ShlinkVisitsOverview & Action; +export type GetVisitsOverviewAction = PayloadAction; const initialState: VisitsOverview = { visitsCount: 0, @@ -26,33 +23,30 @@ const initialState: VisitsOverview = { error: false, }; -export default buildReducer({ - [GET_OVERVIEW_START]: () => ({ ...initialState, loading: true }), - [GET_OVERVIEW_ERROR]: () => ({ ...initialState, error: true }), - [GET_OVERVIEW]: (_, { visitsCount, orphanVisitsCount }) => ({ ...initialState, visitsCount, orphanVisitsCount }), - [createNewVisits.toString()]: ({ visitsCount, orphanVisitsCount = 0, ...rest }, { payload }) => { - const { regularVisits, orphanVisits } = groupNewVisitsByType(payload.createdVisits); +export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => createAsyncThunk( + `${REDUCER_PREFIX}/loadVisitsOverview`, + (_: void, { getState }): Promise => buildShlinkApiClient(getState).getVisitsOverview(), +); - return { - ...rest, - visitsCount: visitsCount + regularVisits.length, - orphanVisitsCount: orphanVisitsCount + orphanVisits.length, - }; +export const visitsOverviewReducerCreator = ( + loadVisitsOverviewThunk: ReturnType, +) => createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: {}, + extraReducers: (builder) => { + builder.addCase(loadVisitsOverviewThunk.pending, () => ({ ...initialState, loading: true })); + builder.addCase(loadVisitsOverviewThunk.rejected, () => ({ ...initialState, error: true })); + builder.addCase(loadVisitsOverviewThunk.fulfilled, (_, { payload }) => ({ ...initialState, ...payload })); + + builder.addCase(createNewVisits, ({ visitsCount, orphanVisitsCount = 0, ...rest }, { payload }) => { + const { createdVisits } = payload; + const { regularVisits, orphanVisits } = groupNewVisitsByType(createdVisits); + return { + ...rest, + visitsCount: visitsCount + regularVisits.length, + orphanVisitsCount: orphanVisitsCount + orphanVisits.length, + }; + }); }, -}, initialState); - -export const loadVisitsOverview = (buildShlinkApiClient: ShlinkApiClientBuilder) => () => async ( - dispatch: Dispatch, - getState: GetState, -) => { - dispatch({ type: GET_OVERVIEW_START }); - - try { - const { getVisitsOverview } = buildShlinkApiClient(getState); - const result = await getVisitsOverview(); - - dispatch({ type: GET_OVERVIEW, ...result }); - } catch (e) { - dispatch({ type: GET_OVERVIEW_ERROR }); - } -}; +}); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index a6b0d931..28684da3 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -1,4 +1,5 @@ import Bottle from 'bottlejs'; +import { prop } from 'ramda'; import { MapModal } from '../helpers/MapModal'; import { createNewVisits } from '../reducers/visitCreation'; import { ShortUrlVisits } from '../ShortUrlVisits'; @@ -11,7 +12,7 @@ import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; -import { loadVisitsOverview } from '../reducers/visitsOverview'; +import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; import { DomainVisits } from '../DomainVisits'; @@ -70,6 +71,10 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); + + // Reducers + bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); + bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/visitsOverview.test.ts b/test/visits/reducers/visitsOverview.test.ts index e93a6565..15ce1cf8 100644 --- a/test/visits/reducers/visitsOverview.test.ts +++ b/test/visits/reducers/visitsOverview.test.ts @@ -1,11 +1,9 @@ import { Mock } from 'ts-mockery'; -import reducer, { - GET_OVERVIEW_START, - GET_OVERVIEW_ERROR, - GET_OVERVIEW, +import { GetVisitsOverviewAction, VisitsOverview, - loadVisitsOverview, + loadVisitsOverview as loadVisitsOverviewCreator, + visitsOverviewReducerCreator, } from '../../../src/visits/reducers/visitsOverview'; import { createNewVisits, CreateVisitsAction } from '../../../src/visits/reducers/visitCreation'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; @@ -14,29 +12,42 @@ import { ShlinkState } from '../../../src/container/types'; import { CreateVisit, OrphanVisit, Visit } from '../../../src/visits/types'; describe('visitsOverviewReducer', () => { + const getVisitsOverview = jest.fn(); + const buildApiClientMock = () => Mock.of({ getVisitsOverview }); + const loadVisitsOverview = loadVisitsOverviewCreator(buildApiClientMock); + const { reducer } = visitsOverviewReducerCreator(loadVisitsOverview); + + beforeEach(jest.clearAllMocks); + describe('reducer', () => { const action = (type: string) => Mock.of({ type }) as GetVisitsOverviewAction & CreateVisitsAction; const state = (payload: Partial = {}) => Mock.of(payload); it('returns loading on GET_OVERVIEW_START', () => { - const { loading } = reducer(state({ loading: false, error: false }), action(GET_OVERVIEW_START)); + const { loading } = reducer( + state({ loading: false, error: false }), + action(loadVisitsOverview.pending.toString()), + ); expect(loading).toEqual(true); }); it('stops loading and returns error on GET_OVERVIEW_ERROR', () => { - const { loading, error } = reducer(state({ loading: true, error: false }), action(GET_OVERVIEW_ERROR)); + const { loading, error } = reducer( + state({ loading: true, error: false }), + action(loadVisitsOverview.rejected.toString()), + ); expect(loading).toEqual(false); expect(error).toEqual(true); }); it('return visits overview on GET_OVERVIEW', () => { - const { loading, error, visitsCount } = reducer( - state({ loading: true, error: false }), - { type: GET_OVERVIEW, visitsCount: 100 } as unknown as GetVisitsOverviewAction & CreateVisitsAction, - ); + const { loading, error, visitsCount } = reducer(state({ loading: true, error: false }), { + type: loadVisitsOverview.fulfilled.toString(), + payload: { visitsCount: 100 }, + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -76,35 +87,41 @@ describe('visitsOverviewReducer', () => { }); describe('loadVisitsOverview', () => { - const buildApiClientMock = (returned: Promise) => Mock.of({ - getVisitsOverview: jest.fn(async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of(); beforeEach(() => dispatchMock.mockReset()); it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject()); + getVisitsOverview.mockRejectedValue(undefined); - await loadVisitsOverview(() => ShlinkApiClient)()(dispatchMock, getState); + await loadVisitsOverview()(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_OVERVIEW_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_OVERVIEW_ERROR }); - expect(ShlinkApiClient.getVisitsOverview).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: loadVisitsOverview.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: loadVisitsOverview.rejected.toString(), + })); + expect(getVisitsOverview).toHaveBeenCalledTimes(1); }); it('dispatches start and success when promise is resolved', async () => { const resolvedOverview = Mock.of({ visitsCount: 50 }); - const shlinkApiClient = buildApiClientMock(Promise.resolve(resolvedOverview)); + getVisitsOverview.mockResolvedValue(resolvedOverview); - await loadVisitsOverview(() => shlinkApiClient)()(dispatchMock, getState); + await loadVisitsOverview()(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_OVERVIEW_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_OVERVIEW, visitsCount: 50 }); - expect(shlinkApiClient.getVisitsOverview).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: loadVisitsOverview.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: loadVisitsOverview.fulfilled.toString(), + payload: { visitsCount: 50 }, + })); + expect(getVisitsOverview).toHaveBeenCalledTimes(1); }); }); });