mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 18:27:25 +03:00
Merge pull request #735 from acelaya-forks/feature/visits-rtk
Feature/visits rtk
This commit is contained in:
commit
cc620ddf79
32 changed files with 694 additions and 904 deletions
|
@ -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
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
import { Action } from 'redux';
|
||||
import { ProblemDetailsError } from './errors';
|
||||
|
||||
/** @deprecated */
|
||||
export interface ApiErrorAction extends Action<string> {
|
||||
errorData?: ProblemDetailsError;
|
||||
}
|
|
@ -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)),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 }>,
|
||||
|
|
|
@ -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)}`;
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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));
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
26
src/visits/reducers/types/index.ts
Normal file
26
src/visits/reducers/types/index.ts
Normal 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;
|
||||
};
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
||||
|
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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 () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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' }));
|
||||
|
|
|
@ -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() })));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() }));
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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() }));
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue