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]
### 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.
* [#531](https://github.com/shlinkio/shlink-web-client/pull/531) Added custom slug field to the basic creation form in the Overview page.
### Changed
* [#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
* 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
* *Nothing*

View file

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

View file

@ -1,13 +1,13 @@
import { subDays, startOfDay, endOfDay } from 'date-fns';
import { filter, isEmpty } from 'ramda';
import { formatInternational } from '../../helpers/date';
import { cond, filter, isEmpty, T } from 'ramda';
import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
export interface DateRange {
startDate?: 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
|| isEmpty(filter(Boolean, dateRange as any));
@ -21,7 +21,7 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
last7Days: 'Last 7 days',
last30Days: 'Last 30 days',
last90Days: 'Last 90 days',
last180days: 'Last 180 days',
last180Days: 'Last 180 days',
last365Days: 'Last 365 days',
all: undefined,
};
@ -75,7 +75,7 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
return endingToday(startOfDaysAgo(30));
case 'last90Days':
return endingToday(startOfDaysAgo(90));
case 'last180days':
case 'last180Days':
return endingToday(startOfDaysAgo(180));
case 'last365Days':
return endingToday(startOfDaysAgo(365));
@ -83,3 +83,18 @@ export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
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';
type DateOrString = Date | string;
export type DateOrString = Date | string;
type NullableDate = DateOrString | null;
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());
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 => {
if (!start && end) {
return isBefore(parseISO(date), parseISO(end));
try {
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 { parseQuery, stringifyQuery } from './query';
@ -66,3 +66,12 @@ export const useQueryState = <T>(paramName: string, initialState: T): [ T, (newV
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';
export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps {
getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void;
getOrphanVisits: (
params?: ShlinkVisitsParams,
orphanVisitsType?: OrphanVisitType,
doIntervalFallback?: boolean,
) => void;
orphanVisits: VisitsInfo;
cancelGetOrphanVisits: () => void;
}
@ -25,7 +29,8 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
selectedServer,
}: OrphanVisitsProps) => {
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 (
<VisitsStats

View file

@ -14,7 +14,7 @@ import { CommonVisitsProps } from './types/CommonVisitsProps';
import { toApiParams } from './types/helpers';
export interface ShortUrlVisitsProps extends CommonVisitsProps, RouteComponentProps<{ shortCode: string }> {
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void;
getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams, doIntervalFallback?: boolean) => void;
shortUrlVisits: ShortUrlVisitsState;
getShortUrlDetail: Function;
shortUrlDetail: ShortUrlDetail;
@ -35,7 +35,8 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub((
}: ShortUrlVisitsProps) => {
const { shortCode } = params;
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(
`short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`,
visits,

View file

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

View file

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

View file

@ -1,9 +1,10 @@
import { flatten, prop, range, splitEvery } from 'ramda';
import { Action, Dispatch } from 'redux';
import { ShlinkPaginator, ShlinkVisits } from '../../api/types';
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
import { Visit } from '../types';
import { parseApiError } from '../../api/utils';
import { ApiErrorAction } from '../../api/types/actions';
import { dateToMatchingInterval } from '../../utils/dates/types';
const ITEMS_PER_PAGE = 5000;
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;
type VisitsLoader = (page: number, itemsPerPage: number) => Promise<ShlinkVisits>;
type LastVisitLoader = () => Promise<Visit | undefined>;
interface ActionMap {
start: string;
large: string;
finish: string;
error: string;
progress: string;
fallbackToInterval: string;
}
export const getVisitsWithLoader = async <T extends Action<string> & { visits: Visit[] }>(
visitsLoader: VisitsLoader,
lastVisitLoader: LastVisitLoader,
extraFinishActionData: Partial<T>,
actionMap: ActionMap,
dispatch: Dispatch,
@ -69,10 +73,25 @@ export const getVisitsWithLoader = async <T extends Action<string> & { visits: V
};
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) {
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 { OrphanVisit, OrphanVisitType, Visit, VisitsInfo, VisitsLoadProgressChangedAction } 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 { GetState } from '../../container/types';
@ -7,7 +14,7 @@ import { ShlinkVisitsParams } from '../../api/types';
import { isOrphanVisit } from '../types/helpers';
import { ApiErrorAction } from '../../api/types/actions';
import { isBetween } from '../../utils/helpers/date';
import { getVisitsWithLoader } from './common';
import { getVisitsWithLoader, lastVisitLoaderForLoader } from './common';
import { CREATE_VISITS, CreateVisitsAction } from './visitCreation';
/* 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_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';
/* eslint-enable padding-line-between-statements */
export interface OrphanVisitsAction extends Action<string> {
@ -26,6 +34,7 @@ export interface OrphanVisitsAction extends Action<string> {
type OrphanVisitsCombinedAction = OrphanVisitsAction
& VisitsLoadProgressChangedAction
& VisitsFallbackIntervalAction
& CreateVisitsAction
& ApiErrorAction;
@ -41,10 +50,11 @@ const initialState: VisitsInfo = {
export default buildReducer<VisitsInfo, OrphanVisitsCombinedAction>({
[GET_ORPHAN_VISITS_START]: () => ({ ...initialState, loading: true }),
[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_CANCEL]: (state) => ({ ...state, cancelLoad: true }),
[GET_ORPHAN_VISITS_PROGRESS_CHANGED]: (state, { progress }) => ({ ...state, progress }),
[GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL]: (state, { fallbackInterval }) => ({ ...state, fallbackInterval }),
[CREATE_VISITS]: (state, { createdVisits }) => {
const { visits, query = {} } = state;
const { startDate, endDate } = query;
@ -62,6 +72,7 @@ const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) =>
export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (
query: ShlinkVisitsParams = {},
orphanVisitsType?: OrphanVisitType,
doIntervalFallback = false,
) => async (dispatch: Dispatch, getState: GetState) => {
const { getOrphanVisits } = buildShlinkApiClient(getState);
const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage })
@ -70,6 +81,7 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
return { ...result, data: visits };
});
const lastVisitLoader = lastVisitLoaderForLoader(doIntervalFallback, getOrphanVisits);
const shouldCancel = () => getState().orphanVisits.cancelLoad;
const extraFinishActionData: Partial<OrphanVisitsAction> = { query };
const actionMap = {
@ -78,9 +90,10 @@ export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) =>
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, extraFinishActionData, actionMap, dispatch, shouldCancel);
return getVisitsWithLoader(visitsLoader, lastVisitLoader, extraFinishActionData, actionMap, dispatch, shouldCancel);
};
export const cancelGetOrphanVisits = buildActionCreator(GET_ORPHAN_VISITS_CANCEL);

View file

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

View file

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

View file

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

View file

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

View file

@ -54,9 +54,9 @@ describe('settingsReducer', () => {
describe('setVisitsSettings', () => {
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();
beforeEach(() => {
wrapper = shallow(<DateIntervalDropdownItems allText="All" active="last180days" onChange={onChange} />);
wrapper = shallow(<DateIntervalDropdownItems allText="All" active="last180Days" onChange={onChange} />);
});
afterEach(jest.clearAllMocks);

View file

@ -44,7 +44,7 @@ describe('<DateRangeSelector />', () => {
[ 'last7Days' as DateInterval, 1 ],
[ 'last30Days' as DateInterval, 1 ],
[ 'last90Days' as DateInterval, 1 ],
[ 'last180days' as DateInterval, 1 ],
[ 'last180Days' as DateInterval, 1 ],
[ 'last365Days' as DateInterval, 1 ],
[{ startDate: new Date() }, 0 ],
])('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 {
DateInterval,
dateRangeIsEmpty,
dateToMatchingInterval,
intervalToDateRange,
rangeIsInterval,
rangeOrIntervalToString,
@ -9,6 +10,9 @@ import {
import { parseDate } from '../../../../src/utils/helpers/date';
describe('date-types', () => {
const now = () => new Date();
const daysBack = (days: number) => subDays(new Date(), days);
describe('dateRangeIsEmpty', () => {
it.each([
[ undefined, true ],
@ -48,7 +52,7 @@ describe('date-types', () => {
[ 'last7Days' as DateInterval, 'Last 7 days' ],
[ 'last30Days' as DateInterval, 'Last 30 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' ],
[{}, undefined ],
[{ startDate: null }, undefined ],
@ -71,8 +75,6 @@ describe('date-types', () => {
});
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');
it.each([
@ -82,7 +84,7 @@ describe('date-types', () => {
[ 'last7Days' as DateInterval, daysBack(7), now() ],
[ 'last30Days' as DateInterval, daysBack(30), 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() ],
])('returns proper result', (interval, expectedStartDate, expectedEndDate) => {
const { startDate, endDate } = intervalToDateRange(interval);
@ -91,4 +93,27 @@ describe('date-types', () => {
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 { formatDate, formatIsoDate, parseDate } from '../../../src/utils/helpers/date';
import { addDays, formatISO, subDays } from 'date-fns';
import { formatDate, formatIsoDate, isBeforeOrEqual, isBetween, parseDate } from '../../../src/utils/helpers/date';
describe('date', () => {
const now = new Date();
describe('formatDate', () => {
it.each([
[ 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);
});
});
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 { addDays, subDays } from 'date-fns';
import { addDays, formatISO, subDays } from 'date-fns';
import reducer, {
getOrphanVisits,
cancelGetOrphanVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_ORPHAN_VISITS_LARGE,
GET_ORPHAN_VISITS_CANCEL,
GET_ORPHAN_VISITS_PROGRESS_CHANGED,
GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL,
} from '../../../src/visits/reducers/orphanVisits';
import { CREATE_VISITS } from '../../../src/visits/reducers/visitCreation';
import { rangeOf } from '../../../src/utils/utils';
@ -17,6 +18,7 @@ import { ShlinkVisits } from '../../../src/api/types';
import ShlinkApiClient from '../../../src/api/services/ShlinkApiClient';
import { ShlinkState } from '../../../src/container/types';
import { formatIsoDate } from '../../../src/utils/helpers/date';
import { DateInterval } from '../../../src/utils/dates/types';
describe('orphanVisitsReducer', () => {
const now = new Date();
@ -116,6 +118,13 @@ describe('orphanVisitsReducer', () => {
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', () => {
@ -163,6 +172,38 @@ describe('orphanVisitsReducer', () => {
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_ORPHAN_VISITS, visits, query: query ?? {} });
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', () => {

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery';
import { addDays, subDays } from 'date-fns';
import { addDays, formatISO, subDays } from 'date-fns';
import reducer, {
getShortUrlVisits,
cancelGetShortUrlVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_SHORT_URL_VISITS_LARGE,
GET_SHORT_URL_VISITS_CANCEL,
GET_SHORT_URL_VISITS_PROGRESS_CHANGED,
GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL,
ShortUrlVisits,
} from '../../../src/visits/reducers/shortUrlVisits';
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 { ShlinkState } from '../../../src/container/types';
import { formatIsoDate } from '../../../src/utils/helpers/date';
import { DateInterval } from '../../../src/utils/dates/types';
describe('shortUrlVisitsReducer', () => {
const now = new Date();
@ -137,6 +139,13 @@ describe('shortUrlVisitsReducer', () => {
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', () => {
@ -209,6 +218,38 @@ describe('shortUrlVisitsReducer', () => {
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', () => {

View file

@ -1,5 +1,5 @@
import { Mock } from 'ts-mockery';
import { addDays, subDays } from 'date-fns';
import { addDays, formatISO, subDays } from 'date-fns';
import reducer, {
getTagVisits,
cancelGetTagVisits,
@ -9,6 +9,7 @@ import reducer, {
GET_TAG_VISITS_LARGE,
GET_TAG_VISITS_CANCEL,
GET_TAG_VISITS_PROGRESS_CHANGED,
GET_TAG_VISITS_FALLBACK_TO_INTERVAL,
TagVisits,
} from '../../../src/visits/reducers/tagVisits';
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 { ShlinkState } from '../../../src/container/types';
import { formatIsoDate } from '../../../src/utils/helpers/date';
import { DateInterval } from '../../../src/utils/dates/types';
describe('tagVisitsReducer', () => {
const now = new Date();
@ -137,6 +139,13 @@ describe('tagVisitsReducer', () => {
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', () => {
@ -149,8 +158,9 @@ describe('tagVisitsReducer', () => {
const getState = () => Mock.of<ShlinkState>({
tagVisits: { cancelLoad: false },
});
const tag = 'foo';
beforeEach(jest.resetAllMocks);
beforeEach(jest.clearAllMocks);
it('dispatches start and error when promise is rejected', async () => {
const ShlinkApiClient = buildApiClientMock(Promise.reject({}));
@ -168,7 +178,6 @@ describe('tagVisitsReducer', () => {
[{}],
])('dispatches start and success when promise is resolved', async (query) => {
const visits = visitsMocks;
const tag = 'foo';
const ShlinkApiClient = buildApiClientMock(Promise.resolve({
data: visitsMocks,
pagination: {
@ -185,6 +194,38 @@ describe('tagVisitsReducer', () => {
expect(dispatchMock).toHaveBeenNthCalledWith(2, { type: GET_TAG_VISITS, visits, tag, query: query ?? {} });
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', () => {