Merge pull request #541 from acelaya-forks/feature/not-empty-resultsets

Feature/not empty resultsets
This commit is contained in:
Alejandro Celaya 2021-12-23 10:57:45 +01:00 committed by GitHub
commit e77508edcc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 353 additions and 68 deletions

View file

@ -6,12 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased] ## [Unreleased]
### Added ### Added
* [#407](https://github.com/shlinkio/shlink-web-client/pull/407) Improved how visits (short URLs, tags and orphan) are loaded, to avoid ending up in a page with "There are no visits matching current filter".
Now, the app will try to load visits for the configured default interval, and in parallel, it will load the latest visit.
If the resulting list for that interval is empty, it will try to infer the closest interval with visits, based on the latest visit's date, and reload visits for that interval.
* [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer. * [#535](https://github.com/shlinkio/shlink-web-client/pull/535) Allowed editing default domain redirects when consuming Shlink 2.10 or newer.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page. * [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
### Changed ### Changed
* [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios. * [#534](https://github.com/shlinkio/shlink-web-client/pull/534) Updated axios.
* [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0 * [#538](https://github.com/shlinkio/shlink-web-client/pull/538) Switched to the `<field>-<dir>` notation in `orderBy` param for short URLs list, in preparation for Shlink v3.0.0
* Fixed typo in identifier for "Last 180 days" interval.
If that was your default interval, you will see now "All visits" is selected instead. You will need to go to settings page and change it again to "Last 180 days".
### Deprecated ### Deprecated
* *Nothing* * *Nothing*

View file

@ -1,6 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { DropdownItem } from 'reactstrap'; import { DropdownItem } from 'reactstrap';
import { DropdownBtn } from '../DropdownBtn'; import { DropdownBtn } from '../DropdownBtn';
import { useEffectExceptFirstTime } from '../helpers/hooks';
import { import {
DateInterval, DateInterval,
DateRange, DateRange,
@ -17,10 +18,11 @@ export interface DateRangeSelectorProps {
disabled?: boolean; disabled?: boolean;
onDatesChange: (dateRange: DateRange) => void; onDatesChange: (dateRange: DateRange) => void;
defaultText: string; defaultText: string;
updatable?: boolean;
} }
export const DateRangeSelector = ( export const DateRangeSelector = (
{ onDatesChange, initialDateRange, defaultText, disabled }: DateRangeSelectorProps, { onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
) => { ) => {
const initialIntervalIsRange = rangeIsInterval(initialDateRange); const initialIntervalIsRange = rangeIsInterval(initialDateRange);
const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined); const [ activeInterval, setActiveInterval ] = useState(initialIntervalIsRange ? initialDateRange : undefined);
@ -37,6 +39,13 @@ export const DateRangeSelector = (
onDatesChange(intervalToDateRange(dateInterval)); onDatesChange(intervalToDateRange(dateInterval));
}; };
updatable && useEffectExceptFirstTime(() => {
const isDateInterval = rangeIsInterval(initialDateRange);
isDateInterval && updateInterval(initialDateRange);
initialDateRange && !isDateInterval && updateDateRange(initialDateRange);
}, [ initialDateRange ]);
return ( return (
<DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}> <DropdownBtn disabled={disabled} text={rangeOrIntervalToString(activeInterval ?? activeDateRange) ?? defaultText}>
<DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} /> <DateIntervalDropdownItems allText={defaultText} active={activeInterval} onChange={updateInterval} />

View file

@ -1,13 +1,13 @@
import { subDays, startOfDay, endOfDay } from 'date-fns'; import { subDays, startOfDay, endOfDay } from 'date-fns';
import { filter, isEmpty } from 'ramda'; import { cond, filter, isEmpty, T } from 'ramda';
import { formatInternational } from '../../helpers/date'; import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
export interface DateRange { export interface DateRange {
startDate?: Date | null; startDate?: Date | null;
endDate?: Date | null; endDate?: Date | null;
} }
export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180days' | 'last365Days'; export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30Days' | 'last90Days' | 'last180Days' | 'last365Days';
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|| isEmpty(filter(Boolean, dateRange as any)); || isEmpty(filter(Boolean, dateRange as any));
@ -21,7 +21,7 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
last7Days: 'Last 7 days', last7Days: 'Last 7 days',
last30Days: 'Last 30 days', last30Days: 'Last 30 days',
last90Days: 'Last 90 days', last90Days: 'Last 90 days',
last180days: 'Last 180 days', last180Days: 'Last 180 days',
last365Days: 'Last 365 days', last365Days: 'Last 365 days',
all: undefined, all: undefined,
}; };
@ -75,7 +75,7 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
return endingToday(startOfDaysAgo(30)); return endingToday(startOfDaysAgo(30));
case 'last90Days': case 'last90Days':
return endingToday(startOfDaysAgo(90)); return endingToday(startOfDaysAgo(90));
case 'last180days': case 'last180Days':
return endingToday(startOfDaysAgo(180)); return endingToday(startOfDaysAgo(180));
case 'last365Days': case 'last365Days':
return endingToday(startOfDaysAgo(365)); return endingToday(startOfDaysAgo(365));
@ -83,3 +83,18 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
return {}; return {};
}; };
export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
const theDate: Date = parseISO(date);
return cond<never, DateInterval>([
[ () => isBeforeOrEqual(startOfDay(new Date()), theDate), () => 'today' ],
[ () => isBeforeOrEqual(startOfDaysAgo(1), theDate), () => 'yesterday' ],
[ () => isBeforeOrEqual(startOfDaysAgo(7), theDate), () => 'last7Days' ],
[ () => isBeforeOrEqual(startOfDaysAgo(30), theDate), () => 'last30Days' ],
[ () => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days' ],
[ () => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days' ],
[ () => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days' ],
[ T, () => 'all' ],
])();
};

View file

@ -1,7 +1,8 @@
import { format, formatISO, isAfter, isBefore, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns'; import { format, formatISO, isBefore, isEqual, isWithinInterval, parse, parseISO as stdParseISO } from 'date-fns';
import { OptionalString } from '../utils'; import { OptionalString } from '../utils';
type DateOrString = Date | string; export type DateOrString = Date | string;
type NullableDate = DateOrString | null; type NullableDate = DateOrString | null;
export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string'; export const isDateObject = (date: DateOrString): date is Date => typeof date !== 'string';
@ -22,20 +23,15 @@ export const formatInternational = formatDate();
export const parseDate = (date: string, format: string) => parse(date, format, new Date()); export const parseDate = (date: string, format: string) => parse(date, format, new Date());
const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date); export const parseISO = (date: DateOrString): Date => isDateObject(date) ? date : stdParseISO(date);
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => { export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
if (!start && end) { try {
return isBefore(parseISO(date), parseISO(end)); return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
} catch (e) {
return false;
} }
if (start && !end) {
return isAfter(parseISO(date), parseISO(start));
}
if (start && end) {
return isWithinInterval(parseISO(date), { start: parseISO(start), end: parseISO(end) });
}
return true;
}; };
export const isBeforeOrEqual = (date: Date | number, dateToCompare: Date | number) =>
isEqual(date, dateToCompare) || isBefore(date, dateToCompare);

View file

@ -1,4 +1,4 @@
import { useState, useRef } from 'react'; import { useState, useRef, EffectCallback, DependencyList, useEffect } from 'react';
import { useSwipeable as useReactSwipeable } from 'react-swipeable'; import { useSwipeable as useReactSwipeable } from 'react-swipeable';
import { parseQuery, stringifyQuery } from './query'; import { parseQuery, stringifyQuery } from './query';
@ -66,3 +66,12 @@ export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newV
return [ value, setValueWithLocation ]; return [ value, setValueWithLocation ];
}; };
export const useEffectExceptFirstTime = (callback: EffectCallback, deps: DependencyList): void => {
const isFirstLoad = useRef(true);
useEffect(() => {
!isFirstLoad.current && callback();
isFirstLoad.current = false;
}, deps);
};

View file

@ -10,7 +10,11 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps {
getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void; getOrphanVisits: (
params?: ShlinkVisitsParams,
orphanVisitsType?: OrphanVisitType,
doIntervalFallback?: boolean,
) => void;
orphanVisits: VisitsInfo; orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void; cancelGetOrphanVisits: () => void;
} }
@ -25,7 +29,8 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
selectedServer, selectedServer,
}: OrphanVisitsProps) => { }: OrphanVisitsProps) => {
const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits);
const loadVisits = (params: VisitsParams) => getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType); const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType, doIntervalFallback);
return ( return (
<VisitsStats <VisitsStats

View file

@ -14,7 +14,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> { export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
shortUrlVisits: ShortUrlVisitsState; shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: Function; getShortUrlDetail: Function;
shortUrlDetail: ShortUrlDetail; shortUrlDetail: ShortUrlDetail;
@ -35,7 +35,8 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
}: ShortUrlVisitsProps) => { }: ShortUrlVisitsProps) => {
const { shortCode } = params; const { shortCode } = params;
const { domain } = parseQuery<{ domain?: string }>(search); const { domain } = parseQuery<{ domain?: string }>(search);
const loadVisits = (params: VisitsParams) => getShortUrlVisits(shortCode, { ...toApiParams(params), domain }); const loadVisits = (params: VisitsParams, doIntervalFallback?: boolean) =>
getShortUrlVisits(shortCode, { ...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

@ -12,7 +12,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers'; import { toApiParams } from './types/helpers';
export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> {
getTagVisits: (tag: string, query?: ShlinkVisitsParams) => void; getTagVisits: (tag: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
tagVisits: TagVisitsState; tagVisits: TagVisitsState;
cancelGetTagVisits: () => void; cancelGetTagVisits: () => void;
} }
@ -27,7 +27,8 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor
selectedServer, selectedServer,
}: TagVisitsProps) => { }: TagVisitsProps) => {
const { tag } = params; const { tag } = params;
const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, toApiParams(params)); const loadVisits = (params: ShlinkVisitsParams, doIntervalFallback?: boolean) =>
getTagVisits(tag, 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

@ -1,5 +1,5 @@
import { isEmpty, propEq, values } from 'ramda'; import { isEmpty, propEq, values } from 'ramda';
import { useState, useEffect, useMemo, FC } from 'react'; import { useState, useEffect, useMemo, FC, useRef } from 'react';
import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons'; import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } from '@fortawesome/free-solid-svg-icons';
@ -28,7 +28,7 @@ import { SortableBarChartCard } from './charts/SortableBarChartCard';
import './VisitsStats.scss'; import './VisitsStats.scss';
export interface VisitsStatsProps { export interface VisitsStatsProps {
getVisits: (params: VisitsParams) => void; getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
visitsInfo: VisitsInfo; visitsInfo: VisitsInfo;
settings: Settings; settings: Settings;
selectedServer: SelectedServer; selectedServer: SelectedServer;
@ -81,19 +81,22 @@ const VisitsStats: FC<VisitsStatsProps> = ({
selectedServer, selectedServer,
isOrphanVisits = false, isOrphanVisits = false,
}) => { }) => {
const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
const [ initialInterval, setInitialInterval ] = useState<DateInterval>(
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
);
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval)); const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]); const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>(); const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({}); const [ visitsFilter, setVisitsFilter ] = useState<VisitsFilter>({});
const botsSupported = supportsBotVisits(selectedServer); const botsSupported = supportsBotVisits(selectedServer);
const isFirstLoad = useRef(true);
const buildSectionUrl = (subPath?: string) => { const buildSectionUrl = (subPath?: string) => {
const query = domain ? `?domain=${domain}` : ''; const query = domain ? `?domain=${domain}` : '';
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
}; };
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
() => processStatsFromVisits(normalizedVisits), () => processStatsFromVisits(normalizedVisits),
@ -121,8 +124,12 @@ const VisitsStats: FC<VisitsStatsProps> = ({
useEffect(() => cancelGetVisits, []); useEffect(() => cancelGetVisits, []);
useEffect(() => { useEffect(() => {
getVisits({ dateRange, filter: visitsFilter }); getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current);
isFirstLoad.current = false;
}, [ dateRange, visitsFilter ]); }, [ dateRange, visitsFilter ]);
useEffect(() => {
fallbackInterval && setInitialInterval(fallbackInterval);
}, [ fallbackInterval ]);
const renderVisitsContent = () => { const renderVisitsContent = () => {
if (loadingLarge) { if (loadingLarge) {
@ -272,6 +279,7 @@ const VisitsStats: FC<VisitsStatsProps> = ({
<div className="d-md-flex"> <div className="d-md-flex">
<div className="flex-fill"> <div className="flex-fill">
<DateRangeSelector <DateRangeSelector
updatable
disabled={loading} disabled={loading}
initialDateRange={initialInterval} initialDateRange={initialInterval}
defaultText="All visits" defaultText="All visits"

View file

@ -1,9 +1,10 @@
import { flatten, prop, range, splitEvery } from 'ramda'; import { flatten, prop, range, splitEvery } from 'ramda';
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { ShlinkPaginator, ShlinkVisits } from '../../api/types'; import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
import { Visit } from '../types'; import { Visit } from '../types';
import { parseApiError } from '../../api/utils'; import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
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;
@ -13,16 +14,19 @@ const isLastPage = ({ currentPage, pagesCount }: ShlinkPaginator): boolean => cu
const calcProgress = (total: number, current: number): number => current * 100 / total; const calcProgress = (total: number, current: number): number => current * 100 / total;
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>; type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
type LastVisitLoader = () => Promise<Visit | undefined>;
interface ActionMap { interface ActionMap {
start: string; start: string;
large: string; large: string;
finish: string; finish: string;
error: string; error: string;
progress: string; progress: string;
fallbackToInterval: string;
} }
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>( export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
visitsLoader: VisitsLoader, visitsLoader: VisitsLoader,
lastVisitLoader: LastVisitLoader,
extraFinishActionData: Partial<T>, extraFinishActionData: Partial<T>,
actionMap: ActionMap, actionMap: ActionMap,
dispatch: Dispatch, dispatch: Dispatch,
@ -69,10 +73,25 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
}; };
try { try {
const visits = await loadVisits(); const [ visits, lastVisit ] = await Promise.all([ loadVisits(), lastVisitLoader() ]);
dispatch({ ...extraFinishActionData, visits, type: actionMap.finish }); dispatch(
!visits.length && lastVisit
? { type: actionMap.fallbackToInterval, fallbackInterval: dateToMatchingInterval(lastVisit.date) }
: { ...extraFinishActionData, visits, type: actionMap.finish },
);
} catch (e: any) { } catch (e: any) {
dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) }); dispatch<ApiErrorAction>({ type: actionMap.error, errorData: parseApiError(e) });
} }
}; };
export const lastVisitLoaderForLoader = (
doIntervalFallback: boolean,
loader: (params: ShlinkVisitsParams) => Promise<ShlinkVisits>,
): LastVisitLoader => {
if (!doIntervalFallback) {
return async () => Promise.resolve(undefined);
}
return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]);
};

