mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 09:47:28 +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).
|
||||
|
||||
## [Unreleased]
|
||||
## [3.8.0] - 2022-12-03
|
||||
### Added
|
||||
* [#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.
|
||||
* [#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
|
||||
* [#713](https://github.com/shlinkio/shlink-web-client/issues/713) Updated dependencies.
|
||||
|
|
|
@ -17,6 +17,7 @@ module.exports = {
|
|||
},
|
||||
setupFilesAfterEnv: ['<rootDir>/config/jest/setupTests.ts'],
|
||||
testMatch: ['<rootDir>/test/**/*.test.{ts,tsx}'],
|
||||
modulePathIgnorePatterns: ['<rootDir>/.stryker-tmp'],
|
||||
testEnvironment: 'jsdom',
|
||||
testEnvironmentOptions: {
|
||||
url: 'http://localhost',
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { createSlice, PayloadAction, PrepareAction } from '@reduxjs/toolkit';
|
||||
import { mergeDeepRight } from 'ramda';
|
||||
import { Theme } from '../../utils/theme';
|
||||
import { DateInterval } from '../../utils/dates/types';
|
||||
import { DateInterval } from '../../utils/helpers/dateIntervals';
|
||||
import { TagsOrder } from '../../tags/data/TagsListChildrenProps';
|
||||
import { ShortUrlsOrder } from '../../short-urls/data';
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
import { FC } from 'react';
|
||||
import { isEmpty, pipe } from 'ramda';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { Button, InputGroup, Row, UncontrolledTooltip } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faTag, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
|
@ -8,7 +7,7 @@ import classNames from 'classnames';
|
|||
import { SearchField } from '../utils/SearchField';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
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 { SelectedServer } from '../servers/data';
|
||||
import { OrderDir } from '../utils/helpers/ordering';
|
||||
|
@ -27,8 +26,6 @@ export interface ShortUrlsFilteringProps {
|
|||
shortUrlsAmount?: number;
|
||||
}
|
||||
|
||||
const dateOrNull = (date?: string) => (date ? parseISO(date) : null);
|
||||
|
||||
export const ShortUrlsFilteringBar = (
|
||||
ExportShortUrlsBtn: FC<ExportShortUrlsBtnProps>,
|
||||
TagsSelector: FC<TagsSelectorProps>,
|
||||
|
@ -74,10 +71,7 @@ export const ShortUrlsFilteringBar = (
|
|||
<div className="col-lg-8 col-xl-6 mt-3">
|
||||
<DateRangeSelector
|
||||
defaultText="All short URLs"
|
||||
initialDateRange={{
|
||||
startDate: dateOrNull(startDate),
|
||||
endDate: dateOrNull(endDate),
|
||||
}}
|
||||
initialDateRange={datesToDateRange(startDate, endDate)}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -6,8 +6,6 @@ import { ShortUrlsOrder, ShortUrlsOrderableFields } from '../data';
|
|||
import { orderToString, stringToOrder } from '../../utils/helpers/ordering';
|
||||
import { TagsFilteringMode } from '../../api/types';
|
||||
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
|
||||
interface ShortUrlsQueryCommon {
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
|
@ -25,35 +23,36 @@ interface ShortUrlsFiltering extends ShortUrlsQueryCommon {
|
|||
tags: string[];
|
||||
}
|
||||
|
||||
type ToFirstPage = (extra: Partial<ShortUrlsFiltering>) => void;
|
||||
|
||||
export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const params = useParams<{ serverId: string }>();
|
||||
const { search } = useLocation();
|
||||
const { serverId = '' } = useParams<{ serverId: string }>();
|
||||
|
||||
const query = useMemo(
|
||||
const filtering = useMemo(
|
||||
pipe(
|
||||
() => parseQuery<ShortUrlsQuery>(location.search),
|
||||
() => parseQuery<ShortUrlsQuery>(search),
|
||||
({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => {
|
||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||
const parsedTags = tags?.split(',') ?? [];
|
||||
|
||||
return { ...rest, orderBy: parsedOrderBy, tags: parsedTags };
|
||||
},
|
||||
),
|
||||
[location.search],
|
||||
[search],
|
||||
);
|
||||
const toFirstPageWithExtra = (extra: Partial<ShortUrlsFiltering>) => {
|
||||
const { orderBy, tags, ...mergedQuery } = { ...query, ...extra };
|
||||
const normalizedQuery: ShortUrlsQuery = {
|
||||
...mergedQuery,
|
||||
const { orderBy, tags, ...mergedFiltering } = { ...filtering, ...extra };
|
||||
const query: ShortUrlsQuery = {
|
||||
...mergedFiltering,
|
||||
orderBy: orderBy && orderToString(orderBy),
|
||||
tags: tags.length > 0 ? tags.join(',') : undefined,
|
||||
};
|
||||
const evolvedQuery = stringifyQuery(normalizedQuery);
|
||||
const queryString = isEmpty(evolvedQuery) ? '' : `?${evolvedQuery}`;
|
||||
const stringifiedQuery = stringifyQuery(query);
|
||||
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 { FC } from 'react';
|
||||
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from './types';
|
||||
import { DATE_INTERVALS, DateInterval, rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||
|
||||
export interface DateIntervalDropdownProps {
|
||||
active?: DateInterval;
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { FC } from 'react';
|
||||
import { DropdownBtn } from '../DropdownBtn';
|
||||
import { rangeOrIntervalToString } from './types';
|
||||
import { rangeOrIntervalToString } from '../helpers/dateIntervals';
|
||||
import { DateIntervalDropdownItems, DateIntervalDropdownProps } from './DateIntervalDropdownItems';
|
||||
|
||||
export const DateIntervalSelector: FC<DateIntervalDropdownProps> = ({ onChange, active, allText }) => (
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { endOfDay } from 'date-fns';
|
||||
import { DateInput } from './DateInput';
|
||||
import { DateRange } from './types';
|
||||
import { DateRange } from '../helpers/dateIntervals';
|
||||
|
||||
interface DateRangeRowProps extends DateRange {
|
||||
onStartDateChange: (date: Date | null) => void;
|
||||
|
|
|
@ -9,7 +9,7 @@ import {
|
|||
intervalToDateRange,
|
||||
rangeIsInterval,
|
||||
dateRangeIsEmpty,
|
||||
} from './types';
|
||||
} from '../helpers/dateIntervals';
|
||||
import { DateRangeRow } from './DateRangeRow';
|
||||
import { DateIntervalDropdownItems } from './DateIntervalDropdownItems';
|
||||
|
||||
|
@ -25,7 +25,9 @@ export const DateRangeSelector = (
|
|||
{ onDatesChange, initialDateRange, defaultText, disabled, updatable = false }: DateRangeSelectorProps,
|
||||
) => {
|
||||
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 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 dateOrNull = (date?: string): Date | null => (date ? parseISO(date) : null);
|
||||
|
||||
export const isBetween = (date: DateOrString, start?: DateOrString, end?: DateOrString): boolean => {
|
||||
try {
|
||||
return isWithinInterval(parseISO(date), { start: parseISO(start ?? date), end: parseISO(end ?? date) });
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||
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 {
|
||||
startDate?: Date | null;
|
||||
endDate?: Date | null;
|
||||
}
|
||||
|
||||
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));
|
||||
|
||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
||||
typeof range === 'string';
|
||||
|
||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||
const ALL = 'all';
|
||||
const INTERVAL_TO_STRING_MAP = {
|
||||
today: 'Today',
|
||||
yesterday: 'Yesterday',
|
||||
last7Days: 'Last 7 days',
|
||||
|
@ -23,10 +16,25 @@ const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
|||
last90Days: 'Last 90 days',
|
||||
last180Days: 'Last 180 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 => {
|
||||
if (!range || dateRangeIsEmpty(range)) {
|
||||
|
@ -45,7 +53,7 @@ const dateRangeToString = (range?: DateRange): string | undefined => {
|
|||
};
|
||||
|
||||
export const rangeOrIntervalToString = (range?: DateRange | DateInterval): string | undefined => {
|
||||
if (!range || range === 'all') {
|
||||
if (!range || range === ALL) {
|
||||
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()) });
|
||||
|
||||
export const intervalToDateRange = (dateInterval?: DateInterval): DateRange => {
|
||||
if (!dateInterval || dateInterval === 'all') {
|
||||
if (!dateInterval || dateInterval === ALL) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
@ -95,6 +103,14 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
|||
[() => isBeforeOrEqual(startOfDaysAgo(90), theDate), () => 'last90Days'],
|
||||
[() => isBeforeOrEqual(startOfDaysAgo(180), theDate), () => 'last180Days'],
|
||||
[() => 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> => {
|
||||
const [field, dir] = order.split('-') as [ T | undefined, OrderDir | undefined ];
|
||||
const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined];
|
||||
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 { Button, Progress, Row } from 'reactstrap';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
|
@ -8,7 +8,6 @@ import { Route, Routes, Navigate } from 'react-router-dom';
|
|||
import classNames from 'classnames';
|
||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||
import { Message } from '../utils/Message';
|
||||
import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types';
|
||||
import { Result } from '../utils/Result';
|
||||
import { ShlinkApiError } from '../api/ShlinkApiError';
|
||||
import { Settings } from '../settings/reducers/settings';
|
||||
|
@ -19,7 +18,7 @@ import { NavPillItem, NavPills } from '../utils/NavPills';
|
|||
import { ExportBtn } from '../utils/ExportBtn';
|
||||
import { LineChartCard } from './charts/LineChartCard';
|
||||
import { VisitsTable } from './VisitsTable';
|
||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsParams } from './types';
|
||||
import { NormalizedOrphanVisit, NormalizedVisit, VisitsParams } from './types';
|
||||
import { OpenMapModalBtn } from './helpers/OpenMapModalBtn';
|
||||
import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser';
|
||||
import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown';
|
||||
|
@ -27,6 +26,8 @@ import { HighlightableProps, highlightedVisitsToStats } from './types/helpers';
|
|||
import { DoughnutChartCard } from './charts/DoughnutChartCard';
|
||||
import { SortableBarChartCard } from './charts/SortableBarChartCard';
|
||||
import { VisitsInfo } from './reducers/types';
|
||||
import { useVisitsQuery } from './helpers/hooks';
|
||||
import { DateInterval, DateRange, toDateRange } from '../utils/helpers/dateIntervals';
|
||||
|
||||
export type VisitsStatsProps = PropsWithChildren<{
|
||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||
|
@ -68,19 +69,26 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
isOrphanVisits = false,
|
||||
}) => {
|
||||
const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo;
|
||||
const [initialInterval, setInitialInterval] = useState<DateInterval>(
|
||||
fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days',
|
||||
const [{ dateRange, visitsFilter }, updateFiltering] = useVisitsQuery();
|
||||
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 [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
||||
const [visitsFilter, setVisitsFilter] = useState<VisitsFilter>({});
|
||||
const botsSupported = supportsBotVisits(selectedServer);
|
||||
const isFirstLoad = useRef(true);
|
||||
|
||||
const buildSectionUrl = (subPath?: string) => {
|
||||
const query = domain ? `?domain=${domain}` : '';
|
||||
|
||||
return !subPath ? `${query}` : `${subPath}${query}`;
|
||||
};
|
||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [visits]);
|
||||
|
@ -110,12 +118,10 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
|
||||
useEffect(() => cancelGetVisits, []);
|
||||
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;
|
||||
}, [dateRange, visitsFilter]);
|
||||
useEffect(() => {
|
||||
fallbackInterval && setInitialInterval(fallbackInterval);
|
||||
}, [fallbackInterval]);
|
||||
|
||||
const renderVisitsContent = () => {
|
||||
if (loadingLarge) {
|
||||
|
@ -284,9 +290,9 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
<DateRangeSelector
|
||||
updatable
|
||||
disabled={loading}
|
||||
initialDateRange={initialInterval}
|
||||
initialDateRange={initialInterval.current}
|
||||
defaultText="All visits"
|
||||
onDatesChange={setDateRange}
|
||||
onDatesChange={setDates}
|
||||
/>
|
||||
</div>
|
||||
<VisitsFilterDropdown
|
||||
|
@ -294,7 +300,7 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
isOrphanVisits={isOrphanVisits}
|
||||
botsSupported={botsSupported}
|
||||
selected={visitsFilter}
|
||||
onChange={setVisitsFilter}
|
||||
onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -14,7 +14,7 @@ interface MapModalProps {
|
|||
|
||||
const OpenStreetMapTile: FC = () => (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -46,7 +46,12 @@ export const VisitsFilterDropdown = (
|
|||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
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 { ShlinkPaginator, ShlinkVisits, ShlinkVisitsParams } from '../../api/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 { createAsyncThunk } from '../../utils/helpers/redux';
|
||||
import { ShlinkState } from '../../container/types';
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import { ShlinkVisitsParams } from '../../../api/types';
|
||||
import { DateInterval } from '../../../utils/dates/types';
|
||||
import { DateInterval } from '../../../utils/helpers/dateIntervals';
|
||||
import { ProblemDetailsError } from '../../../api/types/errors';
|
||||
import { Visit } from '../../types';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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';
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ import { endOfDay, formatISO, startOfDay } from 'date-fns';
|
|||
import { MemoryRouter, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { ShortUrlsFilteringBar as filteringBarCreator } from '../../src/short-urls/ShortUrlsFilteringBar';
|
||||
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 { renderWithEvents } from '../__helpers__/setUpTest';
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { screen, waitFor } from '@testing-library/react';
|
||||
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 { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
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 { renderWithEvents } from '../../__helpers__/setUpTest';
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { screen, waitFor } from '@testing-library/react';
|
||||
import { Mock } from 'ts-mockery';
|
||||
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';
|
||||
|
||||
describe('<DateRangeSelector />', () => {
|
||||
|
|
|
@ -6,8 +6,9 @@ import {
|
|||
intervalToDateRange,
|
||||
rangeIsInterval,
|
||||
rangeOrIntervalToString,
|
||||
} from '../../../../src/utils/dates/types';
|
||||
import { parseDate } from '../../../../src/utils/helpers/date';
|
||||
toDateRange,
|
||||
} from '../../../src/utils/helpers/dateIntervals';
|
||||
import { parseDate } from '../../../src/utils/helpers/date';
|
||||
|
||||
describe('date-types', () => {
|
||||
const now = () => new Date();
|
||||
|
@ -116,4 +117,23 @@ describe('date-types', () => {
|
|||
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 { Router } from 'react-router-dom';
|
||||
import { createMemoryHistory } from 'history';
|
||||
import { VisitsStats } from '../../src/visits/VisitsStats';
|
||||
import { Visit } from '../../src/visits/types';
|
||||
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 { rangeOf } from '../../src/utils/utils';
|
||||
import { VisitsInfo } from '../../src/visits/reducers/types';
|
||||
|
@ -18,18 +18,21 @@ describe('<VisitsStats />', () => {
|
|||
const history = createMemoryHistory();
|
||||
history.push(activeRoute);
|
||||
|
||||
return renderWithEvents(
|
||||
<Router location={history.location} navigator={history}>
|
||||
<VisitsStats
|
||||
getVisits={getVisitsMock}
|
||||
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
|
||||
cancelGetVisits={() => {}}
|
||||
settings={Mock.all<Settings>()}
|
||||
exportCsv={exportCsv}
|
||||
selectedServer={Mock.all<SelectedServer>()}
|
||||
/>
|
||||
</Router>,
|
||||
);
|
||||
return {
|
||||
history,
|
||||
...renderWithEvents(
|
||||
<Router location={history.location} navigator={history}>
|
||||
<VisitsStats
|
||||
getVisits={getVisitsMock}
|
||||
visitsInfo={Mock.of<VisitsInfo>(visitsInfo)}
|
||||
cancelGetVisits={() => {}}
|
||||
settings={Mock.all<Settings>()}
|
||||
exportCsv={exportCsv}
|
||||
selectedServer={Mock.of<ReachableServer>({ version: '3.0.0' })}
|
||||
/>
|
||||
</Router>,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
it('renders a preloader when visits are loading', () => {
|
||||
|
@ -81,4 +84,24 @@ describe('<VisitsStats />', () => {
|
|||
await user.click(screen.getByRole('button', { name: /Export/ }));
|
||||
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' }, {}],
|
||||
[2, { orphanVisitsType: 'invalid_short_url' }, {}],
|
||||
[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) => {
|
||||
const { user } = setUp(selected);
|
||||
|
||||
|
|
|
@ -167,7 +167,7 @@ exports[`<MapModal /> renders expected map 1`] = `
|
|||
</span>
|
||||
©
|
||||
<a
|
||||
href="http://osm.org/copyright"
|
||||
href="https://osm.org/copyright"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
|
|
|
@ -167,7 +167,7 @@ exports[`<OpenMapModalBtn /> filters out non-active cities from list of location
|
|||
</span>
|
||||
©
|
||||
<a
|
||||
href="http://osm.org/copyright"
|
||||
href="https://osm.org/copyright"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
|
@ -335,7 +335,7 @@ exports[`<OpenMapModalBtn /> filters out non-active cities from list of location
|
|||
</span>
|
||||
©
|
||||
<a
|
||||
href="http://osm.org/copyright"
|
||||
href="https://osm.org/copyright"
|
||||
>
|
||||
OpenStreetMap
|
||||
</a>
|
||||
|
|
|
@ -12,7 +12,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';
|
||||
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { ShortUrl } from '../../../src/short-urls/data';
|
||||
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 { ShlinkState } from '../../../src/container/types';
|
||||
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 { VisitsInfo } from '../../../src/visits/reducers/types';
|
||||
|
||||
|
|
|
@ -10,7 +10,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';
|
||||
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||
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 { ShlinkState } from '../../../src/container/types';
|
||||
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';
|
||||
|
||||
describe('shortUrlVisitsReducer', () => {
|
||||
|
|
|
@ -11,7 +11,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';
|
||||
import { DateInterval } from '../../../src/utils/helpers/dateIntervals';
|
||||
import { createNewVisits } from '../../../src/visits/reducers/visitCreation';
|
||||
|
||||
describe('tagVisitsReducer', () => {
|
||||
|
|
Loading…
Reference in a new issue