Merge pull request #735 from acelaya-forks/feature/visits-rtk

Feature/visits rtk
This commit is contained in:
Alejandro Celaya 2022-11-12 20:46:50 +01:00 committed by GitHub
commit cc620ddf79
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 694 additions and 904 deletions

View file

@ -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

View file

@ -1,7 +0,0 @@
import { Action } from 'redux';
import { ProblemDetailsError } from './errors';
/** @deprecated */
export interface ApiErrorAction extends Action<string> {
errorData?: ProblemDetailsError;
}

View file

@ -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)),
});

View file

@ -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;

View file

@ -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<ShlinkState>({
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,

View file

@ -1,25 +1,6 @@
import { createAsyncThunk as baseCreateAsyncThunk, AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
import { Action } from 'redux';
import { ShlinkState } from '../../container/types';
type ActionHandler<State, AT> = (currentState: State, action: AT) => State;
type ActionHandlerMap<State, AT> = Record<string, ActionHandler<State, AT>>;
/** @deprecated */
export const buildReducer = <State, AT extends Action>(map: ActionHandlerMap<State, AT>, 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 = <T extends string>(type: T) => (): Action<T> => ({ type });
export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState }>,

View file

@ -21,10 +21,6 @@ type Optional<T> = T | null | undefined;
export type OptionalString = Optional<string>;
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
export const nonEmptyValueOrNull = <T>(value: T): T | null => (isEmpty(value) ? null : value);
export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;

View file

@ -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 (

View file

@ -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 (
<VisitsStats

View file

@ -1,20 +1,17 @@
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types';
import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader';
import { VisitsInfo } from './reducers/types';
import { LoadOrphanVisits } from './reducers/orphanVisits';
export interface OrphanVisitsProps extends CommonVisitsProps {
getOrphanVisits: (
params?: ShlinkVisitsParams,
orphanVisitsType?: OrphanVisitType,
doIntervalFallback?: boolean,
) => void;
getOrphanVisits: (params: LoadOrphanVisits) => void;
orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void;
}
@ -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 (
<VisitsStats

View file

@ -1,13 +1,12 @@
import { useEffect } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { parseQuery } from '../utils/helpers/query';
import { Topics } from '../mercure/helpers/Topics';
import { ShortUrlDetail } from '../short-urls/reducers/shortUrlDetail';
import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter';
import { ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import { LoadShortUrlVisits, ShortUrlVisits as ShortUrlVisitsState } from './reducers/shortUrlVisits';
import { ShortUrlVisitsHeader } from './ShortUrlVisitsHeader';
import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, VisitsParams } from './types';
@ -17,7 +16,7 @@ import { urlDecodeShortCode } from '../short-urls/helpers';
import { ShortUrlIdentifier } from '../short-urls/data';
export interface ShortUrlVisitsProps extends CommonVisitsProps {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
getShortUrlVisits: (params: LoadShortUrlVisits) => void;
shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: (shortUrl: ShortUrlIdentifier) => void;
shortUrlDetail: ShortUrlDetail;
@ -37,8 +36,11 @@ export const ShortUrlVisits = ({ exportVisits }: ReportExporter) => boundToMercu
const { search } = useLocation();
const goBack = useGoBack();
const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getShortUrlVisits(urlDecodeShortCode(shortCode), { ...toApiParams(params), domain }, doIntervalFallback);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getShortUrlVisits({
shortCode: urlDecodeShortCode(shortCode),
query: { ...toApiParams(params), domain },
doIntervalFallback,
});
const exportCsv = (visits: NormalizedVisit[]) => exportVisits(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits,

View file

@ -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 (

View file

@ -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;

View file

@ -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<ShlinkVisits>;
type LastVisitLoader = () => Promise<Visit | undefined>;
interface ActionMap {
start: string;
large: string;
finish: string;
error: string;
progress: string;
fallbackToInterval: string;
interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
name: string;
createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader];
getExtraFulfilledPayload: (params: T) => Partial<R>;
shouldCancel: (getState: () => ShlinkState) => boolean;
}
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
visitsLoader: VisitsLoader,
lastVisitLoader: LastVisitLoader,
extraFinishActionData: Partial<T>,
actionMap: ActionMap,
dispatch: Dispatch,
shouldCancel: () => boolean,
export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
{ name, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
) => {
dispatch({ type: actionMap.start });
const progressChangedAction = createAction<number>(`${name}/progressChanged`);
const largeAction = createAction<void>(`${name}/large`);
const fallbackToIntervalAction = createAction<DateInterval>(`${name}/fallbackToInterval`);
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
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<R> => {
const [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => {
if (shouldCancel()) {
return [];
}
const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
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<Visit[]> => {
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<ApiErrorAction>({ 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 = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
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 };
};

View file

@ -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<string> {
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<DomainVisits, DomainVisitsCombinedAction>({
[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<DomainVisitsAction> = { 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<typeof getDomainVisits>,
) => 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),
);
},
);

View file

@ -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<string> {
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<VisitsInfo, NonOrphanVisitsCombinedAction>({
[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<NonOrphanVisitsAction> = { 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<typeof getNonOrphanVisits>,
) => createVisitsReducer(
REDUCER_PREFIX,
getVisitsCreator,
initialState,
({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
},
);

View file

@ -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<string> {
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<VisitsInfo, OrphanVisitsCombinedAction>({
[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<OrphanVisitsAction> = { 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<typeof getOrphanVisits>,
) => createVisitsReducer(
REDUCER_PREFIX,
getVisitsCreator,
initialState,
({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate));
},
);

View file

@ -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<string>, 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<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[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<ShortUrlVisitsAction> = { 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<typeof getShortUrlVisits>,
) => 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),
);
},
);

View file

@ -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<string> {
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<TagVisits, TagsVisitsCombinedAction>({
[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<TagVisitsAction> = { 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<typeof getTagVisits>) => 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),
);
},
);

View file

@ -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 = {}> = T & {
visits: Visit[];
query?: ShlinkVisitsParams;
};

View file

@ -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;

View file

@ -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<string> {
progress: number;
}
export interface VisitsFallbackIntervalAction extends Action<string> {
fallbackInterval: DateInterval;
}
import { DateRange } from '../../utils/dates/types';
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';

View file

@ -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<string, Action>({
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());
});
});
});

View file

@ -38,7 +38,7 @@ describe('<DomainVisits />', () => {
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 () => {

View file

@ -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('<NonOrphanVisits />', () => {
const exportVisits = jest.fn();

View file

@ -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('<OrphanVisits />', () => {
const getOrphanVisits = jest.fn();

View file

@ -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('<VisitsStats />', () => {
const visits = rangeOf(3, () => Mock.of<Visit>({ date: '2020-01-01' }));

View file

@ -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<Visit>());
const getDomainVisitsCall = jest.fn();
const buildApiClientMock = () => Mock.of<ShlinkApiClient>({ 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<DomainVisits>) => Mock.of<DomainVisits>(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<ShortUrl>({ 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<ShlinkVisits> | ((shortCode: string, query: any) => Promise<ShlinkVisits>);
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
getDomainVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({
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<Visit>({ date: formatISO(subDays(new Date(), 20)) })],
{ type: GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' },
{ type: fallbackToIntervalAction.toString(), payload: 'last30Days' },
3,
],
[
[Mock.of<Visit>({ 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<ShlinkApiClient>({ 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() })));
});
});

View file

@ -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<Visit>());
const getNonOrphanVisitsCall = jest.fn();
const buildShlinkApiClient = () => Mock.of<ShlinkApiClient>({ 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<VisitsInfo>) => Mock.of<VisitsInfo>(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<ShlinkVisits> | ((query: any) => Promise<ShlinkVisits>);
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
getNonOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({
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<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
{ type: GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' },
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ 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<ShlinkApiClient>({ 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() }));
});
});

View file

@ -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<Visit>());
const getOrphanVisitsCall = jest.fn();
const buildShlinkApiClientMock = () => Mock.of<ShlinkApiClient>({ 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<VisitsInfo>) => Mock.of<VisitsInfo>(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<ShlinkVisits> | ((query: any) => Promise<ShlinkVisits>);
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
getOrphanVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({
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<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
{ type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' },
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ 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<ShlinkApiClient>({ 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() }));
});
});

View file

@ -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<Visit>());
const getShortUrlVisitsCall = jest.fn();
const buildApiClientMock = () => Mock.of<ShlinkApiClient>({ 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<ShortUrlVisits>) => Mock.of<ShortUrlVisits>(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<ShlinkVisits> | ((shortCode: string, query: any) => Promise<ShlinkVisits>);
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
getShortUrlVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({
shortUrlVisits: Mock.of<ShortUrlVisits>({ 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<Visit>({ date: formatISO(subDays(new Date(), 5)) })],
{ type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' },
{ type: fallbackToIntervalAction.toString(), payload: 'last7Days' },
3,
],
[
[Mock.of<Visit>({ 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<ShlinkApiClient>({ 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() }));
});
});

View file

@ -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<Visit>());
const getTagVisitsCall = jest.fn();
const buildShlinkApiClientMock = () => Mock.of<ShlinkApiClient>({ 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<TagVisits>) => Mock.of<TagVisits>(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<ShlinkVisits> | ((shortCode: string, query: any) => Promise<ShlinkVisits>);
const buildApiClientMock = (returned: GetVisitsReturn) => Mock.of<ShlinkApiClient>({
getTagVisits: jest.fn(typeof returned === 'function' ? returned : async () => returned),
});
const dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({
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<Visit>({ date: formatISO(subDays(new Date(), 20)) })],
{ type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' },
{ type: fallbackToIntervalAction.toString(), payload: 'last30Days' },
3,
],
[
[Mock.of<Visit>({ 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<ShlinkApiClient>({ 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() }));
});
});