From dac69daf034f0b5b33a1215db8532c9390590079 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 20:02:58 +0100 Subject: [PATCH] Migrated tagVisits reducer to RTK --- src/api/types/actions.ts | 7 -- src/reducers/index.ts | 5 +- src/utils/helpers/redux.ts | 16 --- src/utils/utils.ts | 4 - src/visits/reducers/common.ts | 70 +---------- src/visits/reducers/tagVisits.ts | 126 +++++++++---------- src/visits/reducers/types/index.ts | 7 -- src/visits/services/provideServices.ts | 10 +- test/utils/helpers/redux.test.ts | 48 ------- test/visits/reducers/nonOrphanVisits.test.ts | 11 +- test/visits/reducers/tagVisits.test.ts | 120 +++++++++--------- 11 files changed, 134 insertions(+), 290 deletions(-) delete mode 100644 src/api/types/actions.ts delete mode 100644 test/utils/helpers/redux.test.ts diff --git a/src/api/types/actions.ts b/src/api/types/actions.ts deleted file mode 100644 index 2c8f6d38..00000000 --- a/src/api/types/actions.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { Action } from 'redux'; -import { ProblemDetailsError } from './errors'; - -/** @deprecated */ -export interface ApiErrorAction extends Action { - errorData?: ProblemDetailsError; -} diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 2d6be9a0..56734ac7 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,7 +1,6 @@ import { IContainer } from 'bottlejs'; import { combineReducers } from '@reduxjs/toolkit'; import { serversReducer } from '../servers/reducers/servers'; -import tagVisitsReducer from '../visits/reducers/tagVisits'; import { settingsReducer } from '../settings/reducers/settings'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { sidebarReducer } from '../common/reducers/sidebar'; @@ -16,7 +15,7 @@ export default (container: IContainer) => combineReducers({ shortUrlEdition: container.shortUrlEditionReducer, shortUrlDetail: container.shortUrlDetailReducer, shortUrlVisits: container.shortUrlVisitsReducer, - tagVisits: tagVisitsReducer, + tagVisits: container.tagVisitsReducer, domainVisits: container.domainVisitsReducer, orphanVisits: container.orphanVisitsReducer, nonOrphanVisits: container.nonOrphanVisitsReducer, @@ -29,4 +28,4 @@ export default (container: IContainer) => combineReducers({ visitsOverview: container.visitsOverviewReducer, appUpdated: appUpdatesReducer, sidebar: sidebarReducer, -} as any); // TODO Fix this +}); diff --git a/src/utils/helpers/redux.ts b/src/utils/helpers/redux.ts index 238d0fa2..f55518a9 100644 --- a/src/utils/helpers/redux.ts +++ b/src/utils/helpers/redux.ts @@ -1,22 +1,6 @@ import { createAsyncThunk as baseCreateAsyncThunk, AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; -import { Action } from 'redux'; import { ShlinkState } from '../../container/types'; -type ActionHandler = (currentState: State, action: AT) => State; -type ActionHandlerMap = Record>; - -/** @deprecated */ -export const buildReducer = (map: ActionHandlerMap, initialState: State) => ( - state: State | undefined, - action: AT, -): State => { - const { type } = action; - const actionHandler = map[type]; - const currentState = state ?? initialState; - - return actionHandler ? actionHandler(currentState, action) : currentState; -}; - export const createAsyncThunk = ( typePrefix: string, payloadCreator: AsyncThunkPayloadCreator, diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 8f07a336..44654994 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -21,10 +21,6 @@ type Optional = T | null | undefined; export type OptionalString = Optional; -export type RecursivePartial = { - [P in keyof T]?: RecursivePartial; -}; - export const nonEmptyValueOrNull = (value: T): T | null => (isEmpty(value) ? null : value); export const capitalize = (value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 5516ce83..bfd454df 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,11 +1,9 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { createAction, Dispatch } from '@reduxjs/toolkit'; +import { createAction } from '@reduxjs/toolkit'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; import { Visit } from '../types'; -import { parseApiError } from '../../api/utils'; -import { ApiErrorAction } from '../../api/types/actions'; import { DateInterval, dateToMatchingInterval } from '../../utils/dates/types'; -import { LoadVisits, VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; +import { LoadVisits, VisitsLoaded } from './types'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkState } from '../../container/types'; @@ -26,70 +24,6 @@ interface VisitsAsyncThunkOptions ShlinkState) => boolean; } -export const getVisitsWithLoader = async ( - visitsLoader: VisitsLoader, - lastVisitLoader: LastVisitLoader, - extraFulfilledPayload: Partial, - actionsPrefix: string, - dispatch: Dispatch, - shouldCancel: () => boolean, -) => { - dispatch({ type: `${actionsPrefix}/pending` }); - - const loadVisitsInParallel = async (pages: number[]): Promise => - Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); - - const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { - if (shouldCancel()) { - return []; - } - - const data = await loadVisitsInParallel(pagesBlocks[index]); - - dispatch({ - type: `${actionsPrefix}/progressChanged`, - payload: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE), - }); - - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } - - return data; - }; - - const loadVisits = async (page = 1) => { - const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); - - // If pagination was not returned, then this is an old shlink version. Just return data - if (!pagination || isLastPage(pagination)) { - return data; - } - - // If there are more pages, make requests in blocks of 4 - const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); - const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); - - if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { - dispatch({ type: `${actionsPrefix}/large` }); - } - - return data.concat(await loadPagesBlocks(pagesBlocks)); - }; - - try { - const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]); - - dispatch( - !visits.length && lastVisit - ? { type: `${actionsPrefix}/fallbackToInterval`, payload: dateToMatchingInterval(lastVisit.date) } - : { type: `${actionsPrefix}/fulfilled`, payload: { ...extraFulfilledPayload, visits } }, - ); - } catch (e: any) { - dispatch({ type: `${actionsPrefix}/rejected`, errorData: parseApiError(e) }); - } -}; - export const createVisitsAsyncThunk = ( { actionsPrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index ff7d2bb7..4bc06511 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,45 +1,20 @@ -import { createAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; -import { buildReducer } from '../../utils/helpers/redux'; +import { createSlice } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; -import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; -import { createNewVisits, CreateVisitsAction } from './visitCreation'; -import { - LoadVisits, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoaded, - VisitsLoadedAction, - VisitsLoadProgressChangedAction, -} from './types'; +import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; +import { createNewVisits } from './visitCreation'; +import { LoadVisits, VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/tagVisits'; -export const GET_TAG_VISITS_START = `${REDUCER_PREFIX}/getTagVisits/pending`; -export const GET_TAG_VISITS_ERROR = `${REDUCER_PREFIX}/getTagVisits/rejected`; -export const GET_TAG_VISITS = `${REDUCER_PREFIX}/getTagVisits/fulfilled`; -export const GET_TAG_VISITS_LARGE = `${REDUCER_PREFIX}/getTagVisits/large`; -export const GET_TAG_VISITS_CANCEL = `${REDUCER_PREFIX}/getTagVisits/cancel`; -export const GET_TAG_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getTagVisits/progressChanged`; -export const GET_TAG_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getTagVisits/fallbackToInterval`; -export interface TagVisits extends VisitsInfo { +interface WithTag { tag: string; } -export interface LoadTagVisits extends LoadVisits { - tag: string; -} +export interface TagVisits extends VisitsInfo, WithTag {} -export type TagVisitsAction = VisitsLoadedAction<{ tag: string }>; - -type TagsVisitsCombinedAction = TagVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; +export interface LoadTagVisits extends LoadVisits, WithTag {} const initialState: TagVisits = { visits: [], @@ -51,43 +26,58 @@ const initialState: TagVisits = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getTagVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getTagVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getTagVisits/fulfilled`]: (state, { payload }: TagVisitsAction) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - ), - [`${REDUCER_PREFIX}/getTagVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getTagVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getTagVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getTagVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { tag, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); +export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getTagVisits`, + createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => { + const { getTagVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( + tag, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params)); - return { ...state, visits: [...newVisits, ...visits] }; + return [visitsLoader, lastVisitLoader]; }, -}, initialState); + getExtraFulfilledPayload: ({ tag, query = {} }: LoadTagVisits) => ({ tag, query }), + shouldCancel: (getState) => getState().tagVisits.cancelLoad, +}); -export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { tag, query = {}, doIntervalFallback = false }: LoadTagVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getTagVisits: getVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( - tag, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params)); - const shouldCancel = () => getState().tagVisits.cancelLoad; - const extraFinishActionData: Partial> = { tag, query }; - const prefix = `${REDUCER_PREFIX}/getTagVisits`; +export const tagVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetTagVisits: (state) => ({ ...state, cancelLoad: true }), + }, + extraReducers: (builder) => { + builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true })); + builder.addCase(asyncThunk.rejected, (_, { error }) => ( + { ...initialState, error: true, errorData: parseApiError(error) } + )); + builder.addCase(asyncThunk.fulfilled, (state, { payload }) => ( + { ...state, ...payload, loading: false, loadingLarge: false, error: false } + )); - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); + builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true })); + builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress })); + builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => ( + { ...state, fallbackInterval } + )); + + builder.addCase(createNewVisits, (state, { payload }) => { + const { tag, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter(({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + const { cancelGetTagVisits } = actions; + + return { reducer, cancelGetTagVisits }; }; - -export const cancelGetTagVisits = createAction(`${REDUCER_PREFIX}/getTagVisits/cancel`); diff --git a/src/visits/reducers/types/index.ts b/src/visits/reducers/types/index.ts index 692b4591..b9dd1adf 100644 --- a/src/visits/reducers/types/index.ts +++ b/src/visits/reducers/types/index.ts @@ -1,4 +1,3 @@ -import { PayloadAction } from '@reduxjs/toolkit'; import { ShlinkVisitsParams } from '../../../api/types'; import { DateInterval } from '../../../utils/dates/types'; import { ProblemDetailsError } from '../../../api/types/errors'; @@ -25,9 +24,3 @@ export type VisitsLoaded = T & { visits: Visit[]; query?: ShlinkVisitsParams; }; - -export type VisitsLoadedAction = PayloadAction>; - -export type VisitsLoadProgressChangedAction = PayloadAction; - -export type VisitsFallbackIntervalAction = PayloadAction; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index f00f6766..3c93ef52 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -7,7 +7,7 @@ import { TagVisits } from '../TagVisits'; import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits'; -import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; +import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits'; import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits'; import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits'; @@ -58,8 +58,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator'); bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetShortUrlVisits'), 'shortUrlVisitsReducerCreator'); - bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); + bottle.serviceFactory('getTagVisitsCreator', getTagVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getTagVisits', prop('asyncThunk'), 'getTagVisitsCreator'); + bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetTagVisits'), 'tagVisitsReducerCreator'); bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); @@ -91,6 +92,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisitsCreator'); bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator'); + + bottle.serviceFactory('tagVisitsReducerCreator', tagVisitsReducerCreator, 'getTagVisitsCreator'); + bottle.serviceFactory('tagVisitsReducer', prop('reducer'), 'tagVisitsReducerCreator'); }; export default provideServices; diff --git a/test/utils/helpers/redux.test.ts b/test/utils/helpers/redux.test.ts deleted file mode 100644 index 472afa7b..00000000 --- a/test/utils/helpers/redux.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Action } from 'redux'; -import { buildReducer } from '../../../src/utils/helpers/redux'; - -describe('redux', () => { - beforeEach(jest.clearAllMocks); - - describe('buildReducer', () => { - const fooActionHandler = jest.fn(() => 'foo result'); - const barActionHandler = jest.fn(() => 'bar result'); - const initialState = 'initial state'; - let reducer: Function; - - beforeEach(() => { - reducer = buildReducer({ - foo: fooActionHandler, - bar: barActionHandler, - }, initialState); - }); - - it('returns a reducer which returns initial state when provided with unknown action', () => { - expect(reducer(undefined, { type: 'unknown action' })).toEqual(initialState); - expect(fooActionHandler).not.toHaveBeenCalled(); - expect(barActionHandler).not.toHaveBeenCalled(); - }); - - it.each([ - ['foo', 'foo result', fooActionHandler, barActionHandler], - ['bar', 'bar result', barActionHandler, fooActionHandler], - ])( - 'returns a reducer which calls corresponding action handler', - (type, expected, invokedActionHandler, notInvokedActionHandler) => { - expect(reducer(undefined, { type })).toEqual(expected); - expect(invokedActionHandler).toHaveBeenCalled(); - expect(notInvokedActionHandler).not.toHaveBeenCalled(); - }, - ); - - it.each([ - [undefined, initialState], - ['foo', 'foo'], - ['something', 'something'], - ])('returns a reducer which calls action handler with provided state or initial', (state, expected) => { - reducer(state, { type: 'foo' }); - - expect(fooActionHandler).toHaveBeenCalledWith(expected, expect.anything()); - }); - }); -}); diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 59a36658..da735c52 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -55,11 +55,10 @@ describe('nonOrphanVisitsReducer', () => { it('return visits on GET_NON_ORPHAN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { type: getNonOrphanVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -105,13 +104,13 @@ describe('nonOrphanVisitsReducer', () => { const { visits } = reducer(prevState, { type: createNewVisits.toString(), payload: { createdVisits: [{ visit }, { visit }] }, - } as any); + }); expect(visits).toHaveLength(expectedVisits); }); it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 } as any); + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -119,7 +118,7 @@ describe('nonOrphanVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: fallbackToIntervalAction.toString(), payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 0d91406e..1b17b5db 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -1,15 +1,8 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getTagVisits, - cancelGetTagVisits, - GET_TAG_VISITS_START, - GET_TAG_VISITS_ERROR, - GET_TAG_VISITS, - GET_TAG_VISITS_LARGE, - GET_TAG_VISITS_CANCEL, - GET_TAG_VISITS_PROGRESS_CHANGED, - GET_TAG_VISITS_FALLBACK_TO_INTERVAL, +import { + getTagVisits as getTagVisitsCreator, + tagVisitsReducerCreator, TagVisits, } from '../../../src/visits/reducers/tagVisits'; import { rangeOf } from '../../../src/utils/utils'; @@ -24,34 +17,37 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('tagVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getTagVisitsCall = jest.fn(); + const buildShlinkApiClientMock = () => Mock.of({ getTagVisits: getTagVisitsCall }); + const creator = getTagVisitsCreator(buildShlinkApiClientMock); + const { asyncThunk: getTagVisits, fallbackToIntervalAction, largeAction, progressChangedAction } = creator; + const { reducer, cancelGetTagVisits } = tagVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_TAG_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_TAG_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getTagVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_TAG_VISITS_LARGE } as any); - const { loadingLarge } = state; - + const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() }); expect(loadingLarge).toEqual(true); }); it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_TAG_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetTagVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_TAG_VISITS_ERROR } as any); - const { loading, error } = state; + const { loading, error } = reducer( + buildState({ loading: true, error: false }), + { type: getTagVisits.rejected.toString() }, + ); expect(loading).toEqual(false); expect(error).toEqual(true); @@ -59,11 +55,10 @@ describe('tagVisitsReducer', () => { it('return visits on GET_TAG_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { - type: GET_TAG_VISITS, + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getTagVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -129,48 +124,44 @@ describe('tagVisitsReducer', () => { const { visits } = reducer(prevState, { type: createNewVisits.toString(), payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, - } as any); + }); expect(visits).toHaveLength(expectedVisits); }); it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_TAG_VISITS_PROGRESS_CHANGED, payload: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any); + const state = reducer(undefined, { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); }); describe('getTagVisits', () => { - type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getTagVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ tagVisits: { cancelLoad: false }, }); const tag = 'foo'; - beforeEach(jest.clearAllMocks); - it('dispatches start and error when promise is rejected', async () => { - const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); + getTagVisitsCall.mockRejectedValue(new Error()); - await getTagVisits(() => shlinkApiClient)({ tag })(dispatchMock, getState); + await getTagVisits({ tag })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS_ERROR }); - expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getTagVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getTagVisits.rejected.toString(), + })); + expect(getTagVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -178,37 +169,45 @@ describe('tagVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks; - const shlinkApiClient = buildApiClientMock(Promise.resolve({ + getTagVisitsCall.mockResolvedValue({ data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getTagVisits(() => shlinkApiClient)({ tag, query })(dispatchMock, getState); + await getTagVisits({ tag, query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_TAG_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getTagVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getTagVisits.fulfilled.toString(), payload: { visits, tag, query: query ?? {} }, - }); - expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + })); + expect(getTagVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: 'last30Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: 'last180Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last180Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_TAG_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getTagVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedDispatchCalls, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -217,22 +216,23 @@ describe('tagVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkTagVisits = jest.fn() + getTagVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getTagVisits: getShlinkTagVisits }); - await getTagVisits(() => ShlinkApiClient)({ tag, doIntervalFallback: true })(dispatchMock, getState); + await getTagVisits({ tag, doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getTagVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkTagVisits).toHaveBeenCalledTimes(2); + expect(getTagVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetTagVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetTagVisits()).toEqual({ type: GET_TAG_VISITS_CANCEL })); + expect(cancelGetTagVisits()).toEqual({ type: cancelGetTagVisits.toString() })); }); });