Merge pull request #754 from acelaya-forks/feature/visits-filters

Feature/visits filters
This commit is contained in:
Alejandro Celaya 2022-12-03 13:29:05 +01:00 committed by GitHub
commit 97a54d44c7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 227 additions and 95 deletions

View file

@ -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.

View file

@ -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',

View file

@ -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';

View file

@ -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>

View file

@ -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];
}; };

View file

@ -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;

View file

@ -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 }) => (

View file

@ -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;

View file

@ -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) => {

View file

@ -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) });

View file

@ -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;
};

View file

@ -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 };
}; };

View file

@ -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>

View file

@ -14,7 +14,7 @@ interface MapModalProps {
const OpenStreetMapTile: FC = () => ( const OpenStreetMapTile: FC = () => (
<TileLayer <TileLayer
attribution='&amp;copy <a href="http://osm.org/copyright">OpenStreetMap</a> contributors' attribution='&amp;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"
/> />
); );

View file

@ -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>
); );
}; };

View 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];
};

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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 />', () => {

View file

@ -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);
});
});
}); });

View file

@ -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']);
});
}); });

View file

@ -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);

View file

@ -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>

View file

@ -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>

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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', () => {

View file

@ -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', () => {