From c6eec8b266bf3d055b979848484161bc91c2303f Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 08:49:14 +0100 Subject: [PATCH 01/14] Changed getVisitsWithLoader reducer helper so that it expects an action prefix instead of an action map --- src/visits/reducers/common.ts | 25 ++++++------- src/visits/reducers/domainVisits.ts | 47 ++++++++++++------------ src/visits/reducers/nonOrphanVisits.ts | 49 +++++++++++++------------- src/visits/reducers/orphanVisits.ts | 47 ++++++++++++------------ src/visits/reducers/shortUrlVisits.ts | 47 ++++++++++++------------ src/visits/reducers/tagVisits.ts | 47 ++++++++++++------------ 6 files changed, 122 insertions(+), 140 deletions(-) diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 1a08d7a7..4c30e58a 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -15,24 +15,16 @@ const calcProgress = (total: number, current: number): number => (current * 100) type VisitsLoader = (page: number, itemsPerPage: number) => Promise; type LastVisitLoader = () => Promise; -interface ActionMap { - start: string; - large: string; - finish: string; - error: string; - progress: string; - fallbackToInterval: string; -} export const getVisitsWithLoader = async & { visits: Visit[] }>( visitsLoader: VisitsLoader, lastVisitLoader: LastVisitLoader, extraFinishActionData: Partial, - actionMap: ActionMap, + actionsPrefix: string, dispatch: Dispatch, shouldCancel: () => boolean, ) => { - dispatch({ type: actionMap.start }); + 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); @@ -44,7 +36,10 @@ export const getVisitsWithLoader = async & { visits: V const data = await loadVisitsInParallel(pagesBlocks[index]); - dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) }); + dispatch({ + type: `${actionsPrefix}/progressChanged`, + progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE), + }); if (index < pagesBlocks.length - 1) { return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); @@ -66,7 +61,7 @@ export const getVisitsWithLoader = async & { visits: V const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange); if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { - dispatch({ type: actionMap.large }); + dispatch({ type: `${actionsPrefix}/large` }); } return data.concat(await loadPagesBlocks(pagesBlocks)); @@ -77,11 +72,11 @@ export const getVisitsWithLoader = async & { visits: V dispatch( !visits.length && lastVisit - ? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) } - : { ...extraFinishActionData, visits, type: actionMap.finish }, + ? { type: `${actionsPrefix}/fallbackToInterval`, fallbackInterval: dateToMatchingInterval(lastVisit.date) } + : { ...extraFinishActionData, visits, type: `${actionsPrefix}/fulfilled` }, ); } catch (e: any) { - dispatch({ type: actionMap.error, errorData: parseApiError(e) }); + dispatch({ type: `${actionsPrefix}/rejected`, errorData: parseApiError(e) }); } }; diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index eb2c80fe..7f964cba 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,6 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; @@ -10,13 +11,14 @@ import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { domainMatches } from '../../short-urls/helpers'; -export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START'; -export const GET_DOMAIN_VISITS_ERROR = 'shlink/domainVisits/GET_DOMAIN_VISITS_ERROR'; -export const GET_DOMAIN_VISITS = 'shlink/domainVisits/GET_DOMAIN_VISITS'; -export const GET_DOMAIN_VISITS_LARGE = 'shlink/domainVisits/GET_DOMAIN_VISITS_LARGE'; -export const GET_DOMAIN_VISITS_CANCEL = 'shlink/domainVisits/GET_DOMAIN_VISITS_CANCEL'; -export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = 'shlink/domainVisits/GET_DOMAIN_VISITS_PROGRESS_CHANGED'; -export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/domainVisits/GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL'; +const REDUCER_PREFIX = 'shlink/domainVisits'; +export const GET_DOMAIN_VISITS_START = `${REDUCER_PREFIX}/getDomainVisits/pending`; +export const GET_DOMAIN_VISITS_ERROR = `${REDUCER_PREFIX}/getDomainVisits/rejected`; +export const GET_DOMAIN_VISITS = `${REDUCER_PREFIX}/getDomainVisits/fulfilled`; +export const GET_DOMAIN_VISITS_LARGE = `${REDUCER_PREFIX}/getDomainVisits/large`; +export const GET_DOMAIN_VISITS_CANCEL = `${REDUCER_PREFIX}/getDomainVisits/cancel`; +export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getDomainVisits/progressChanged`; +export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`; export const DEFAULT_DOMAIN = 'DEFAULT'; @@ -47,15 +49,17 @@ const initialState: DomainVisits = { }; export default buildReducer({ - [GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }), - [GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => ( + [`${REDUCER_PREFIX}/getDomainVisits/pending`]: () => ({ ...initialState, loading: true }), + [`${REDUCER_PREFIX}/getDomainVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [`${REDUCER_PREFIX}/getDomainVisits/fulfilled`]: (state, { visits, domain, query }) => ( { ...state, visits, domain, query, loading: false, loadingLarge: false, error: false } ), - [GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), - [GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [`${REDUCER_PREFIX}/getDomainVisits/large`]: (state) => ({ ...state, loadingLarge: true }), + [`${REDUCER_PREFIX}/getDomainVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), + [`${REDUCER_PREFIX}/getDomainVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + { ...state, fallbackInterval } + ), [createNewVisits.toString()]: (state, { payload }) => { const { domain, visits, query = {} } = state; const { startDate, endDate } = query; @@ -81,16 +85,9 @@ export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); const shouldCancel = () => getState().domainVisits.cancelLoad; const extraFinishActionData: Partial = { domain, query }; - const actionMap = { - start: GET_DOMAIN_VISITS_START, - large: GET_DOMAIN_VISITS_LARGE, - finish: GET_DOMAIN_VISITS, - error: GET_DOMAIN_VISITS_ERROR, - progress: GET_DOMAIN_VISITS_PROGRESS_CHANGED, - fallbackToInterval: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, - }; + const prefix = `${REDUCER_PREFIX}/getDomainVisits`; - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); }; -export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL); +export const cancelGetDomainVisits = createAction(`${REDUCER_PREFIX}/getDomainVisits/cancel`); diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index 94c037fc..0b4a307c 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,3 +1,4 @@ +import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { Visit, @@ -5,7 +6,7 @@ import { VisitsInfo, VisitsLoadProgressChangedAction, } from '../types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; @@ -14,13 +15,14 @@ import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -export const GET_NON_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_START'; -export const GET_NON_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_ERROR'; -export const GET_NON_ORPHAN_VISITS = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS'; -export const GET_NON_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_LARGE'; -export const GET_NON_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_CANCEL'; -export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED'; -export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL'; +const REDUCER_PREFIX = 'shlink/orphanVisits'; +export const GET_NON_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getNonOrphanVisits/pending`; +export const GET_NON_ORPHAN_VISITS_ERROR = `${REDUCER_PREFIX}/getNonOrphanVisits/rejected`; +export const GET_NON_ORPHAN_VISITS = `${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`; +export const GET_NON_ORPHAN_VISITS_LARGE = `${REDUCER_PREFIX}/getNonOrphanVisits/large`; +export const GET_NON_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getNonOrphanVisits/cancel`; +export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`; +export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`; export interface NonOrphanVisitsAction extends Action { visits: Visit[]; @@ -43,15 +45,19 @@ const initialState: VisitsInfo = { }; export default buildReducer({ - [GET_NON_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), - [GET_NON_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_NON_ORPHAN_VISITS]: (state, { visits, query }) => ( + [`${REDUCER_PREFIX}/getNonOrphanVisits/pending`]: () => ({ ...initialState, loading: true }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/rejected`]: (_, { errorData }) => ( + { ...initialState, error: true, errorData } + ), + [`${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`]: (state, { visits, query }) => ( { ...state, visits, query, loading: false, loadingLarge: false, error: false } ), - [GET_NON_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), - [GET_NON_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + { ...state, fallbackInterval } + ), [createNewVisits.toString()]: (state, { payload }) => { const { visits, query = {} } = state; const { startDate, endDate } = query; @@ -73,16 +79,9 @@ export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; const extraFinishActionData: Partial = { query }; - const actionMap = { - start: GET_NON_ORPHAN_VISITS_START, - large: GET_NON_ORPHAN_VISITS_LARGE, - finish: GET_NON_ORPHAN_VISITS, - error: GET_NON_ORPHAN_VISITS_ERROR, - progress: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, - fallbackToInterval: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, - }; + const prefix = `${REDUCER_PREFIX}/getNonOrphanVisits`; - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); }; -export const cancelGetNonOrphanVisits = buildActionCreator(GET_NON_ORPHAN_VISITS_CANCEL); +export const cancelGetNonOrphanVisits = createAction(`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index ecbe832d..188018f5 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,3 +1,4 @@ +import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { OrphanVisit, @@ -7,7 +8,7 @@ import { VisitsInfo, VisitsLoadProgressChangedAction, } from '../types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; @@ -17,13 +18,14 @@ import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -export const GET_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_ORPHAN_VISITS_START'; -export const GET_ORPHAN_VISITS_ERROR = 'shlink/orphanVisits/GET_ORPHAN_VISITS_ERROR'; -export const GET_ORPHAN_VISITS = 'shlink/orphanVisits/GET_ORPHAN_VISITS'; -export const GET_ORPHAN_VISITS_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE'; -export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL'; -export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = 'shlink/orphanVisits/GET_ORPHAN_VISITS_PROGRESS_CHANGED'; -export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL'; +const REDUCER_PREFIX = 'shlink/orphanVisits'; +export const GET_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getOrphanVisits/pending`; +export const GET_ORPHAN_VISITS_ERROR = `${REDUCER_PREFIX}/getOrphanVisits/rejected`; +export const GET_ORPHAN_VISITS = `${REDUCER_PREFIX}/getOrphanVisits/fulfilled`; +export const GET_ORPHAN_VISITS_LARGE = `${REDUCER_PREFIX}/getOrphanVisits/large`; +export const GET_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getOrphanVisits/cancel`; +export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getOrphanVisits/progressChanged`; +export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`; export interface OrphanVisitsAction extends Action { visits: Visit[]; @@ -46,15 +48,17 @@ const initialState: VisitsInfo = { }; export default buildReducer({ - [GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), - [GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_ORPHAN_VISITS]: (state, { visits, query }) => ( + [`${REDUCER_PREFIX}/getOrphanVisits/pending`]: () => ({ ...initialState, loading: true }), + [`${REDUCER_PREFIX}/getOrphanVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [`${REDUCER_PREFIX}/getOrphanVisits/fulfilled`]: (state, { visits, query }) => ( { ...state, visits, query, loading: false, loadingLarge: false, error: false } ), - [GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), - [GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [`${REDUCER_PREFIX}/getOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), + [`${REDUCER_PREFIX}/getOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), + [`${REDUCER_PREFIX}/getOrphanVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + { ...state, fallbackInterval } + ), [createNewVisits.toString()]: (state, { payload }) => { const { visits, query = {} } = state; const { startDate, endDate } = query; @@ -84,16 +88,9 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; const extraFinishActionData: Partial = { query }; - const actionMap = { - start: GET_ORPHAN_VISITS_START, - large: GET_ORPHAN_VISITS_LARGE, - finish: GET_ORPHAN_VISITS, - error: GET_ORPHAN_VISITS_ERROR, - progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED, - fallbackToInterval: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, - }; + const prefix = `${REDUCER_PREFIX}/getOrphanVisits`; - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); }; -export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); +export const cancelGetOrphanVisits = createAction(`${REDUCER_PREFIX}/getOrphanVisits/cancel`); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index c768d8b5..2e314f96 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,8 +1,9 @@ +import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; @@ -11,13 +12,14 @@ import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; -export const GET_SHORT_URL_VISITS_ERROR = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_ERROR'; -export const GET_SHORT_URL_VISITS = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS'; -export const GET_SHORT_URL_VISITS_LARGE = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_LARGE'; -export const GET_SHORT_URL_VISITS_CANCEL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_CANCEL'; -export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_PROGRESS_CHANGED'; -export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL'; +const REDUCER_PREFIX = 'shlink/shortUrlVisits'; +export const GET_SHORT_URL_VISITS_START = `${REDUCER_PREFIX}/getShortUrlVisits/pending`; +export const GET_SHORT_URL_VISITS_ERROR = `${REDUCER_PREFIX}/getShortUrlVisits/rejected`; +export const GET_SHORT_URL_VISITS = `${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`; +export const GET_SHORT_URL_VISITS_LARGE = `${REDUCER_PREFIX}/getShortUrlVisits/large`; +export const GET_SHORT_URL_VISITS_CANCEL = `${REDUCER_PREFIX}/getShortUrlVisits/cancel`; +export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`; +export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`; export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} @@ -44,9 +46,9 @@ const initialState: ShortUrlVisits = { }; export default buildReducer({ - [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), - [GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({ + [`${REDUCER_PREFIX}/getShortUrlVisits/pending`]: () => ({ ...initialState, loading: true }), + [`${REDUCER_PREFIX}/getShortUrlVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [`${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`]: (state, { visits, query, shortCode, domain }) => ({ ...state, visits, shortCode, @@ -56,10 +58,12 @@ export default buildReducer({ loadingLarge: false, error: false, }), - [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), - [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [`${REDUCER_PREFIX}/getShortUrlVisits/large`]: (state) => ({ ...state, loadingLarge: true }), + [`${REDUCER_PREFIX}/getShortUrlVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), + [`${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + { ...state, fallbackInterval } + ), [createNewVisits.toString()]: (state, { payload }) => { const { shortCode, domain, visits, query = {} } = state; const { startDate, endDate } = query; @@ -90,16 +94,9 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) ); const shouldCancel = () => getState().shortUrlVisits.cancelLoad; const extraFinishActionData: Partial = { shortCode, query, domain: query.domain }; - const actionMap = { - start: GET_SHORT_URL_VISITS_START, - large: GET_SHORT_URL_VISITS_LARGE, - finish: GET_SHORT_URL_VISITS, - error: GET_SHORT_URL_VISITS_ERROR, - progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, - fallbackToInterval: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, - }; + const prefix = `${REDUCER_PREFIX}/getShortUrlVisits`; - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); }; -export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL); +export const cancelGetShortUrlVisits = createAction(`${REDUCER_PREFIX}/getShortUrlVisits/cancel`); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 65b0bfc8..adb1dff2 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,6 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; import { ShlinkVisitsParams } from '../../api/types'; @@ -9,13 +10,14 @@ import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; -export const GET_TAG_VISITS_ERROR = 'shlink/tagVisits/GET_TAG_VISITS_ERROR'; -export const GET_TAG_VISITS = 'shlink/tagVisits/GET_TAG_VISITS'; -export const GET_TAG_VISITS_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE'; -export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; -export const GET_TAG_VISITS_PROGRESS_CHANGED = 'shlink/tagVisits/GET_TAG_VISITS_PROGRESS_CHANGED'; -export const GET_TAG_VISITS_FALLBACK_TO_INTERVAL = 'shlink/tagVisits/GET_TAG_VISITS_FALLBACK_TO_INTERVAL'; +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 { tag: string; @@ -44,15 +46,17 @@ const initialState: TagVisits = { }; export default buildReducer({ - [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), - [GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [GET_TAG_VISITS]: (state, { visits, tag, query }) => ( + [`${REDUCER_PREFIX}/getTagVisits/pending`]: () => ({ ...initialState, loading: true }), + [`${REDUCER_PREFIX}/getTagVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), + [`${REDUCER_PREFIX}/getTagVisits/fulfilled`]: (state, { visits, tag, query }) => ( { ...state, visits, tag, query, loading: false, loadingLarge: false, error: false } ), - [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), - [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), - [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), - [GET_TAG_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }), + [`${REDUCER_PREFIX}/getTagVisits/large`]: (state) => ({ ...state, loadingLarge: true }), + [`${REDUCER_PREFIX}/getTagVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), + [`${REDUCER_PREFIX}/getTagVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getTagVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + { ...state, fallbackInterval } + ), [createNewVisits.toString()]: (state, { payload }) => { const { tag, visits, query = {} } = state; const { startDate, endDate } = query; @@ -77,16 +81,9 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params)); const shouldCancel = () => getState().tagVisits.cancelLoad; const extraFinishActionData: Partial = { tag, query }; - const actionMap = { - start: GET_TAG_VISITS_START, - large: GET_TAG_VISITS_LARGE, - finish: GET_TAG_VISITS, - error: GET_TAG_VISITS_ERROR, - progress: GET_TAG_VISITS_PROGRESS_CHANGED, - fallbackToInterval: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, - }; + const prefix = `${REDUCER_PREFIX}/getTagVisits`; - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); + return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); }; -export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL); +export const cancelGetTagVisits = createAction(`${REDUCER_PREFIX}/getTagVisits/cancel`); From 32f7374d921cc24bc0d38ac12acdc1af72308a76 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 09:01:43 +0100 Subject: [PATCH 02/14] Migrated progress and fallback visits actions to payload actions --- src/reducers/index.ts | 2 +- src/visits/reducers/common.ts | 8 ++++---- src/visits/reducers/domainVisits.ts | 6 +++--- src/visits/reducers/nonOrphanVisits.ts | 6 +++--- src/visits/reducers/orphanVisits.ts | 6 +++--- src/visits/reducers/shortUrlVisits.ts | 6 +++--- src/visits/reducers/tagVisits.ts | 6 +++--- src/visits/types/index.ts | 10 +++------- test/visits/reducers/domainVisits.test.ts | 11 +++++++---- test/visits/reducers/nonOrphanVisits.test.ts | 11 +++++++---- test/visits/reducers/orphanVisits.test.ts | 11 +++++++---- test/visits/reducers/shortUrlVisits.test.ts | 11 +++++++---- test/visits/reducers/tagVisits.test.ts | 8 ++++---- 13 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index ae1a6ba0..8d0e05fb 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -33,4 +33,4 @@ export default (container: IContainer) => combineReducers({ visitsOverview: container.visitsOverviewReducer, appUpdated: appUpdatesReducer, sidebar: sidebarReducer, -}); +} as any); // TODO Fix this diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 4c30e58a..1912dfc5 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,7 +1,7 @@ import { flatten, prop, range, splitEvery } from 'ramda'; import { Action, Dispatch } from 'redux'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; -import { Visit } from '../types'; +import { Visit, VisitsLoadProgressChangedAction } from '../types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; import { dateToMatchingInterval } from '../../utils/dates/types'; @@ -36,9 +36,9 @@ export const getVisitsWithLoader = async & { visits: V const data = await loadVisitsInParallel(pagesBlocks[index]); - dispatch({ + dispatch({ type: `${actionsPrefix}/progressChanged`, - progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE), + payload: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE), }); if (index < pagesBlocks.length - 1) { @@ -72,7 +72,7 @@ export const getVisitsWithLoader = async & { visits: V dispatch( !visits.length && lastVisit - ? { type: `${actionsPrefix}/fallbackToInterval`, fallbackInterval: dateToMatchingInterval(lastVisit.date) } + ? { type: `${actionsPrefix}/fallbackToInterval`, payload: dateToMatchingInterval(lastVisit.date) } : { ...extraFinishActionData, visits, type: `${actionsPrefix}/fulfilled` }, ); } catch (e: any) { diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 7f964cba..0b226ac4 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -56,11 +56,11 @@ export default buildReducer({ ), [`${REDUCER_PREFIX}/getDomainVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getDomainVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getDomainVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + [`${REDUCER_PREFIX}/getDomainVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } ), - [createNewVisits.toString()]: (state, { payload }) => { + [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { const { domain, visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = payload.createdVisits diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index 0b4a307c..e8254cc8 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -54,11 +54,11 @@ export default buildReducer({ ), [`${REDUCER_PREFIX}/getNonOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + [`${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } ), - [createNewVisits.toString()]: (state, { payload }) => { + [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { const { visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = payload.createdVisits diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 188018f5..1514efc6 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -55,11 +55,11 @@ export default buildReducer({ ), [`${REDUCER_PREFIX}/getOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getOrphanVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + [`${REDUCER_PREFIX}/getOrphanVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } ), - [createNewVisits.toString()]: (state, { payload }) => { + [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { const { visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = payload.createdVisits diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 2e314f96..5026ec65 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -60,11 +60,11 @@ export default buildReducer({ }), [`${REDUCER_PREFIX}/getShortUrlVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getShortUrlVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + [`${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } ), - [createNewVisits.toString()]: (state, { payload }) => { + [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { const { shortCode, domain, visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = payload.createdVisits diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index adb1dff2..b5fa3d23 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -53,11 +53,11 @@ export default buildReducer({ ), [`${REDUCER_PREFIX}/getTagVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getTagVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getTagVisits/progressChanged`]: (state, { progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getTagVisits/fallbackToInterval`]: (state, { fallbackInterval }) => ( + [`${REDUCER_PREFIX}/getTagVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), + [`${REDUCER_PREFIX}/getTagVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( { ...state, fallbackInterval } ), - [createNewVisits.toString()]: (state, { payload }) => { + [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { const { tag, visits, query = {} } = state; const { startDate, endDate } = query; const newVisits = payload.createdVisits diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index a110ded0..7af91b18 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,4 +1,4 @@ -import { Action } from 'redux'; +import { PayloadAction } from '@reduxjs/toolkit'; import { ShortUrl } from '../../short-urls/data'; import { ShlinkVisitsParams } from '../../api/types'; import { DateInterval, DateRange } from '../../utils/dates/types'; @@ -16,13 +16,9 @@ export interface VisitsInfo { fallbackInterval?: DateInterval; } -export interface VisitsLoadProgressChangedAction extends Action { - progress: number; -} +export type VisitsLoadProgressChangedAction = PayloadAction; -export interface VisitsFallbackIntervalAction extends Action { - fallbackInterval: DateInterval; -} +export type VisitsFallbackIntervalAction = PayloadAction; export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index d7f92bbb..48cc0f49 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -142,14 +142,17 @@ describe('domainVisitsReducer', () => { }); it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_DOMAIN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + const state = reducer(undefined, { type: GET_DOMAIN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + const state = reducer( + undefined, + { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -205,11 +208,11 @@ describe('domainVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last30Days' }, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last180Days' }, + { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last180Days' }, ], [[], expect.objectContaining({ type: GET_DOMAIN_VISITS })], ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 285b1e5e..2c43d236 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -114,14 +114,17 @@ describe('nonOrphanVisitsReducer', () => { }); it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + const state = reducer( + undefined, + { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -176,11 +179,11 @@ describe('nonOrphanVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, ], [[], expect.objectContaining({ type: GET_NON_ORPHAN_VISITS })], ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 5c259a6e..4b044a10 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -114,14 +114,17 @@ describe('orphanVisitsReducer', () => { }); it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_ORPHAN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + const state = reducer(undefined, { type: GET_ORPHAN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + const state = reducer( + undefined, + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -176,11 +179,11 @@ describe('orphanVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, ], [[], expect.objectContaining({ type: GET_ORPHAN_VISITS })], ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index f1134557..e5c301c0 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -135,14 +135,17 @@ describe('shortUrlVisitsReducer', () => { }); it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, payload: 85 } as any); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); it('returns fallbackInterval on GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL', () => { const fallbackInterval: DateInterval = 'last30Days'; - const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any); + const state = reducer( + undefined, + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -222,11 +225,11 @@ describe('shortUrlVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' }, + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, ], [[], expect.objectContaining({ type: GET_SHORT_URL_VISITS })], ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 9e5c9e78..7292dc28 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -135,14 +135,14 @@ describe('tagVisitsReducer', () => { }); it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_TAG_VISITS_PROGRESS_CHANGED, progress: 85 } as any); + const state = reducer(undefined, { type: GET_TAG_VISITS_PROGRESS_CHANGED, payload: 85 } as any); 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, fallbackInterval } as any); + const state = reducer(undefined, { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); @@ -198,11 +198,11 @@ describe('tagVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: 'last30Days' }, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last180Days' }, + { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, payload: 'last180Days' }, ], [[], expect.objectContaining({ type: GET_TAG_VISITS })], ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { From 3b96b894927581edc1fe6b69ce80b755053bb14d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 09:18:37 +0100 Subject: [PATCH 03/14] Updated getDomainVisits action so that it expects a signle DTO param --- src/container/types.ts | 2 +- src/visits/DomainVisits.tsx | 6 ++--- src/visits/NonOrphanVisits.tsx | 3 ++- src/visits/OrphanVisits.tsx | 3 ++- src/visits/VisitsStats.tsx | 3 ++- src/visits/reducers/common.ts | 3 ++- src/visits/reducers/domainVisits.ts | 11 ++++++--- src/visits/reducers/nonOrphanVisits.ts | 8 ++---- src/visits/reducers/orphanVisits.ts | 10 ++------ src/visits/reducers/shortUrlVisits.ts | 3 ++- src/visits/reducers/tagVisits.ts | 3 ++- src/visits/reducers/types/index.ts | 26 ++++++++++++++++++++ src/visits/types/index.ts | 21 +--------------- test/visits/DomainVisits.test.tsx | 2 +- test/visits/NonOrphanVisits.test.tsx | 3 ++- test/visits/OrphanVisits.test.tsx | 3 ++- test/visits/VisitsStats.test.tsx | 3 ++- test/visits/reducers/domainVisits.test.ts | 6 ++--- test/visits/reducers/nonOrphanVisits.test.ts | 3 ++- test/visits/reducers/orphanVisits.test.ts | 3 ++- 20 files changed, 68 insertions(+), 57 deletions(-) create mode 100644 src/visits/reducers/types/index.ts diff --git a/src/container/types.ts b/src/container/types.ts index 24b4cb73..3be8ee4c 100644 --- a/src/container/types.ts +++ b/src/container/types.ts @@ -13,9 +13,9 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits'; import { TagVisits } from '../visits/reducers/tagVisits'; import { DomainsList } from '../domains/reducers/domainsList'; import { VisitsOverview } from '../visits/reducers/visitsOverview'; -import { VisitsInfo } from '../visits/types'; import { Sidebar } from '../common/reducers/sidebar'; import { DomainVisits } from '../visits/reducers/domainVisits'; +import { VisitsInfo } from '../visits/reducers/types'; export interface ShlinkState { servers: ServersMap; diff --git a/src/visits/DomainVisits.tsx b/src/visits/DomainVisits.tsx index b759f167..13232a69 100644 --- a/src/visits/DomainVisits.tsx +++ b/src/visits/DomainVisits.tsx @@ -1,7 +1,7 @@ import { useParams } from 'react-router-dom'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { ShlinkVisitsParams } from '../api/types'; -import { DomainVisits as DomainVisitsState } from './reducers/domainVisits'; +import { DomainVisits as DomainVisitsState, LoadDomainVisits } from './reducers/domainVisits'; import { ReportExporter } from '../common/services/ReportExporter'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { Topics } from '../mercure/helpers/Topics'; @@ -12,7 +12,7 @@ import { VisitsStats } from './VisitsStats'; import { VisitsHeader } from './VisitsHeader'; export interface DomainVisitsProps extends CommonVisitsProps { - getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + getDomainVisits: (params: LoadDomainVisits) => void; domainVisits: DomainVisitsState; cancelGetDomainVisits: () => void; } @@ -28,7 +28,7 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure const { domain = '' } = useParams(); const [authority, domainId = authority] = domain.split('_'); const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => - getDomainVisits(domainId, toApiParams(params), doIntervalFallback); + getDomainVisits({ domain: domainId, query: toApiParams(params), doIntervalFallback }); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits); return ( diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx index 6113cfd6..9452167c 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -4,10 +4,11 @@ import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; import { VisitsStats } from './VisitsStats'; -import { NormalizedVisit, VisitsInfo, VisitsParams } from './types'; +import { NormalizedVisit, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; +import { VisitsInfo } from './reducers/types'; export interface NonOrphanVisitsProps extends CommonVisitsProps { getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index dbd3d8ba..14af9c31 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -4,10 +4,11 @@ import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; import { VisitsStats } from './VisitsStats'; -import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types'; +import { NormalizedVisit, OrphanVisitType, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; +import { VisitsInfo } from './reducers/types'; export interface OrphanVisitsProps extends CommonVisitsProps { getOrphanVisits: ( diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 1980ded8..2f22f5e7 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -19,13 +19,14 @@ import { NavPillItem, NavPills } from '../utils/NavPills'; import { ExportBtn } from '../utils/ExportBtn'; import { LineChartCard } from './charts/LineChartCard'; import { VisitsTable } from './VisitsTable'; -import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsParams } from './types'; import { OpenMapModalBtn } from './helpers/OpenMapModalBtn'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; import { HighlightableProps, highlightedVisitsToStats } from './types/helpers'; import { DoughnutChartCard } from './charts/DoughnutChartCard'; import { SortableBarChartCard } from './charts/SortableBarChartCard'; +import { VisitsInfo } from './reducers/types'; export type VisitsStatsProps = PropsWithChildren<{ getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 1912dfc5..52544b69 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,10 +1,11 @@ import { flatten, prop, range, splitEvery } from 'ramda'; import { Action, Dispatch } from 'redux'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; -import { Visit, VisitsLoadProgressChangedAction } from '../types'; +import { Visit } from '../types'; import { parseApiError } from '../../api/utils'; import { ApiErrorAction } from '../../api/types/actions'; import { dateToMatchingInterval } from '../../utils/dates/types'; +import { VisitsLoadProgressChangedAction } from './types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 0b226ac4..42e1f371 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; -import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { Visit } from '../types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; @@ -10,6 +10,7 @@ import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { domainMatches } from '../../short-urls/helpers'; +import { LoadVisits, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/domainVisits'; export const GET_DOMAIN_VISITS_START = `${REDUCER_PREFIX}/getDomainVisits/pending`; @@ -26,6 +27,10 @@ export interface DomainVisits extends VisitsInfo { domain: string; } +export interface LoadDomainVisits extends LoadVisits { + domain: string; +} + export interface DomainVisitsAction extends Action { visits: Visit[]; domain: string; @@ -73,9 +78,7 @@ export default buildReducer({ }, initialState); export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - domain: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, + { domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, ) => async (dispatch: Dispatch, getState: GetState) => { const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index e8254cc8..a2061e79 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,11 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; -import { - Visit, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoadProgressChangedAction, -} from '../types'; +import { Visit } from '../types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; @@ -14,6 +9,7 @@ import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/orphanVisits'; export const GET_NON_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getNonOrphanVisits/pending`; diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 1514efc6..9f85f2d6 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,13 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; -import { - OrphanVisit, - OrphanVisitType, - Visit, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoadProgressChangedAction, -} from '../types'; +import { OrphanVisit, OrphanVisitType, Visit } from '../types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; @@ -17,6 +10,7 @@ import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/orphanVisits'; export const GET_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getOrphanVisits/pending`; diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 5026ec65..0c34a1f8 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,7 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { Visit } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; @@ -11,6 +11,7 @@ import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/shortUrlVisits'; export const GET_SHORT_URL_VISITS_START = `${REDUCER_PREFIX}/getShortUrlVisits/pending`; diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index b5fa3d23..0b173613 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,6 +1,6 @@ import { createAction } from '@reduxjs/toolkit'; import { Action, Dispatch } from 'redux'; -import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; +import { Visit } from '../types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; @@ -9,6 +9,7 @@ import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/tagVisits'; export const GET_TAG_VISITS_START = `${REDUCER_PREFIX}/getTagVisits/pending`; diff --git a/src/visits/reducers/types/index.ts b/src/visits/reducers/types/index.ts new file mode 100644 index 00000000..a14920c0 --- /dev/null +++ b/src/visits/reducers/types/index.ts @@ -0,0 +1,26 @@ +import { PayloadAction } from '@reduxjs/toolkit'; +import { ShlinkVisitsParams } from '../../../api/types'; +import { DateInterval } from '../../../utils/dates/types'; +import { ProblemDetailsError } from '../../../api/types/errors'; +import { Visit } from '../../types'; + +export interface VisitsInfo { + visits: Visit[]; + loading: boolean; + loadingLarge: boolean; + error: boolean; + errorData?: ProblemDetailsError; + progress: number; + cancelLoad: boolean; + query?: ShlinkVisitsParams; + fallbackInterval?: DateInterval; +} + +export interface LoadVisits { + query?: ShlinkVisitsParams; + doIntervalFallback?: boolean; +} + +export type VisitsLoadProgressChangedAction = PayloadAction; + +export type VisitsFallbackIntervalAction = PayloadAction; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 7af91b18..cb0499aa 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,24 +1,5 @@ -import { PayloadAction } from '@reduxjs/toolkit'; import { ShortUrl } from '../../short-urls/data'; -import { ShlinkVisitsParams } from '../../api/types'; -import { DateInterval, DateRange } from '../../utils/dates/types'; -import { ProblemDetailsError } from '../../api/types/errors'; - -export interface VisitsInfo { - visits: Visit[]; - loading: boolean; - loadingLarge: boolean; - error: boolean; - errorData?: ProblemDetailsError; - progress: number; - cancelLoad: boolean; - query?: ShlinkVisitsParams; - fallbackInterval?: DateInterval; -} - -export type VisitsLoadProgressChangedAction = PayloadAction; - -export type VisitsFallbackIntervalAction = PayloadAction; +import { DateRange } from '../../utils/dates/types'; export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; diff --git a/test/visits/DomainVisits.test.tsx b/test/visits/DomainVisits.test.tsx index 19be1892..c8b17e0b 100644 --- a/test/visits/DomainVisits.test.tsx +++ b/test/visits/DomainVisits.test.tsx @@ -38,7 +38,7 @@ describe('', () => { it('wraps visits stats and header', () => { setUp(); expect(screen.getByRole('heading', { name: '"foo.com" visits' })).toBeInTheDocument(); - expect(getDomainVisits).toHaveBeenCalledWith('DEFAULT', expect.anything(), expect.anything()); + expect(getDomainVisits).toHaveBeenCalledWith(expect.objectContaining({ domain: 'DEFAULT' })); }); it('exports visits when clicking the button', async () => { diff --git a/test/visits/NonOrphanVisits.test.tsx b/test/visits/NonOrphanVisits.test.tsx index 1c3572de..a4eb1a9b 100644 --- a/test/visits/NonOrphanVisits.test.tsx +++ b/test/visits/NonOrphanVisits.test.tsx @@ -4,11 +4,12 @@ import { Mock } from 'ts-mockery'; import { formatISO } from 'date-fns'; import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import { Visit, VisitsInfo } from '../../src/visits/types'; +import { Visit } from '../../src/visits/types'; import { Settings } from '../../src/settings/reducers/settings'; import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; import { renderWithEvents } from '../__helpers__/setUpTest'; +import { VisitsInfo } from '../../src/visits/reducers/types'; describe('', () => { const exportVisits = jest.fn(); diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx index 6f92edf4..005445cf 100644 --- a/test/visits/OrphanVisits.test.tsx +++ b/test/visits/OrphanVisits.test.tsx @@ -4,11 +4,12 @@ import { Mock } from 'ts-mockery'; import { formatISO } from 'date-fns'; import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits'; import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; -import { Visit, VisitsInfo } from '../../src/visits/types'; +import { Visit } from '../../src/visits/types'; import { Settings } from '../../src/settings/reducers/settings'; import { ReportExporter } from '../../src/common/services/ReportExporter'; import { SelectedServer } from '../../src/servers/data'; import { renderWithEvents } from '../__helpers__/setUpTest'; +import { VisitsInfo } from '../../src/visits/reducers/types'; describe('', () => { const getOrphanVisits = jest.fn(); diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index 8bd2fb8f..ce503fac 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -3,11 +3,12 @@ import { Mock } from 'ts-mockery'; import { Router } from 'react-router-dom'; import { createMemoryHistory } from 'history'; import { VisitsStats } from '../../src/visits/VisitsStats'; -import { Visit, VisitsInfo } from '../../src/visits/types'; +import { Visit } from '../../src/visits/types'; import { Settings } from '../../src/settings/reducers/settings'; import { SelectedServer } from '../../src/servers/data'; import { renderWithEvents } from '../__helpers__/setUpTest'; import { rangeOf } from '../../src/utils/utils'; +import { VisitsInfo } from '../../src/visits/reducers/types'; describe('', () => { const visits = rangeOf(3, () => Mock.of({ date: '2020-01-01' })); diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index 48cc0f49..8564fd76 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -175,7 +175,7 @@ describe('domainVisitsReducer', () => { it('dispatches start and error when promise is rejected', async () => { const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); - await getDomainVisits(() => shlinkApiClient)('foo.com')(dispatchMock, getState); + await getDomainVisits(() => shlinkApiClient)({ domain })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); @@ -197,7 +197,7 @@ describe('domainVisitsReducer', () => { }, })); - await getDomainVisits(() => shlinkApiClient)(domain, query)(dispatchMock, getState); + await getDomainVisits(() => shlinkApiClient)({ domain, query })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); @@ -229,7 +229,7 @@ describe('domainVisitsReducer', () => { .mockResolvedValueOnce(buildVisitsResult(lastVisits)); const ShlinkApiClient = Mock.of({ getDomainVisits: getShlinkDomainVisits }); - await getDomainVisits(() => ShlinkApiClient)(domain, {}, true)(dispatchMock, getState); + await getDomainVisits(() => ShlinkApiClient)({ domain, doIntervalFallback: true })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 2c43d236..a5a4533c 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -12,13 +12,14 @@ import reducer, { GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, } from '../../../src/visits/reducers/nonOrphanVisits'; import { rangeOf } from '../../../src/utils/utils'; -import { Visit, VisitsInfo } from '../../../src/visits/types'; +import { Visit } from '../../../src/visits/types'; import { ShlinkVisits } from '../../../src/api/types'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { formatIsoDate } from '../../../src/utils/helpers/date'; import { DateInterval } from '../../../src/utils/dates/types'; import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; +import { VisitsInfo } from '../../../src/visits/reducers/types'; describe('nonOrphanVisitsReducer', () => { const now = new Date(); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 4b044a10..663d1e14 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -12,13 +12,14 @@ import reducer, { GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, } from '../../../src/visits/reducers/orphanVisits'; import { rangeOf } from '../../../src/utils/utils'; -import { Visit, VisitsInfo } from '../../../src/visits/types'; +import { Visit } from '../../../src/visits/types'; import { ShlinkVisits } from '../../../src/api/types'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkState } from '../../../src/container/types'; import { formatIsoDate } from '../../../src/utils/helpers/date'; import { DateInterval } from '../../../src/utils/dates/types'; import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; +import { VisitsInfo } from '../../../src/visits/reducers/types'; describe('orphanVisitsReducer', () => { const now = new Date(); From b9efdd69f1c5c963f1675fa69ed43d3fc3fa7d16 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 09:21:23 +0100 Subject: [PATCH 04/14] Updated getNonOrphanVisits action so that it expects a signle DTO param --- src/visits/NonOrphanVisits.tsx | 7 +++---- src/visits/reducers/nonOrphanVisits.ts | 5 ++--- test/visits/reducers/nonOrphanVisits.test.ts | 6 +++--- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/visits/NonOrphanVisits.tsx b/src/visits/NonOrphanVisits.tsx index 9452167c..8c939558 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -1,5 +1,4 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; -import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; @@ -8,10 +7,10 @@ import { NormalizedVisit, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; -import { VisitsInfo } from './reducers/types'; +import { LoadVisits, VisitsInfo } from './reducers/types'; export interface NonOrphanVisitsProps extends CommonVisitsProps { - getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + getNonOrphanVisits: (params: LoadVisits) => void; nonOrphanVisits: VisitsInfo; cancelGetNonOrphanVisits: () => void; } @@ -26,7 +25,7 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => - getNonOrphanVisits(toApiParams(params), doIntervalFallback); + getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback }); return ( ({ }, initialState); export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, + { query = {}, doIntervalFallback = false }: LoadVisits, ) => async (dispatch: Dispatch, getState: GetState) => { const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index a5a4533c..0afde97e 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -147,7 +147,7 @@ describe('nonOrphanVisitsReducer', () => { it('dispatches start and error when promise is rejected', async () => { const ShlinkApiClient = buildApiClientMock(Promise.reject({})); - await getNonOrphanVisits(() => ShlinkApiClient)()(dispatchMock, getState); + await getNonOrphanVisits(() => ShlinkApiClient)({})(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); @@ -169,7 +169,7 @@ describe('nonOrphanVisitsReducer', () => { }, })); - await getNonOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState); + await getNonOrphanVisits(() => ShlinkApiClient)({ query })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); @@ -201,7 +201,7 @@ describe('nonOrphanVisitsReducer', () => { .mockResolvedValueOnce(buildVisitsResult(lastVisits)); const ShlinkApiClient = Mock.of({ getNonOrphanVisits: getShlinkOrphanVisits }); - await getNonOrphanVisits(() => ShlinkApiClient)({}, true)(dispatchMock, getState); + await getNonOrphanVisits(() => ShlinkApiClient)({ doIntervalFallback: true })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); From 8e6b9c5afb82564bb16fcfc8ef02d03ca1d9903d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 09:32:52 +0100 Subject: [PATCH 05/14] Updated getOrphanVisits action so that it expects a signle DTO param --- src/visits/OrphanVisits.tsx | 15 ++++++--------- src/visits/reducers/orphanVisits.ts | 10 ++++++---- test/visits/reducers/orphanVisits.test.ts | 6 +++--- 3 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 14af9c31..b35ed6d3 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -1,21 +1,17 @@ import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; -import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; import { VisitsStats } from './VisitsStats'; -import { NormalizedVisit, OrphanVisitType, VisitsParams } from './types'; +import { NormalizedVisit, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; import { VisitsInfo } from './reducers/types'; +import { LoadOrphanVisits } from './reducers/orphanVisits'; export interface OrphanVisitsProps extends CommonVisitsProps { - getOrphanVisits: ( - params?: ShlinkVisitsParams, - orphanVisitsType?: OrphanVisitType, - doIntervalFallback?: boolean, - ) => void; + getOrphanVisits: (params: LoadOrphanVisits) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } @@ -29,8 +25,9 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure }: OrphanVisitsProps) => { const goBack = useGoBack(); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); - const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => - getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback); + const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits( + { query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback }, + ); return ( { query?: ShlinkVisitsParams; } +export interface LoadOrphanVisits extends LoadVisits { + orphanVisitsType?: OrphanVisitType; +} + type OrphanVisitsCombinedAction = OrphanVisitsAction & VisitsLoadProgressChangedAction & VisitsFallbackIntervalAction @@ -68,9 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => !orphanVisitsType || orphanVisitsType === visit.type; export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - query: ShlinkVisitsParams = {}, - orphanVisitsType?: OrphanVisitType, - doIntervalFallback = false, + { orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, ) => async (dispatch: Dispatch, getState: GetState) => { const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 663d1e14..9ef91e74 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -147,7 +147,7 @@ describe('orphanVisitsReducer', () => { it('dispatches start and error when promise is rejected', async () => { const ShlinkApiClient = buildApiClientMock(Promise.reject({})); - await getOrphanVisits(() => ShlinkApiClient)()(dispatchMock, getState); + await getOrphanVisits(() => ShlinkApiClient)({})(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); @@ -169,7 +169,7 @@ describe('orphanVisitsReducer', () => { }, })); - await getOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState); + await getOrphanVisits(() => ShlinkApiClient)({ query })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); @@ -201,7 +201,7 @@ describe('orphanVisitsReducer', () => { .mockResolvedValueOnce(buildVisitsResult(lastVisits)); const ShlinkApiClient = Mock.of({ getOrphanVisits: getShlinkOrphanVisits }); - await getOrphanVisits(() => ShlinkApiClient)({}, undefined, true)(dispatchMock, getState); + await getOrphanVisits(() => ShlinkApiClient)({ doIntervalFallback: true })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); From a6ed0c811dbe7027a2d41e5c7a7c0207196715fa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 09:38:24 +0100 Subject: [PATCH 06/14] Updated getShortUrlVisits action so that it expects a signle DTO param --- src/visits/ShortUrlVisits.tsx | 12 +++++++----- src/visits/reducers/shortUrlVisits.ts | 10 ++++++---- test/visits/reducers/shortUrlVisits.test.ts | 11 +++++++---- 3 files changed, 20 insertions(+), 13 deletions(-) diff --git a/src/visits/ShortUrlVisits.tsx b/src/visits/ShortUrlVisits.tsx index 5ef1b005..3e17ae46 100644 --- a/src/visits/ShortUrlVisits.tsx +++ b/src/visits/ShortUrlVisits.tsx @@ -1,13 +1,12 @@ import { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; -import { ShlinkVisitsParams } from '../api/types'; import { parseQuery } from '../utils/helpers/query'; import { Topics } from '../mercure/helpers/Topics'; import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; -import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; +import { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits'; import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader'; import { VisitsStats } from './VisitsStats'; import { NormalizedVisit, VisitsParams } from './types'; @@ -17,7 +16,7 @@ import { urlDecodeShortCode } from '../short-urls/helpers'; import { ShortUrlIdentifier } from '../short-urls/data'; export interface ShortUrlVisitsProps extends CommonVisitsProps { - getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + getShortUrlVisits: (params: LoadShortUrlVisits) => void; shortUrlVisits: ShortUrlVisitsState; getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void; shortUrlDetail: ShortUrlDetail; @@ -37,8 +36,11 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu const { search } = useLocation(); const goBack = useGoBack(); const { domain } = parseQuery<{ domain?: string }>(search); - const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => - getShortUrlVisits(urlDecodeShortCode(shortCode), { ...toApiParams(params), domain }, doIntervalFallback); + const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({ + shortCode: urlDecodeShortCode(shortCode), + query: { ...toApiParams(params), domain }, + doIntervalFallback, + }); const exportCsv = (visits: NormalizedVisit[]) => exportVisits( `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`, visits, diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 0c34a1f8..9c2a9e9c 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -11,7 +11,7 @@ import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; +import { LoadVisits, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; const REDUCER_PREFIX = 'shlink/shortUrlVisits'; export const GET_SHORT_URL_VISITS_START = `${REDUCER_PREFIX}/getShortUrlVisits/pending`; @@ -29,6 +29,10 @@ interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { query?: ShlinkVisitsParams; } +export interface LoadShortUrlVisits extends LoadVisits { + shortCode: string; +} + type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction & VisitsFallbackIntervalAction @@ -80,9 +84,7 @@ export default buildReducer({ }, initialState); export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - shortCode: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, + { shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, ) => async (dispatch: Dispatch, getState: GetState) => { const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index e5c301c0..9f5d017b 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -167,7 +167,7 @@ describe('shortUrlVisitsReducer', () => { it('dispatches start and error when promise is rejected', async () => { const ShlinkApiClient = buildApiClientMock(Promise.reject({})); - await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123' })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); @@ -191,7 +191,7 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)({ shortCode, query })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); @@ -214,7 +214,7 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123' })(dispatchMock, getState); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ @@ -246,7 +246,10 @@ describe('shortUrlVisitsReducer', () => { .mockResolvedValueOnce(buildVisitsResult(lastVisits)); const ShlinkApiClient = Mock.of({ getShortUrlVisits: getShlinkShortUrlVisits }); - await getShortUrlVisits(() => ShlinkApiClient)('abc123', {}, true)(dispatchMock, getState); + await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123', doIntervalFallback: true })( + dispatchMock, + getState, + ); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); From a3cc3d5fc2542edf83a7ff5a63345e812157b01a Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 10:33:52 +0100 Subject: [PATCH 07/14] Migrated visits-loading actions to payload actions --- src/utils/helpers/redux.ts | 3 -- src/visits/TagVisits.tsx | 6 ++-- src/visits/reducers/common.ts | 12 +++---- src/visits/reducers/domainVisits.ts | 33 ++++++++++---------- src/visits/reducers/nonOrphanVisits.ts | 26 +++++++-------- src/visits/reducers/orphanVisits.ts | 27 ++++++++-------- src/visits/reducers/shortUrlVisits.ts | 27 ++++++++-------- src/visits/reducers/tagVisits.ts | 29 +++++++++-------- src/visits/reducers/types/index.ts | 7 +++++ test/utils/helpers/redux.test.ts | 15 +-------- test/visits/reducers/domainVisits.test.ts | 13 +++++--- test/visits/reducers/nonOrphanVisits.test.ts | 13 +++++--- test/visits/reducers/orphanVisits.test.ts | 13 +++++--- test/visits/reducers/shortUrlVisits.test.ts | 20 ++++++------ test/visits/reducers/tagVisits.test.ts | 19 ++++++----- 15 files changed, 136 insertions(+), 127 deletions(-) diff --git a/src/utils/helpers/redux.ts b/src/utils/helpers/redux.ts index 8ae09e05..238d0fa2 100644 --- a/src/utils/helpers/redux.ts +++ b/src/utils/helpers/redux.ts @@ -17,9 +17,6 @@ export const buildReducer = (map: ActionHandlerMap(type: T) => (): Action => ({ type }); - export const createAsyncThunk = ( typePrefix: string, payloadCreator: AsyncThunkPayloadCreator, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index a7ce5ba1..ff70f1bf 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -5,7 +5,7 @@ import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import { useGoBack } from '../utils/helpers/hooks'; import { ReportExporter } from '../common/services/ReportExporter'; -import { TagVisits as TagVisitsState } from './reducers/tagVisits'; +import { LoadTagVisits, TagVisits as TagVisitsState } from './reducers/tagVisits'; import { TagVisitsHeader } from './TagVisitsHeader'; import { VisitsStats } from './VisitsStats'; import { NormalizedVisit } from './types'; @@ -13,7 +13,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; export interface TagVisitsProps extends CommonVisitsProps { - getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; + getTagVisits: (params: LoadTagVisits) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; } @@ -28,7 +28,7 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo const goBack = useGoBack(); const { tag = '' } = useParams(); const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => - getTagVisits(tag, toApiParams(params), doIntervalFallback); + getTagVisits({ tag, query: toApiParams(params), doIntervalFallback }); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits); return ( diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 52544b69..1f888413 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,11 +1,11 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { Action, Dispatch } from 'redux'; +import { Dispatch } 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 { dateToMatchingInterval } from '../../utils/dates/types'; -import { VisitsLoadProgressChangedAction } from './types'; +import { VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -17,10 +17,10 @@ const calcProgress = (total: number, current: number): number => (current * 100) type VisitsLoader = (page: number, itemsPerPage: number) => Promise; type LastVisitLoader = () => Promise; -export const getVisitsWithLoader = async & { visits: Visit[] }>( +export const getVisitsWithLoader = async ( visitsLoader: VisitsLoader, lastVisitLoader: LastVisitLoader, - extraFinishActionData: Partial, + extraFulfilledPayload: Partial, actionsPrefix: string, dispatch: Dispatch, shouldCancel: () => boolean, @@ -74,7 +74,7 @@ export const getVisitsWithLoader = async & { visits: V dispatch( !visits.length && lastVisit ? { type: `${actionsPrefix}/fallbackToInterval`, payload: dateToMatchingInterval(lastVisit.date) } - : { ...extraFinishActionData, visits, type: `${actionsPrefix}/fulfilled` }, + : { type: `${actionsPrefix}/fulfilled`, payload: { ...extraFulfilledPayload, visits } }, ); } catch (e: any) { dispatch({ type: `${actionsPrefix}/rejected`, errorData: parseApiError(e) }); @@ -89,5 +89,5 @@ export const lastVisitLoaderForLoader = ( return async () => Promise.resolve(undefined); } - return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]); + return async () => loader({ page: 1, itemsPerPage: 1 }).then(({ data }) => data[0]); }; diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 42e1f371..1fd46a0e 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,16 +1,21 @@ import { createAction } from '@reduxjs/toolkit'; -import { Action, Dispatch } from 'redux'; -import { Visit } from '../types'; +import { Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { ShlinkVisitsParams } from '../../api/types'; import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { domainMatches } from '../../short-urls/helpers'; -import { LoadVisits, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; +import { + LoadVisits, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoaded, + VisitsLoadedAction, + VisitsLoadProgressChangedAction, +} from './types'; const REDUCER_PREFIX = 'shlink/domainVisits'; export const GET_DOMAIN_VISITS_START = `${REDUCER_PREFIX}/getDomainVisits/pending`; @@ -23,19 +28,15 @@ export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getDoma export const DEFAULT_DOMAIN = 'DEFAULT'; -export interface DomainVisits extends VisitsInfo { +interface WithDomain { domain: string; } -export interface LoadDomainVisits extends LoadVisits { - domain: string; -} +export interface DomainVisits extends VisitsInfo, WithDomain {} -export interface DomainVisitsAction extends Action { - visits: Visit[]; - domain: string; - query?: ShlinkVisitsParams; -} +export interface LoadDomainVisits extends LoadVisits, WithDomain {} + +type DomainVisitsAction = VisitsLoadedAction; type DomainVisitsCombinedAction = DomainVisitsAction & VisitsLoadProgressChangedAction @@ -56,8 +57,8 @@ const initialState: DomainVisits = { export default buildReducer({ [`${REDUCER_PREFIX}/getDomainVisits/pending`]: () => ({ ...initialState, loading: true }), [`${REDUCER_PREFIX}/getDomainVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getDomainVisits/fulfilled`]: (state, { visits, domain, query }) => ( - { ...state, visits, domain, query, loading: false, loadingLarge: false, error: false } + [`${REDUCER_PREFIX}/getDomainVisits/fulfilled`]: (state, { payload }: DomainVisitsAction) => ( + { ...state, ...payload, loading: false, loadingLarge: false, error: false } ), [`${REDUCER_PREFIX}/getDomainVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getDomainVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), @@ -87,7 +88,7 @@ export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); const shouldCancel = () => getState().domainVisits.cancelLoad; - const extraFinishActionData: Partial = { domain, query }; + const extraFinishActionData: Partial> = { domain, query }; const prefix = `${REDUCER_PREFIX}/getDomainVisits`; return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index 8db3e519..632b78e9 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,15 +1,20 @@ import { createAction } from '@reduxjs/toolkit'; -import { Action, Dispatch } from 'redux'; -import { Visit } from '../types'; +import { Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { ShlinkVisitsParams } from '../../api/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, VisitsLoadProgressChangedAction } from './types'; +import { + LoadVisits, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoaded, + VisitsLoadedAction, + VisitsLoadProgressChangedAction, +} from './types'; const REDUCER_PREFIX = 'shlink/orphanVisits'; export const GET_NON_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getNonOrphanVisits/pending`; @@ -20,12 +25,7 @@ export const GET_NON_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getNonOrphanVisit export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`; export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`; -export interface NonOrphanVisitsAction extends Action { - visits: Visit[]; - query?: ShlinkVisitsParams; -} - -type NonOrphanVisitsCombinedAction = NonOrphanVisitsAction +type NonOrphanVisitsCombinedAction = VisitsLoadedAction & VisitsLoadProgressChangedAction & VisitsFallbackIntervalAction & CreateVisitsAction @@ -45,8 +45,8 @@ export default buildReducer({ [`${REDUCER_PREFIX}/getNonOrphanVisits/rejected`]: (_, { errorData }) => ( { ...initialState, error: true, errorData } ), - [`${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`]: (state, { visits, query }) => ( - { ...state, visits, query, loading: false, loadingLarge: false, error: false } + [`${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`]: (state, { payload }: VisitsLoadedAction) => ( + { ...state, ...payload, loading: false, loadingLarge: false, error: false } ), [`${REDUCER_PREFIX}/getNonOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), @@ -73,7 +73,7 @@ export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; - const extraFinishActionData: Partial = { query }; + const extraFinishActionData: Partial = { query }; const prefix = `${REDUCER_PREFIX}/getNonOrphanVisits`; return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 206f7d79..102f9c0c 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,16 +1,22 @@ import { createAction } from '@reduxjs/toolkit'; -import { Action, Dispatch } from 'redux'; -import { OrphanVisit, OrphanVisitType, Visit } from '../types'; +import { Dispatch } from 'redux'; +import { OrphanVisit, OrphanVisitType } from '../types'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { ShlinkVisitsParams } from '../../api/types'; import { isOrphanVisit } from '../types/helpers'; 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, VisitsLoadProgressChangedAction } from './types'; +import { + LoadVisits, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoaded, + VisitsLoadedAction, + VisitsLoadProgressChangedAction, +} from './types'; const REDUCER_PREFIX = 'shlink/orphanVisits'; export const GET_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getOrphanVisits/pending`; @@ -21,16 +27,11 @@ export const GET_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getOrphanVisits/cance export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getOrphanVisits/progressChanged`; export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`; -export interface OrphanVisitsAction extends Action { - visits: Visit[]; - query?: ShlinkVisitsParams; -} - export interface LoadOrphanVisits extends LoadVisits { orphanVisitsType?: OrphanVisitType; } -type OrphanVisitsCombinedAction = OrphanVisitsAction +type OrphanVisitsCombinedAction = VisitsLoadedAction & VisitsLoadProgressChangedAction & VisitsFallbackIntervalAction & CreateVisitsAction @@ -48,8 +49,8 @@ const initialState: VisitsInfo = { export default buildReducer({ [`${REDUCER_PREFIX}/getOrphanVisits/pending`]: () => ({ ...initialState, loading: true }), [`${REDUCER_PREFIX}/getOrphanVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getOrphanVisits/fulfilled`]: (state, { visits, query }) => ( - { ...state, visits, query, loading: false, loadingLarge: false, error: false } + [`${REDUCER_PREFIX}/getOrphanVisits/fulfilled`]: (state, { payload }: VisitsLoadedAction) => ( + { ...state, ...payload, loading: false, loadingLarge: false, error: false } ), [`${REDUCER_PREFIX}/getOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), [`${REDUCER_PREFIX}/getOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), @@ -83,7 +84,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => }); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); const shouldCancel = () => getState().orphanVisits.cancelLoad; - const extraFinishActionData: Partial = { query }; + const extraFinishActionData: Partial = { query }; const prefix = `${REDUCER_PREFIX}/getOrphanVisits`; return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 9c2a9e9c..fd720a88 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,17 +1,22 @@ import { createAction } from '@reduxjs/toolkit'; -import { Action, Dispatch } from 'redux'; +import { Dispatch } from 'redux'; import { shortUrlMatches } from '../../short-urls/helpers'; -import { Visit } from '../types'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { ShlinkVisitsParams } from '../../api/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, VisitsLoadProgressChangedAction } from './types'; +import { + LoadVisits, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoaded, + VisitsLoadedAction, + VisitsLoadProgressChangedAction, +} from './types'; const REDUCER_PREFIX = 'shlink/shortUrlVisits'; export const GET_SHORT_URL_VISITS_START = `${REDUCER_PREFIX}/getShortUrlVisits/pending`; @@ -24,10 +29,7 @@ export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getS export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} -interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { - visits: Visit[]; - query?: ShlinkVisitsParams; -} +type ShortUrlVisitsAction = VisitsLoadedAction; export interface LoadShortUrlVisits extends LoadVisits { shortCode: string; @@ -53,12 +55,9 @@ const initialState: ShortUrlVisits = { export default buildReducer({ [`${REDUCER_PREFIX}/getShortUrlVisits/pending`]: () => ({ ...initialState, loading: true }), [`${REDUCER_PREFIX}/getShortUrlVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`]: (state, { visits, query, shortCode, domain }) => ({ + [`${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`]: (state, { payload }: ShortUrlVisitsAction) => ({ ...state, - visits, - shortCode, - domain, - query, + ...payload, loading: false, loadingLarge: false, error: false, @@ -96,7 +95,7 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }), ); const shouldCancel = () => getState().shortUrlVisits.cancelLoad; - const extraFinishActionData: Partial = { shortCode, query, domain: query.domain }; + const extraFinishActionData: Partial> = { shortCode, query, domain: query.domain }; const prefix = `${REDUCER_PREFIX}/getShortUrlVisits`; return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 0b173613..ff7d2bb7 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,15 +1,20 @@ import { createAction } from '@reduxjs/toolkit'; -import { Action, Dispatch } from 'redux'; -import { Visit } from '../types'; +import { Dispatch } from 'redux'; import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { ShlinkVisitsParams } from '../../api/types'; import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -import { VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from './types'; +import { + LoadVisits, + VisitsFallbackIntervalAction, + VisitsInfo, + VisitsLoaded, + VisitsLoadedAction, + VisitsLoadProgressChangedAction, +} from './types'; const REDUCER_PREFIX = 'shlink/tagVisits'; export const GET_TAG_VISITS_START = `${REDUCER_PREFIX}/getTagVisits/pending`; @@ -24,12 +29,12 @@ export interface TagVisits extends VisitsInfo { tag: string; } -export interface TagVisitsAction extends Action { - visits: Visit[]; +export interface LoadTagVisits extends LoadVisits { tag: string; - query?: ShlinkVisitsParams; } +export type TagVisitsAction = VisitsLoadedAction<{ tag: string }>; + type TagsVisitsCombinedAction = TagVisitsAction & VisitsLoadProgressChangedAction & VisitsFallbackIntervalAction @@ -49,8 +54,8 @@ const initialState: TagVisits = { export default buildReducer({ [`${REDUCER_PREFIX}/getTagVisits/pending`]: () => ({ ...initialState, loading: true }), [`${REDUCER_PREFIX}/getTagVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getTagVisits/fulfilled`]: (state, { visits, tag, query }) => ( - { ...state, visits, tag, query, loading: false, loadingLarge: false, error: false } + [`${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 }), @@ -70,9 +75,7 @@ export default buildReducer({ }, initialState); export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - tag: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, + { tag, query = {}, doIntervalFallback = false }: LoadTagVisits, ) => async (dispatch: Dispatch, getState: GetState) => { const { getTagVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( @@ -81,7 +84,7 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( ); const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params)); const shouldCancel = () => getState().tagVisits.cancelLoad; - const extraFinishActionData: Partial = { tag, query }; + const extraFinishActionData: Partial> = { tag, query }; const prefix = `${REDUCER_PREFIX}/getTagVisits`; return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); diff --git a/src/visits/reducers/types/index.ts b/src/visits/reducers/types/index.ts index a14920c0..692b4591 100644 --- a/src/visits/reducers/types/index.ts +++ b/src/visits/reducers/types/index.ts @@ -21,6 +21,13 @@ export interface LoadVisits { doIntervalFallback?: boolean; } +export type VisitsLoaded = T & { + visits: Visit[]; + query?: ShlinkVisitsParams; +}; + +export type VisitsLoadedAction = PayloadAction>; + export type VisitsLoadProgressChangedAction = PayloadAction; export type VisitsFallbackIntervalAction = PayloadAction; diff --git a/test/utils/helpers/redux.test.ts b/test/utils/helpers/redux.test.ts index 69c0bc82..472afa7b 100644 --- a/test/utils/helpers/redux.test.ts +++ b/test/utils/helpers/redux.test.ts @@ -1,22 +1,9 @@ import { Action } from 'redux'; -import { buildActionCreator, buildReducer } from '../../../src/utils/helpers/redux'; +import { buildReducer } from '../../../src/utils/helpers/redux'; describe('redux', () => { beforeEach(jest.clearAllMocks); - describe('buildActionCreator', () => { - it.each([ - ['foo', { type: 'foo' }], - ['bar', { type: 'bar' }], - ['something', { type: 'something' }], - ])('returns an action creator', (type, expected) => { - const actionCreator = buildActionCreator(type); - - expect(actionCreator).toBeInstanceOf(Function); - expect(actionCreator()).toEqual(expected); - }); - }); - describe('buildReducer', () => { const fooActionHandler = jest.fn(() => 'foo result'); const barActionHandler = jest.fn(() => 'bar result'); diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index 8564fd76..4a8cc825 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -61,10 +61,10 @@ describe('domainVisitsReducer', () => { it('return visits on GET_DOMAIN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer( - buildState({ loading: true, error: false }), - { type: GET_DOMAIN_VISITS, visits: actionVisits } as any, - ); + const state = reducer(buildState({ loading: true, error: false }), { + type: GET_DOMAIN_VISITS, + payload: { visits: actionVisits }, + } as any); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -201,7 +201,10 @@ describe('domainVisitsReducer', () => { expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS, visits, domain, query: query ?? {} }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { + type: GET_DOMAIN_VISITS, + payload: { visits, domain, query: query ?? {} }, + }); expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); }); diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 0afde97e..b9beb629 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -59,10 +59,10 @@ describe('nonOrphanVisitsReducer', () => { it('return visits on GET_NON_ORPHAN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer( - buildState({ loading: true, error: false }), - { type: GET_NON_ORPHAN_VISITS, visits: actionVisits } as any, - ); + const state = reducer(buildState({ loading: true, error: false }), { + type: GET_NON_ORPHAN_VISITS, + payload: { visits: actionVisits }, + } as any); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -173,7 +173,10 @@ describe('nonOrphanVisitsReducer', () => { expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS, visits, query: query ?? {} }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { + type: GET_NON_ORPHAN_VISITS, + payload: { visits, query: query ?? {} }, + }); expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); }); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 9ef91e74..e763d3ad 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -59,10 +59,10 @@ describe('orphanVisitsReducer', () => { it('return visits on GET_ORPHAN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer( - buildState({ loading: true, error: false }), - { type: GET_ORPHAN_VISITS, visits: actionVisits } as any, - ); + const state = reducer(buildState({ loading: true, error: false }), { + type: GET_ORPHAN_VISITS, + payload: { visits: actionVisits }, + } as any); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -173,7 +173,10 @@ describe('orphanVisitsReducer', () => { expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { + type: GET_ORPHAN_VISITS, + payload: { visits, query: query ?? {} }, + }); expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); }); diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index 9f5d017b..b7afd964 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -59,10 +59,10 @@ describe('shortUrlVisitsReducer', () => { it('return visits on GET_SHORT_URL_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer( - buildState({ loading: true, error: false }), - { type: GET_SHORT_URL_VISITS, visits: actionVisits } as any, - ); + const state = reducer(buildState({ loading: true, error: false }), { + type: GET_SHORT_URL_VISITS, + payload: { visits: actionVisits }, + } as any); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -195,10 +195,10 @@ describe('shortUrlVisitsReducer', () => { expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith( - 2, - { type: GET_SHORT_URL_VISITS, visits, shortCode, domain, query: query ?? {} }, - ); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { + type: GET_SHORT_URL_VISITS, + payload: { visits, shortCode, domain, query: query ?? {} }, + }); expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); }); @@ -218,7 +218,9 @@ describe('shortUrlVisitsReducer', () => { expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ - visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], + payload: expect.objectContaining({ + visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], + }), })); }); diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 7292dc28..0d91406e 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -59,10 +59,10 @@ describe('tagVisitsReducer', () => { it('return visits on GET_TAG_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer( - buildState({ loading: true, error: false }), - { type: GET_TAG_VISITS, visits: actionVisits } as any, - ); + const state = reducer(buildState({ loading: true, error: false }), { + type: GET_TAG_VISITS, + payload: { visits: actionVisits }, + } as any); const { loading, error, visits } = state; expect(loading).toEqual(false); @@ -165,7 +165,7 @@ describe('tagVisitsReducer', () => { it('dispatches start and error when promise is rejected', async () => { const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); - await getTagVisits(() => shlinkApiClient)('foo')(dispatchMock, getState); + await getTagVisits(() => shlinkApiClient)({ tag })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); @@ -187,11 +187,14 @@ describe('tagVisitsReducer', () => { }, })); - await getTagVisits(() => shlinkApiClient)(tag, query)(dispatchMock, getState); + await getTagVisits(() => shlinkApiClient)({ 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, visits, tag, query: query ?? {} }); + expect(dispatchMock).toHaveBeenNthCalledWith(2, { + type: GET_TAG_VISITS, + payload: { visits, tag, query: query ?? {} }, + }); expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); }); @@ -219,7 +222,7 @@ describe('tagVisitsReducer', () => { .mockResolvedValueOnce(buildVisitsResult(lastVisits)); const ShlinkApiClient = Mock.of({ getTagVisits: getShlinkTagVisits }); - await getTagVisits(() => ShlinkApiClient)(tag, {}, true)(dispatchMock, getState); + await getTagVisits(() => ShlinkApiClient)({ tag, doIntervalFallback: true })(dispatchMock, getState); expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); From ab7c52d0491a6dac98a3c41e0b75273965cb9aaa Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 17:51:37 +0100 Subject: [PATCH 08/14] Migrated domainVisits reducer to RTK --- config/jest/setupTests.ts | 3 + src/container/store.ts | 2 +- src/reducers/index.ts | 3 +- src/visits/reducers/common.ts | 75 ++++++++++++- src/visits/reducers/domainVisits.ts | 124 ++++++++++------------ src/visits/services/provideServices.ts | 10 +- test/visits/reducers/domainVisits.test.ts | 122 ++++++++++----------- 7 files changed, 200 insertions(+), 139 deletions(-) diff --git a/config/jest/setupTests.ts b/config/jest/setupTests.ts index cec71a7a..e8900e09 100644 --- a/config/jest/setupTests.ts +++ b/config/jest/setupTests.ts @@ -1,8 +1,11 @@ import '@testing-library/jest-dom'; import 'jest-canvas-mock'; import ResizeObserver from 'resize-observer-polyfill'; +import { setAutoFreeze } from 'immer'; (global as any).ResizeObserver = ResizeObserver; (global as any).scrollTo = () => {}; (global as any).prompt = () => {}; (global as any).matchMedia = (media: string) => ({ matches: false, media }); + +setAutoFreeze(false); // TODO Bypassing a bug on jest diff --git a/src/container/store.ts b/src/container/store.ts index cf48b3c2..590c8382 100644 --- a/src/container/store.ts +++ b/src/container/store.ts @@ -19,7 +19,7 @@ export const setUpStore = (container: IContainer) => configureStore({ reducer: reducer(container), preloadedState, middleware: (defaultMiddlewaresIncludingReduxThunk) => - defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false })// State is too big for these + defaultMiddlewaresIncludingReduxThunk({ immutableCheck: false, serializableCheck: false }) // State is too big for these .prepend(container.selectServerListener.middleware) .concat(save(localStorageConfig)), }); diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 8d0e05fb..70635df1 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -3,7 +3,6 @@ import { combineReducers } from '@reduxjs/toolkit'; import { serversReducer } from '../servers/reducers/servers'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; -import domainVisitsReducer from '../visits/reducers/domainVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import { settingsReducer } from '../settings/reducers/settings'; @@ -21,7 +20,7 @@ export default (container: IContainer) => combineReducers({ shortUrlDetail: container.shortUrlDetailReducer, shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, - domainVisits: domainVisitsReducer, + domainVisits: container.domainVisitsReducer, orphanVisits: orphanVisitsReducer, nonOrphanVisits: nonOrphanVisitsReducer, tagsList: container.tagsListReducer, diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index 1f888413..5516ce83 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,11 +1,13 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { Dispatch } from '@reduxjs/toolkit'; +import { createAction, Dispatch } 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 { dateToMatchingInterval } from '../../utils/dates/types'; -import { VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; +import { DateInterval, dateToMatchingInterval } from '../../utils/dates/types'; +import { LoadVisits, VisitsLoaded, VisitsLoadProgressChangedAction } from './types'; +import { createAsyncThunk } from '../../utils/helpers/redux'; +import { ShlinkState } from '../../container/types'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -17,6 +19,13 @@ const calcProgress = (total: number, current: number): number => (current * 100) type VisitsLoader = (page: number, itemsPerPage: number) => Promise; type LastVisitLoader = () => Promise; +interface VisitsAsyncThunkOptions { + actionsPrefix: string; + createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; + getExtraFulfilledPayload: (params: T) => Partial; + shouldCancel: (getState: () => ShlinkState) => boolean; +} + export const getVisitsWithLoader = async ( visitsLoader: VisitsLoader, lastVisitLoader: LastVisitLoader, @@ -81,6 +90,66 @@ export const getVisitsWithLoader = async ( } }; +export const createVisitsAsyncThunk = ( + { actionsPrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, +) => { + const progressChangedAction = createAction(`${actionsPrefix}/progressChanged`); + const largeAction = createAction(`${actionsPrefix}/large`); + const fallbackToIntervalAction = createAction(`${actionsPrefix}/fallbackToInterval`); + + const asyncThunk = createAsyncThunk(actionsPrefix, async (params: T, { getState, dispatch }): Promise => { + const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); + + 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(getState)) { + return []; + } + + const data = await loadVisitsInParallel(pagesBlocks[index]); + + dispatch(progressChangedAction(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(largeAction()); + } + + return data.concat(await loadPagesBlocks(pagesBlocks)); + }; + + const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]); + + if (!visits.length && lastVisit) { + dispatch(fallbackToIntervalAction(dateToMatchingInterval(lastVisit.date))); + } + + return { ...getExtraFulfilledPayload(params), visits } as any; // TODO Get rid of this casting + }); + + return { asyncThunk, progressChangedAction, largeAction, fallbackToIntervalAction }; +}; + export const lastVisitLoaderForLoader = ( doIntervalFallback: boolean, loader: (params: ShlinkVisitsParams) => Promise, diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 1fd46a0e..0cb42ef7 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,30 +1,13 @@ -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 { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; +import { createNewVisits } from './visitCreation'; import { domainMatches } from '../../short-urls/helpers'; -import { - LoadVisits, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoaded, - VisitsLoadedAction, - VisitsLoadProgressChangedAction, -} from './types'; +import { LoadVisits, VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/domainVisits'; -export const GET_DOMAIN_VISITS_START = `${REDUCER_PREFIX}/getDomainVisits/pending`; -export const GET_DOMAIN_VISITS_ERROR = `${REDUCER_PREFIX}/getDomainVisits/rejected`; -export const GET_DOMAIN_VISITS = `${REDUCER_PREFIX}/getDomainVisits/fulfilled`; -export const GET_DOMAIN_VISITS_LARGE = `${REDUCER_PREFIX}/getDomainVisits/large`; -export const GET_DOMAIN_VISITS_CANCEL = `${REDUCER_PREFIX}/getDomainVisits/cancel`; -export const GET_DOMAIN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getDomainVisits/progressChanged`; -export const GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`; export const DEFAULT_DOMAIN = 'DEFAULT'; @@ -36,14 +19,6 @@ export interface DomainVisits extends VisitsInfo, WithDomain {} export interface LoadDomainVisits extends LoadVisits, WithDomain {} -type DomainVisitsAction = VisitsLoadedAction; - -type DomainVisitsCombinedAction = DomainVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: DomainVisits = { visits: [], domain: '', @@ -54,44 +29,61 @@ const initialState: DomainVisits = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getDomainVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getDomainVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getDomainVisits/fulfilled`]: (state, { payload }: DomainVisitsAction) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - ), - [`${REDUCER_PREFIX}/getDomainVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getDomainVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getDomainVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getDomainVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { domain, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ shortUrl, visit }) => - shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); +export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getDomainVisits`, + createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => { + const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( + domain, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); - return { ...state, visits: [...newVisits, ...visits] }; + return [visitsLoader, lastVisitLoader]; }, -}, initialState); + getExtraFulfilledPayload: ({ domain, query = {} }: LoadDomainVisits) => ({ domain, query }), + shouldCancel: (getState) => getState().domainVisits.cancelLoad, +}); -export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( - domain, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); - const shouldCancel = () => getState().domainVisits.cancelLoad; - const extraFinishActionData: Partial> = { domain, query }; - const prefix = `${REDUCER_PREFIX}/getDomainVisits`; +export const domainVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetDomainVisits: (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 { domain, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter(({ shortUrl, visit }) => + shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + + const { cancelGetDomainVisits } = actions; + + return { reducer, cancelGetDomainVisits }; }; - -export const cancelGetDomainVisits = createAction(`${REDUCER_PREFIX}/getDomainVisits/cancel`); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 28684da3..55574ebe 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -8,7 +8,7 @@ import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; -import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits'; +import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; @@ -60,8 +60,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); - bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); + bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); + bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetDomainVisits'), 'domainVisitsReducerCreator'); bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); @@ -75,6 +76,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Reducers bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); + + bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisitsCreator'); + bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index 4a8cc825..35f24650 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -1,17 +1,10 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getDomainVisits, - cancelGetDomainVisits, - GET_DOMAIN_VISITS_START, - GET_DOMAIN_VISITS_ERROR, - GET_DOMAIN_VISITS, - GET_DOMAIN_VISITS_LARGE, - GET_DOMAIN_VISITS_CANCEL, - GET_DOMAIN_VISITS_PROGRESS_CHANGED, - GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, +import { + getDomainVisits as getDomainVisitsCreator, DomainVisits, DEFAULT_DOMAIN, + domainVisitsReducerCreator, } from '../../../src/visits/reducers/domainVisits'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; @@ -26,33 +19,34 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('domainVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getDomainVisitsCall = jest.fn(); + const buildApiClientMock = () => Mock.of({ getDomainVisits: getDomainVisitsCall }); + const creator = getDomainVisitsCreator(buildApiClientMock); + const { asyncThunk: getDomainVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; + const { reducer, cancelGetDomainVisits } = domainVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_DOMAIN_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_DOMAIN_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getDomainVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_DOMAIN_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_DOMAIN_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_DOMAIN_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetDomainVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_DOMAIN_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_DOMAIN_VISITS_ERROR } as any); + const state = reducer(buildState({ loading: true, error: false }), { type: getDomainVisits.rejected.toString() }); const { loading, error } = state; expect(loading).toEqual(false); @@ -61,11 +55,10 @@ describe('domainVisitsReducer', () => { it('return visits on GET_DOMAIN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { - type: GET_DOMAIN_VISITS, + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getDomainVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -128,21 +121,16 @@ describe('domainVisitsReducer', () => { ], ])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => { const shortUrl = Mock.of({ domain: shortUrlDomain }); - const prevState = buildState({ - ...state, - visits: visitsMocks, - }); - - const { visits } = reducer(prevState, { + const { visits } = reducer(buildState({ ...state, visits: visitsMocks }), { type: createNewVisits.toString(), payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, - } as any); + }); expect(visits).toHaveLength(expectedVisits); }); it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_DOMAIN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -151,7 +139,7 @@ describe('domainVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -159,28 +147,25 @@ describe('domainVisitsReducer', () => { }); describe('getDomainVisits', () => { - type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getDomainVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ domainVisits: { cancelLoad: false }, }); const domain = 'foo.com'; - beforeEach(jest.clearAllMocks); - it('dispatches start and error when promise is rejected', async () => { - const shlinkApiClient = buildApiClientMock(Promise.reject(new Error())); + getDomainVisitsCall.mockRejectedValue(new Error()); - await getDomainVisits(() => shlinkApiClient)({ domain })(dispatchMock, getState); + await getDomainVisits({ domain })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS_ERROR }); - expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getDomainVisits.rejected.toString(), + })); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -188,37 +173,45 @@ describe('domainVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks; - const shlinkApiClient = buildApiClientMock(Promise.resolve({ + getDomainVisitsCall.mockResolvedValue({ data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getDomainVisits(() => shlinkApiClient)({ domain, query })(dispatchMock, getState); + await getDomainVisits({ domain, query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_DOMAIN_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getDomainVisits.fulfilled.toString(), payload: { visits, domain, query: query ?? {} }, - }); - expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + })); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last30Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last180Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last180Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_DOMAIN_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getDomainVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedDispatchCalls, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -227,22 +220,23 @@ describe('domainVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkDomainVisits = jest.fn() + getDomainVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getDomainVisits: getShlinkDomainVisits }); - await getDomainVisits(() => ShlinkApiClient)({ domain, doIntervalFallback: true })(dispatchMock, getState); + await getDomainVisits({ domain, doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getDomainVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkDomainVisits).toHaveBeenCalledTimes(2); + expect(getDomainVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetDomainVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetDomainVisits()).toEqual({ type: GET_DOMAIN_VISITS_CANCEL })); + expect(cancelGetDomainVisits()).toEqual(expect.objectContaining({ type: cancelGetDomainVisits.toString() }))); }); }); From fd80fd65c9d4058451de605ebf0dbb3f1782b3a0 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 18:18:16 +0100 Subject: [PATCH 09/14] Migrated nonOrphanVisits reducer to RTK --- src/reducers/index.ts | 3 +- src/visits/reducers/domainVisits.ts | 8 +- src/visits/reducers/nonOrphanVisits.ts | 116 +++++++++---------- src/visits/services/provideServices.ts | 10 +- test/visits/reducers/nonOrphanVisits.test.ts | 111 +++++++++--------- 5 files changed, 121 insertions(+), 127 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 70635df1..b70858a3 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -4,7 +4,6 @@ import { serversReducer } from '../servers/reducers/servers'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; import orphanVisitsReducer from '../visits/reducers/orphanVisits'; -import nonOrphanVisitsReducer from '../visits/reducers/nonOrphanVisits'; import { settingsReducer } from '../settings/reducers/settings'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { sidebarReducer } from '../common/reducers/sidebar'; @@ -22,7 +21,7 @@ export default (container: IContainer) => combineReducers({ tagVisits: tagVisitsReducer, domainVisits: container.domainVisitsReducer, orphanVisits: orphanVisitsReducer, - nonOrphanVisits: nonOrphanVisitsReducer, + nonOrphanVisits: container.nonOrphanVisitsReducer, tagsList: container.tagsListReducer, tagDelete: container.tagDeleteReducer, tagEdit: container.tagEditReducer, diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 0cb42ef7..86b03cd7 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -65,10 +65,9 @@ export const domainVisitsReducerCreator = ( 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(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => ( + { ...state, fallbackInterval } + )); builder.addCase(createNewVisits, (state, { payload }) => { const { domain, visits, query = {} } = state; @@ -82,7 +81,6 @@ export const domainVisitsReducerCreator = ( }); }, }); - const { cancelGetDomainVisits } = actions; return { reducer, cancelGetDomainVisits }; diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index 632b78e9..c4edcf96 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,35 +1,12 @@ -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 { VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/orphanVisits'; -export const GET_NON_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getNonOrphanVisits/pending`; -export const GET_NON_ORPHAN_VISITS_ERROR = `${REDUCER_PREFIX}/getNonOrphanVisits/rejected`; -export const GET_NON_ORPHAN_VISITS = `${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`; -export const GET_NON_ORPHAN_VISITS_LARGE = `${REDUCER_PREFIX}/getNonOrphanVisits/large`; -export const GET_NON_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getNonOrphanVisits/cancel`; -export const GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`; -export const GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`; - -type NonOrphanVisitsCombinedAction = VisitsLoadedAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; const initialState: VisitsInfo = { visits: [], @@ -40,43 +17,56 @@ const initialState: VisitsInfo = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getNonOrphanVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/rejected`]: (_, { errorData }) => ( - { ...initialState, error: true, errorData } - ), - [`${REDUCER_PREFIX}/getNonOrphanVisits/fulfilled`]: (state, { payload }: VisitsLoadedAction) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - ), - [`${REDUCER_PREFIX}/getNonOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getNonOrphanVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ visit }) => isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); +export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`, + createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => { + const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => + shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); - return { ...state, visits: [...newVisits, ...visits] }; + return [visitsLoader, lastVisitLoader]; }, -}, initialState); + getExtraFulfilledPayload: ({ query = {} }) => ({ query }), + shouldCancel: (getState) => getState().orphanVisits.cancelLoad, +}); -export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { query = {}, doIntervalFallback = false }: LoadVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => - shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); - const shouldCancel = () => getState().orphanVisits.cancelLoad; - const extraFinishActionData: Partial = { query }; - const prefix = `${REDUCER_PREFIX}/getNonOrphanVisits`; +export const nonOrphanVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetNonOrphanVisits: (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 { visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter(({ visit }) => isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + const { cancelGetNonOrphanVisits } = actions; + + return { reducer, cancelGetNonOrphanVisits }; }; - -export const cancelGetNonOrphanVisits = createAction(`${REDUCER_PREFIX}/getNonOrphanVisits/cancel`); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 55574ebe..9529831d 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -10,7 +10,7 @@ import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrl import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; -import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; +import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; @@ -67,8 +67,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); - bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetNonOrphanVisits', () => cancelGetNonOrphanVisits); + bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetNonOrphanVisits'), 'nonOrphanVisitsReducerCreator'); bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); @@ -79,6 +80,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('domainVisitsReducerCreator', domainVisitsReducerCreator, 'getDomainVisitsCreator'); bottle.serviceFactory('domainVisitsReducer', prop('reducer'), 'domainVisitsReducerCreator'); + + bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index b9beb629..59a36658 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -1,15 +1,8 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getNonOrphanVisits, - cancelGetNonOrphanVisits, - GET_NON_ORPHAN_VISITS_START, - GET_NON_ORPHAN_VISITS_ERROR, - GET_NON_ORPHAN_VISITS, - GET_NON_ORPHAN_VISITS_LARGE, - GET_NON_ORPHAN_VISITS_CANCEL, - GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, - GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, +import { + getNonOrphanVisits as getNonOrphanVisitsCreator, + nonOrphanVisitsReducerCreator, } from '../../../src/visits/reducers/nonOrphanVisits'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; @@ -24,34 +17,37 @@ import { VisitsInfo } from '../../../src/visits/reducers/types'; describe('nonOrphanVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getNonOrphanVisitsCall = jest.fn(); + const buildShlinkApiClient = () => Mock.of({ getNonOrphanVisits: getNonOrphanVisitsCall }); + const creator = getNonOrphanVisitsCreator(buildShlinkApiClient); + const { asyncThunk: getNonOrphanVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; + const { reducer, cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_NON_ORPHAN_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_NON_ORPHAN_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getNonOrphanVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_NON_ORPHAN_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_NON_ORPHAN_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_NON_ORPHAN_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_NON_ORPHAN_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetNonOrphanVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_NON_ORPHAN_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_NON_ORPHAN_VISITS_ERROR } as any); - const { loading, error } = state; + const { loading, error } = reducer( + buildState({ loading: true, error: false }), + { type: getNonOrphanVisits.rejected.toString() }, + ); expect(loading).toEqual(false); expect(error).toEqual(true); @@ -60,7 +56,7 @@ describe('nonOrphanVisitsReducer', () => { it('return visits on GET_NON_ORPHAN_VISITS', () => { const actionVisits = [{}, {}]; const state = reducer(buildState({ loading: true, error: false }), { - type: GET_NON_ORPHAN_VISITS, + type: getNonOrphanVisits.fulfilled.toString(), payload: { visits: actionVisits }, } as any); const { loading, error, visits } = state; @@ -115,8 +111,7 @@ describe('nonOrphanVisitsReducer', () => { }); it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 } as any); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -124,7 +119,7 @@ describe('nonOrphanVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval } as any, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -132,11 +127,6 @@ describe('nonOrphanVisitsReducer', () => { }); describe('getNonOrphanVisits', () => { - type GetVisitsReturn = Promise | ((query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getNonOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ orphanVisits: { cancelLoad: false }, @@ -145,14 +135,18 @@ describe('nonOrphanVisitsReducer', () => { beforeEach(jest.resetAllMocks); it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject({})); + getNonOrphanVisitsCall.mockRejectedValue({}); - await getNonOrphanVisits(() => ShlinkApiClient)({})(dispatchMock, getState); + await getNonOrphanVisits({})(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS_ERROR }); - expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getNonOrphanVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getNonOrphanVisits.rejected.toString(), + })); + expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -160,37 +154,45 @@ describe('nonOrphanVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); - const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + getNonOrphanVisitsCall.mockResolvedValue({ data: visits, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getNonOrphanVisits(() => ShlinkApiClient)({ query })(dispatchMock, getState); + await getNonOrphanVisits({ query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_NON_ORPHAN_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining( + { type: getNonOrphanVisits.pending.toString() }, + )); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getNonOrphanVisits.fulfilled.toString(), payload: { visits, query: query ?? {} }, - }); - expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); + })); + expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_NON_ORPHAN_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getNonOrphanVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedAmountOfDispatches, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -199,22 +201,23 @@ describe('nonOrphanVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkOrphanVisits = jest.fn() + getNonOrphanVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getNonOrphanVisits: getShlinkOrphanVisits }); - await getNonOrphanVisits(() => ShlinkApiClient)({ doIntervalFallback: true })(dispatchMock, getState); + await getNonOrphanVisits({ doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedAmountOfDispatches); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getNonOrphanVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); + expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetNonOrphanVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetNonOrphanVisits()).toEqual({ type: GET_NON_ORPHAN_VISITS_CANCEL })); + expect(cancelGetNonOrphanVisits()).toEqual({ type: cancelGetNonOrphanVisits.toString() })); }); }); From f81999a4fe86a674c663a2a4d361935a8dcebd4c Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 19:15:41 +0100 Subject: [PATCH 10/14] Migrated orphanVisits reducer to RTK --- src/reducers/index.ts | 3 +- src/visits/reducers/orphanVisits.ts | 124 ++++++++++------------ src/visits/services/provideServices.ts | 10 +- test/visits/reducers/orphanVisits.test.ts | 120 ++++++++++----------- 4 files changed, 126 insertions(+), 131 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index b70858a3..80ffe315 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -3,7 +3,6 @@ import { combineReducers } from '@reduxjs/toolkit'; import { serversReducer } from '../servers/reducers/servers'; import shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; -import orphanVisitsReducer from '../visits/reducers/orphanVisits'; import { settingsReducer } from '../settings/reducers/settings'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { sidebarReducer } from '../common/reducers/sidebar'; @@ -20,7 +19,7 @@ export default (container: IContainer) => combineReducers({ shortUrlVisits: shortUrlVisitsReducer, tagVisits: tagVisitsReducer, domainVisits: container.domainVisitsReducer, - orphanVisits: orphanVisitsReducer, + orphanVisits: container.orphanVisitsReducer, nonOrphanVisits: container.nonOrphanVisitsReducer, tagsList: container.tagsListReducer, tagDelete: container.tagDeleteReducer, diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 102f9c0c..6c7485e4 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,42 +1,19 @@ -import { createAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; +import { createSlice } from '@reduxjs/toolkit'; import { OrphanVisit, OrphanVisitType } from '../types'; -import { buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; -import { GetState } from '../../container/types'; import { isOrphanVisit } from '../types/helpers'; -import { ApiErrorAction } from '../../api/types/actions'; import { isBetween } from '../../utils/helpers/date'; -import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; +import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -import { - LoadVisits, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoaded, - VisitsLoadedAction, - VisitsLoadProgressChangedAction, -} from './types'; +import { LoadVisits, VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/orphanVisits'; -export const GET_ORPHAN_VISITS_START = `${REDUCER_PREFIX}/getOrphanVisits/pending`; -export const GET_ORPHAN_VISITS_ERROR = `${REDUCER_PREFIX}/getOrphanVisits/rejected`; -export const GET_ORPHAN_VISITS = `${REDUCER_PREFIX}/getOrphanVisits/fulfilled`; -export const GET_ORPHAN_VISITS_LARGE = `${REDUCER_PREFIX}/getOrphanVisits/large`; -export const GET_ORPHAN_VISITS_CANCEL = `${REDUCER_PREFIX}/getOrphanVisits/cancel`; -export const GET_ORPHAN_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getOrphanVisits/progressChanged`; -export const GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`; export interface LoadOrphanVisits extends LoadVisits { orphanVisitsType?: OrphanVisitType; } -type OrphanVisitsCombinedAction = VisitsLoadedAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: VisitsInfo = { visits: [], loading: false, @@ -46,48 +23,63 @@ const initialState: VisitsInfo = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getOrphanVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getOrphanVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getOrphanVisits/fulfilled`]: (state, { payload }: VisitsLoadedAction) => ( - { ...state, ...payload, loading: false, loadingLarge: false, error: false } - ), - [`${REDUCER_PREFIX}/getOrphanVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getOrphanVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getOrphanVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getOrphanVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); - - return { ...state, visits: [...newVisits, ...visits] }; - }, -}, initialState); - const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => !orphanVisitsType || orphanVisitsType === visit.type; -export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) - .then((result) => { - const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); +export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getOrphanVisits`, + createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => { + const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) + .then((result) => { + const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); - return { ...result, data: visits }; - }); - const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); - const shouldCancel = () => getState().orphanVisits.cancelLoad; - const extraFinishActionData: Partial = { query }; - const prefix = `${REDUCER_PREFIX}/getOrphanVisits`; + return { ...result, data: visits }; + }); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, prefix, dispatch, shouldCancel); + return [visitsLoader, lastVisitLoader]; + }, + getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }), + shouldCancel: (getState) => getState().orphanVisits.cancelLoad, +}); + +export const orphanVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetOrphanVisits: (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 } + )); + + 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 }: CreateVisitsAction) => { + const { visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)) + .map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + const { cancelGetOrphanVisits } = actions; + + return { reducer, cancelGetOrphanVisits }; }; - -export const cancelGetOrphanVisits = createAction(`${REDUCER_PREFIX}/getOrphanVisits/cancel`); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 9529831d..99bd1aa6 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -9,7 +9,7 @@ import { NonOrphanVisits } from '../NonOrphanVisits'; import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; -import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; +import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits'; import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; @@ -64,8 +64,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetDomainVisits'), 'domainVisitsReducerCreator'); - bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); + bottle.serviceFactory('getOrphanVisitsCreator', getOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getOrphanVisits', prop('asyncThunk'), 'getOrphanVisitsCreator'); + bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetOrphanVisits'), 'orphanVisitsReducerCreator'); bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator'); @@ -83,6 +84,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisitsCreator'); bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator'); + + bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisitsCreator'); + bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index e763d3ad..0c3dd17a 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -1,15 +1,8 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getOrphanVisits, - cancelGetOrphanVisits, - GET_ORPHAN_VISITS_START, - GET_ORPHAN_VISITS_ERROR, - GET_ORPHAN_VISITS, - GET_ORPHAN_VISITS_LARGE, - GET_ORPHAN_VISITS_CANCEL, - GET_ORPHAN_VISITS_PROGRESS_CHANGED, - GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, +import { + getOrphanVisits as getOrphanVisitsCreator, + orphanVisitsReducerCreator, } from '../../../src/visits/reducers/orphanVisits'; import { rangeOf } from '../../../src/utils/utils'; import { Visit } from '../../../src/visits/types'; @@ -24,34 +17,37 @@ import { VisitsInfo } from '../../../src/visits/reducers/types'; describe('orphanVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getOrphanVisitsCall = jest.fn(); + const buildShlinkApiClientMock = () => Mock.of({ getOrphanVisits: getOrphanVisitsCall }); + const creator = getOrphanVisitsCreator(buildShlinkApiClientMock); + const { asyncThunk: getOrphanVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; + const { reducer, cancelGetOrphanVisits } = orphanVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_ORPHAN_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_ORPHAN_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getOrphanVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_ORPHAN_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_ORPHAN_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_ORPHAN_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetOrphanVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_ORPHAN_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_ORPHAN_VISITS_ERROR } as any); - const { loading, error } = state; + const { loading, error } = reducer( + buildState({ loading: true, error: false }), + { type: getOrphanVisits.rejected.toString() }, + ); expect(loading).toEqual(false); expect(error).toEqual(true); @@ -59,11 +55,10 @@ describe('orphanVisitsReducer', () => { it('return visits on GET_ORPHAN_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { - type: GET_ORPHAN_VISITS, + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getOrphanVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -109,14 +104,13 @@ describe('orphanVisitsReducer', () => { const { visits } = reducer(prevState, { type: createNewVisits.toString(), payload: { createdVisits: [{ visit }, { visit }] }, - } as any); + }); expect(visits).toHaveLength(expectedVisits); }); it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_ORPHAN_VISITS_PROGRESS_CHANGED, payload: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -124,7 +118,7 @@ describe('orphanVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -132,27 +126,24 @@ describe('orphanVisitsReducer', () => { }); describe('getOrphanVisits', () => { - type GetVisitsReturn = Promise | ((query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ orphanVisits: { cancelLoad: false }, }); - beforeEach(jest.resetAllMocks); - it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject({})); + getOrphanVisitsCall.mockRejectedValue({}); - await getOrphanVisits(() => ShlinkApiClient)({})(dispatchMock, getState); + await getOrphanVisits({})(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS_ERROR }); - expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getOrphanVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getOrphanVisits.rejected.toString(), + })); + expect(getOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -160,37 +151,45 @@ describe('orphanVisitsReducer', () => { [{}], ])('dispatches start and success when promise is resolved', async (query) => { const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); - const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + getOrphanVisitsCall.mockResolvedValue({ data: visits, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getOrphanVisits(() => ShlinkApiClient)({ query })(dispatchMock, getState); + await getOrphanVisits({ query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_ORPHAN_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getOrphanVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getOrphanVisits.fulfilled.toString(), payload: { visits, query: query ?? {} }, - }); - expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); + })); + expect(getOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_ORPHAN_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getOrphanVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedDispatchCalls, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -199,22 +198,23 @@ describe('orphanVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkOrphanVisits = jest.fn() + getOrphanVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getOrphanVisits: getShlinkOrphanVisits }); - await getOrphanVisits(() => ShlinkApiClient)({ doIntervalFallback: true })(dispatchMock, getState); + await getOrphanVisits({ doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getOrphanVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); + expect(getOrphanVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetOrphanVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetOrphanVisits()).toEqual({ type: GET_ORPHAN_VISITS_CANCEL })); + expect(cancelGetOrphanVisits()).toEqual({ type: cancelGetOrphanVisits.toString() })); }); }); From 3e474a3f2d1e9fce2e629340e8cbf37822755604 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 19:36:12 +0100 Subject: [PATCH 11/14] Migrated shortUrlVisits reducer to RTK --- src/reducers/index.ts | 3 +- src/visits/reducers/shortUrlVisits.ts | 136 +++++++++----------- src/visits/services/provideServices.ts | 10 +- test/visits/reducers/shortUrlVisits.test.ts | 129 +++++++++---------- 4 files changed, 133 insertions(+), 145 deletions(-) diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 80ffe315..2d6be9a0 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 shortUrlVisitsReducer from '../visits/reducers/shortUrlVisits'; import tagVisitsReducer from '../visits/reducers/tagVisits'; import { settingsReducer } from '../settings/reducers/settings'; import { appUpdatesReducer } from '../app/reducers/appUpdates'; @@ -16,7 +15,7 @@ export default (container: IContainer) => combineReducers({ shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, shortUrlDetail: container.shortUrlDetailReducer, - shortUrlVisits: shortUrlVisitsReducer, + shortUrlVisits: container.shortUrlVisitsReducer, tagVisits: tagVisitsReducer, domainVisits: container.domainVisitsReducer, orphanVisits: container.orphanVisitsReducer, diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index fd720a88..453c9c0d 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,46 +1,21 @@ -import { createAction } from '@reduxjs/toolkit'; -import { Dispatch } from 'redux'; +import { createSlice } from '@reduxjs/toolkit'; import { shortUrlMatches } from '../../short-urls/helpers'; import { ShortUrlIdentifier } from '../../short-urls/data'; -import { buildReducer } from '../../utils/helpers/redux'; 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 { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; import { createNewVisits, CreateVisitsAction } from './visitCreation'; -import { - LoadVisits, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoaded, - VisitsLoadedAction, - VisitsLoadProgressChangedAction, -} from './types'; +import { LoadVisits, VisitsInfo } from './types'; +import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/shortUrlVisits'; -export const GET_SHORT_URL_VISITS_START = `${REDUCER_PREFIX}/getShortUrlVisits/pending`; -export const GET_SHORT_URL_VISITS_ERROR = `${REDUCER_PREFIX}/getShortUrlVisits/rejected`; -export const GET_SHORT_URL_VISITS = `${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`; -export const GET_SHORT_URL_VISITS_LARGE = `${REDUCER_PREFIX}/getShortUrlVisits/large`; -export const GET_SHORT_URL_VISITS_CANCEL = `${REDUCER_PREFIX}/getShortUrlVisits/cancel`; -export const GET_SHORT_URL_VISITS_PROGRESS_CHANGED = `${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`; -export const GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL = `${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`; export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} -type ShortUrlVisitsAction = VisitsLoadedAction; - export interface LoadShortUrlVisits extends LoadVisits { shortCode: string; } -type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: ShortUrlVisits = { visits: [], shortCode: '', @@ -52,53 +27,66 @@ const initialState: ShortUrlVisits = { progress: 0, }; -export default buildReducer({ - [`${REDUCER_PREFIX}/getShortUrlVisits/pending`]: () => ({ ...initialState, loading: true }), - [`${REDUCER_PREFIX}/getShortUrlVisits/rejected`]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), - [`${REDUCER_PREFIX}/getShortUrlVisits/fulfilled`]: (state, { payload }: ShortUrlVisitsAction) => ({ - ...state, - ...payload, - loading: false, - loadingLarge: false, - error: false, - }), - [`${REDUCER_PREFIX}/getShortUrlVisits/large`]: (state) => ({ ...state, loadingLarge: true }), - [`${REDUCER_PREFIX}/getShortUrlVisits/cancel`]: (state) => ({ ...state, cancelLoad: true }), - [`${REDUCER_PREFIX}/getShortUrlVisits/progressChanged`]: (state, { payload: progress }) => ({ ...state, progress }), - [`${REDUCER_PREFIX}/getShortUrlVisits/fallbackToInterval`]: (state, { payload: fallbackInterval }) => ( - { ...state, fallbackInterval } - ), - [createNewVisits.toString()]: (state, { payload }: CreateVisitsAction) => { - const { shortCode, domain, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter( - ({ shortUrl, visit }) => - shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), - ) - .map(({ visit }) => visit); +export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + actionsPrefix: `${REDUCER_PREFIX}/getShortUrlVisits`, + createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => { + const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); + const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( + shortCode, + { ...query, page, itemsPerPage }, + ); + const lastVisitLoader = lastVisitLoaderForLoader( + doIntervalFallback, + async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }), + ); - return newVisits.length === 0 ? state : { ...state, visits: [...newVisits, ...visits] }; + return [visitsLoader, lastVisitLoader]; }, -}, initialState); + getExtraFulfilledPayload: ({ shortCode, query = {} }: LoadShortUrlVisits) => ( + { shortCode, query, domain: query.domain } + ), + shouldCancel: (getState) => getState().shortUrlVisits.cancelLoad, +}); -export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( - { shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, -) => async (dispatch: Dispatch, getState: GetState) => { - const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( - shortCode, - { ...query, page, itemsPerPage }, - ); - const lastVisitLoader = lastVisitLoaderForLoader( - doIntervalFallback, - async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }), - ); - const shouldCancel = () => getState().shortUrlVisits.cancelLoad; - const extraFinishActionData: Partial> = { shortCode, query, domain: query.domain }; - const prefix = `${REDUCER_PREFIX}/getShortUrlVisits`; +export const shortUrlVisitsReducerCreator = ( + { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, +) => { + const { reducer, actions } = createSlice({ + name: REDUCER_PREFIX, + initialState, + reducers: { + cancelGetShortUrlVisits: (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 }: CreateVisitsAction) => { + const { shortCode, domain, visits, query = {} } = state; + const { startDate, endDate } = query; + const newVisits = payload.createdVisits + .filter( + ({ shortUrl, visit }) => + shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), + ) + .map(({ visit }) => visit); + + return newVisits.length === 0 ? state : { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + const { cancelGetShortUrlVisits } = actions; + + return { reducer, cancelGetShortUrlVisits }; }; - -export const cancelGetShortUrlVisits = createAction(`${REDUCER_PREFIX}/getShortUrlVisits/cancel`); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 99bd1aa6..f00f6766 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -6,7 +6,7 @@ import { ShortUrlVisits } from '../ShortUrlVisits'; import { TagVisits } from '../TagVisits'; import { OrphanVisits } from '../OrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits'; -import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; +import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits'; import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits'; @@ -54,8 +54,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('VisitsParser', () => visitsParser); // Actions - bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); + bottle.serviceFactory('getShortUrlVisitsCreator', getShortUrlVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator'); + bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetShortUrlVisits'), 'shortUrlVisitsReducerCreator'); bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); @@ -87,6 +88,9 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisitsCreator'); bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator'); + + bottle.serviceFactory('shortUrlVisitsReducerCreator', shortUrlVisitsReducerCreator, 'getShortUrlVisitsCreator'); + bottle.serviceFactory('shortUrlVisitsReducer', prop('reducer'), 'shortUrlVisitsReducerCreator'); }; export default provideServices; diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index b7afd964..aae52691 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -1,15 +1,8 @@ import { Mock } from 'ts-mockery'; import { addDays, formatISO, subDays } from 'date-fns'; -import reducer, { - getShortUrlVisits, - cancelGetShortUrlVisits, - GET_SHORT_URL_VISITS_START, - GET_SHORT_URL_VISITS_ERROR, - GET_SHORT_URL_VISITS, - GET_SHORT_URL_VISITS_LARGE, - GET_SHORT_URL_VISITS_CANCEL, - GET_SHORT_URL_VISITS_PROGRESS_CHANGED, - GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, +import { + getShortUrlVisits as getShortUrlVisitsCreator, + shortUrlVisitsReducerCreator, ShortUrlVisits, } from '../../../src/visits/reducers/shortUrlVisits'; import { rangeOf } from '../../../src/utils/utils'; @@ -24,34 +17,37 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; describe('shortUrlVisitsReducer', () => { const now = new Date(); const visitsMocks = rangeOf(2, () => Mock.all()); + const getShortUrlVisitsCall = jest.fn(); + const buildApiClientMock = () => Mock.of({ getShortUrlVisits: getShortUrlVisitsCall }); + const creator = getShortUrlVisitsCreator(buildApiClientMock); + const { asyncThunk: getShortUrlVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; + const { reducer, cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(creator); + + beforeEach(jest.clearAllMocks); describe('reducer', () => { const buildState = (data: Partial) => Mock.of(data); it('returns loading on GET_SHORT_URL_VISITS_START', () => { - const state = reducer(buildState({ loading: false }), { type: GET_SHORT_URL_VISITS_START } as any); - const { loading } = state; - + const { loading } = reducer(buildState({ loading: false }), { type: getShortUrlVisits.pending.toString() }); expect(loading).toEqual(true); }); it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => { - const state = reducer(buildState({ loadingLarge: false }), { type: GET_SHORT_URL_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_SHORT_URL_VISITS_CANCEL', () => { - const state = reducer(buildState({ cancelLoad: false }), { type: GET_SHORT_URL_VISITS_CANCEL } as any); - const { cancelLoad } = state; - + const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetShortUrlVisits.toString() }); expect(cancelLoad).toEqual(true); }); it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => { - const state = reducer(buildState({ loading: true, error: false }), { type: GET_SHORT_URL_VISITS_ERROR } as any); - const { loading, error } = state; + const { loading, error } = reducer( + buildState({ loading: true, error: false }), + { type: getShortUrlVisits.rejected.toString() }, + ); expect(loading).toEqual(false); expect(error).toEqual(true); @@ -59,11 +55,10 @@ describe('shortUrlVisitsReducer', () => { it('return visits on GET_SHORT_URL_VISITS', () => { const actionVisits = [{}, {}]; - const state = reducer(buildState({ loading: true, error: false }), { - type: GET_SHORT_URL_VISITS, + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getShortUrlVisits.fulfilled.toString(), payload: { visits: actionVisits }, - } as any); - const { loading, error, visits } = state; + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -129,14 +124,13 @@ describe('shortUrlVisitsReducer', () => { 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_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { - const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, payload: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); expect(state).toEqual(expect.objectContaining({ progress: 85 })); }); @@ -144,7 +138,7 @@ describe('shortUrlVisitsReducer', () => { const fallbackInterval: DateInterval = 'last30Days'; const state = reducer( undefined, - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: fallbackInterval } as any, + { type: fallbackToIntervalAction.toString(), payload: fallbackInterval }, ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); @@ -152,27 +146,24 @@ describe('shortUrlVisitsReducer', () => { }); describe('getShortUrlVisits', () => { - type GetVisitsReturn = Promise | ((shortCode: string, query: any) => Promise); - - const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of({ - getShortUrlVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned), - }); const dispatchMock = jest.fn(); const getState = () => Mock.of({ shortUrlVisits: Mock.of({ cancelLoad: false }), }); - beforeEach(() => dispatchMock.mockReset()); - it('dispatches start and error when promise is rejected', async () => { - const ShlinkApiClient = buildApiClientMock(Promise.reject({})); + getShortUrlVisitsCall.mockRejectedValue({}); - await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123' })(dispatchMock, getState); + await getShortUrlVisits({ shortCode: 'abc123' })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS_ERROR }); - expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getShortUrlVisits.rejected.toString(), + })); + expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ @@ -182,29 +173,31 @@ describe('shortUrlVisitsReducer', () => { ])('dispatches start and success when promise is resolved', async (query, domain) => { const visits = visitsMocks; const shortCode = 'abc123'; - const ShlinkApiClient = buildApiClientMock(Promise.resolve({ + getShortUrlVisitsCall.mockResolvedValue({ data: visitsMocks, pagination: { currentPage: 1, pagesCount: 1, totalItems: 1, }, - })); + }); - await getShortUrlVisits(() => ShlinkApiClient)({ shortCode, query })(dispatchMock, getState); + await getShortUrlVisits({ shortCode, query })(dispatchMock, getState, {}); expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); - expect(dispatchMock).toHaveBeenNthCalledWith(2, { - type: GET_SHORT_URL_VISITS, + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlVisits.pending.toString(), + })); + expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({ + type: getShortUrlVisits.fulfilled.toString(), payload: { visits, shortCode, domain, query: query ?? {} }, - }); - expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); + })); + expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(1); }); it('performs multiple API requests when response contains more pages', async () => { const expectedRequests = 3; - const ShlinkApiClient = buildApiClientMock(async (_, { page }) => + getShortUrlVisitsCall.mockImplementation(async (_, { page }) => Promise.resolve({ data: visitsMocks, pagination: { @@ -214,9 +207,9 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123' })(dispatchMock, getState); + await getShortUrlVisits({ shortCode: 'abc123' })(dispatchMock, getState, {}); - expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); + expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(expectedRequests); expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ payload: expect.objectContaining({ visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], @@ -227,14 +220,20 @@ describe('shortUrlVisitsReducer', () => { it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, payload: 'last365Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last365Days' }, + 3, ], - [[], expect.objectContaining({ type: GET_SHORT_URL_VISITS })], - ])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { + [[], expect.objectContaining({ type: getShortUrlVisits.fulfilled.toString() }), 2], + ])('dispatches fallback interval when the list of visits is empty', async ( + lastVisits, + expectedSecondDispatch, + expectedDispatchCalls, + ) => { const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ data, pagination: { @@ -243,25 +242,23 @@ describe('shortUrlVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkShortUrlVisits = jest.fn() + getShortUrlVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getShortUrlVisits: getShlinkShortUrlVisits }); - await getShortUrlVisits(() => ShlinkApiClient)({ shortCode: 'abc123', doIntervalFallback: true })( - dispatchMock, - getState, - ); + await getShortUrlVisits({ shortCode: 'abc123', doIntervalFallback: true })(dispatchMock, getState, {}); - expect(dispatchMock).toHaveBeenCalledTimes(2); - expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); + expect(dispatchMock).toHaveBeenCalledTimes(expectedDispatchCalls); + expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({ + type: getShortUrlVisits.pending.toString(), + })); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); - expect(getShlinkShortUrlVisits).toHaveBeenCalledTimes(2); + expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(2); }); }); describe('cancelGetShortUrlVisits', () => { it('just returns the action with proper type', () => - expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); + expect(cancelGetShortUrlVisits()).toEqual({ type: cancelGetShortUrlVisits.toString() })); }); }); From dac69daf034f0b5b33a1215db8532c9390590079 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 20:02:58 +0100 Subject: [PATCH 12/14] 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() })); }); }); From 4b2c3d2db7d64bd419309fec3a41d5c61a93ee2b Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 20:37:04 +0100 Subject: [PATCH 13/14] Extracted duplicated code on creating visits reducers to a common helper function --- src/visits/reducers/common.ts | 66 +++++++++++++++++--- src/visits/reducers/domainVisits.ts | 60 +++++------------- src/visits/reducers/nonOrphanVisits.ts | 55 ++++------------ src/visits/reducers/orphanVisits.ts | 55 ++++------------ src/visits/reducers/shortUrlVisits.ts | 62 +++++------------- src/visits/reducers/tagVisits.ts | 58 +++++------------ src/visits/services/provideServices.ts | 10 +-- test/visits/reducers/domainVisits.test.ts | 2 +- test/visits/reducers/nonOrphanVisits.test.ts | 2 +- test/visits/reducers/orphanVisits.test.ts | 2 +- test/visits/reducers/shortUrlVisits.test.ts | 2 +- test/visits/reducers/tagVisits.test.ts | 2 +- 12 files changed, 137 insertions(+), 239 deletions(-) diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index bfd454df..f32048d7 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,11 +1,13 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { createAction } from '@reduxjs/toolkit'; +import { createAction, createSlice } from '@reduxjs/toolkit'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types'; -import { Visit } from '../types'; +import { CreateVisit, Visit } from '../types'; import { DateInterval, dateToMatchingInterval } from '../../utils/dates/types'; -import { LoadVisits, VisitsLoaded } from './types'; +import { LoadVisits, VisitsInfo, VisitsLoaded } from './types'; import { createAsyncThunk } from '../../utils/helpers/redux'; import { ShlinkState } from '../../container/types'; +import { parseApiError } from '../../api/utils'; +import { createNewVisits } from './visitCreation'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -18,20 +20,20 @@ type VisitsLoader = (page: number, itemsPerPage: number) => Promise Promise; interface VisitsAsyncThunkOptions { - actionsPrefix: string; + name: string; createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; getExtraFulfilledPayload: (params: T) => Partial; shouldCancel: (getState: () => ShlinkState) => boolean; } export const createVisitsAsyncThunk = ( - { actionsPrefix, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, + { name, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { - const progressChangedAction = createAction(`${actionsPrefix}/progressChanged`); - const largeAction = createAction(`${actionsPrefix}/large`); - const fallbackToIntervalAction = createAction(`${actionsPrefix}/fallbackToInterval`); + const progressChangedAction = createAction(`${name}/progressChanged`); + const largeAction = createAction(`${name}/large`); + const fallbackToIntervalAction = createAction(`${name}/fallbackToInterval`); - const asyncThunk = createAsyncThunk(actionsPrefix, async (params: T, { getState, dispatch }): Promise => { + const asyncThunk = createAsyncThunk(name, async (params: T, { getState, dispatch }): Promise => { const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); const loadVisitsInParallel = async (pages: number[]): Promise => @@ -94,3 +96,49 @@ export const lastVisitLoaderForLoader = ( return async () => loader({ page: 1, itemsPerPage: 1 }).then(({ data }) => data[0]); }; + +export const createVisitsReducer = >( + name: string, + { + asyncThunk, + largeAction, + fallbackToIntervalAction, + progressChangedAction, + }: AT, + initialState: State, + filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[], +) => { + const { reducer, actions } = createSlice({ + name, + initialState, + reducers: { + cancelGetVisits: (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 } + )); + + 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 { visits } = state; + // @ts-expect-error TODO Fix the state inferred type + const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit); + + return { ...state, visits: [...newVisits, ...visits] }; + }); + }, + }); + const { cancelGetVisits } = actions; + + return { reducer, cancelGetVisits }; +}; diff --git a/src/visits/reducers/domainVisits.ts b/src/visits/reducers/domainVisits.ts index 86b03cd7..9b258d09 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,11 +1,8 @@ -import { createSlice } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { isBetween } from '../../utils/helpers/date'; -import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; -import { createNewVisits } from './visitCreation'; +import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { domainMatches } from '../../short-urls/helpers'; import { LoadVisits, VisitsInfo } from './types'; -import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/domainVisits'; @@ -30,7 +27,7 @@ const initialState: DomainVisits = { }; export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - actionsPrefix: `${REDUCER_PREFIX}/getDomainVisits`, + name: `${REDUCER_PREFIX}/getDomainVisits`, createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => { const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( @@ -46,42 +43,17 @@ export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => }); export const domainVisitsReducerCreator = ( - { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, -) => { - const { reducer, actions } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: { - cancelGetDomainVisits: (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 } - )); - - 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 { domain, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ shortUrl, visit }) => - shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); - - return { ...state, visits: [...newVisits, ...visits] }; - }); - }, - }); - const { cancelGetDomainVisits } = actions; - - return { reducer, cancelGetDomainVisits }; -}; + getVisitsCreator: ReturnType, +) => createVisitsReducer( + REDUCER_PREFIX, + // @ts-expect-error TODO Fix type inference + getVisitsCreator, + initialState, + ({ domain, query = {} }, createdVisits) => { + const { startDate, endDate } = query; + return createdVisits.filter( + ({ shortUrl, visit }) => + shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate), + ); + }, +); diff --git a/src/visits/reducers/nonOrphanVisits.ts b/src/visits/reducers/nonOrphanVisits.ts index c4edcf96..6edde6a0 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,10 +1,7 @@ -import { createSlice } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { isBetween } from '../../utils/helpers/date'; -import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; -import { createNewVisits } from './visitCreation'; +import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { VisitsInfo } from './types'; -import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/orphanVisits'; @@ -18,7 +15,7 @@ const initialState: VisitsInfo = { }; export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - actionsPrefix: `${REDUCER_PREFIX}/getNonOrphanVisits`, + name: `${REDUCER_PREFIX}/getNonOrphanVisits`, createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => { const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => @@ -32,41 +29,13 @@ export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) }); export const nonOrphanVisitsReducerCreator = ( - { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, -) => { - const { reducer, actions } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: { - cancelGetNonOrphanVisits: (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 } - )); - - 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 { visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ visit }) => isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); - - return { ...state, visits: [...newVisits, ...visits] }; - }); - }, - }); - const { cancelGetNonOrphanVisits } = actions; - - return { reducer, cancelGetNonOrphanVisits }; -}; + getVisitsCreator: ReturnType, +) => createVisitsReducer( + REDUCER_PREFIX, + getVisitsCreator, + initialState, + ({ query = {} }, createdVisits) => { + const { startDate, endDate } = query; + return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate)); + }, +); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 6c7485e4..3b1bf87a 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,12 +1,9 @@ -import { createSlice } from '@reduxjs/toolkit'; import { OrphanVisit, OrphanVisitType } from '../types'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { isOrphanVisit } from '../types/helpers'; import { isBetween } from '../../utils/helpers/date'; -import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; -import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { LoadVisits, VisitsInfo } from './types'; -import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/orphanVisits'; @@ -27,7 +24,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => !orphanVisitsType || orphanVisitsType === visit.type; export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - actionsPrefix: `${REDUCER_PREFIX}/getOrphanVisits`, + name: `${REDUCER_PREFIX}/getOrphanVisits`, createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => { const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) @@ -45,41 +42,13 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => }); export const orphanVisitsReducerCreator = ( - { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, -) => { - const { reducer, actions } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: { - cancelGetOrphanVisits: (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 } - )); - - 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 }: CreateVisitsAction) => { - const { visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)) - .map(({ visit }) => visit); - - return { ...state, visits: [...newVisits, ...visits] }; - }); - }, - }); - const { cancelGetOrphanVisits } = actions; - - return { reducer, cancelGetOrphanVisits }; -}; + getVisitsCreator: ReturnType, +) => createVisitsReducer( + REDUCER_PREFIX, + getVisitsCreator, + initialState, + ({ query = {} }, createdVisits) => { + const { startDate, endDate } = query; + return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate)); + }, +); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 453c9c0d..dc434850 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,12 +1,9 @@ -import { createSlice } from '@reduxjs/toolkit'; import { shortUrlMatches } from '../../short-urls/helpers'; import { ShortUrlIdentifier } from '../../short-urls/data'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { isBetween } from '../../utils/helpers/date'; -import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; -import { createNewVisits, CreateVisitsAction } from './visitCreation'; +import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { LoadVisits, VisitsInfo } from './types'; -import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/shortUrlVisits'; @@ -28,7 +25,7 @@ const initialState: ShortUrlVisits = { }; export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - actionsPrefix: `${REDUCER_PREFIX}/getShortUrlVisits`, + name: `${REDUCER_PREFIX}/getShortUrlVisits`, createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => { const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( @@ -49,44 +46,17 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) }); export const shortUrlVisitsReducerCreator = ( - { asyncThunk, largeAction, progressChangedAction, fallbackToIntervalAction }: ReturnType, -) => { - const { reducer, actions } = createSlice({ - name: REDUCER_PREFIX, - initialState, - reducers: { - cancelGetShortUrlVisits: (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 } - )); - - 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 }: CreateVisitsAction) => { - const { shortCode, domain, visits, query = {} } = state; - const { startDate, endDate } = query; - const newVisits = payload.createdVisits - .filter( - ({ shortUrl, visit }) => - shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), - ) - .map(({ visit }) => visit); - - return newVisits.length === 0 ? state : { ...state, visits: [...newVisits, ...visits] }; - }); - }, - }); - const { cancelGetShortUrlVisits } = actions; - - return { reducer, cancelGetShortUrlVisits }; -}; + getVisitsCreator: ReturnType, +) => createVisitsReducer( + REDUCER_PREFIX, + // @ts-expect-error TODO Fix type inference + getVisitsCreator, + initialState, + ({ shortCode, domain, query = {} }, createdVisits) => { + const { startDate, endDate } = query; + return createdVisits.filter( + ({ shortUrl, visit }) => + shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate), + ); + }, +); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 4bc06511..1c1dace5 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,10 +1,7 @@ -import { createSlice } from '@reduxjs/toolkit'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { isBetween } from '../../utils/helpers/date'; -import { createVisitsAsyncThunk, lastVisitLoaderForLoader } from './common'; -import { createNewVisits } from './visitCreation'; +import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { LoadVisits, VisitsInfo } from './types'; -import { parseApiError } from '../../api/utils'; const REDUCER_PREFIX = 'shlink/tagVisits'; @@ -27,7 +24,7 @@ const initialState: TagVisits = { }; export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ - actionsPrefix: `${REDUCER_PREFIX}/getTagVisits`, + name: `${REDUCER_PREFIX}/getTagVisits`, createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => { const { getTagVisits: getVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( @@ -42,42 +39,15 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => cr shouldCancel: (getState) => getState().tagVisits.cancelLoad, }); -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 } - )); - - 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 tagVisitsReducerCreator = (getTagVisitsCreator: ReturnType) => createVisitsReducer( + REDUCER_PREFIX, + // @ts-expect-error TODO Fix type inference + getTagVisitsCreator, + initialState, + ({ tag, query = {} }, createdVisits) => { + const { startDate, endDate } = query; + return createdVisits.filter( + ({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate), + ); + }, +); diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 3c93ef52..19cadd4c 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -56,23 +56,23 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => { // Actions bottle.serviceFactory('getShortUrlVisitsCreator', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator'); - bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetShortUrlVisits'), 'shortUrlVisitsReducerCreator'); + bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator'); bottle.serviceFactory('getTagVisitsCreator', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getTagVisits', prop('asyncThunk'), 'getTagVisitsCreator'); - bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetTagVisits'), 'tagVisitsReducerCreator'); + bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator'); bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); - bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetDomainVisits'), 'domainVisitsReducerCreator'); + bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator'); bottle.serviceFactory('getOrphanVisitsCreator', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getOrphanVisits', prop('asyncThunk'), 'getOrphanVisitsCreator'); - bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetOrphanVisits'), 'orphanVisitsReducerCreator'); + bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator'); bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator'); - bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetNonOrphanVisits'), 'nonOrphanVisitsReducerCreator'); + bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator'); bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index 35f24650..da683d16 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -23,7 +23,7 @@ describe('domainVisitsReducer', () => { const buildApiClientMock = () => Mock.of({ getDomainVisits: getDomainVisitsCall }); const creator = getDomainVisitsCreator(buildApiClientMock); const { asyncThunk: getDomainVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetDomainVisits } = domainVisitsReducerCreator(creator); + const { reducer, cancelGetVisits: cancelGetDomainVisits } = domainVisitsReducerCreator(creator); beforeEach(jest.clearAllMocks); diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index da735c52..5ba44eed 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -21,7 +21,7 @@ describe('nonOrphanVisitsReducer', () => { const buildShlinkApiClient = () => Mock.of({ getNonOrphanVisits: getNonOrphanVisitsCall }); const creator = getNonOrphanVisitsCreator(buildShlinkApiClient); const { asyncThunk: getNonOrphanVisits, progressChangedAction, largeAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(creator); + const { reducer, cancelGetVisits: cancelGetNonOrphanVisits } = nonOrphanVisitsReducerCreator(creator); beforeEach(jest.clearAllMocks); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 0c3dd17a..0a58c3b9 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -21,7 +21,7 @@ describe('orphanVisitsReducer', () => { const buildShlinkApiClientMock = () => Mock.of({ getOrphanVisits: getOrphanVisitsCall }); const creator = getOrphanVisitsCreator(buildShlinkApiClientMock); const { asyncThunk: getOrphanVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetOrphanVisits } = orphanVisitsReducerCreator(creator); + const { reducer, cancelGetVisits: cancelGetOrphanVisits } = orphanVisitsReducerCreator(creator); beforeEach(jest.clearAllMocks); diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index aae52691..01f87e30 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -21,7 +21,7 @@ describe('shortUrlVisitsReducer', () => { const buildApiClientMock = () => Mock.of({ getShortUrlVisits: getShortUrlVisitsCall }); const creator = getShortUrlVisitsCreator(buildApiClientMock); const { asyncThunk: getShortUrlVisits, largeAction, progressChangedAction, fallbackToIntervalAction } = creator; - const { reducer, cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(creator); + const { reducer, cancelGetVisits: cancelGetShortUrlVisits } = shortUrlVisitsReducerCreator(creator); beforeEach(jest.clearAllMocks); diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 1b17b5db..0b3573ca 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -21,7 +21,7 @@ describe('tagVisitsReducer', () => { const buildShlinkApiClientMock = () => Mock.of({ getTagVisits: getTagVisitsCall }); const creator = getTagVisitsCreator(buildShlinkApiClientMock); const { asyncThunk: getTagVisits, fallbackToIntervalAction, largeAction, progressChangedAction } = creator; - const { reducer, cancelGetTagVisits } = tagVisitsReducerCreator(creator); + const { reducer, cancelGetVisits: cancelGetTagVisits } = tagVisitsReducerCreator(creator); beforeEach(jest.clearAllMocks); From 6103f6a89bb3eee7ef7abc879869f39af7b602ff Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 12 Nov 2022 20:41:55 +0100 Subject: [PATCH 14/14] Separated param definition and unpacking for readibility --- src/visits/reducers/common.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index f32048d7..af1eedd1 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -99,15 +99,11 @@ export const lastVisitLoaderForLoader = ( export const createVisitsReducer = >( name: string, - { - asyncThunk, - largeAction, - fallbackToIntervalAction, - progressChangedAction, - }: AT, + asyncThunkCreator: AT, initialState: State, filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[], ) => { + const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator; const { reducer, actions } = createSlice({ name, initialState,