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/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/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/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/reducers/index.ts b/src/reducers/index.ts index ae1a6ba0..56734ac7 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -1,11 +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 domainVisitsReducer from '../visits/reducers/domainVisits'; -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'; @@ -19,11 +14,11 @@ export default (container: IContainer) => combineReducers({ shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlEdition: container.shortUrlEditionReducer, shortUrlDetail: container.shortUrlDetailReducer, - shortUrlVisits: shortUrlVisitsReducer, - tagVisits: tagVisitsReducer, - domainVisits: domainVisitsReducer, - orphanVisits: orphanVisitsReducer, - nonOrphanVisits: nonOrphanVisitsReducer, + shortUrlVisits: container.shortUrlVisitsReducer, + tagVisits: container.tagVisitsReducer, + domainVisits: container.domainVisitsReducer, + orphanVisits: container.orphanVisitsReducer, + nonOrphanVisits: container.nonOrphanVisitsReducer, tagsList: container.tagsListReducer, tagDelete: container.tagDeleteReducer, tagEdit: container.tagEditReducer, diff --git a/src/utils/helpers/redux.ts b/src/utils/helpers/redux.ts index 8ae09e05..f55518a9 100644 --- a/src/utils/helpers/redux.ts +++ b/src/utils/helpers/redux.ts @@ -1,25 +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; -}; - -/** @deprecated */ -export const buildActionCreator = (type: T) => (): Action => ({ type }); - 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/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..8c939558 100644 --- a/src/visits/NonOrphanVisits.tsx +++ b/src/visits/NonOrphanVisits.tsx @@ -1,16 +1,16 @@ 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, VisitsInfo, VisitsParams } from './types'; +import { NormalizedVisit, VisitsParams } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; import { toApiParams } from './types/helpers'; import { VisitsHeader } from './VisitsHeader'; +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; } @@ -25,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 ( void; + getOrphanVisits: (params: LoadOrphanVisits) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } @@ -28,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 ( 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/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/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 1a08d7a7..af1eedd1 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -1,10 +1,13 @@ import { flatten, prop, range, splitEvery } from 'ramda'; -import { Action, Dispatch } from 'redux'; +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, VisitsInfo, VisitsLoaded } from './types'; +import { createAsyncThunk } from '../../utils/helpers/redux'; +import { ShlinkState } from '../../container/types'; import { parseApiError } from '../../api/utils'; -import { ApiErrorAction } from '../../api/types/actions'; -import { dateToMatchingInterval } from '../../utils/dates/types'; +import { createNewVisits } from './visitCreation'; const ITEMS_PER_PAGE = 5000; const PARALLEL_REQUESTS_COUNT = 4; @@ -15,74 +18,72 @@ 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; + +interface VisitsAsyncThunkOptions { + name: string; + createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader]; + getExtraFulfilledPayload: (params: T) => Partial; + shouldCancel: (getState: () => ShlinkState) => boolean; } -export const getVisitsWithLoader = async & { visits: Visit[] }>( - visitsLoader: VisitsLoader, - lastVisitLoader: LastVisitLoader, - extraFinishActionData: Partial, - actionMap: ActionMap, - dispatch: Dispatch, - shouldCancel: () => boolean, +export const createVisitsAsyncThunk = ( + { name, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions, ) => { - dispatch({ type: actionMap.start }); + const progressChangedAction = createAction(`${name}/progressChanged`); + const largeAction = createAction(`${name}/large`); + const fallbackToIntervalAction = createAction(`${name}/fallbackToInterval`); - const loadVisitsInParallel = async (pages: number[]): Promise => - Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); + const asyncThunk = createAsyncThunk(name, async (params: T, { getState, dispatch }): Promise => { + const [visitsLoader, lastVisitLoader] = createLoaders(params, getState); - const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { - if (shouldCancel()) { - return []; - } + const loadVisitsInParallel = async (pages: number[]): Promise => + Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); - const data = await loadVisitsInParallel(pagesBlocks[index]); + const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise => { + if (shouldCancel(getState)) { + return []; + } - dispatch({ type: actionMap.progress, progress: calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE) }); + const data = await loadVisitsInParallel(pagesBlocks[index]); - if (index < pagesBlocks.length - 1) { - return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); - } + dispatch(progressChangedAction(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE))); - return data; - }; + if (index < pagesBlocks.length - 1) { + return data.concat(await loadPagesBlocks(pagesBlocks, index + 1)); + } - 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); + const loadVisits = async (page = 1) => { + const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE); - if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { - dispatch({ type: actionMap.large }); - } + // If pagination was not returned, then this is an old shlink version. Just return data + if (!pagination || isLastPage(pagination)) { + return data; + } - return data.concat(await loadPagesBlocks(pagesBlocks)); - }; + // 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)); + }; - try { const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]); - dispatch( - !visits.length && lastVisit - ? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) } - : { ...extraFinishActionData, visits, type: actionMap.finish }, - ); - } catch (e: any) { - dispatch({ type: actionMap.error, errorData: parseApiError(e) }); - } + 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 = ( @@ -93,5 +94,47 @@ 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]); +}; + +export const createVisitsReducer = >( + name: string, + asyncThunkCreator: AT, + initialState: State, + filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[], +) => { + const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator; + 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 eb2c80fe..9b258d09 100644 --- a/src/visits/reducers/domainVisits.ts +++ b/src/visits/reducers/domainVisits.ts @@ -1,40 +1,20 @@ -import { Action, Dispatch } from 'redux'; -import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; -import { buildActionCreator, 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 { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; import { domainMatches } from '../../short-urls/helpers'; +import { LoadVisits, VisitsInfo } from './types'; -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 DEFAULT_DOMAIN = 'DEFAULT'; -export interface DomainVisits extends VisitsInfo { +interface WithDomain { domain: string; } -export interface DomainVisitsAction extends Action { - visits: Visit[]; - domain: string; - query?: ShlinkVisitsParams; -} +export interface DomainVisits extends VisitsInfo, WithDomain {} -type DomainVisitsCombinedAction = DomainVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; +export interface LoadDomainVisits extends LoadVisits, WithDomain {} const initialState: DomainVisits = { visits: [], @@ -46,51 +26,34 @@ const initialState: DomainVisits = { progress: 0, }; -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 }) => ( - { ...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 }), - [createNewVisits.toString()]: (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); +export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + name: `${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: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, -) => 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 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, - }; - - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); -}; - -export const cancelGetDomainVisits = buildActionCreator(GET_DOMAIN_VISITS_CANCEL); +export const domainVisitsReducerCreator = ( + 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 94c037fc..6edde6a0 100644 --- a/src/visits/reducers/nonOrphanVisits.ts +++ b/src/visits/reducers/nonOrphanVisits.ts @@ -1,37 +1,9 @@ -import { Action, Dispatch } from 'redux'; -import { - Visit, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoadProgressChangedAction, -} from '../types'; -import { buildActionCreator, 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 { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; +import { VisitsInfo } from './types'; -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'; - -export interface NonOrphanVisitsAction extends Action { - visits: Visit[]; - query?: ShlinkVisitsParams; -} - -type NonOrphanVisitsCombinedAction = NonOrphanVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; +const REDUCER_PREFIX = 'shlink/orphanVisits'; const initialState: VisitsInfo = { visits: [], @@ -42,47 +14,28 @@ const initialState: VisitsInfo = { progress: 0, }; -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 }) => ( - { ...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 }), - [createNewVisits.toString()]: (state, { payload }) => { - 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({ + name: `${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: ShlinkVisitsParams = {}, - doIntervalFallback = false, -) => 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 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, - }; - - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); -}; - -export const cancelGetNonOrphanVisits = buildActionCreator(GET_NON_ORPHAN_VISITS_CANCEL); +export const nonOrphanVisitsReducerCreator = ( + 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 ecbe832d..3b1bf87a 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,41 +1,16 @@ -import { Action, Dispatch } from 'redux'; -import { - OrphanVisit, - OrphanVisitType, - Visit, - VisitsFallbackIntervalAction, - VisitsInfo, - VisitsLoadProgressChangedAction, -} from '../types'; -import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; +import { OrphanVisit, OrphanVisitType } from '../types'; 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 { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; +import { LoadVisits, VisitsInfo } from './types'; -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 interface OrphanVisitsAction extends Action { - visits: Visit[]; - query?: ShlinkVisitsParams; +export interface LoadOrphanVisits extends LoadVisits { + orphanVisitsType?: OrphanVisitType; } -type OrphanVisitsCombinedAction = OrphanVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: VisitsInfo = { visits: [], loading: false, @@ -45,55 +20,35 @@ const initialState: VisitsInfo = { progress: 0, }; -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 }) => ( - { ...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 }), - [createNewVisits.toString()]: (state, { payload }) => { - 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) => ( - query: ShlinkVisitsParams = {}, - orphanVisitsType?: OrphanVisitType, - doIntervalFallback = false, -) => 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({ + 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 }) + .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 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, - }; + return { ...result, data: visits }; + }); + const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); -}; + return [visitsLoader, lastVisitLoader]; + }, + getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }), + shouldCancel: (getState) => getState().orphanVisits.cancelLoad, +}); -export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); +export const orphanVisitsReducerCreator = ( + 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 c768d8b5..dc434850 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -1,37 +1,18 @@ -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 { 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 { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; +import { LoadVisits, VisitsInfo } from './types'; -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 interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} -interface ShortUrlVisitsAction extends Action, ShortUrlIdentifier { - visits: Visit[]; - query?: ShlinkVisitsParams; +export interface LoadShortUrlVisits extends LoadVisits { + shortCode: string; } -type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; - const initialState: ShortUrlVisits = { visits: [], shortCode: '', @@ -43,63 +24,39 @@ const initialState: ShortUrlVisits = { progress: 0, }; -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 }) => ({ - ...state, - visits, - shortCode, - domain, - query, - loading: false, - 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 }), - [createNewVisits.toString()]: (state, { payload }) => { - 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({ + name: `${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: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, -) => 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 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, - }; - - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); -}; - -export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL); +export const shortUrlVisitsReducerCreator = ( + 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 65b0bfc8..1c1dace5 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -1,37 +1,17 @@ -import { Action, Dispatch } from 'redux'; -import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; -import { buildActionCreator, 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 { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common'; +import { LoadVisits, VisitsInfo } from './types'; -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 interface TagVisits extends VisitsInfo { +interface WithTag { tag: string; } -export interface TagVisitsAction extends Action { - visits: Visit[]; - tag: string; - query?: ShlinkVisitsParams; -} +export interface TagVisits extends VisitsInfo, WithTag {} -type TagsVisitsCombinedAction = TagVisitsAction -& VisitsLoadProgressChangedAction -& VisitsFallbackIntervalAction -& CreateVisitsAction -& ApiErrorAction; +export interface LoadTagVisits extends LoadVisits, WithTag {} const initialState: TagVisits = { visits: [], @@ -43,50 +23,31 @@ const initialState: TagVisits = { progress: 0, }; -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 }) => ( - { ...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 }), - [createNewVisits.toString()]: (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); +export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({ + name: `${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: string, - query: ShlinkVisitsParams = {}, - doIntervalFallback = false, -) => 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 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, - }; - - return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); -}; - -export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL); +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/reducers/types/index.ts b/src/visits/reducers/types/index.ts new file mode 100644 index 00000000..b9dd1adf --- /dev/null +++ b/src/visits/reducers/types/index.ts @@ -0,0 +1,26 @@ +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 VisitsLoaded = T & { + visits: Visit[]; + query?: ShlinkVisitsParams; +}; diff --git a/src/visits/services/provideServices.ts b/src/visits/services/provideServices.ts index 28684da3..19cadd4c 100644 --- a/src/visits/services/provideServices.ts +++ b/src/visits/services/provideServices.ts @@ -6,11 +6,11 @@ import { ShortUrlVisits } from '../ShortUrlVisits'; import { TagVisits } from '../TagVisits'; 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 { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; -import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; +import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits'; +import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits'; +import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits'; +import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits'; +import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits'; import { ConnectDecorator } from '../../container/types'; import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; import * as visitsParser from './VisitsParser'; @@ -54,20 +54,25 @@ 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('cancelGetVisits'), '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('cancelGetVisits'), 'tagVisitsReducerCreator'); - bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); + bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator'); + bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), '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('cancelGetVisits'), 'orphanVisitsReducerCreator'); - bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); - bottle.serviceFactory('cancelGetNonOrphanVisits', () => cancelGetNonOrphanVisits); + bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient'); + bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator'); bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); @@ -75,6 +80,21 @@ 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'); + + bottle.serviceFactory('nonOrphanVisitsReducerCreator', nonOrphanVisitsReducerCreator, 'getNonOrphanVisitsCreator'); + bottle.serviceFactory('nonOrphanVisitsReducer', prop('reducer'), 'nonOrphanVisitsReducerCreator'); + + bottle.serviceFactory('orphanVisitsReducerCreator', orphanVisitsReducerCreator, 'getOrphanVisitsCreator'); + bottle.serviceFactory('orphanVisitsReducer', prop('reducer'), 'orphanVisitsReducerCreator'); + + 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/src/visits/types/index.ts b/src/visits/types/index.ts index a110ded0..cb0499aa 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,28 +1,5 @@ -import { Action } from 'redux'; 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 interface VisitsLoadProgressChangedAction extends Action { - progress: number; -} - -export interface VisitsFallbackIntervalAction extends Action { - fallbackInterval: DateInterval; -} +import { DateRange } from '../../utils/dates/types'; export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; diff --git a/test/utils/helpers/redux.test.ts b/test/utils/helpers/redux.test.ts deleted file mode 100644 index 69c0bc82..00000000 --- a/test/utils/helpers/redux.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { Action } from 'redux'; -import { buildActionCreator, 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'); - 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/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 d7f92bbb..da683d16 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, cancelGetVisits: 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, visits: actionVisits } as any, - ); - const { loading, error, visits } = state; + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getDomainVisits.fulfilled.toString(), + payload: { visits: actionVisits }, + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -128,56 +121,51 @@ 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, progress: 85 } as any); + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); 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: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); }); 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)('foo.com')(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([ @@ -185,34 +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, visits, domain, query: query ?? {} }); - expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); + 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(getDomainVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: '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: { @@ -221,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, {}, 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() }))); }); }); diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 285b1e5e..5ba44eed 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -1,56 +1,53 @@ 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, 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(); 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, cancelGetVisits: 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); @@ -58,11 +55,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 { loading, error, visits } = state; + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getNonOrphanVisits.fulfilled.toString(), + payload: { visits: actionVisits }, + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -108,31 +104,28 @@ 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: GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED, progress: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); 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: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); }); 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 }, @@ -141,14 +134,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([ @@ -156,34 +153,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, visits, query: query ?? {} }); - expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); + 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(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: '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: { @@ -192,22 +200,23 @@ describe('nonOrphanVisitsReducer', () => { totalItems: 1, }, }); - const getShlinkOrphanVisits = jest.fn() + getNonOrphanVisitsCall .mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult(lastVisits)); - const ShlinkApiClient = Mock.of({ getNonOrphanVisits: getShlinkOrphanVisits }); - await getNonOrphanVisits(() => ShlinkApiClient)({}, 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() })); }); }); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 5c259a6e..0a58c3b9 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -1,56 +1,53 @@ 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, 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(); 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, cancelGetVisits: 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); @@ -58,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, visits: actionVisits } as any, - ); - const { loading, error, visits } = state; + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getOrphanVisits.fulfilled.toString(), + payload: { visits: actionVisits }, + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -108,47 +104,46 @@ 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, progress: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); 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: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); }); 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([ @@ -156,34 +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, visits, query: query ?? {} }); - expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); + 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(getOrphanVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: '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: { @@ -192,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)({}, undefined, 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() })); }); }); diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index f1134557..01f87e30 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, cancelGetVisits: 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, visits: actionVisits } as any, - ); - const { loading, error, visits } = state; + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getShortUrlVisits.fulfilled.toString(), + payload: { visits: actionVisits }, + }); expect(loading).toEqual(false); expect(error).toEqual(false); @@ -129,47 +124,46 @@ 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, progress: 85 } as any); - + const state = reducer(undefined, { type: progressChangedAction.toString(), payload: 85 }); 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: fallbackToIntervalAction.toString(), payload: fallbackInterval }, + ); expect(state).toEqual(expect.objectContaining({ fallbackInterval })); }); }); 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)('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([ @@ -179,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, visits, shortCode, domain, query: query ?? {} }, - ); - expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); + 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(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: { @@ -211,25 +207,33 @@ describe('shortUrlVisitsReducer', () => { }, })); - await getShortUrlVisits(() => ShlinkApiClient)('abc123')(dispatchMock, getState); + await getShortUrlVisits({ shortCode: 'abc123' })(dispatchMock, getState, {}); - expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(expectedRequests); + expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(expectedRequests); expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({ - visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], + payload: expect.objectContaining({ + visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], + }), })); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 5)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last7Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 200)) })], - { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: '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: { @@ -238,22 +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)('abc123', {}, 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() })); }); }); diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 9e5c9e78..0b3573ca 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, cancelGetVisits: 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, visits: actionVisits } as any, - ); - const { loading, error, visits } = state; + const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), { + type: getTagVisits.fulfilled.toString(), + payload: { visits: actionVisits }, + }); 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, progress: 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, 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)('foo')(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,34 +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, visits, tag, query: query ?? {} }); - expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); + 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(getTagVisitsCall).toHaveBeenCalledTimes(1); }); it.each([ [ [Mock.of({ date: formatISO(subDays(new Date(), 20)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + { type: fallbackToIntervalAction.toString(), payload: 'last30Days' }, + 3, ], [ [Mock.of({ date: formatISO(subDays(new Date(), 100)) })], - { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: '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: { @@ -214,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, {}, 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() })); }); });