mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-10 02:07:26 +03:00
Merge pull request #754 from acelaya-forks/feature/visits-filters
Feature/visits filters
This commit is contained in:
commit
97a54d44c7
33 changed files with 227 additions and 95 deletions
|
@ -4,10 +4,11 @@ All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).
|
||||||
|
|
||||||
## [Unreleased]
|
## [3.8.0] - 2022-12-03
|
||||||
### Added
|
### Added
|
||||||
* [#708](https://github.com/shlinkio/shlink-web-client/issues/708) Added support for API v3.
|
* [#708](https://github.com/shlinkio/shlink-web-client/issues/708) Added support for API v3.
|
||||||
* [#717](https://github.com/shlinkio/shlink-web-client/issues/717) Allowed to select time in 10 minute intervals when configuring "enabled since" and "enabled until" on short URLs.
|
* [#717](https://github.com/shlinkio/shlink-web-client/issues/717) Allowed to select time in 10 minute intervals when configuring "enabled since" and "enabled until" on short URLs.
|
||||||
|
* [#748](https://github.com/shlinkio/shlink-web-client/issues/748) Improved visits section to add filters to the query string, allowing to navigate to a specific state or bookmarking filters.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies.
|
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies.
|
||||||
|
|
|
@ -17,6 +17,7 @@ module.exports = {
|
||||||
},
|
},
|
||||||
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||||
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||||
|
modulePathIgnorePatterns: ['<rootDir>/.stryker-tmp'],
|
||||||
testEnvironment: 'jsdom',
|
testEnvironment: 'jsdom',
|
||||||
testEnvironmentOptions: {
|
testEnvironmentOptions: {
|
||||||
url: 'http://localhost',
|
url: 'http://localhost',
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
|
||||||
import { mergeDeepRight } from 'ramda';
|
import { mergeDeepRight } from 'ramda';
|
||||||
import { Theme } from '../../utils/theme';
|
import { Theme } from '../../utils/theme';
|
||||||
import { DateInterval } from '../../utils/dates/types';
|
import { DateInterval } from '../../utils/helpers/dateIntervals';
|
||||||
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||||
import { ShortUrlsOrder } from '../../short-urls/data';
|
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { isEmpty, pipe } from 'ramda';
|
import { isEmpty, pipe } from 'ramda';
|
||||||
import { parseISO } from 'date-fns';
|
|
||||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
@ -8,7 +7,7 @@ import classNames from 'classnames';
|
||||||
import { SearchField } from '../utils/SearchField';
|
import { SearchField } from '../utils/SearchField';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
import { DateRange } from '../utils/dates/types';
|
import { DateRange, datesToDateRange } from '../utils/helpers/dateIntervals';
|
||||||
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
import { supportsAllTagsFiltering } from '../utils/helpers/features';
|
||||||
import { SelectedServer } from '../servers/data';
|
import { SelectedServer } from '../servers/data';
|
||||||
import { OrderDir } from '../utils/helpers/ordering';
|
import { OrderDir } from '../utils/helpers/ordering';
|
||||||
|
@ -27,8 +26,6 @@ export interface ShortUrlsFilteringProps {
|
||||||
shortUrlsAmount?: number;
|
shortUrlsAmount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
|
||||||
|
|
||||||
export const ShortUrlsFilteringBar = (
|
export const ShortUrlsFilteringBar = (
|
||||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||||
TagsSelector: FC<TagsSelectorProps>,
|
TagsSelector: FC<TagsSelectorProps>,
|
||||||
|
@ -74,10 +71,7 @@ export const ShortUrlsFilteringBar = (
|
||||||
<div className="col-lg-8 col-xl-6 mt-3">
|
<div className="col-lg-8 col-xl-6 mt-3">
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
defaultText="All short URLs"
|
defaultText="All short URLs"
|
||||||
initialDateRange={{
|
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||||
startDate: dateOrNull(startDate),
|
|
||||||
endDate: dateOrNull(endDate),
|
|
||||||
}}
|
|
||||||
onDatesChange={setDates}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -6,8 +6,6 @@ import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
||||||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||||
import { TagsFilteringMode } from '../../api/types';
|
import { TagsFilteringMode } from '../../api/types';
|
||||||
|
|
||||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
|
||||||
|
|
||||||
interface ShortUrlsQueryCommon {
|
interface ShortUrlsQueryCommon {
|
||||||
search?: string;
|
search?: string;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
|
@ -25,35 +23,36 @@ interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
||||||
tags: string[];
|
tags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||||
|
|
||||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const { search } = useLocation();
|
||||||
const params = useParams<{ serverId: string }>();
|
const { serverId = '' } = useParams<{ serverId: string }>();
|
||||||
|
|
||||||
const query = useMemo(
|
const filtering = useMemo(
|
||||||
pipe(
|
pipe(
|
||||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
() => parseQuery<ShortUrlsQuery>(search),
|
||||||
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||||
const parsedTags = tags?.split(',') ?? [];
|
const parsedTags = tags?.split(',') ?? [];
|
||||||
|
|
||||||
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
[location.search],
|
[search],
|
||||||
);
|
);
|
||||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||||
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra };
|
||||||
const normalizedQuery: ShortUrlsQuery = {
|
const query: ShortUrlsQuery = {
|
||||||
...mergedQuery,
|
...mergedFiltering,
|
||||||
orderBy: orderBy && orderToString(orderBy),
|
orderBy: orderBy && orderToString(orderBy),
|
||||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||||
};
|
};
|
||||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
const stringifiedQuery = stringifyQuery(query);
|
||||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||||
|
|
||||||
navigate(`/server/${params.serverId ?? ''}/list-short-urls/1${queryString}`);
|
navigate(`/server/${serverId}/list-short-urls/1${queryString}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return [query, toFirstPageWithExtra];
|
return [filtering, toFirstPageWithExtra];
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { DropdownItem } from 'reactstrap';
|
import { DropdownItem } from 'reactstrap';
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
|
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||||
|
|
||||||
export interface DateIntervalDropdownProps {
|
export interface DateIntervalDropdownProps {
|
||||||
active?: DateInterval;
|
active?: DateInterval;
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { FC } from 'react';
|
import { FC } from 'react';
|
||||||
import { DropdownBtn } from '../DropdownBtn';
|
import { DropdownBtn } from '../DropdownBtn';
|
||||||
import { rangeOrIntervalToString } from './types';
|
import { rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||||
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
||||||
|
|
||||||
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { endOfDay } from 'date-fns';
|
import { endOfDay } from 'date-fns';
|
||||||
import { DateInput } from './DateInput';
|
import { DateInput } from './DateInput';
|
||||||
import { DateRange } from './types';
|
import { DateRange } from '../helpers/dateIntervals';
|
||||||
|
|
||||||
interface DateRangeRowProps extends DateRange {
|
interface DateRangeRowProps extends DateRange {
|
||||||
onStartDateChange: (date: Date | null) => void;
|
onStartDateChange: (date: Date | null) => void;
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
intervalToDateRange,
|
intervalToDateRange,
|
||||||
rangeIsInterval,
|
rangeIsInterval,
|
||||||
dateRangeIsEmpty,
|
dateRangeIsEmpty,
|
||||||
} from './types';
|
} from '../helpers/dateIntervals';
|
||||||
import { DateRangeRow } from './DateRangeRow';
|
import { DateRangeRow } from './DateRangeRow';
|
||||||
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
||||||
|
|
||||||
|
@ -25,7 +25,9 @@ export const DateRangeSelector = (
|
||||||
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: 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<DateInterval | undefined>(
|
||||||
|
initialIntervalIsRange ? initialDateRange : undefined,
|
||||||
|
);
|
||||||
const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange);
|
||||||
|
|
||||||
const updateDateRange = (dateRange: DateRange) => {
|
const updateDateRange = (dateRange: DateRange) => {
|
||||||
|
|
|
@ -32,6 +32,8 @@ export const parseDate = (date: string, theFormat: string) => parse(date, theFor
|
||||||
|
|
||||||
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
export const parseISO = (date: DateOrString): Date => (isDateObject(date) ? date : stdParseISO(date));
|
||||||
|
|
||||||
|
export const dateOrNull = (date?: string): Date | null => (date ? parseISO(date) : null);
|
||||||
|
|
||||||
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
||||||
try {
|
try {
|
||||||
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
||||||
|
|
|
@ -1,21 +1,14 @@
|
||||||
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||||
import { cond, filter, isEmpty, T } from 'ramda';
|
import { cond, filter, isEmpty, T } from 'ramda';
|
||||||
import { DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from './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';
|
const ALL = 'all';
|
||||||
|
const INTERVAL_TO_STRING_MAP = {
|
||||||
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
|
||||||
|| isEmpty(filter(Boolean, dateRange as any));
|
|
||||||
|
|
||||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
|
||||||
typeof range === 'string';
|
|
||||||
|
|
||||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
yesterday: 'Yesterday',
|
yesterday: 'Yesterday',
|
||||||
last7Days: 'Last 7 days',
|
last7Days: 'Last 7 days',
|
||||||
|
@ -23,10 +16,25 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||||
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,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DATE_INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP).filter((value) => value !== 'all') as DateInterval[];
|
export type DateInterval = keyof typeof INTERVAL_TO_STRING_MAP;
|
||||||
|
|
||||||
|
const INTERVALS = Object.keys(INTERVAL_TO_STRING_MAP) as DateInterval[];
|
||||||
|
|
||||||
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
|
||||||
|
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
||||||
|
typeof range === 'string' && INTERVALS.includes(range);
|
||||||
|
|
||||||
|
export const DATE_INTERVALS = INTERVALS.filter((value) => value !== ALL) as DateInterval[];
|
||||||
|
|
||||||
|
export const datesToDateRange = (startDate?: string, endDate?: string): DateRange => ({
|
||||||
|
startDate: dateOrNull(startDate),
|
||||||
|
endDate: dateOrNull(endDate),
|
||||||
|
});
|
||||||
|
|
||||||
const dateRangeToString = (range?: DateRange): string | undefined => {
|
const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
if (!range || dateRangeIsEmpty(range)) {
|
if (!range || dateRangeIsEmpty(range)) {
|
||||||
|
@ -45,7 +53,7 @@ const dateRangeToString = (range?: DateRange): string | undefined => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
||||||
if (!range || range === 'all') {
|
if (!range || range === ALL) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -60,7 +68,7 @@ const startOfDaysAgo = (daysAgo: number) => startOfDay(subDays(new Date(), daysA
|
||||||
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
const endingToday = (startDate: Date): DateRange => ({ startDate, endDate: endOfDay(new Date()) });
|
||||||
|
|
||||||
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||||
if (!dateInterval || dateInterval === 'all') {
|
if (!dateInterval || dateInterval === ALL) {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,6 +103,14 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
|
||||||
[() => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days'],
|
[() => isBeforeOrEqual(startOfDaysAgo(365), theDate), () => 'last365Days'],
|
||||||
[T, () => 'all'],
|
[T, () => ALL],
|
||||||
])();
|
])();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toDateRange = (rangeOrInterval: DateRange | DateInterval): DateRange => {
|
||||||
|
if (rangeIsInterval(rangeOrInterval)) {
|
||||||
|
return intervalToDateRange(rangeOrInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rangeOrInterval;
|
||||||
|
};
|
|
@ -36,6 +36,6 @@ export const orderToString = <T>(order: Order<T>): string | undefined => (
|
||||||
);
|
);
|
||||||
|
|
||||||
export const stringToOrder = <T>(order: string): Order<T> => {
|
export const stringToOrder = <T>(order: string): Order<T> => {
|
||||||
const [field, dir] = order.split('-') as [ T | undefined, OrderDir | undefined ];
|
const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined];
|
||||||
return { field, dir };
|
return { field, dir };
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { isEmpty, propEq, values } from 'ramda';
|
import { isEmpty, pipe, propEq, values } from 'ramda';
|
||||||
import { useState, useEffect, useMemo, FC, useRef, PropsWithChildren } from 'react';
|
import { useState, useEffect, useMemo, FC, useRef, PropsWithChildren } from 'react';
|
||||||
import { Button, Progress, Row } from 'reactstrap';
|
import { Button, Progress, Row } from 'reactstrap';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
@ -8,7 +8,6 @@ import { Route, Routes, Navigate } from 'react-router-dom';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import { Message } from '../utils/Message';
|
import { Message } from '../utils/Message';
|
||||||
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
|
|
||||||
import { Result } from '../utils/Result';
|
import { Result } from '../utils/Result';
|
||||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||||
import { Settings } from '../settings/reducers/settings';
|
import { Settings } from '../settings/reducers/settings';
|
||||||
|
@ -19,7 +18,7 @@ import { NavPillItem, NavPills } from '../utils/NavPills';
|
||||||
import { ExportBtn } from '../utils/ExportBtn';
|
import { ExportBtn } from '../utils/ExportBtn';
|
||||||
import { LineChartCard } from './charts/LineChartCard';
|
import { LineChartCard } from './charts/LineChartCard';
|
||||||
import { VisitsTable } from './VisitsTable';
|
import { VisitsTable } from './VisitsTable';
|
||||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsParams } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
|
||||||
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
||||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||||
|
@ -27,6 +26,8 @@ import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
||||||
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||||
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||||
import { VisitsInfo } from './reducers/types';
|
import { VisitsInfo } from './reducers/types';
|
||||||
|
import { useVisitsQuery } from './helpers/hooks';
|
||||||
|
import { DateInterval, DateRange, toDateRange } from '../utils/helpers/dateIntervals';
|
||||||
|
|
||||||
export type VisitsStatsProps = PropsWithChildren<{
|
export type VisitsStatsProps = PropsWithChildren<{
|
||||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
|
@ -68,19 +69,26 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
isOrphanVisits = false,
|
isOrphanVisits = false,
|
||||||
}) => {
|
}) => {
|
||||||
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
||||||
const [initialInterval, setInitialInterval] = useState<DateInterval>(
|
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
|
||||||
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
const setDates = pipe(
|
||||||
|
({ startDate: theStartDate, endDate: theEndDate }: DateRange) => ({
|
||||||
|
dateRange: {
|
||||||
|
startDate: theStartDate ?? undefined,
|
||||||
|
endDate: theEndDate ?? undefined,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
updateFiltering,
|
||||||
|
);
|
||||||
|
const initialInterval = useRef<DateRange | DateInterval>(
|
||||||
|
dateRange ?? fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
||||||
);
|
);
|
||||||
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 botsSupported = supportsBotVisits(selectedServer);
|
const botsSupported = supportsBotVisits(selectedServer);
|
||||||
const isFirstLoad = useRef(true);
|
const isFirstLoad = useRef(true);
|
||||||
|
|
||||||
const buildSectionUrl = (subPath?: string) => {
|
const buildSectionUrl = (subPath?: string) => {
|
||||||
const query = domain ? `?domain=${domain}` : '';
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
|
||||||
return !subPath ? `${query}` : `${subPath}${query}`;
|
return !subPath ? `${query}` : `${subPath}${query}`;
|
||||||
};
|
};
|
||||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
||||||
|
@ -110,12 +118,10 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
useEffect(() => cancelGetVisits, []);
|
useEffect(() => cancelGetVisits, []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
getVisits({ dateRange, filter: visitsFilter }, isFirstLoad.current);
|
const resolvedDateRange = !isFirstLoad.current ? dateRange : (dateRange ?? toDateRange(initialInterval.current));
|
||||||
|
getVisits({ dateRange: resolvedDateRange, filter: visitsFilter }, isFirstLoad.current);
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
}, [dateRange, visitsFilter]);
|
}, [dateRange, visitsFilter]);
|
||||||
useEffect(() => {
|
|
||||||
fallbackInterval && setInitialInterval(fallbackInterval);
|
|
||||||
}, [fallbackInterval]);
|
|
||||||
|
|
||||||
const renderVisitsContent = () => {
|
const renderVisitsContent = () => {
|
||||||
if (loadingLarge) {
|
if (loadingLarge) {
|
||||||
|
@ -284,9 +290,9 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
updatable
|
updatable
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
initialDateRange={initialInterval}
|
initialDateRange={initialInterval.current}
|
||||||
defaultText="All visits"
|
defaultText="All visits"
|
||||||
onDatesChange={setDateRange}
|
onDatesChange={setDates}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<VisitsFilterDropdown
|
<VisitsFilterDropdown
|
||||||
|
@ -294,7 +300,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
isOrphanVisits={isOrphanVisits}
|
isOrphanVisits={isOrphanVisits}
|
||||||
botsSupported={botsSupported}
|
botsSupported={botsSupported}
|
||||||
selected={visitsFilter}
|
selected={visitsFilter}
|
||||||
onChange={setVisitsFilter}
|
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -14,7 +14,7 @@ interface MapModalProps {
|
||||||
|
|
||||||
const OpenStreetMapTile: FC = () => (
|
const OpenStreetMapTile: FC = () => (
|
||||||
<TileLayer
|
<TileLayer
|
||||||
attribution='&copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
attribution='&copy <a href="https://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
|
@ -46,7 +46,12 @@ export const VisitsFilterDropdown = (
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem disabled={!hasValue(selected)} onClick={() => onChange({})}><i>Clear filters</i></DropdownItem>
|
<DropdownItem
|
||||||
|
disabled={!hasValue(selected)}
|
||||||
|
onClick={() => onChange({ excludeBots: false, orphanVisitsType: undefined })}
|
||||||
|
>
|
||||||
|
<i>Clear filters</i>
|
||||||
|
</DropdownItem>
|
||||||
</DropdownBtn>
|
</DropdownBtn>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
63
src/visits/helpers/hooks.ts
Normal file
63
src/visits/helpers/hooks.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import { DeepPartial } from '@reduxjs/toolkit';
|
||||||
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { isEmpty, mergeDeepRight, pipe } from 'ramda';
|
||||||
|
import { DateRange, datesToDateRange } from '../../utils/helpers/dateIntervals';
|
||||||
|
import { OrphanVisitType, VisitsFilter } from '../types';
|
||||||
|
import { parseQuery, stringifyQuery } from '../../utils/helpers/query';
|
||||||
|
import { formatIsoDate } from '../../utils/helpers/date';
|
||||||
|
|
||||||
|
interface VisitsQuery {
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
orphanVisitsType?: OrphanVisitType;
|
||||||
|
excludeBots?: 'true';
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitsFiltering {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
visitsFilter: VisitsFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitsFilteringAndDomain {
|
||||||
|
filtering: VisitsFiltering;
|
||||||
|
domain?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
|
||||||
|
|
||||||
|
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
const { filtering, domain: theDomain } = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => parseQuery<VisitsQuery>(search),
|
||||||
|
({ startDate, endDate, orphanVisitsType, excludeBots, domain }: VisitsQuery): VisitsFilteringAndDomain => ({
|
||||||
|
domain,
|
||||||
|
filtering: {
|
||||||
|
dateRange: startDate || endDate ? datesToDateRange(startDate, endDate) : undefined,
|
||||||
|
visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
[search],
|
||||||
|
);
|
||||||
|
const updateFiltering = (extra: DeepPartial<VisitsFiltering>) => {
|
||||||
|
const { dateRange, visitsFilter } = mergeDeepRight(filtering, extra);
|
||||||
|
const query: VisitsQuery = {
|
||||||
|
startDate: (dateRange?.startDate && formatIsoDate(dateRange.startDate)) || undefined,
|
||||||
|
endDate: (dateRange?.endDate && formatIsoDate(dateRange.endDate)) || undefined,
|
||||||
|
excludeBots: visitsFilter.excludeBots ? 'true' : undefined,
|
||||||
|
orphanVisitsType: visitsFilter.orphanVisitsType,
|
||||||
|
domain: theDomain,
|
||||||
|
};
|
||||||
|
const stringifiedQuery = stringifyQuery(query);
|
||||||
|
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||||
|
|
||||||
|
navigate(queryString, { replace: true, relative: 'route' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return [filtering, updateFiltering];
|
||||||
|
};
|
|
@ -2,7 +2,7 @@ import { flatten, prop, range, splitEvery } from 'ramda';
|
||||||
import { createAction, createSlice } from '@reduxjs/toolkit';
|
import { createAction, createSlice } from '@reduxjs/toolkit';
|
||||||
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
|
import { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/types';
|
||||||
import { CreateVisit, Visit } from '../types';
|
import { CreateVisit, Visit } from '../types';
|
||||||
import { DateInterval, dateToMatchingInterval } from '../../utils/dates/types';
|
import { DateInterval, dateToMatchingInterval } from '../../utils/helpers/dateIntervals';
|
||||||
import { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
|
import { LoadVisits, VisitsInfo, VisitsLoaded } from './types';
|
||||||
import { createAsyncThunk } from '../../utils/helpers/redux';
|
import { createAsyncThunk } from '../../utils/helpers/redux';
|
||||||
import { ShlinkState } from '../../container/types';
|
import { ShlinkState } from '../../container/types';
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ShlinkVisitsParams } from '../../../api/types';
|
import { ShlinkVisitsParams } from '../../../api/types';
|
||||||
import { DateInterval } from '../../../utils/dates/types';
|
import { DateInterval } from '../../../utils/helpers/dateIntervals';
|
||||||
import { ProblemDetailsError } from '../../../api/types/errors';
|
import { ProblemDetailsError } from '../../../api/types/errors';
|
||||||
import { Visit } from '../../types';
|
import { Visit } from '../../types';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { ShortUrl } from '../../short-urls/data';
|
import { ShortUrl } from '../../short-urls/data';
|
||||||
import { DateRange } from '../../utils/dates/types';
|
import { DateRange } from '../../utils/helpers/dateIntervals';
|
||||||
|
|
||||||
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { endOfDay, formatISO, startOfDay } from 'date-fns';
|
||||||
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
|
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
|
||||||
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
import { ReachableServer, SelectedServer } from '../../src/servers/data';
|
||||||
import { DateRange } from '../../src/utils/dates/types';
|
import { DateRange } from '../../src/utils/helpers/dateIntervals';
|
||||||
import { formatDate } from '../../src/utils/helpers/date';
|
import { formatDate } from '../../src/utils/helpers/date';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
|
import { DateIntervalDropdownItems } from '../../../src/utils/dates/DateIntervalDropdownItems';
|
||||||
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from '../../../src/utils/dates/types';
|
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { DropdownBtn } from '../../../src/utils/DropdownBtn';
|
import { DropdownBtn } from '../../../src/utils/DropdownBtn';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { DateInterval, rangeOrIntervalToString } from '../../../src/utils/dates/types';
|
import { DateInterval, rangeOrIntervalToString } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { DateIntervalSelector } from '../../../src/utils/dates/DateIntervalSelector';
|
import { DateIntervalSelector } from '../../../src/utils/dates/DateIntervalSelector';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
|
import { DateRangeSelector, DateRangeSelectorProps } from '../../../src/utils/dates/DateRangeSelector';
|
||||||
import { DateInterval } from '../../../src/utils/dates/types';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
import { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||||
|
|
||||||
describe('<DateRangeSelector />', () => {
|
describe('<DateRangeSelector />', () => {
|
||||||
|
|
|
@ -6,8 +6,9 @@ import {
|
||||||
intervalToDateRange,
|
intervalToDateRange,
|
||||||
rangeIsInterval,
|
rangeIsInterval,
|
||||||
rangeOrIntervalToString,
|
rangeOrIntervalToString,
|
||||||
} from '../../../../src/utils/dates/types';
|
toDateRange,
|
||||||
import { parseDate } from '../../../../src/utils/helpers/date';
|
} from '../../../src/utils/helpers/dateIntervals';
|
||||||
|
import { parseDate } from '../../../src/utils/helpers/date';
|
||||||
|
|
||||||
describe('date-types', () => {
|
describe('date-types', () => {
|
||||||
const now = () => new Date();
|
const now = () => new Date();
|
||||||
|
@ -116,4 +117,23 @@ describe('date-types', () => {
|
||||||
expect(dateToMatchingInterval(date)).toEqual(expectedInterval);
|
expect(dateToMatchingInterval(date)).toEqual(expectedInterval);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('toDateRange', () => {
|
||||||
|
it.each([
|
||||||
|
['today' as DateInterval, intervalToDateRange('today')],
|
||||||
|
['yesterday' as DateInterval, intervalToDateRange('yesterday')],
|
||||||
|
['last7Days' as DateInterval, intervalToDateRange('last7Days')],
|
||||||
|
['last30Days' as DateInterval, intervalToDateRange('last30Days')],
|
||||||
|
['last90Days' as DateInterval, intervalToDateRange('last90Days')],
|
||||||
|
['last180Days' as DateInterval, intervalToDateRange('last180Days')],
|
||||||
|
['last365Days' as DateInterval, intervalToDateRange('last365Days')],
|
||||||
|
['all' as DateInterval, intervalToDateRange('all')],
|
||||||
|
[{}, {}],
|
||||||
|
[{ startDate: now() }, { startDate: now() }],
|
||||||
|
[{ endDate: now() }, { endDate: now() }],
|
||||||
|
[{ startDate: daysBack(10), endDate: now() }, { startDate: daysBack(10), endDate: now() }],
|
||||||
|
])('returns properly parsed interval or range', (rangeOrInterval, expectedResult) => {
|
||||||
|
expect(toDateRange(rangeOrInterval)).toEqual(expectedResult);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
|
@ -1,11 +1,11 @@
|
||||||
import { screen } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
import { Mock } from 'ts-mockery';
|
import { Mock } from 'ts-mockery';
|
||||||
import { Router } from 'react-router-dom';
|
import { Router } from 'react-router-dom';
|
||||||
import { createMemoryHistory } from 'history';
|
import { createMemoryHistory } from 'history';
|
||||||
import { VisitsStats } from '../../src/visits/VisitsStats';
|
import { VisitsStats } from '../../src/visits/VisitsStats';
|
||||||
import { Visit } from '../../src/visits/types';
|
import { Visit } from '../../src/visits/types';
|
||||||
import { Settings } from '../../src/settings/reducers/settings';
|
import { Settings } from '../../src/settings/reducers/settings';
|
||||||
import { SelectedServer } from '../../src/servers/data';
|
import { ReachableServer } from '../../src/servers/data';
|
||||||
import { renderWithEvents } from '../__helpers__/setUpTest';
|
import { renderWithEvents } from '../__helpers__/setUpTest';
|
||||||
import { rangeOf } from '../../src/utils/utils';
|
import { rangeOf } from '../../src/utils/utils';
|
||||||
import { VisitsInfo } from '../../src/visits/reducers/types';
|
import { VisitsInfo } from '../../src/visits/reducers/types';
|
||||||
|
@ -18,18 +18,21 @@ describe('<VisitsStats />', () => {
|
||||||
const history = createMemoryHistory();
|
const history = createMemoryHistory();
|
||||||
history.push(activeRoute);
|
history.push(activeRoute);
|
||||||
|
|
||||||
return renderWithEvents(
|
return {
|
||||||
<Router location={history.location} navigator={history}>
|
history,
|
||||||
<VisitsStats
|
...renderWithEvents(
|
||||||
getVisits={getVisitsMock}
|
<Router location={history.location} navigator={history}>
|
||||||
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
|
<VisitsStats
|
||||||
cancelGetVisits={() => {}}
|
getVisits={getVisitsMock}
|
||||||
settings={Mock.all<Settings>()}
|
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
|
||||||
exportCsv={exportCsv}
|
cancelGetVisits={() => {}}
|
||||||
selectedServer={Mock.all<SelectedServer>()}
|
settings={Mock.all<Settings>()}
|
||||||
/>
|
exportCsv={exportCsv}
|
||||||
</Router>,
|
selectedServer={Mock.of<ReachableServer>({ version: '3.0.0' })}
|
||||||
);
|
/>
|
||||||
|
</Router>,
|
||||||
|
),
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
it('renders a preloader when visits are loading', () => {
|
it('renders a preloader when visits are loading', () => {
|
||||||
|
@ -81,4 +84,24 @@ describe('<VisitsStats />', () => {
|
||||||
await user.click(screen.getByRole('button', { name: /Export/ }));
|
await user.click(screen.getByRole('button', { name: /Export/ }));
|
||||||
expect(exportCsv).toHaveBeenCalled();
|
expect(exportCsv).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets filters in query string', async () => {
|
||||||
|
const { history, user } = setUp({ visits });
|
||||||
|
const expectSearchContains = (contains: string[]) => {
|
||||||
|
expect(contains).not.toHaveLength(0);
|
||||||
|
contains.forEach((entry) => expect(history.location.search).toContain(entry));
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(history.location.search).toEqual('');
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Filters/ }));
|
||||||
|
await waitFor(() => screen.getByRole('menu'));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: 'Exclude potential bots' }));
|
||||||
|
expectSearchContains(['excludeBots=true']);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: /Last 30 days/ }));
|
||||||
|
await waitFor(() => screen.getByRole('menu'));
|
||||||
|
await user.click(screen.getByRole('menuitem', { name: /Last 180 days/ }));
|
||||||
|
expectSearchContains(['startDate', 'endDate']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -60,7 +60,7 @@ describe('<VisitsFilterDropdown />', () => {
|
||||||
[1, { orphanVisitsType: 'base_url' }, {}],
|
[1, { orphanVisitsType: 'base_url' }, {}],
|
||||||
[2, { orphanVisitsType: 'invalid_short_url' }, {}],
|
[2, { orphanVisitsType: 'invalid_short_url' }, {}],
|
||||||
[3, { orphanVisitsType: 'regular_404' }, {}],
|
[3, { orphanVisitsType: 'regular_404' }, {}],
|
||||||
[4, {}, { excludeBots: true }],
|
[4, { orphanVisitsType: undefined, excludeBots: false }, { excludeBots: true }],
|
||||||
])('invokes onChange with proper selection when an item is clicked', async (index, expectedSelection, selected) => {
|
])('invokes onChange with proper selection when an item is clicked', async (index, expectedSelection, selected) => {
|
||||||
const { user } = setUp(selected);
|
const { user } = setUp(selected);
|
||||||
|
|
||||||
|
|
|
@ -167,7 +167,7 @@ exports[`<MapModal /> renders expected map 1`] = `
|
||||||
</span>
|
</span>
|
||||||
©
|
©
|
||||||
<a
|
<a
|
||||||
href="http://osm.org/copyright"
|
href="https://osm.org/copyright"
|
||||||
>
|
>
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -167,7 +167,7 @@ exports[`<OpenMapModalBtn /> filters out non-active cities from list of location
|
||||||
</span>
|
</span>
|
||||||
©
|
©
|
||||||
<a
|
<a
|
||||||
href="http://osm.org/copyright"
|
href="https://osm.org/copyright"
|
||||||
>
|
>
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
</a>
|
</a>
|
||||||
|
@ -335,7 +335,7 @@ exports[`<OpenMapModalBtn /> filters out non-active cities from list of location
|
||||||
</span>
|
</span>
|
||||||
©
|
©
|
||||||
<a
|
<a
|
||||||
href="http://osm.org/copyright"
|
href="https://osm.org/copyright"
|
||||||
>
|
>
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
</a>
|
</a>
|
||||||
|
|
|
@ -12,7 +12,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';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { ShortUrl } from '../../../src/short-urls/data';
|
import { ShortUrl } from '../../../src/short-urls/data';
|
||||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,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';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
import { VisitsInfo } from '../../../src/visits/reducers/types';
|
import { VisitsInfo } from '../../../src/visits/reducers/types';
|
||||||
|
|
||||||
|
|
|
@ -10,7 +10,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';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
import { VisitsInfo } from '../../../src/visits/reducers/types';
|
import { VisitsInfo } from '../../../src/visits/reducers/types';
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,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';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
|
||||||
describe('shortUrlVisitsReducer', () => {
|
describe('shortUrlVisitsReducer', () => {
|
||||||
|
|
|
@ -11,7 +11,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';
|
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||||
|
|
||||||
describe('tagVisitsReducer', () => {
|
describe('tagVisitsReducer', () => {
|
||||||
|
|
Loading…
Reference in a new issue