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

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

View File

@ -1,8 +1,11 @@
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import 'jest-canvas-mock'; import 'jest-canvas-mock';
import ResizeObserver from 'resize-observer-polyfill'; import ResizeObserver from 'resize-observer-polyfill';
import { setAutoFreeze } from 'immer';
(global as any).ResizeObserver = ResizeObserver; (global as any).ResizeObserver = ResizeObserver;
(global as any).scrollTo = () => {}; (global as any).scrollTo = () => {};
(global as any).prompt = () => {}; (global as any).prompt = () => {};
(global as any).matchMedia = (media: string) => ({ matches: false, media }); (global as any).matchMedia = (media: string) => ({ matches: false, media });
setAutoFreeze(false); // TODO Bypassing a bug on jest

View File

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

View File

@ -19,7 +19,7 @@ export const setUpStore = (container: IContainer) => configureStore({
reducer: reducer(container), reducer: reducer(container),
preloadedState, preloadedState,
middleware: (defaultMiddlewaresIncludingReduxThunk) => 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) .prepend(container.selectServerListener.middleware)
.concat(save(localStorageConfig)), .concat(save(localStorageConfig)),
}); });

View File

@ -13,9 +13,9 @@ import { ShortUrlVisits } from '../visits/reducers/shortUrlVisits';
import { TagVisits } from '../visits/reducers/tagVisits'; import { TagVisits } from '../visits/reducers/tagVisits';
import { DomainsList } from '../domains/reducers/domainsList'; import { DomainsList } from '../domains/reducers/domainsList';
import { VisitsOverview } from '../visits/reducers/visitsOverview'; import { VisitsOverview } from '../visits/reducers/visitsOverview';
import { VisitsInfo } from '../visits/types';
import { Sidebar } from '../common/reducers/sidebar'; import { Sidebar } from '../common/reducers/sidebar';
import { DomainVisits } from '../visits/reducers/domainVisits'; import { DomainVisits } from '../visits/reducers/domainVisits';
import { VisitsInfo } from '../visits/reducers/types';
export interface ShlinkState { export interface ShlinkState {
servers: ServersMap; servers: ServersMap;

View File

@ -1,11 +1,6 @@
import { IContainer } from 'bottlejs'; import { IContainer } from 'bottlejs';
import { combineReducers } from '@reduxjs/toolkit'; import { combineReducers } from '@reduxjs/toolkit';
import { serversReducer } from '../servers/reducers/servers'; 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 { settingsReducer } from '../settings/reducers/settings';
import { appUpdatesReducer } from '../app/reducers/appUpdates'; import { appUpdatesReducer } from '../app/reducers/appUpdates';
import { sidebarReducer } from '../common/reducers/sidebar'; import { sidebarReducer } from '../common/reducers/sidebar';
@ -19,11 +14,11 @@ export default (container: IContainer) => combineReducers<ShlinkState>({
shortUrlDeletion: container.shortUrlDeletionReducer, shortUrlDeletion: container.shortUrlDeletionReducer,
shortUrlEdition: container.shortUrlEditionReducer, shortUrlEdition: container.shortUrlEditionReducer,
shortUrlDetail: container.shortUrlDetailReducer, shortUrlDetail: container.shortUrlDetailReducer,
shortUrlVisits: shortUrlVisitsReducer, shortUrlVisits: container.shortUrlVisitsReducer,
tagVisits: tagVisitsReducer, tagVisits: container.tagVisitsReducer,
domainVisits: domainVisitsReducer, domainVisits: container.domainVisitsReducer,
orphanVisits: orphanVisitsReducer, orphanVisits: container.orphanVisitsReducer,
nonOrphanVisits: nonOrphanVisitsReducer, nonOrphanVisits: container.nonOrphanVisitsReducer,
tagsList: container.tagsListReducer, tagsList: container.tagsListReducer,
tagDelete: container.tagDeleteReducer, tagDelete: container.tagDeleteReducer,
tagEdit: container.tagEditReducer, tagEdit: container.tagEditReducer,

View File

@ -1,25 +1,6 @@
import { createAsyncThunk as baseCreateAsyncThunk, AsyncThunkPayloadCreator } from '@reduxjs/toolkit'; import { createAsyncThunk as baseCreateAsyncThunk, AsyncThunkPayloadCreator } from '@reduxjs/toolkit';
import { Action } from 'redux';
import { ShlinkState } from '../../container/types'; 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>( export const createAsyncThunk = <Returned, ThunkArg>(
typePrefix: string, typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState }>, payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, { state: ShlinkState }>,

View File

@ -21,10 +21,6 @@ type Optional<T> = T | null | undefined;
export type OptionalString = Optional<string>; export type 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 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)}`; export const capitalize = <T extends string>(value: T): string => `${value.charAt(0).toUpperCase()}${value.slice(1)}`;

View File

@ -1,7 +1,7 @@
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { CommonVisitsProps } from './types/CommonVisitsProps'; import { CommonVisitsProps } from './types/CommonVisitsProps';
import { ShlinkVisitsParams } from '../api/types'; 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 { ReportExporter } from '../common/services/ReportExporter';
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
@ -12,7 +12,7 @@ import { VisitsStats } from './VisitsStats';
import { VisitsHeader } from './VisitsHeader'; import { VisitsHeader } from './VisitsHeader';
export interface DomainVisitsProps extends CommonVisitsProps { export interface DomainVisitsProps extends CommonVisitsProps {
getDomainVisits: (domain: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; getDomainVisits: (params: LoadDomainVisits) => void;
domainVisits: DomainVisitsState; domainVisits: DomainVisitsState;
cancelGetDomainVisits: () => void; cancelGetDomainVisits: () => void;
} }
@ -28,7 +28,7 @@ export const DomainVisits = ({ exportVisits }: ReportExporter) => boundToMercure
const { domain = '' } = useParams(); const { domain = '' } = useParams();
const [authority, domainId = authority] = domain.split('_'); const [authority, domainId = authority] = domain.split('_');
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => 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); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`domain_${authority}_visits.csv`, visits);
return ( return (

View File

@ -1,16 +1,16 @@
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter'; import { ReportExporter } from '../common/services/ReportExporter';
import { VisitsStats } from './VisitsStats'; import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, VisitsInfo, VisitsParams } from './types'; import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps'; import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader'; import { VisitsHeader } from './VisitsHeader';
import { LoadVisits, VisitsInfo } from './reducers/types';
export interface NonOrphanVisitsProps extends CommonVisitsProps { export interface NonOrphanVisitsProps extends CommonVisitsProps {
getNonOrphanVisits: (params?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; getNonOrphanVisits: (params: LoadVisits) => void;
nonOrphanVisits: VisitsInfo; nonOrphanVisits: VisitsInfo;
cancelGetNonOrphanVisits: () => void; cancelGetNonOrphanVisits: () => void;
} }
@ -25,7 +25,7 @@ export const NonOrphanVisits = ({ exportVisits }: ReportExporter) => boundToMerc
const goBack = useGoBack(); const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('non_orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getNonOrphanVisits(toApiParams(params), doIntervalFallback); getNonOrphanVisits({ query: toApiParams(params), doIntervalFallback });
return ( return (
<VisitsStats <VisitsStats

View File

@ -1,20 +1,17 @@
import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub'; import { boundToMercureHub } from '../mercure/helpers/boundToMercureHub';
import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter'; import { ReportExporter } from '../common/services/ReportExporter';
import { VisitsStats } from './VisitsStats'; import { VisitsStats } from './VisitsStats';
import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types'; import { NormalizedVisit, VisitsParams } from './types';
import { CommonVisitsProps } from './types/CommonVisitsProps'; import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
import { VisitsHeader } from './VisitsHeader'; import { VisitsHeader } from './VisitsHeader';
import { VisitsInfo } from './reducers/types';
import { LoadOrphanVisits } from './reducers/orphanVisits';
export interface OrphanVisitsProps extends CommonVisitsProps { export interface OrphanVisitsProps extends CommonVisitsProps {
getOrphanVisits: ( getOrphanVisits: (params: LoadOrphanVisits) => void;
params?: ShlinkVisitsParams,
orphanVisitsType?: OrphanVisitType,
doIntervalFallback?: boolean,
) => void;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void; cancelGetOrphanVisits: () => void;
} }
@ -28,8 +25,9 @@ export const OrphanVisits = ({ exportVisits }: ReportExporter) => boundToMercure
}: OrphanVisitsProps) => { }: OrphanVisitsProps) => {
const goBack = useGoBack(); const goBack = useGoBack();
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) => getOrphanVisits(
getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback); { query: toApiParams(params), orphanVisitsType: params.filter?.orphanVisitsType, doIntervalFallback },
);
return ( return (
<VisitsStats <VisitsStats

View File

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

View File

@ -5,7 +5,7 @@ import { ShlinkVisitsParams } from '../api/types';
import { Topics } from '../mercure/helpers/Topics'; import { Topics } from '../mercure/helpers/Topics';
import { useGoBack } from '../utils/helpers/hooks'; import { useGoBack } from '../utils/helpers/hooks';
import { ReportExporter } from '../common/services/ReportExporter'; 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 { TagVisitsHeader } from './TagVisitsHeader';
import { VisitsStats } from './VisitsStats'; import { VisitsStats } from './VisitsStats';
import { NormalizedVisit } from './types'; import { NormalizedVisit } from './types';
@ -13,7 +13,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
export interface TagVisitsProps extends CommonVisitsProps { export interface TagVisitsProps extends CommonVisitsProps {
getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void; getTagVisits: (params: LoadTagVisits) => void;
tagVisits: TagVisitsState; tagVisits: TagVisitsState;
cancelGetTagVisits: () => void; cancelGetTagVisits: () => void;
} }
@ -28,7 +28,7 @@ export const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: Repo
const goBack = useGoBack(); const goBack = useGoBack();
const { tag = '' } = useParams(); const { tag = '' } = useParams();
const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) => 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); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits);
return ( return (

View File

@ -19,13 +19,14 @@ import { NavPillItem, NavPills } from '../utils/NavPills';
import { ExportBtn } from '../utils/ExportBtn'; import { ExportBtn } from '../utils/ExportBtn';
import { LineChartCard } from './charts/LineChartCard'; import { LineChartCard } from './charts/LineChartCard';
import { VisitsTable } from './VisitsTable'; 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 { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
import { HighlightableProps, highlightedVisitsToStats } from './types/helpers'; import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
import { DoughnutChartCard } from './charts/DoughnutChartCard'; import { DoughnutChartCard } from './charts/DoughnutChartCard';
import { SortableBarChartCard } from './charts/SortableBarChartCard'; import { SortableBarChartCard } from './charts/SortableBarChartCard';
import { VisitsInfo } from './reducers/types';
export type VisitsStatsProps = PropsWithChildren<{ export type VisitsStatsProps = PropsWithChildren<{
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;

View File

@ -1,10 +1,13 @@
import { flatten, prop, range, splitEvery } from 'ramda'; 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 { 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 { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { createNewVisits } from './visitCreation';
import { dateToMatchingInterval } from '../../utils/dates/types';
const ITEMS_PER_PAGE = 5000; const ITEMS_PER_PAGE = 5000;
const PARALLEL_REQUESTS_COUNT = 4; 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 VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
type LastVisitLoader = () => Promise<Visit | undefined>; type LastVisitLoader = () => Promise<Visit | undefined>;
interface ActionMap {
start: string; interface VisitsAsyncThunkOptions<T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded> {
large: string; name: string;
finish: string; createLoaders: (params: T, getState: () => ShlinkState) => [VisitsLoader, LastVisitLoader];
error: string; getExtraFulfilledPayload: (params: T) => Partial<R>;
progress: string; shouldCancel: (getState: () => ShlinkState) => boolean;
fallbackToInterval: string;
} }
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>( export const createVisitsAsyncThunk = <T extends LoadVisits = LoadVisits, R extends VisitsLoaded = VisitsLoaded>(
visitsLoader: VisitsLoader, { name, createLoaders, getExtraFulfilledPayload, shouldCancel }: VisitsAsyncThunkOptions<T, R>,
lastVisitLoader: LastVisitLoader,
extraFinishActionData: Partial<T>,
actionMap: ActionMap,
dispatch: Dispatch,
shouldCancel: () => boolean,
) => { ) => {
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[]> => const asyncThunk = createAsyncThunk(name, async (params: T, { getState, dispatch }): Promise<R> => {
Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten); const [visitsLoader, lastVisitLoader] = createLoaders(params, getState);
const loadPagesBlocks = async (pagesBlocks: number[][], index = 0): Promise<Visit[]> => { const loadVisitsInParallel = async (pages: number[]): Promise<Visit[]> =>
if (shouldCancel()) { Promise.all(pages.map(async (page) => visitsLoader(page, ITEMS_PER_PAGE).then(prop('data')))).then(flatten);
return [];
}
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) { dispatch(progressChangedAction(calcProgress(pagesBlocks.length, index + PARALLEL_STARTING_PAGE)));
return data.concat(await loadPagesBlocks(pagesBlocks, index + 1));
}
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; return data;
} };
// If there are more pages, make requests in blocks of 4 const loadVisits = async (page = 1) => {
const pagesRange = range(PARALLEL_STARTING_PAGE, pagination.pagesCount + 1); const { pagination, data } = await visitsLoader(page, ITEMS_PER_PAGE);
const pagesBlocks = splitEvery(PARALLEL_REQUESTS_COUNT, pagesRange);
if (pagination.pagesCount - 1 > PARALLEL_REQUESTS_COUNT) { // If pagination was not returned, then this is an old shlink version. Just return data
dispatch({ type: actionMap.large }); 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()]); const [visits, lastVisit] = await Promise.all([loadVisits(), lastVisitLoader()]);
dispatch( if (!visits.length && lastVisit) {
!visits.length && lastVisit dispatch(fallbackToIntervalAction(dateToMatchingInterval(lastVisit.date)));
? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) } }
: { ...extraFinishActionData, visits, type: actionMap.finish },
); return { ...getExtraFulfilledPayload(params), visits } as any; // TODO Get rid of this casting
} catch (e: any) { });
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
} return { asyncThunk, progressChangedAction, largeAction, fallbackToIntervalAction };
}; };
export const lastVisitLoaderForLoader = ( export const lastVisitLoaderForLoader = (
@ -93,5 +94,47 @@ export const lastVisitLoaderForLoader = (
return async () => Promise.resolve(undefined); return async () => Promise.resolve(undefined);
} }
return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]); return async () => loader({ page: 1, itemsPerPage: 1 }).then(({ data }) => data[0]);
};
export const createVisitsReducer = <State extends VisitsInfo, AT extends ReturnType<typeof createVisitsAsyncThunk>>(
name: string,
asyncThunkCreator: AT,
initialState: State,
filterCreatedVisits: (state: State, createdVisits: CreateVisit[]) => CreateVisit[],
) => {
const { asyncThunk, largeAction, fallbackToIntervalAction, progressChangedAction } = asyncThunkCreator;
const { reducer, actions } = createSlice({
name,
initialState,
reducers: {
cancelGetVisits: (state) => ({ ...state, cancelLoad: true }),
},
extraReducers: (builder) => {
builder.addCase(asyncThunk.pending, () => ({ ...initialState, loading: true }));
builder.addCase(asyncThunk.rejected, (_, { error }) => (
{ ...initialState, error: true, errorData: parseApiError(error) }
));
builder.addCase(asyncThunk.fulfilled, (state, { payload }) => (
{ ...state, ...payload, loading: false, loadingLarge: false, error: false }
));
builder.addCase(largeAction, (state) => ({ ...state, loadingLarge: true }));
builder.addCase(progressChangedAction, (state, { payload: progress }) => ({ ...state, progress }));
builder.addCase(fallbackToIntervalAction, (state, { payload: fallbackInterval }) => (
{ ...state, fallbackInterval }
));
builder.addCase(createNewVisits, (state, { payload }) => {
const { visits } = state;
// @ts-expect-error TODO Fix the state inferred type
const newVisits = filterCreatedVisits(state, payload.createdVisits).map(({ visit }) => visit);
return { ...state, visits: [...newVisits, ...visits] };
});
},
});
const { cancelGetVisits } = actions;
return { reducer, cancelGetVisits };
}; };

View File

@ -1,40 +1,20 @@
import { Action, Dispatch } from 'redux';
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { 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 { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import { createNewVisits, CreateVisitsAction } from './visitCreation';
import { domainMatches } from '../../short-urls/helpers'; import { domainMatches } from '../../short-urls/helpers';
import { LoadVisits, VisitsInfo } from './types';
export const GET_DOMAIN_VISITS_START = 'shlink/domainVisits/GET_DOMAIN_VISITS_START'; const REDUCER_PREFIX = 'shlink/domainVisits';
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';
export const DEFAULT_DOMAIN = 'DEFAULT'; export const DEFAULT_DOMAIN = 'DEFAULT';
export interface DomainVisits extends VisitsInfo { interface WithDomain {
domain: string; domain: string;
} }
export interface DomainVisitsAction extends Action<string> { export interface DomainVisits extends VisitsInfo, WithDomain {}
visits: Visit[];
domain: string;
query?: ShlinkVisitsParams;
}
type DomainVisitsCombinedAction = DomainVisitsAction export interface LoadDomainVisits extends LoadVisits, WithDomain {}
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
const initialState: DomainVisits = { const initialState: DomainVisits = {
visits: [], visits: [],
@ -46,51 +26,34 @@ const initialState: DomainVisits = {
progress: 0, progress: 0,
}; };
export default buildReducer<DomainVisits, DomainVisitsCombinedAction>({ export const getDomainVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
[GET_DOMAIN_VISITS_START]: () => ({ ...initialState, loading: true }), name: `${REDUCER_PREFIX}/getDomainVisits`,
[GET_DOMAIN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), createLoaders: ({ domain, query = {}, doIntervalFallback = false }: LoadDomainVisits, getState) => {
[GET_DOMAIN_VISITS]: (state, { visits, domain, query }) => ( const { getDomainVisits: getVisits } = buildShlinkApiClient(getState);
{ ...state, visits, domain, query, loading: false, loadingLarge: false, error: false } const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
), domain,
[GET_DOMAIN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), { ...query, page, itemsPerPage },
[GET_DOMAIN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), );
[GET_DOMAIN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params));
[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);
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) => ( export const domainVisitsReducerCreator = (
domain: string, getVisitsCreator: ReturnType<typeof getDomainVisits>,
query: ShlinkVisitsParams = {}, ) => createVisitsReducer(
doIntervalFallback = false, REDUCER_PREFIX,
) => async (dispatch: Dispatch, getState: GetState) => { // @ts-expect-error TODO Fix type inference
const { getDomainVisits: getVisits } = buildShlinkApiClient(getState); getVisitsCreator,
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( initialState,
domain, ({ domain, query = {} }, createdVisits) => {
{ ...query, page, itemsPerPage }, const { startDate, endDate } = query;
); return createdVisits.filter(
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(domain, params)); ({ shortUrl, visit }) =>
const shouldCancel = () => getState().domainVisits.cancelLoad; shortUrl && domainMatches(shortUrl, domain) && isBetween(visit.date, startDate, endDate),
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);

View File

@ -1,37 +1,9 @@
import { Action, Dispatch } from 'redux';
import {
Visit,
VisitsFallbackIntervalAction,
VisitsInfo,
VisitsLoadProgressChangedAction,
} from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { 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 { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { VisitsInfo } from './types';
export const GET_NON_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_NON_ORPHAN_VISITS_START'; const REDUCER_PREFIX = 'shlink/orphanVisits';
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 initialState: VisitsInfo = { const initialState: VisitsInfo = {
visits: [], visits: [],
@ -42,47 +14,28 @@ const initialState: VisitsInfo = {
progress: 0, progress: 0,
}; };
export default buildReducer<VisitsInfo, NonOrphanVisitsCombinedAction>({ export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
[GET_NON_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), name: `${REDUCER_PREFIX}/getNonOrphanVisits`,
[GET_NON_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), createLoaders: ({ query = {}, doIntervalFallback = false }, getState) => {
[GET_NON_ORPHAN_VISITS]: (state, { visits, query }) => ( const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState);
{ ...state, visits, query, loading: false, loadingLarge: false, error: false } const visitsLoader = async (page: number, itemsPerPage: number) =>
), shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage });
[GET_NON_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits);
[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);
return { ...state, visits: [...newVisits, ...visits] }; return [visitsLoader, lastVisitLoader];
}, },
}, initialState); getExtraFulfilledPayload: ({ query = {} }) => ({ query }),
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
});
export const getNonOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const nonOrphanVisitsReducerCreator = (
query: ShlinkVisitsParams = {}, getVisitsCreator: ReturnType<typeof getNonOrphanVisits>,
doIntervalFallback = false, ) => createVisitsReducer(
) => async (dispatch: Dispatch, getState: GetState) => { REDUCER_PREFIX,
const { getNonOrphanVisits: shlinkGetNonOrphanVisits } = buildShlinkApiClient(getState); getVisitsCreator,
const visitsLoader = async (page: number, itemsPerPage: number) => initialState,
shlinkGetNonOrphanVisits({ ...query, page, itemsPerPage }); ({ query = {} }, createdVisits) => {
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, shlinkGetNonOrphanVisits); const { startDate, endDate } = query;
const shouldCancel = () => getState().orphanVisits.cancelLoad; return createdVisits.filter(({ visit }) => isBetween(visit.date, startDate, endDate));
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);

View File

@ -1,41 +1,16 @@
import { Action, Dispatch } from 'redux'; import { OrphanVisit, OrphanVisitType } from '../types';
import {
OrphanVisit,
OrphanVisitType,
Visit,
VisitsFallbackIntervalAction,
VisitsInfo,
VisitsLoadProgressChangedAction,
} from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers'; import { isOrphanVisit } from '../types/helpers';
import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date'; import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { LoadVisits, VisitsInfo } from './types';
export const GET_ORPHAN_VISITS_START = 'shlink/orphanVisits/GET_ORPHAN_VISITS_START'; const REDUCER_PREFIX = 'shlink/orphanVisits';
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';
export interface OrphanVisitsAction extends Action<string> { export interface LoadOrphanVisits extends LoadVisits {
visits: Visit[]; orphanVisitsType?: OrphanVisitType;
query?: ShlinkVisitsParams;
} }
type OrphanVisitsCombinedAction = OrphanVisitsAction
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
const initialState: VisitsInfo = { const initialState: VisitsInfo = {
visits: [], visits: [],
loading: false, loading: false,
@ -45,55 +20,35 @@ const initialState: VisitsInfo = {
progress: 0, 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) => const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
!orphanVisitsType || orphanVisitsType === visit.type; !orphanVisitsType || orphanVisitsType === visit.type;
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
query: ShlinkVisitsParams = {}, name: `${REDUCER_PREFIX}/getOrphanVisits`,
orphanVisitsType?: OrphanVisitType, createLoaders: ({ orphanVisitsType, query = {}, doIntervalFallback = false }: LoadOrphanVisits, getState) => {
doIntervalFallback = false, const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState);
) => async (dispatch: Dispatch, getState: GetState) => { const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage })
const { getOrphanVisits: getVisits } = buildShlinkApiClient(getState); .then((result) => {
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits({ ...query, page, itemsPerPage }) const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
.then((result) => {
const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType));
return { ...result, data: visits }; return { ...result, data: visits };
}); });
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getVisits); 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 getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); return [visitsLoader, lastVisitLoader];
}; },
getExtraFulfilledPayload: ({ query = {} }: LoadOrphanVisits) => ({ query }),
shouldCancel: (getState) => getState().orphanVisits.cancelLoad,
});
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); export const orphanVisitsReducerCreator = (
getVisitsCreator: ReturnType<typeof getOrphanVisits>,
) => createVisitsReducer(
REDUCER_PREFIX,
getVisitsCreator,
initialState,
({ query = {} }, createdVisits) => {
const { startDate, endDate } = query;
return createdVisits.filter(({ visit, shortUrl }) => !shortUrl && isBetween(visit.date, startDate, endDate));
},
);

View File

@ -1,37 +1,18 @@
import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../../short-urls/helpers'; import { shortUrlMatches } from '../../short-urls/helpers';
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
import { ShortUrlIdentifier } from '../../short-urls/data'; import { ShortUrlIdentifier } from '../../short-urls/data';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; 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 { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { LoadVisits, VisitsInfo } from './types';
export const GET_SHORT_URL_VISITS_START = 'shlink/shortUrlVisits/GET_SHORT_URL_VISITS_START'; const REDUCER_PREFIX = 'shlink/shortUrlVisits';
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';
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier { export interface LoadShortUrlVisits extends LoadVisits {
visits: Visit[]; shortCode: string;
query?: ShlinkVisitsParams;
} }
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
const initialState: ShortUrlVisits = { const initialState: ShortUrlVisits = {
visits: [], visits: [],
shortCode: '', shortCode: '',
@ -43,63 +24,39 @@ const initialState: ShortUrlVisits = {
progress: 0, progress: 0,
}; };
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), name: `${REDUCER_PREFIX}/getShortUrlVisits`,
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), createLoaders: ({ shortCode, query = {}, doIntervalFallback = false }: LoadShortUrlVisits, getState) => {
[GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({ const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState);
...state, const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits(
visits, shortCode,
shortCode, { ...query, page, itemsPerPage },
domain, );
query, const lastVisitLoader = lastVisitLoaderForLoader(
loading: false, doIntervalFallback,
loadingLarge: false, async (params) => shlinkGetShortUrlVisits(shortCode, { ...params, domain: query.domain }),
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);
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) => ( export const shortUrlVisitsReducerCreator = (
shortCode: string, getVisitsCreator: ReturnType<typeof getShortUrlVisits>,
query: ShlinkVisitsParams = {}, ) => createVisitsReducer(
doIntervalFallback = false, REDUCER_PREFIX,
) => async (dispatch: Dispatch, getState: GetState) => { // @ts-expect-error TODO Fix type inference
const { getShortUrlVisits: shlinkGetShortUrlVisits } = buildShlinkApiClient(getState); getVisitsCreator,
const visitsLoader = async (page: number, itemsPerPage: number) => shlinkGetShortUrlVisits( initialState,
shortCode, ({ shortCode, domain, query = {} }, createdVisits) => {
{ ...query, page, itemsPerPage }, const { startDate, endDate } = query;
); return createdVisits.filter(
const lastVisitLoader = lastVisitLoaderForLoader( ({ shortUrl, visit }) =>
doIntervalFallback, shortUrl && shortUrlMatches(shortUrl, shortCode, domain) && isBetween(visit.date, startDate, endDate),
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);

View File

@ -1,37 +1,17 @@
import { Action, Dispatch } from 'redux';
import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { 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 { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common'; import { createVisitsAsyncThunk, createVisitsReducer, lastVisitLoaderForLoader } from './common';
import { createNewVisits, CreateVisitsAction } from './visitCreation'; import { LoadVisits, VisitsInfo } from './types';
export const GET_TAG_VISITS_START = 'shlink/tagVisits/GET_TAG_VISITS_START'; const REDUCER_PREFIX = 'shlink/tagVisits';
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';
export interface TagVisits extends VisitsInfo { interface WithTag {
tag: string; tag: string;
} }
export interface TagVisitsAction extends Action<string> { export interface TagVisits extends VisitsInfo, WithTag {}
visits: Visit[];
tag: string;
query?: ShlinkVisitsParams;
}
type TagsVisitsCombinedAction = TagVisitsAction export interface LoadTagVisits extends LoadVisits, WithTag {}
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
const initialState: TagVisits = { const initialState: TagVisits = {
visits: [], visits: [],
@ -43,50 +23,31 @@ const initialState: TagVisits = {
progress: 0, progress: 0,
}; };
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => createVisitsAsyncThunk({
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), name: `${REDUCER_PREFIX}/getTagVisits`,
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), createLoaders: ({ tag, query = {}, doIntervalFallback = false }: LoadTagVisits, getState) => {
[GET_TAG_VISITS]: (state, { visits, tag, query }) => ( const { getTagVisits: getVisits } = buildShlinkApiClient(getState);
{ ...state, visits, tag, query, loading: false, loadingLarge: false, error: false } const visitsLoader = async (page: number, itemsPerPage: number) => getVisits(
), tag,
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), { ...query, page, itemsPerPage },
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), );
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getVisits(tag, params));
[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);
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) => ( export const tagVisitsReducerCreator = (getTagVisitsCreator: ReturnType<typeof getTagVisits>) => createVisitsReducer(
tag: string, REDUCER_PREFIX,
query: ShlinkVisitsParams = {}, // @ts-expect-error TODO Fix type inference
doIntervalFallback = false, getTagVisitsCreator,
) => async (dispatch: Dispatch, getState: GetState) => { initialState,
const { getTagVisits: getVisits } = buildShlinkApiClient(getState); ({ tag, query = {} }, createdVisits) => {
const visitsLoader = async (page: number, itemsPerPage: number) => getVisits( const { startDate, endDate } = query;
tag, return createdVisits.filter(
{ ...query, page, itemsPerPage }, ({ shortUrl, visit }) => shortUrl?.tags.includes(tag) && isBetween(visit.date, startDate, endDate),
); );
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);

View File

@ -0,0 +1,26 @@
import { ShlinkVisitsParams } from '../../../api/types';
import { DateInterval } from '../../../utils/dates/types';
import { ProblemDetailsError } from '../../../api/types/errors';
import { Visit } from '../../types';
export interface VisitsInfo {
visits: Visit[];
loading: boolean;
loadingLarge: boolean;
error: boolean;
errorData?: ProblemDetailsError;
progress: number;
cancelLoad: boolean;
query?: ShlinkVisitsParams;
fallbackInterval?: DateInterval;
}
export interface LoadVisits {
query?: ShlinkVisitsParams;
doIntervalFallback?: boolean;
}
export type VisitsLoaded<T = {}> = T & {
visits: Visit[];
query?: ShlinkVisitsParams;
};

View File

@ -6,11 +6,11 @@ import { ShortUrlVisits } from '../ShortUrlVisits';
import { TagVisits } from '../TagVisits'; import { TagVisits } from '../TagVisits';
import { OrphanVisits } from '../OrphanVisits'; import { OrphanVisits } from '../OrphanVisits';
import { NonOrphanVisits } from '../NonOrphanVisits'; import { NonOrphanVisits } from '../NonOrphanVisits';
import { cancelGetShortUrlVisits, getShortUrlVisits } from '../reducers/shortUrlVisits'; import { getShortUrlVisits, shortUrlVisitsReducerCreator } from '../reducers/shortUrlVisits';
import { cancelGetTagVisits, getTagVisits } from '../reducers/tagVisits'; import { getTagVisits, tagVisitsReducerCreator } from '../reducers/tagVisits';
import { cancelGetDomainVisits, getDomainVisits } from '../reducers/domainVisits'; import { getDomainVisits, domainVisitsReducerCreator } from '../reducers/domainVisits';
import { cancelGetOrphanVisits, getOrphanVisits } from '../reducers/orphanVisits'; import { getOrphanVisits, orphanVisitsReducerCreator } from '../reducers/orphanVisits';
import { cancelGetNonOrphanVisits, getNonOrphanVisits } from '../reducers/nonOrphanVisits'; import { getNonOrphanVisits, nonOrphanVisitsReducerCreator } from '../reducers/nonOrphanVisits';
import { ConnectDecorator } from '../../container/types'; import { ConnectDecorator } from '../../container/types';
import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview'; import { loadVisitsOverview, visitsOverviewReducerCreator } from '../reducers/visitsOverview';
import * as visitsParser from './VisitsParser'; import * as visitsParser from './VisitsParser';
@ -54,20 +54,25 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
bottle.serviceFactory('VisitsParser', () => visitsParser); bottle.serviceFactory('VisitsParser', () => visitsParser);
// Actions // Actions
bottle.serviceFactory('getShortUrlVisits', getShortUrlVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getShortUrlVisitsCreator', getShortUrlVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetShortUrlVisits', () => cancelGetShortUrlVisits); bottle.serviceFactory('getShortUrlVisits', prop('asyncThunk'), 'getShortUrlVisitsCreator');
bottle.serviceFactory('cancelGetShortUrlVisits', prop('cancelGetVisits'), 'shortUrlVisitsReducerCreator');
bottle.serviceFactory('getTagVisits', getTagVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getTagVisitsCreator', getTagVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetTagVisits', () => cancelGetTagVisits); bottle.serviceFactory('getTagVisits', prop('asyncThunk'), 'getTagVisitsCreator');
bottle.serviceFactory('cancelGetTagVisits', prop('cancelGetVisits'), 'tagVisitsReducerCreator');
bottle.serviceFactory('getDomainVisits', getDomainVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getDomainVisitsCreator', getDomainVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetDomainVisits', () => cancelGetDomainVisits); bottle.serviceFactory('getDomainVisits', prop('asyncThunk'), 'getDomainVisitsCreator');
bottle.serviceFactory('cancelGetDomainVisits', prop('cancelGetVisits'), 'domainVisitsReducerCreator');
bottle.serviceFactory('getOrphanVisits', getOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getOrphanVisitsCreator', getOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetOrphanVisits', () => cancelGetOrphanVisits); bottle.serviceFactory('getOrphanVisits', prop('asyncThunk'), 'getOrphanVisitsCreator');
bottle.serviceFactory('cancelGetOrphanVisits', prop('cancelGetVisits'), 'orphanVisitsReducerCreator');
bottle.serviceFactory('getNonOrphanVisits', getNonOrphanVisits, 'buildShlinkApiClient'); bottle.serviceFactory('getNonOrphanVisitsCreator', getNonOrphanVisits, 'buildShlinkApiClient');
bottle.serviceFactory('cancelGetNonOrphanVisits', () => cancelGetNonOrphanVisits); bottle.serviceFactory('getNonOrphanVisits', prop('asyncThunk'), 'getNonOrphanVisitsCreator');
bottle.serviceFactory('cancelGetNonOrphanVisits', prop('cancelGetVisits'), 'nonOrphanVisitsReducerCreator');
bottle.serviceFactory('createNewVisits', () => createNewVisits); bottle.serviceFactory('createNewVisits', () => createNewVisits);
bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient'); bottle.serviceFactory('loadVisitsOverview', loadVisitsOverview, 'buildShlinkApiClient');
@ -75,6 +80,21 @@ const provideServices = (bottle: Bottle, connect: ConnectDecorator) => {
// Reducers // Reducers
bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview'); bottle.serviceFactory('visitsOverviewReducerCreator', visitsOverviewReducerCreator, 'loadVisitsOverview');
bottle.serviceFactory('visitsOverviewReducer', prop('reducer'), 'visitsOverviewReducerCreator'); 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; export default provideServices;

View File

@ -1,28 +1,5 @@
import { Action } from 'redux';
import { ShortUrl } from '../../short-urls/data'; import { ShortUrl } from '../../short-urls/data';
import { ShlinkVisitsParams } from '../../api/types'; import { DateRange } from '../../utils/dates/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;
}
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';

View File

@ -1,61 +0,0 @@
import { Action } from 'redux';
import { buildActionCreator, buildReducer } from '../../../src/utils/helpers/redux';
describe('redux', () => {
beforeEach(jest.clearAllMocks);
describe('buildActionCreator', () => {
it.each([
['foo', { type: 'foo' }],
['bar', { type: 'bar' }],
['something', { type: 'something' }],
])('returns an action creator', (type, expected) => {
const actionCreator = buildActionCreator(type);
expect(actionCreator).toBeInstanceOf(Function);
expect(actionCreator()).toEqual(expected);
});
});
describe('buildReducer', () => {
const fooActionHandler = jest.fn(() => 'foo result');
const barActionHandler = jest.fn(() => 'bar result');
const initialState = 'initial state';
let reducer: Function;
beforeEach(() => {
reducer = buildReducer<string, Action>({
foo: fooActionHandler,
bar: barActionHandler,
}, initialState);
});
it('returns a reducer which returns initial state when provided with unknown action', () => {
expect(reducer(undefined, { type: 'unknown action' })).toEqual(initialState);
expect(fooActionHandler).not.toHaveBeenCalled();
expect(barActionHandler).not.toHaveBeenCalled();
});
it.each([
['foo', 'foo result', fooActionHandler, barActionHandler],
['bar', 'bar result', barActionHandler, fooActionHandler],
])(
'returns a reducer which calls corresponding action handler',
(type, expected, invokedActionHandler, notInvokedActionHandler) => {
expect(reducer(undefined, { type })).toEqual(expected);
expect(invokedActionHandler).toHaveBeenCalled();
expect(notInvokedActionHandler).not.toHaveBeenCalled();
},
);
it.each([
[undefined, initialState],
['foo', 'foo'],
['something', 'something'],
])('returns a reducer which calls action handler with provided state or initial', (state, expected) => {
reducer(state, { type: 'foo' });
expect(fooActionHandler).toHaveBeenCalledWith(expected, expect.anything());
});
});
});

View File

@ -38,7 +38,7 @@ describe('<DomainVisits />', () => {
it('wraps visits stats and header', () => { it('wraps visits stats and header', () => {
setUp(); setUp();
expect(screen.getByRole('heading', { name: '"foo.com" visits' })).toBeInTheDocument(); 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 () => { it('exports visits when clicking the button', async () => {

View File

@ -4,11 +4,12 @@ import { Mock } from 'ts-mockery';
import { formatISO } from 'date-fns'; import { formatISO } from 'date-fns';
import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits'; import { NonOrphanVisits as createNonOrphanVisits } from '../../src/visits/NonOrphanVisits';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; 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 { Settings } from '../../src/settings/reducers/settings';
import { ReportExporter } from '../../src/common/services/ReportExporter'; import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data'; import { SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { VisitsInfo } from '../../src/visits/reducers/types';
describe('<NonOrphanVisits />', () => { describe('<NonOrphanVisits />', () => {
const exportVisits = jest.fn(); const exportVisits = jest.fn();

View File

@ -4,11 +4,12 @@ import { Mock } from 'ts-mockery';
import { formatISO } from 'date-fns'; import { formatISO } from 'date-fns';
import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits'; import { OrphanVisits as createOrphanVisits } from '../../src/visits/OrphanVisits';
import { MercureBoundProps } from '../../src/mercure/helpers/boundToMercureHub'; 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 { Settings } from '../../src/settings/reducers/settings';
import { ReportExporter } from '../../src/common/services/ReportExporter'; import { ReportExporter } from '../../src/common/services/ReportExporter';
import { SelectedServer } from '../../src/servers/data'; import { SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { VisitsInfo } from '../../src/visits/reducers/types';
describe('<OrphanVisits />', () => { describe('<OrphanVisits />', () => {
const getOrphanVisits = jest.fn(); const getOrphanVisits = jest.fn();

View File

@ -3,11 +3,12 @@ import { Mock } from 'ts-mockery';
import { Router } from 'react-router-dom'; import { Router } from 'react-router-dom';
import { createMemoryHistory } from 'history'; import { createMemoryHistory } from 'history';
import { VisitsStats } from '../../src/visits/VisitsStats'; 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 { Settings } from '../../src/settings/reducers/settings';
import { SelectedServer } from '../../src/servers/data'; import { SelectedServer } from '../../src/servers/data';
import { renderWithEvents } from '../__helpers__/setUpTest'; import { renderWithEvents } from '../__helpers__/setUpTest';
import { rangeOf } from '../../src/utils/utils'; import { rangeOf } from '../../src/utils/utils';
import { VisitsInfo } from '../../src/visits/reducers/types';
describe('<VisitsStats />', () => { describe('<VisitsStats />', () => {
const visits = rangeOf(3, () => Mock.of<Visit>({ date: '2020-01-01' })); const visits = rangeOf(3, () => Mock.of<Visit>({ date: '2020-01-01' }));

View File

@ -1,17 +1,10 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import {
getDomainVisits, getDomainVisits as getDomainVisitsCreator,
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,
DomainVisits, DomainVisits,
DEFAULT_DOMAIN, DEFAULT_DOMAIN,
domainVisitsReducerCreator,
} from '../../../src/visits/reducers/domainVisits'; } from '../../../src/visits/reducers/domainVisits';
import { rangeOf } from '../../../src/utils/utils'; import { rangeOf } from '../../../src/utils/utils';
import { Visit } from '../../../src/visits/types'; import { Visit } from '../../../src/visits/types';
@ -26,33 +19,34 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
describe('domainVisitsReducer', () => { describe('domainVisitsReducer', () => {
const now = new Date(); const now = new Date();
const visitsMocks = rangeOf(2, () => Mock.all<Visit>()); 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', () => { describe('reducer', () => {
const buildState = (data: Partial<DomainVisits>) => Mock.of<DomainVisits>(data); const buildState = (data: Partial<DomainVisits>) => Mock.of<DomainVisits>(data);
it('returns loading on GET_DOMAIN_VISITS_START', () => { it('returns loading on GET_DOMAIN_VISITS_START', () => {
const state = reducer(buildState({ loading: false }), { type: GET_DOMAIN_VISITS_START } as any); const { loading } = reducer(buildState({ loading: false }), { type: getDomainVisits.pending.toString() });
const { loading } = state;
expect(loading).toEqual(true); expect(loading).toEqual(true);
}); });
it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => { it('returns loadingLarge on GET_DOMAIN_VISITS_LARGE', () => {
const state = reducer(buildState({ loadingLarge: false }), { type: GET_DOMAIN_VISITS_LARGE } as any); const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() });
const { loadingLarge } = state;
expect(loadingLarge).toEqual(true); expect(loadingLarge).toEqual(true);
}); });
it('returns cancelLoad on GET_DOMAIN_VISITS_CANCEL', () => { it('returns cancelLoad on GET_DOMAIN_VISITS_CANCEL', () => {
const state = reducer(buildState({ cancelLoad: false }), { type: GET_DOMAIN_VISITS_CANCEL } as any); const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetDomainVisits.toString() });
const { cancelLoad } = state;
expect(cancelLoad).toEqual(true); expect(cancelLoad).toEqual(true);
}); });
it('stops loading and returns error on GET_DOMAIN_VISITS_ERROR', () => { 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; const { loading, error } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
@ -61,11 +55,10 @@ describe('domainVisitsReducer', () => {
it('return visits on GET_DOMAIN_VISITS', () => { it('return visits on GET_DOMAIN_VISITS', () => {
const actionVisits = [{}, {}]; const actionVisits = [{}, {}];
const state = reducer( const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), {
buildState({ loading: true, error: false }), type: getDomainVisits.fulfilled.toString(),
{ type: GET_DOMAIN_VISITS, visits: actionVisits } as any, payload: { visits: actionVisits },
); });
const { loading, error, visits } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
@ -128,56 +121,51 @@ describe('domainVisitsReducer', () => {
], ],
])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => { ])('prepends new visits on CREATE_VISIT', (state, shortUrlDomain, expectedVisits) => {
const shortUrl = Mock.of<ShortUrl>({ domain: shortUrlDomain }); const shortUrl = Mock.of<ShortUrl>({ domain: shortUrlDomain });
const prevState = buildState({ const { visits } = reducer(buildState({ ...state, visits: visitsMocks }), {
...state,
visits: visitsMocks,
});
const { visits } = reducer(prevState, {
type: createNewVisits.toString(), type: createNewVisits.toString(),
payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] },
} as any); });
expect(visits).toHaveLength(expectedVisits); expect(visits).toHaveLength(expectedVisits);
}); });
it('returns new progress on GET_DOMAIN_VISITS_PROGRESS_CHANGED', () => { 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 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL', () => { it('returns fallbackInterval on GET_DOMAIN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days'; 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 })); expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
}); });
}); });
describe('getDomainVisits', () => { 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 dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
domainVisits: { cancelLoad: false }, domainVisits: { cancelLoad: false },
}); });
const domain = 'foo.com'; const domain = 'foo.com';
beforeEach(jest.clearAllMocks);
it('dispatches start and error when promise is rejected', async () => { 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).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS_ERROR }); type: getDomainVisits.pending.toString(),
expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getDomainVisits.rejected.toString(),
}));
expect(getDomainVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@ -185,34 +173,45 @@ describe('domainVisitsReducer', () => {
[{}], [{}],
])('dispatches start and success when promise is resolved', async (query) => { ])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks; const visits = visitsMocks;
const shlinkApiClient = buildApiClientMock(Promise.resolve({ getDomainVisitsCall.mockResolvedValue({
data: visitsMocks, data: visitsMocks,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,
totalItems: 1, totalItems: 1,
}, },
})); });
await getDomainVisits(() => shlinkApiClient)(domain, query)(dispatchMock, getState); await getDomainVisits({ domain, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_DOMAIN_VISITS, visits, domain, query: query ?? {} }); type: getDomainVisits.pending.toString(),
expect(shlinkApiClient.getDomainVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getDomainVisits.fulfilled.toString(),
payload: { visits, domain, query: query ?? {} },
}));
expect(getDomainVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
[ [
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 20)) })], [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)) })], [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 })], [[], expect.objectContaining({ type: getDomainVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { ])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data, data,
pagination: { pagination: {
@ -221,22 +220,23 @@ describe('domainVisitsReducer', () => {
totalItems: 1, totalItems: 1,
}, },
}); });
const getShlinkDomainVisits = jest.fn() getDomainVisitsCall
.mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits)); .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).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_DOMAIN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: getDomainVisits.pending.toString(),
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkDomainVisits).toHaveBeenCalledTimes(2); expect(getDomainVisitsCall).toHaveBeenCalledTimes(2);
}); });
}); });
describe('cancelGetDomainVisits', () => { describe('cancelGetDomainVisits', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetDomainVisits()).toEqual({ type: GET_DOMAIN_VISITS_CANCEL })); expect(cancelGetDomainVisits()).toEqual(expect.objectContaining({ type: cancelGetDomainVisits.toString() })));
}); });
}); });

View File

@ -1,56 +1,53 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import {
getNonOrphanVisits, getNonOrphanVisits as getNonOrphanVisitsCreator,
cancelGetNonOrphanVisits, nonOrphanVisitsReducerCreator,
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,
} from '../../../src/visits/reducers/nonOrphanVisits'; } from '../../../src/visits/reducers/nonOrphanVisits';
import { rangeOf } from '../../../src/utils/utils'; 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 { ShlinkVisits } from '../../../src/api/types';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { formatIsoDate } from '../../../src/utils/helpers/date'; import { formatIsoDate } from '../../../src/utils/helpers/date';
import { DateInterval } from '../../../src/utils/dates/types'; import { DateInterval } from '../../../src/utils/dates/types';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import { VisitsInfo } from '../../../src/visits/reducers/types';
describe('nonOrphanVisitsReducer', () => { describe('nonOrphanVisitsReducer', () => {
const now = new Date(); const now = new Date();
const visitsMocks = rangeOf(2, () => Mock.all<Visit>()); 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', () => { describe('reducer', () => {
const buildState = (data: Partial<VisitsInfo>) => Mock.of<VisitsInfo>(data); const buildState = (data: Partial<VisitsInfo>) => Mock.of<VisitsInfo>(data);
it('returns loading on GET_NON_ORPHAN_VISITS_START', () => { 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 } = reducer(buildState({ loading: false }), { type: getNonOrphanVisits.pending.toString() });
const { loading } = state;
expect(loading).toEqual(true); expect(loading).toEqual(true);
}); });
it('returns loadingLarge on GET_NON_ORPHAN_VISITS_LARGE', () => { 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 } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() });
const { loadingLarge } = state;
expect(loadingLarge).toEqual(true); expect(loadingLarge).toEqual(true);
}); });
it('returns cancelLoad on GET_NON_ORPHAN_VISITS_CANCEL', () => { 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 } = reducer(buildState({ cancelLoad: false }), { type: cancelGetNonOrphanVisits.toString() });
const { cancelLoad } = state;
expect(cancelLoad).toEqual(true); expect(cancelLoad).toEqual(true);
}); });
it('stops loading and returns error on GET_NON_ORPHAN_VISITS_ERROR', () => { 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 } = reducer(
const { loading, error } = state; buildState({ loading: true, error: false }),
{ type: getNonOrphanVisits.rejected.toString() },
);
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(true); expect(error).toEqual(true);
@ -58,11 +55,10 @@ describe('nonOrphanVisitsReducer', () => {
it('return visits on GET_NON_ORPHAN_VISITS', () => { it('return visits on GET_NON_ORPHAN_VISITS', () => {
const actionVisits = [{}, {}]; const actionVisits = [{}, {}];
const state = reducer( const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), {
buildState({ loading: true, error: false }), type: getNonOrphanVisits.fulfilled.toString(),
{ type: GET_NON_ORPHAN_VISITS, visits: actionVisits } as any, payload: { visits: actionVisits },
); });
const { loading, error, visits } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
@ -108,31 +104,28 @@ describe('nonOrphanVisitsReducer', () => {
const { visits } = reducer(prevState, { const { visits } = reducer(prevState, {
type: createNewVisits.toString(), type: createNewVisits.toString(),
payload: { createdVisits: [{ visit }, { visit }] }, payload: { createdVisits: [{ visit }, { visit }] },
} as any); });
expect(visits).toHaveLength(expectedVisits); expect(visits).toHaveLength(expectedVisits);
}); });
it('returns new progress on GET_NON_ORPHAN_VISITS_PROGRESS_CHANGED', () => { 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 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { it('returns fallbackInterval on GET_NON_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days'; 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 })); expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
}); });
}); });
describe('getNonOrphanVisits', () => { 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 dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
orphanVisits: { cancelLoad: false }, orphanVisits: { cancelLoad: false },
@ -141,14 +134,18 @@ describe('nonOrphanVisitsReducer', () => {
beforeEach(jest.resetAllMocks); beforeEach(jest.resetAllMocks);
it('dispatches start and error when promise is rejected', async () => { 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).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS_ERROR }); type: getNonOrphanVisits.pending.toString(),
expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getNonOrphanVisits.rejected.toString(),
}));
expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@ -156,34 +153,45 @@ describe('nonOrphanVisitsReducer', () => {
[{}], [{}],
])('dispatches start and success when promise is resolved', async (query) => { ])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' }));
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ getNonOrphanVisitsCall.mockResolvedValue({
data: visits, data: visits,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,
totalItems: 1, totalItems: 1,
}, },
})); });
await getNonOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState); await getNonOrphanVisits({ query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining(
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_NON_ORPHAN_VISITS, visits, query: query ?? {} }); { type: getNonOrphanVisits.pending.toString() },
expect(ShlinkApiClient.getNonOrphanVisits).toHaveBeenCalledTimes(1); ));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getNonOrphanVisits.fulfilled.toString(),
payload: { visits, query: query ?? {} },
}));
expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
[ [
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })], [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)) })], [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 })], [[], expect.objectContaining({ type: getNonOrphanVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { ])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedAmountOfDispatches,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data, data,
pagination: { pagination: {
@ -192,22 +200,23 @@ describe('nonOrphanVisitsReducer', () => {
totalItems: 1, totalItems: 1,
}, },
}); });
const getShlinkOrphanVisits = jest.fn() getNonOrphanVisitsCall
.mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits)); .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).toHaveBeenCalledTimes(expectedAmountOfDispatches);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_NON_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: getNonOrphanVisits.pending.toString(),
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); expect(getNonOrphanVisitsCall).toHaveBeenCalledTimes(2);
}); });
}); });
describe('cancelGetNonOrphanVisits', () => { describe('cancelGetNonOrphanVisits', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetNonOrphanVisits()).toEqual({ type: GET_NON_ORPHAN_VISITS_CANCEL })); expect(cancelGetNonOrphanVisits()).toEqual({ type: cancelGetNonOrphanVisits.toString() }));
}); });
}); });

View File

@ -1,56 +1,53 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import {
getOrphanVisits, getOrphanVisits as getOrphanVisitsCreator,
cancelGetOrphanVisits, orphanVisitsReducerCreator,
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,
} from '../../../src/visits/reducers/orphanVisits'; } from '../../../src/visits/reducers/orphanVisits';
import { rangeOf } from '../../../src/utils/utils'; 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 { ShlinkVisits } from '../../../src/api/types';
import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient'; import { ShlinkApiClient } from '../../../src/api/services/ShlinkApiClient';
import { ShlinkState } from '../../../src/container/types'; import { ShlinkState } from '../../../src/container/types';
import { formatIsoDate } from '../../../src/utils/helpers/date'; import { formatIsoDate } from '../../../src/utils/helpers/date';
import { DateInterval } from '../../../src/utils/dates/types'; import { DateInterval } from '../../../src/utils/dates/types';
import { createNewVisits } from '../../../src/visits/reducers/visitCreation'; import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
import { VisitsInfo } from '../../../src/visits/reducers/types';
describe('orphanVisitsReducer', () => { describe('orphanVisitsReducer', () => {
const now = new Date(); const now = new Date();
const visitsMocks = rangeOf(2, () => Mock.all<Visit>()); 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', () => { describe('reducer', () => {
const buildState = (data: Partial<VisitsInfo>) => Mock.of<VisitsInfo>(data); const buildState = (data: Partial<VisitsInfo>) => Mock.of<VisitsInfo>(data);
it('returns loading on GET_ORPHAN_VISITS_START', () => { it('returns loading on GET_ORPHAN_VISITS_START', () => {
const state = reducer(buildState({ loading: false }), { type: GET_ORPHAN_VISITS_START } as any); const { loading } = reducer(buildState({ loading: false }), { type: getOrphanVisits.pending.toString() });
const { loading } = state;
expect(loading).toEqual(true); expect(loading).toEqual(true);
}); });
it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => { it('returns loadingLarge on GET_ORPHAN_VISITS_LARGE', () => {
const state = reducer(buildState({ loadingLarge: false }), { type: GET_ORPHAN_VISITS_LARGE } as any); const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() });
const { loadingLarge } = state;
expect(loadingLarge).toEqual(true); expect(loadingLarge).toEqual(true);
}); });
it('returns cancelLoad on GET_ORPHAN_VISITS_CANCEL', () => { it('returns cancelLoad on GET_ORPHAN_VISITS_CANCEL', () => {
const state = reducer(buildState({ cancelLoad: false }), { type: GET_ORPHAN_VISITS_CANCEL } as any); const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetOrphanVisits.toString() });
const { cancelLoad } = state;
expect(cancelLoad).toEqual(true); expect(cancelLoad).toEqual(true);
}); });
it('stops loading and returns error on GET_ORPHAN_VISITS_ERROR', () => { 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 } = reducer(
const { loading, error } = state; buildState({ loading: true, error: false }),
{ type: getOrphanVisits.rejected.toString() },
);
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(true); expect(error).toEqual(true);
@ -58,11 +55,10 @@ describe('orphanVisitsReducer', () => {
it('return visits on GET_ORPHAN_VISITS', () => { it('return visits on GET_ORPHAN_VISITS', () => {
const actionVisits = [{}, {}]; const actionVisits = [{}, {}];
const state = reducer( const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), {
buildState({ loading: true, error: false }), type: getOrphanVisits.fulfilled.toString(),
{ type: GET_ORPHAN_VISITS, visits: actionVisits } as any, payload: { visits: actionVisits },
); });
const { loading, error, visits } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
@ -108,47 +104,46 @@ describe('orphanVisitsReducer', () => {
const { visits } = reducer(prevState, { const { visits } = reducer(prevState, {
type: createNewVisits.toString(), type: createNewVisits.toString(),
payload: { createdVisits: [{ visit }, { visit }] }, payload: { createdVisits: [{ visit }, { visit }] },
} as any); });
expect(visits).toHaveLength(expectedVisits); expect(visits).toHaveLength(expectedVisits);
}); });
it('returns new progress on GET_ORPHAN_VISITS_PROGRESS_CHANGED', () => { 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 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => { it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days'; 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 })); expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
}); });
}); });
describe('getOrphanVisits', () => { 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 dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
orphanVisits: { cancelLoad: false }, orphanVisits: { cancelLoad: false },
}); });
beforeEach(jest.resetAllMocks);
it('dispatches start and error when promise is rejected', async () => { 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).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS_ERROR }); type: getOrphanVisits.pending.toString(),
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getOrphanVisits.rejected.toString(),
}));
expect(getOrphanVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@ -156,34 +151,45 @@ describe('orphanVisitsReducer', () => {
[{}], [{}],
])('dispatches start and success when promise is resolved', async (query) => { ])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' }));
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ getOrphanVisitsCall.mockResolvedValue({
data: visits, data: visits,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,
totalItems: 1, totalItems: 1,
}, },
})); });
await getOrphanVisits(() => ShlinkApiClient)(query)(dispatchMock, getState); await getOrphanVisits({ query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} }); type: getOrphanVisits.pending.toString(),
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getOrphanVisits.fulfilled.toString(),
payload: { visits, query: query ?? {} },
}));
expect(getOrphanVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
[ [
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })], [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)) })], [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 })], [[], expect.objectContaining({ type: getOrphanVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { ])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data, data,
pagination: { pagination: {
@ -192,22 +198,23 @@ describe('orphanVisitsReducer', () => {
totalItems: 1, totalItems: 1,
}, },
}); });
const getShlinkOrphanVisits = jest.fn() getOrphanVisitsCall
.mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits)); .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).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: getOrphanVisits.pending.toString(),
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2); expect(getOrphanVisitsCall).toHaveBeenCalledTimes(2);
}); });
}); });
describe('cancelGetOrphanVisits', () => { describe('cancelGetOrphanVisits', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetOrphanVisits()).toEqual({ type: GET_ORPHAN_VISITS_CANCEL })); expect(cancelGetOrphanVisits()).toEqual({ type: cancelGetOrphanVisits.toString() }));
}); });
}); });

View File

@ -1,15 +1,8 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import {
getShortUrlVisits, getShortUrlVisits as getShortUrlVisitsCreator,
cancelGetShortUrlVisits, shortUrlVisitsReducerCreator,
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,
ShortUrlVisits, ShortUrlVisits,
} from '../../../src/visits/reducers/shortUrlVisits'; } from '../../../src/visits/reducers/shortUrlVisits';
import { rangeOf } from '../../../src/utils/utils'; import { rangeOf } from '../../../src/utils/utils';
@ -24,34 +17,37 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
describe('shortUrlVisitsReducer', () => { describe('shortUrlVisitsReducer', () => {
const now = new Date(); const now = new Date();
const visitsMocks = rangeOf(2, () => Mock.all<Visit>()); 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', () => { describe('reducer', () => {
const buildState = (data: Partial<ShortUrlVisits>) => Mock.of<ShortUrlVisits>(data); const buildState = (data: Partial<ShortUrlVisits>) => Mock.of<ShortUrlVisits>(data);
it('returns loading on GET_SHORT_URL_VISITS_START', () => { 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 } = reducer(buildState({ loading: false }), { type: getShortUrlVisits.pending.toString() });
const { loading } = state;
expect(loading).toEqual(true); expect(loading).toEqual(true);
}); });
it('returns loadingLarge on GET_SHORT_URL_VISITS_LARGE', () => { 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 } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() });
const { loadingLarge } = state;
expect(loadingLarge).toEqual(true); expect(loadingLarge).toEqual(true);
}); });
it('returns cancelLoad on GET_SHORT_URL_VISITS_CANCEL', () => { 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 } = reducer(buildState({ cancelLoad: false }), { type: cancelGetShortUrlVisits.toString() });
const { cancelLoad } = state;
expect(cancelLoad).toEqual(true); expect(cancelLoad).toEqual(true);
}); });
it('stops loading and returns error on GET_SHORT_URL_VISITS_ERROR', () => { 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 } = reducer(
const { loading, error } = state; buildState({ loading: true, error: false }),
{ type: getShortUrlVisits.rejected.toString() },
);
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(true); expect(error).toEqual(true);
@ -59,11 +55,10 @@ describe('shortUrlVisitsReducer', () => {
it('return visits on GET_SHORT_URL_VISITS', () => { it('return visits on GET_SHORT_URL_VISITS', () => {
const actionVisits = [{}, {}]; const actionVisits = [{}, {}];
const state = reducer( const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), {
buildState({ loading: true, error: false }), type: getShortUrlVisits.fulfilled.toString(),
{ type: GET_SHORT_URL_VISITS, visits: actionVisits } as any, payload: { visits: actionVisits },
); });
const { loading, error, visits } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
@ -129,47 +124,46 @@ describe('shortUrlVisitsReducer', () => {
const { visits } = reducer(prevState, { const { visits } = reducer(prevState, {
type: createNewVisits.toString(), type: createNewVisits.toString(),
payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] },
} as any); });
expect(visits).toHaveLength(expectedVisits); expect(visits).toHaveLength(expectedVisits);
}); });
it('returns new progress on GET_SHORT_URL_VISITS_PROGRESS_CHANGED', () => { 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 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL', () => { it('returns fallbackInterval on GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days'; 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 })); expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
}); });
}); });
describe('getShortUrlVisits', () => { 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 dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
shortUrlVisits: Mock.of<ShortUrlVisits>({ cancelLoad: false }), shortUrlVisits: Mock.of<ShortUrlVisits>({ cancelLoad: false }),
}); });
beforeEach(() => dispatchMock.mockReset());
it('dispatches start and error when promise is rejected', async () => { 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).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_SHORT_URL_VISITS_ERROR }); type: getShortUrlVisits.pending.toString(),
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getShortUrlVisits.rejected.toString(),
}));
expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@ -179,29 +173,31 @@ describe('shortUrlVisitsReducer', () => {
])('dispatches start and success when promise is resolved', async (query, domain) => { ])('dispatches start and success when promise is resolved', async (query, domain) => {
const visits = visitsMocks; const visits = visitsMocks;
const shortCode = 'abc123'; const shortCode = 'abc123';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ getShortUrlVisitsCall.mockResolvedValue({
data: visitsMocks, data: visitsMocks,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,
totalItems: 1, totalItems: 1,
}, },
})); });
await getShortUrlVisits(() => ShlinkApiClient)(shortCode, query)(dispatchMock, getState); await getShortUrlVisits({ shortCode, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith( type: getShortUrlVisits.pending.toString(),
2, }));
{ type: GET_SHORT_URL_VISITS, visits, shortCode, domain, query: query ?? {} }, expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
); type: getShortUrlVisits.fulfilled.toString(),
expect(ShlinkApiClient.getShortUrlVisits).toHaveBeenCalledTimes(1); payload: { visits, shortCode, domain, query: query ?? {} },
}));
expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(1);
}); });
it('performs multiple API requests when response contains more pages', async () => { it('performs multiple API requests when response contains more pages', async () => {
const expectedRequests = 3; const expectedRequests = 3;
const ShlinkApiClient = buildApiClientMock(async (_, { page }) => getShortUrlVisitsCall.mockImplementation(async (_, { page }) =>
Promise.resolve({ Promise.resolve({
data: visitsMocks, data: visitsMocks,
pagination: { 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({ expect(dispatchMock).toHaveBeenNthCalledWith(3, expect.objectContaining({
visits: [...visitsMocks, ...visitsMocks, ...visitsMocks], payload: expect.objectContaining({
visits: [...visitsMocks, ...visitsMocks, ...visitsMocks],
}),
})); }));
}); });
it.each([ it.each([
[ [
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) })], [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)) })], [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 })], [[], expect.objectContaining({ type: getShortUrlVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { ])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data, data,
pagination: { pagination: {
@ -238,22 +242,23 @@ describe('shortUrlVisitsReducer', () => {
totalItems: 1, totalItems: 1,
}, },
}); });
const getShlinkShortUrlVisits = jest.fn() getShortUrlVisitsCall
.mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits)); .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).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: getShortUrlVisits.pending.toString(),
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkShortUrlVisits).toHaveBeenCalledTimes(2); expect(getShortUrlVisitsCall).toHaveBeenCalledTimes(2);
}); });
}); });
describe('cancelGetShortUrlVisits', () => { describe('cancelGetShortUrlVisits', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetShortUrlVisits()).toEqual({ type: GET_SHORT_URL_VISITS_CANCEL })); expect(cancelGetShortUrlVisits()).toEqual({ type: cancelGetShortUrlVisits.toString() }));
}); });
}); });

View File

@ -1,15 +1,8 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, formatISO, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import {
getTagVisits, getTagVisits as getTagVisitsCreator,
cancelGetTagVisits, tagVisitsReducerCreator,
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,
TagVisits, TagVisits,
} from '../../../src/visits/reducers/tagVisits'; } from '../../../src/visits/reducers/tagVisits';
import { rangeOf } from '../../../src/utils/utils'; import { rangeOf } from '../../../src/utils/utils';
@ -24,34 +17,37 @@ import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
describe('tagVisitsReducer', () => { describe('tagVisitsReducer', () => {
const now = new Date(); const now = new Date();
const visitsMocks = rangeOf(2, () => Mock.all<Visit>()); 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', () => { describe('reducer', () => {
const buildState = (data: Partial<TagVisits>) => Mock.of<TagVisits>(data); const buildState = (data: Partial<TagVisits>) => Mock.of<TagVisits>(data);
it('returns loading on GET_TAG_VISITS_START', () => { it('returns loading on GET_TAG_VISITS_START', () => {
const state = reducer(buildState({ loading: false }), { type: GET_TAG_VISITS_START } as any); const { loading } = reducer(buildState({ loading: false }), { type: getTagVisits.pending.toString() });
const { loading } = state;
expect(loading).toEqual(true); expect(loading).toEqual(true);
}); });
it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => { it('returns loadingLarge on GET_TAG_VISITS_LARGE', () => {
const state = reducer(buildState({ loadingLarge: false }), { type: GET_TAG_VISITS_LARGE } as any); const { loadingLarge } = reducer(buildState({ loadingLarge: false }), { type: largeAction.toString() });
const { loadingLarge } = state;
expect(loadingLarge).toEqual(true); expect(loadingLarge).toEqual(true);
}); });
it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => { it('returns cancelLoad on GET_TAG_VISITS_CANCEL', () => {
const state = reducer(buildState({ cancelLoad: false }), { type: GET_TAG_VISITS_CANCEL } as any); const { cancelLoad } = reducer(buildState({ cancelLoad: false }), { type: cancelGetTagVisits.toString() });
const { cancelLoad } = state;
expect(cancelLoad).toEqual(true); expect(cancelLoad).toEqual(true);
}); });
it('stops loading and returns error on GET_TAG_VISITS_ERROR', () => { 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 } = reducer(
const { loading, error } = state; buildState({ loading: true, error: false }),
{ type: getTagVisits.rejected.toString() },
);
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(true); expect(error).toEqual(true);
@ -59,11 +55,10 @@ describe('tagVisitsReducer', () => {
it('return visits on GET_TAG_VISITS', () => { it('return visits on GET_TAG_VISITS', () => {
const actionVisits = [{}, {}]; const actionVisits = [{}, {}];
const state = reducer( const { loading, error, visits } = reducer(buildState({ loading: true, error: false }), {
buildState({ loading: true, error: false }), type: getTagVisits.fulfilled.toString(),
{ type: GET_TAG_VISITS, visits: actionVisits } as any, payload: { visits: actionVisits },
); });
const { loading, error, visits } = state;
expect(loading).toEqual(false); expect(loading).toEqual(false);
expect(error).toEqual(false); expect(error).toEqual(false);
@ -129,48 +124,44 @@ describe('tagVisitsReducer', () => {
const { visits } = reducer(prevState, { const { visits } = reducer(prevState, {
type: createNewVisits.toString(), type: createNewVisits.toString(),
payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] }, payload: { createdVisits: [{ shortUrl, visit: { date: formatIsoDate(now) ?? undefined } }] },
} as any); });
expect(visits).toHaveLength(expectedVisits); expect(visits).toHaveLength(expectedVisits);
}); });
it('returns new progress on GET_TAG_VISITS_PROGRESS_CHANGED', () => { 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 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => { it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days'; 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 })); expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
}); });
}); });
describe('getTagVisits', () => { 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 dispatchMock = jest.fn();
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
tagVisits: { cancelLoad: false }, tagVisits: { cancelLoad: false },
}); });
const tag = 'foo'; const tag = 'foo';
beforeEach(jest.clearAllMocks);
it('dispatches start and error when promise is rejected', async () => { 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).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS_ERROR }); type: getTagVisits.pending.toString(),
expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getTagVisits.rejected.toString(),
}));
expect(getTagVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
@ -178,34 +169,45 @@ describe('tagVisitsReducer', () => {
[{}], [{}],
])('dispatches start and success when promise is resolved', async (query) => { ])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks; const visits = visitsMocks;
const shlinkApiClient = buildApiClientMock(Promise.resolve({ getTagVisitsCall.mockResolvedValue({
data: visitsMocks, data: visitsMocks,
pagination: { pagination: {
currentPage: 1, currentPage: 1,
pagesCount: 1, pagesCount: 1,
totalItems: 1, totalItems: 1,
}, },
})); });
await getTagVisits(() => shlinkApiClient)(tag, query)(dispatchMock, getState); await getTagVisits({ tag, query })(dispatchMock, getState, {});
expect(dispatchMock).toHaveBeenCalledTimes(2); expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} }); type: getTagVisits.pending.toString(),
expect(shlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); }));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expect.objectContaining({
type: getTagVisits.fulfilled.toString(),
payload: { visits, tag, query: query ?? {} },
}));
expect(getTagVisitsCall).toHaveBeenCalledTimes(1);
}); });
it.each([ it.each([
[ [
[Mock.of<Visit>({ date: formatISO(subDays(new Date(), 20)) })], [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)) })], [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 })], [[], expect.objectContaining({ type: getTagVisits.fulfilled.toString() }), 2],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => { ])('dispatches fallback interval when the list of visits is empty', async (
lastVisits,
expectedSecondDispatch,
expectedDispatchCalls,
) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({ const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data, data,
pagination: { pagination: {
@ -214,22 +216,23 @@ describe('tagVisitsReducer', () => {
totalItems: 1, totalItems: 1,
}, },
}); });
const getShlinkTagVisits = jest.fn() getTagVisitsCall
.mockResolvedValueOnce(buildVisitsResult()) .mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits)); .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).toHaveBeenCalledTimes(expectedDispatchCalls);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START }); expect(dispatchMock).toHaveBeenNthCalledWith(1, expect.objectContaining({
type: getTagVisits.pending.toString(),
}));
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch); expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkTagVisits).toHaveBeenCalledTimes(2); expect(getTagVisitsCall).toHaveBeenCalledTimes(2);
}); });
}); });
describe('cancelGetTagVisits', () => { describe('cancelGetTagVisits', () => {
it('just returns the action with proper type', () => it('just returns the action with proper type', () =>
expect(cancelGetTagVisits()).toEqual({ type: GET_TAG_VISITS_CANCEL })); expect(cancelGetTagVisits()).toEqual({ type: cancelGetTagVisits.toString() }));
}); });
}); });