View file

@ -1,5 +1,12 @@
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import {
OrphanVisit,
OrphanVisitType,
Visit,
VisitsFallbackIntervalAction,
VisitsInfo,
VisitsLoadProgressChangedAction,
} from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; 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 { GetState } from '../../container/types';
@ -7,7 +14,7 @@ import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers'; import { isOrphanVisit } from '../types/helpers';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date'; import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
@ -17,6 +24,7 @@ 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_LARGE = 'shlink/orphanVisits/GET_ORPHAN_VISITS_LARGE';
export const GET_ORPHAN_VISITS_CANCEL = 'shlink/orphanVisits/GET_ORPHAN_VISITS_CANCEL'; 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_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';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface OrphanVisitsAction extends Action<string> { export interface OrphanVisitsAction extends Action<string> {
@ -26,6 +34,7 @@ export interface OrphanVisitsAction extends Action<string> {
type OrphanVisitsCombinedAction = OrphanVisitsAction type OrphanVisitsCombinedAction = OrphanVisitsAction
& VisitsLoadProgressChangedAction & VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction & CreateVisitsAction
& ApiErrorAction; & ApiErrorAction;
@ -41,10 +50,11 @@ const initialState: VisitsInfo = {
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({ export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_ORPHAN_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_ORPHAN_VISITS]: (_, { visits, query }) => ({ ...initialState, visits, query }), [GET_ORPHAN_VISITS]: (state, { visits, query }) => ({ ...state, visits, query, loading: false, error: false }),
[GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_ORPHAN_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_ORPHAN_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { visits, query = {} } = state; const { visits, query = {} } = state;
const { startDate, endDate } = query; const { startDate, endDate } = query;
@ -62,6 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
query: ShlinkVisitsParams = {}, query: ShlinkVisitsParams = {},
orphanVisitsType?: OrphanVisitType, orphanVisitsType?: OrphanVisitType,
doIntervalFallback = false,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
const { getOrphanVisits } = buildShlinkApiClient(getState); const { getOrphanVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }) const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage })
@ -70,6 +81,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
return { ...result, data: visits }; return { ...result, data: visits };
}); });
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getOrphanVisits);
const shouldCancel = () => getState().orphanVisits.cancelLoad; const shouldCancel = () => getState().orphanVisits.cancelLoad;
const extraFinishActionData: Partial<OrphanVisitsAction> = { query }; const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
const actionMap = { const actionMap = {
@ -78,9 +90,10 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
finish: GET_ORPHAN_VISITS, finish: GET_ORPHAN_VISITS,
error: GET_ORPHAN_VISITS_ERROR, error: GET_ORPHAN_VISITS_ERROR,
progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED, progress: GET_ORPHAN_VISITS_PROGRESS_CHANGED,
fallbackToInterval: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL,
}; };
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
}; };
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL); export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);

