diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb6cea1..e29a5871 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `-` 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* diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index b0a3c104..c9d7dd1a 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -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 ( diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts index 467a0e8f..6041a68f 100644 --- a/src/utils/dates/types/index.ts +++ b/src/utils/dates/types/index.ts @@ -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 = { 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([ + [ () => 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' ], + ])(); +}; diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index 5ce27ae3..98aa1d4d 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -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); diff --git a/src/utils/helpers/hooks.ts b/src/utils/helpers/hooks.ts index 6e9548ad..7d908852 100644 --- a/src/utils/helpers/hooks.ts +++ b/src/utils/helpers/hooks.ts @@ -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 = (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); +}; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index e87a93fe..af4dc7a4 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -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 ( { - 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, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index d7619a0a..702bd837 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -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 ( diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 4f4e3706..8818ada7 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -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 = ({ selectedServer, isOrphanVisits = false, }) => { - const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; + const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; + const [ initialInterval, setInitialInterval ] = useState( + fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days', + ); const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); const [ visitsFilter, setVisitsFilter ] = useState({}); 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 = ({ 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 = ({
cu const calcProgress = (total: number, current: number): number => current * 100 / total; type VisitsLoader = (page: number, itemsPerPage: number) => Promise; +type LastVisitLoader = () => Promise; interface ActionMap { start: string; large: string; finish: string; error: string; progress: string; + fallbackToInterval: string; } export const getVisitsWithLoader = async & { visits: Visit[] }>( visitsLoader: VisitsLoader, + lastVisitLoader: LastVisitLoader, extraFinishActionData: Partial, actionMap: ActionMap, dispatch: Dispatch, @@ -69,10 +73,25 @@ export const getVisitsWithLoader = async & { 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({ type: actionMap.error, errorData: parseApiError(e) }); } }; + +export const lastVisitLoaderForLoader = ( + doIntervalFallback: boolean, + loader: (params: ShlinkVisitsParams) => Promise, +): LastVisitLoader => { + if (!doIntervalFallback) { + return async () => Promise.resolve(undefined); + } + + return async () => loader({ page: 1, itemsPerPage: 1 }).then((result) => result.data[0]); +}; diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 4a86c8e3..bcb06d8a 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -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 { @@ -26,6 +34,7 @@ export interface OrphanVisitsAction extends Action { type OrphanVisitsCombinedAction = OrphanVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -41,10 +50,11 @@ const initialState: VisitsInfo = { export default buildReducer({ [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 = { 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); diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 81b26ca0..9b5cd352 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -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, ShortUrlIdentifier { type ShortUrlVisitsCombinedAction = ShortUrlVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -46,16 +48,19 @@ const initialState: ShortUrlVisits = { export default buildReducer({ [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({ 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 = { 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); diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index c628021f..272bd241 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -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 { type TagsVisitsCombinedAction = TagVisitsAction & VisitsLoadProgressChangedAction +& VisitsFallbackIntervalAction & CreateVisitsAction & ApiErrorAction; @@ -46,10 +48,11 @@ const initialState: TagVisits = { export default buildReducer({ [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({ 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 = { 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); diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 05f8226b..03789a28 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -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 { progress: number; } +export interface VisitsFallbackIntervalAction extends Action { + fallbackInterval: DateInterval; +} + export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { diff --git a/test/settings/Visits.test.tsx b/test/settings/Visits.test.tsx index cfbaf833..89a73546 100644 --- a/test/settings/Visits.test.tsx +++ b/test/settings/Visits.test.tsx @@ -55,12 +55,12 @@ describe('', () => { 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' }); }); }); diff --git a/test/settings/reducers/settings.test.ts b/test/settings/reducers/settings.test.ts index 9699f57a..70c5d4b7 100644 --- a/test/settings/reducers/settings.test.ts +++ b/test/settings/reducers/settings.test.ts @@ -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' } }); }); }); }); diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/test/utils/dates/DateIntervalDropdownItems.test.tsx index d77fb1e4..8587e5e9 100644 --- a/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ b/test/utils/dates/DateIntervalDropdownItems.test.tsx @@ -8,7 +8,7 @@ describe('', () => { const onChange = jest.fn(); beforeEach(() => { - wrapper = shallow(); + wrapper = shallow(); }); afterEach(jest.clearAllMocks); diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx index ef794918..aabd64fd 100644 --- a/test/utils/dates/DateRangeSelector.test.tsx +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -44,7 +44,7 @@ describe('', () => { [ '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) => { diff --git a/test/utils/dates/types/index.test.ts b/test/utils/dates/types/index.test.ts index fa7dc1d4..21cec68f 100644 --- a/test/utils/dates/types/index.test.ts +++ b/test/utils/dates/types/index.test.ts @@ -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); + }); + }); }); diff --git a/test/utils/helpers/date.test.ts b/test/utils/helpers/date.test.ts index ac0444a7..349fae49 100644 --- a/test/utils/helpers/date.test.ts +++ b/test/utils/helpers/date.test.ts @@ -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); + }); + }); }); diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 86296131..8b1e31ba 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -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({ date: formatISO(subDays(new Date(), 5)) }) ], + { type: GET_ORPHAN_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + ], + [ + [ Mock.of({ 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({ 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', () => { diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index 8d45f7da..0c6d3433 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -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({ date: formatISO(subDays(new Date(), 5)) }) ], + { type: GET_SHORT_URL_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last7Days' }, + ], + [ + [ Mock.of({ 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({ 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', () => { diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index f026e5bb..d37f5043 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -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({ 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({ date: formatISO(subDays(new Date(), 20)) }) ], + { type: GET_TAG_VISITS_FALLBACK_TO_INTERVAL, fallbackInterval: 'last30Days' }, + ], + [ + [ Mock.of({ 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({ 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', () => {