View file

@ -1,6 +1,6 @@
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { shortUrlMatches } from '../../short-urls/helpers'; import { shortUrlMatches } from '../../short-urls/helpers';
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; 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 { buildActionCreator, buildReducer } from '../../utils/helpers/redux';
import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder';
@ -8,7 +8,7 @@ import { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date'; import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
@ -18,6 +18,7 @@ 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_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_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_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';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {} export interface ShortUrlVisits extends VisitsInfo, ShortUrlIdentifier {}
@ -29,6 +30,7 @@ interface ShortUrlVisitsAction extends Action<string>, ShortUrlIdentifier {
type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction
& VisitsLoadProgressChangedAction & VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction & CreateVisitsAction
& ApiErrorAction; & ApiErrorAction;
@ -46,16 +48,19 @@ const initialState: ShortUrlVisits = {
export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
[GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_SHORT_URL_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_SHORT_URL_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_SHORT_URL_VISITS]: (_, { visits, query, shortCode, domain }) => ({ [GET_SHORT_URL_VISITS]: (state, { visits, query, shortCode, domain }) => ({
...initialState, ...state,
visits, visits,
shortCode, shortCode,
domain, domain,
query, query,
loading: false,
error: false,
}), }),
[GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_SHORT_URL_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_SHORT_URL_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_SHORT_URL_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { shortCode, domain, visits, query = {} } = state; const { shortCode, domain, visits, query = {} } = state;
const { startDate, endDate } = query; const { startDate, endDate } = query;
@ -73,12 +78,17 @@ export default buildReducer<ShortUrlVisits, ShortUrlVisitsCombinedAction>({
export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
shortCode: string, shortCode: string,
query: ShlinkVisitsParams = {}, query: ShlinkVisitsParams = {},
doIntervalFallback = false,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
const { getShortUrlVisits } = buildShlinkApiClient(getState); const { getShortUrlVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits( const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits(
shortCode, shortCode,
{ ...query, page, itemsPerPage }, { ...query, page, itemsPerPage },
); );
const lastVisitLoader = lastVisitLoaderForLoader(
doIntervalFallback,
async (params) => getShortUrlVisits(shortCode, { ...params, domain: query.domain }),
);
const shouldCancel = () => getState().shortUrlVisits.cancelLoad; const shouldCancel = () => getState().shortUrlVisits.cancelLoad;
const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain }; const extraFinishActionData: Partial<ShortUrlVisitsAction> = { shortCode, query, domain: query.domain };
const actionMap = { const actionMap = {
@ -87,9 +97,10 @@ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder)
finish: GET_SHORT_URL_VISITS, finish: GET_SHORT_URL_VISITS,
error: GET_SHORT_URL_VISITS_ERROR, error: GET_SHORT_URL_VISITS_ERROR,
progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED, progress: GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
fallbackToInterval: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL,
}; };
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
}; };
export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL); export const cancelGetShortUrlVisits = buildActionCreator(GET_SHORT_URL_VISITS_CANCEL);

View file

@ -1,12 +1,12 @@
import { Action, Dispatch } from 'redux'; import { Action, Dispatch } from 'redux';
import { Visit, VisitsInfo, VisitsLoadProgressChangedAction } from '../types'; import { Visit, VisitsFallbackIntervalAction, VisitsInfo, VisitsLoadProgressChangedAction } from '../types';
import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; 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 { GetState } from '../../container/types';
import { ShlinkVisitsParams } from '../../api/types'; import { ShlinkVisitsParams } from '../../api/types';
import { ApiErrorAction } from '../../api/types/actions'; import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date'; import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common'; import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* eslint-disable padding-line-between-statements */ /* eslint-disable padding-line-between-statements */
@ -16,6 +16,7 @@ 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_LARGE = 'shlink/tagVisits/GET_TAG_VISITS_LARGE';
export const GET_TAG_VISITS_CANCEL = 'shlink/tagVisits/GET_TAG_VISITS_CANCEL'; 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_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';
/* eslint-enable padding-line-between-statements */ /* eslint-enable padding-line-between-statements */
export interface TagVisits extends VisitsInfo { export interface TagVisits extends VisitsInfo {
@ -30,6 +31,7 @@ export interface TagVisitsAction extends Action<string> {
type TagsVisitsCombinedAction = TagVisitsAction type TagsVisitsCombinedAction = TagVisitsAction
& VisitsLoadProgressChangedAction & VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction & CreateVisitsAction
& ApiErrorAction; & ApiErrorAction;
@ -46,10 +48,11 @@ const initialState: TagVisits = {
export default buildReducer<TagVisits, TagsVisitsCombinedAction>({ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
[GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }), [GET_TAG_VISITS_START]: () => ({ ...initialState, loading: true }),
[GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }), [GET_TAG_VISITS_ERROR]: (_, { errorData }) => ({ ...initialState, error: true, errorData }),
[GET_TAG_VISITS]: (_, { visits, tag, query }) => ({ ...initialState, visits, tag, query }), [GET_TAG_VISITS]: (state, { visits, tag, query }) => ({ ...state, visits, tag, query, loading: false, error: false }),
[GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }), [GET_TAG_VISITS_LARGE]: (state) => ({ ...state, loadingLarge: true }),
[GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }), [GET_TAG_VISITS_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }), [GET_TAG_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[GET_TAG_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
[CREATE_VISITS]: (state, { createdVisits }) => { [CREATE_VISITS]: (state, { createdVisits }) => {
const { tag, visits, query = {} } = state; const { tag, visits, query = {} } = state;
const { startDate, endDate } = query; const { startDate, endDate } = query;
@ -64,12 +67,14 @@ export default buildReducer<TagVisits, TagsVisitsCombinedAction>({
export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
tag: string, tag: string,
query: ShlinkVisitsParams = {}, query: ShlinkVisitsParams = {},
doIntervalFallback = false,
) => async (dispatch: Dispatch, getState: GetState) => { ) => async (dispatch: Dispatch, getState: GetState) => {
const { getTagVisits } = buildShlinkApiClient(getState); const { getTagVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits( const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits(
tag, tag,
{ ...query, page, itemsPerPage }, { ...query, page, itemsPerPage },
); );
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, async (params) => getTagVisits(tag, params));
const shouldCancel = () => getState().tagVisits.cancelLoad; const shouldCancel = () => getState().tagVisits.cancelLoad;
const extraFinishActionData: Partial<TagVisitsAction> = { tag, query }; const extraFinishActionData: Partial<TagVisitsAction> = { tag, query };
const actionMap = { const actionMap = {
@ -78,9 +83,10 @@ export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
finish: GET_TAG_VISITS, finish: GET_TAG_VISITS,
error: GET_TAG_VISITS_ERROR, error: GET_TAG_VISITS_ERROR,
progress: GET_TAG_VISITS_PROGRESS_CHANGED, progress: GET_TAG_VISITS_PROGRESS_CHANGED,
fallbackToInterval: GET_TAG_VISITS_FALLBACK_TO_INTERVAL,
}; };
return getVisitsWithLoader(visitsLoader, extraFinishActionData, actionMap, dispatch, shouldCancel); return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
}; };
export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL); export const cancelGetTagVisits = buildActionCreator(GET_TAG_VISITS_CANCEL);

View file

@ -1,7 +1,7 @@
import { Action } from 'redux'; import { Action } from 'redux';
import { ShortUrl } from '../../short-urls/data'; import { ShortUrl } from '../../short-urls/data';
import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types'; import { ProblemDetailsError, ShlinkVisitsParams } from '../../api/types';
import { DateRange } from '../../utils/dates/types'; import { DateInterval, DateRange } from '../../utils/dates/types';
export interface VisitsInfo { export interface VisitsInfo {
visits: Visit[]; visits: Visit[];
@ -12,12 +12,17 @@ export interface VisitsInfo {
progress: number; progress: number;
cancelLoad: boolean; cancelLoad: boolean;
query?: ShlinkVisitsParams; query?: ShlinkVisitsParams;
fallbackInterval?: DateInterval;
} }
export interface VisitsLoadProgressChangedAction extends Action<string> { export interface VisitsLoadProgressChangedAction extends Action<string> {
progress: number; 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';
interface VisitLocation { interface VisitLocation {

View file

@ -55,12 +55,12 @@ describe('<Visits />', () => {
const selector = wrapper.find(DateIntervalSelector); const selector = wrapper.find(DateIntervalSelector);
selector.simulate('change', 'last7Days'); selector.simulate('change', 'last7Days');
selector.simulate('change', 'last180days'); selector.simulate('change', 'last180Days');
selector.simulate('change', 'yesterday'); selector.simulate('change', 'yesterday');
expect(setVisitsSettings).toHaveBeenCalledTimes(3); expect(setVisitsSettings).toHaveBeenCalledTimes(3);
expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(1, { defaultInterval: 'last7Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180days' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(2, { defaultInterval: 'last180Days' });
expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' }); expect(setVisitsSettings).toHaveBeenNthCalledWith(3, { defaultInterval: 'yesterday' });
}); });
}); });

View file

@ -54,9 +54,9 @@ describe('settingsReducer', () => {
describe('setVisitsSettings', () => { describe('setVisitsSettings', () => {
it('creates action to set visits settings', () => { it('creates action to set visits settings', () => {
const result = setVisitsSettings({ defaultInterval: 'last180days' }); const result = setVisitsSettings({ defaultInterval: 'last180Days' });
expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180days' } }); expect(result).toEqual({ type: SET_SETTINGS, visits: { defaultInterval: 'last180Days' } });
}); });
}); });
}); });

View file

@ -8,7 +8,7 @@ describe('<DateIntervalDropdownItems />', () => {
const onChange = jest.fn(); const onChange = jest.fn();
beforeEach(() => { beforeEach(() => {
wrapper = shallow(<DateIntervalDropdownItems allText="All" active="last180days" onChange={onChange} />); wrapper = shallow(<DateIntervalDropdownItems allText="All" active="last180Days" onChange={onChange} />);
}); });
afterEach(jest.clearAllMocks); afterEach(jest.clearAllMocks);

View file

@ -44,7 +44,7 @@ describe('<DateRangeSelector />', () => {
[ 'last7Days' as DateInterval, 1 ], [ 'last7Days' as DateInterval, 1 ],
[ 'last30Days' as DateInterval, 1 ], [ 'last30Days' as DateInterval, 1 ],
[ 'last90Days' as DateInterval, 1 ], [ 'last90Days' as DateInterval, 1 ],
[ 'last180days' as DateInterval, 1 ], [ 'last180Days' as DateInterval, 1 ],
[ 'last365Days' as DateInterval, 1 ], [ 'last365Days' as DateInterval, 1 ],
[{ startDate: new Date() }, 0 ], [{ startDate: new Date() }, 0 ],
])('sets proper element as active based on provided date range', (initialDateRange, expectedActiveIntervalItems) => { ])('sets proper element as active based on provided date range', (initialDateRange, expectedActiveIntervalItems) => {

View file

@ -1,7 +1,8 @@
import { format, subDays } from 'date-fns'; import { endOfDay, format, formatISO, startOfDay, subDays } from 'date-fns';
import { import {
DateInterval, DateInterval,
dateRangeIsEmpty, dateRangeIsEmpty,
dateToMatchingInterval,
intervalToDateRange, intervalToDateRange,
rangeIsInterval, rangeIsInterval,
rangeOrIntervalToString, rangeOrIntervalToString,
@ -9,6 +10,9 @@ import {
import { parseDate } from '../../../../src/utils/helpers/date'; import { parseDate } from '../../../../src/utils/helpers/date';
describe('date-types', () => { describe('date-types', () => {
const now = () => new Date();
const daysBack = (days: number) => subDays(new Date(), days);
describe('dateRangeIsEmpty', () => { describe('dateRangeIsEmpty', () => {
it.each([ it.each([
[ undefined, true ], [ undefined, true ],
@ -48,7 +52,7 @@ describe('date-types', () => {
[ 'last7Days' as DateInterval, 'Last 7 days' ], [ 'last7Days' as DateInterval, 'Last 7 days' ],
[ 'last30Days' as DateInterval, 'Last 30 days' ], [ 'last30Days' as DateInterval, 'Last 30 days' ],
[ 'last90Days' as DateInterval, 'Last 90 days' ], [ 'last90Days' as DateInterval, 'Last 90 days' ],
[ 'last180days' as DateInterval, 'Last 180 days' ], [ 'last180Days' as DateInterval, 'Last 180 days' ],
[ 'last365Days' as DateInterval, 'Last 365 days' ], [ 'last365Days' as DateInterval, 'Last 365 days' ],
[{}, undefined ], [{}, undefined ],
[{ startDate: null }, undefined ], [{ startDate: null }, undefined ],
@ -71,8 +75,6 @@ describe('date-types', () => {
}); });
describe('intervalToDateRange', () => { describe('intervalToDateRange', () => {
const now = () => new Date();
const daysBack = (days: number) => subDays(new Date(), days);
const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd'); const formatted = (date?: Date | null): string | undefined => !date ? undefined : format(date, 'yyyy-MM-dd');
it.each([ it.each([
@ -82,7 +84,7 @@ describe('date-types', () => {
[ 'last7Days' as DateInterval, daysBack(7), now() ], [ 'last7Days' as DateInterval, daysBack(7), now() ],
[ 'last30Days' as DateInterval, daysBack(30), now() ], [ 'last30Days' as DateInterval, daysBack(30), now() ],
[ 'last90Days' as DateInterval, daysBack(90), now() ], [ 'last90Days' as DateInterval, daysBack(90), now() ],
[ 'last180days' as DateInterval, daysBack(180), now() ], [ 'last180Days' as DateInterval, daysBack(180), now() ],
[ 'last365Days' as DateInterval, daysBack(365), now() ], [ 'last365Days' as DateInterval, daysBack(365), now() ],
])('returns proper result', (interval, expectedStartDate, expectedEndDate) => { ])('returns proper result', (interval, expectedStartDate, expectedEndDate) => {
const { startDate, endDate } = intervalToDateRange(interval); const { startDate, endDate } = intervalToDateRange(interval);
@ -91,4 +93,27 @@ describe('date-types', () => {
expect(formatted(expectedEndDate)).toEqual(formatted(endDate)); expect(formatted(expectedEndDate)).toEqual(formatted(endDate));
}); });
}); });
describe('dateToMatchingInterval', () => {
it.each([
[ startOfDay(now()), 'today' ],
[ now(), 'today' ],
[ formatISO(now()), 'today' ],
[ daysBack(1), 'yesterday' ],
[ endOfDay(daysBack(1)), 'yesterday' ],
[ daysBack(2), 'last7Days' ],
[ daysBack(7), 'last7Days' ],
[ startOfDay(daysBack(7)), 'last7Days' ],
[ daysBack(18), 'last30Days' ],
[ daysBack(29), 'last30Days' ],
[ daysBack(58), 'last90Days' ],
[ startOfDay(daysBack(90)), 'last90Days' ],
[ daysBack(120), 'last180Days' ],
[ daysBack(250), 'last365Days' ],
[ daysBack(366), 'all' ],
[ formatISO(daysBack(500)), 'all' ],
])('returns the first interval which contains provided date', (date, expectedInterval) => {
expect(dateToMatchingInterval(date)).toEqual(expectedInterval);
});
});
}); });

View file

@ -1,7 +1,9 @@
import { formatISO } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import { formatDate, formatIsoDate, parseDate } from '../../../src/utils/helpers/date'; import { formatDate, formatIsoDate, isBeforeOrEqual, isBetween, parseDate } from '../../../src/utils/helpers/date';
describe('date', () => { describe('date', () => {
const now = new Date();
describe('formatDate', () => { describe('formatDate', () => {
it.each([ it.each([
[ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020' ], [ parseDate('2020-03-05 10:00:10', 'yyyy-MM-dd HH:mm:ss'), 'dd/MM/yyyy', '05/03/2020' ],
@ -30,4 +32,32 @@ describe('date', () => {
expect(formatIsoDate(date)).toEqual(expected); expect(formatIsoDate(date)).toEqual(expected);
}); });
}); });
describe('isBetween', () => {
test.each([
[ now, undefined, undefined, true ],
[ now, subDays(now, 1), undefined, true ],
[ now, now, undefined, true ],
[ now, undefined, addDays(now, 1), true ],
[ now, undefined, now, true ],
[ now, subDays(now, 1), addDays(now, 1), true ],
[ now, now, now, true ],
[ now, addDays(now, 1), undefined, false ],
[ now, undefined, subDays(now, 1), false ],
[ now, subDays(now, 3), subDays(now, 1), false ],
[ now, addDays(now, 1), addDays(now, 3), false ],
])('returns true when a date is between provided range', (date, start, end, expectedResult) => {
expect(isBetween(date, start, end)).toEqual(expectedResult);
});
});
describe('isBeforeOrEqual', () => {
test.each([
[ now, now, true ],
[ now, addDays(now, 1), true ],
[ now, subDays(now, 1), false ],
])('returns true when the date before or equal to provided one', (date, dateToCompare, expectedResult) => {
expect(isBeforeOrEqual(date, dateToCompare)).toEqual(expectedResult);
});
});
}); });

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import reducer, {
getOrphanVisits, getOrphanVisits,
cancelGetOrphanVisits, cancelGetOrphanVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_ORPHAN_VISITS_LARGE, GET_ORPHAN_VISITS_LARGE,
GET_ORPHAN_VISITS_CANCEL, GET_ORPHAN_VISITS_CANCEL,
GET_ORPHAN_VISITS_PROGRESS_CHANGED, GET_ORPHAN_VISITS_PROGRESS_CHANGED,
GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL,
} from '../../../src/visits/reducers/orphanVisits'; } from '../../../src/visits/reducers/orphanVisits';
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { rangeOf } from '../../../src/utils/utils'; import { rangeOf } from '../../../src/utils/utils';
@ -17,6 +18,7 @@ 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';
describe('orphanVisitsReducer', () => { describe('orphanVisitsReducer', () => {
const now = new Date(); const now = new Date();
@ -116,6 +118,13 @@ describe('orphanVisitsReducer', () => {
expect(state).toEqual(expect.objectContaining({ progress: 85 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any);
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
}); });
describe('getOrphanVisits', () => { describe('getOrphanVisits', () => {
@ -163,6 +172,38 @@ describe('orphanVisitsReducer', () => {
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} });
expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getOrphanVisits).toHaveBeenCalledTimes(1);
}); });
it.each([
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) }) ],
{ type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' },
],
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 200)) }) ],
{ type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' },
],
[[], expect.objectContaining({ type: GET_ORPHAN_VISITS }) ],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
const getShlinkOrphanVisits = jest.fn()
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
const ShlinkApiClient = Mock.of<ShlinkApiClient>({ getOrphanVisits: getShlinkOrphanVisits });
await getOrphanVisits(() => ShlinkApiClient)({}, undefined, true)(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_ORPHAN_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkOrphanVisits).toHaveBeenCalledTimes(2);
});
}); });
describe('cancelGetOrphanVisits', () => { describe('cancelGetOrphanVisits', () => {

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import reducer, {
getShortUrlVisits, getShortUrlVisits,
cancelGetShortUrlVisits, cancelGetShortUrlVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_SHORT_URL_VISITS_LARGE, GET_SHORT_URL_VISITS_LARGE,
GET_SHORT_URL_VISITS_CANCEL, GET_SHORT_URL_VISITS_CANCEL,
GET_SHORT_URL_VISITS_PROGRESS_CHANGED, 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 { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
@ -18,6 +19,7 @@ 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';
describe('shortUrlVisitsReducer', () => { describe('shortUrlVisitsReducer', () => {
const now = new Date(); const now = new Date();
@ -137,6 +139,13 @@ describe('shortUrlVisitsReducer', () => {
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', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any);
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
}); });
describe('getShortUrlVisits', () => { describe('getShortUrlVisits', () => {
@ -209,6 +218,38 @@ describe('shortUrlVisitsReducer', () => {
visits: [ ...visitsMocks, ...visitsMocks, ...visitsMocks ], visits: [ ...visitsMocks, ...visitsMocks, ...visitsMocks ],
})); }));
}); });
it.each([
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 5)) }) ],
{ type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' },
],
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 200)) }) ],
{ type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last365Days' },
],
[[], expect.objectContaining({ type: GET_SHORT_URL_VISITS }) ],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
const getShlinkShortUrlVisits = jest.fn()
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
const ShlinkApiClient = Mock.of<ShlinkApiClient>({ getShortUrlVisits: getShlinkShortUrlVisits });
await getShortUrlVisits(() => ShlinkApiClient)('abc123', {}, true)(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_SHORT_URL_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkShortUrlVisits).toHaveBeenCalledTimes(2);
});
}); });
describe('cancelGetShortUrlVisits', () => { describe('cancelGetShortUrlVisits', () => {

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery'; import { Mock } from 'ts-mockery';
import { addDays, subDays } from 'date-fns'; import { addDays, formatISO, subDays } from 'date-fns';
import reducer, { import reducer, {
getTagVisits, getTagVisits,
cancelGetTagVisits, cancelGetTagVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_TAG_VISITS_LARGE, GET_TAG_VISITS_LARGE,
GET_TAG_VISITS_CANCEL, GET_TAG_VISITS_CANCEL,
GET_TAG_VISITS_PROGRESS_CHANGED, GET_TAG_VISITS_PROGRESS_CHANGED,
GET_TAG_VISITS_FALLBACK_TO_INTERVAL,
TagVisits, TagVisits,
} from '../../../src/visits/reducers/tagVisits'; } from '../../../src/visits/reducers/tagVisits';
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation'; import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
@ -18,6 +19,7 @@ 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';
describe('tagVisitsReducer', () => { describe('tagVisitsReducer', () => {
const now = new Date(); const now = new Date();
@ -137,6 +139,13 @@ describe('tagVisitsReducer', () => {
expect(state).toEqual(expect.objectContaining({ progress: 85 })); expect(state).toEqual(expect.objectContaining({ progress: 85 }));
}); });
it('returns fallbackInterval on GET_TAG_VISITS_FALLBACK_TO_INTERVAL', () => {
const fallbackInterval: DateInterval = 'last30Days';
const state = reducer(undefined, { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval } as any);
expect(state).toEqual(expect.objectContaining({ fallbackInterval }));
});
}); });
describe('getTagVisits', () => { describe('getTagVisits', () => {
@ -149,8 +158,9 @@ describe('tagVisitsReducer', () => {
const getState = () => Mock.of<ShlinkState>({ const getState = () => Mock.of<ShlinkState>({
tagVisits: { cancelLoad: false }, tagVisits: { cancelLoad: false },
}); });
const tag = 'foo';
beforeEach(jest.resetAllMocks); 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({})); const ShlinkApiClient = buildApiClientMock(Promise.reject({}));
@ -168,7 +178,6 @@ 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 tag = 'foo';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({ const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visitsMocks, data: visitsMocks,
pagination: { pagination: {
@ -185,6 +194,38 @@ describe('tagVisitsReducer', () => {
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} }); expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} });
expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1); expect(ShlinkApiClient.getTagVisits).toHaveBeenCalledTimes(1);
}); });
it.each([
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 20)) }) ],
{ type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' },
],
[
[ Mock.of<Visit>({ date: formatISO(subDays(new Date(), 100)) }) ],
{ type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last180Days' },
],
[[], expect.objectContaining({ type: GET_TAG_VISITS }) ],
])('dispatches fallback interval when the list of visits is empty', async (lastVisits, expectedSecondDispatch) => {
const buildVisitsResult = (data: Visit[] = []): ShlinkVisits => ({
data,
pagination: {
currentPage: 1,
pagesCount: 1,
totalItems: 1,
},
});
const getShlinkTagVisits = jest.fn()
.mockResolvedValueOnce(buildVisitsResult())
.mockResolvedValueOnce(buildVisitsResult(lastVisits));
const ShlinkApiClient = Mock.of<ShlinkApiClient>({ getTagVisits: getShlinkTagVisits });
await getTagVisits(() => ShlinkApiClient)(tag, {}, true)(dispatchMock, getState);
expect(dispatchMock).toHaveBeenCalledTimes(2);
expect(dispatchMock).toHaveBeenNthCalledWith(1, { type: GET_TAG_VISITS_START });
expect(dispatchMock).toHaveBeenNthCalledWith(2, expectedSecondDispatch);
expect(getShlinkTagVisits).toHaveBeenCalledTimes(2);
});
}); });
describe('cancelGetTagVisits', () => { describe('cancelGetTagVisits', () => {