mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-09 17:57:26 +03:00
Created new hook to handle visits filtering via query string
This commit is contained in:
parent
165afa436d
commit
d2ebc880a0
9 changed files with 96 additions and 24 deletions
|
@ -36,7 +36,6 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => {
|
||||||
({ 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 };
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,6 +2,8 @@ import { subDays, startOfDay, endOfDay } from 'date-fns';
|
||||||
import { cond, filter, isEmpty, T } from 'ramda';
|
import { cond, filter, isEmpty, T } from 'ramda';
|
||||||
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
import { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
||||||
|
|
||||||
|
// TODO Rename this to src/utils/helpers/dateIntervals.ts
|
||||||
|
|
||||||
export interface DateRange {
|
export interface DateRange {
|
||||||
startDate?: Date | null;
|
startDate?: Date | null;
|
||||||
endDate?: Date | null;
|
endDate?: Date | null;
|
||||||
|
@ -12,8 +14,7 @@ export type DateInterval = 'all' | 'today' | 'yesterday' | 'last7Days' | 'last30
|
||||||
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
export const dateRangeIsEmpty = (dateRange?: DateRange): boolean => dateRange === undefined
|
||||||
|| isEmpty(filter(Boolean, dateRange as any));
|
|| isEmpty(filter(Boolean, dateRange as any));
|
||||||
|
|
||||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string';
|
||||||
typeof range === 'string';
|
|
||||||
|
|
||||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||||
today: 'Today',
|
today: 'Today',
|
||||||
|
@ -103,3 +104,11 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
||||||
[T, () => 'all'],
|
[T, () => 'all'],
|
||||||
])();
|
])();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const toDateRange = (rangeOrInterval: DateRange | DateInterval): DateRange => {
|
||||||
|
if (rangeIsInterval(rangeOrInterval)) {
|
||||||
|
return intervalToDateRange(rangeOrInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
return rangeOrInterval;
|
||||||
|
};
|
||||||
|
|
|
@ -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/dates/types';
|
||||||
|
|
||||||
export type VisitsStatsProps = PropsWithChildren<{
|
export type VisitsStatsProps = PropsWithChildren<{
|
||||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||||
|
@ -68,11 +69,19 @@ 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 [visitsFilter, setVisitsFilter] = useState<VisitsFilter>({});
|
|
||||||
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
||||||
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
||||||
const botsSupported = supportsBotVisits(selectedServer);
|
const botsSupported = supportsBotVisits(selectedServer);
|
||||||
|
@ -80,7 +89,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
||||||
|
|
||||||
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
53
src/visits/helpers/hooks.ts
Normal file
53
src/visits/helpers/hooks.ts
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
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/dates/types';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VisitsFiltering {
|
||||||
|
dateRange?: DateRange;
|
||||||
|
visitsFilter: VisitsFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
type UpdateFiltering = (extra: DeepPartial<VisitsFiltering>) => void;
|
||||||
|
|
||||||
|
export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { search } = useLocation();
|
||||||
|
|
||||||
|
const filtering = useMemo(
|
||||||
|
pipe(
|
||||||
|
() => parseQuery<VisitsQuery>(search),
|
||||||
|
({ startDate, endDate, orphanVisitsType, excludeBots }: VisitsQuery): VisitsFiltering => ({
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
const stringifiedQuery = stringifyQuery(query);
|
||||||
|
const queryString = isEmpty(stringifiedQuery) ? '' : `?${stringifiedQuery}`;
|
||||||
|
|
||||||
|
navigate(queryString, { replace: true, relative: 'route' });
|
||||||
|
};
|
||||||
|
|
||||||
|
return [filtering, updateFiltering];
|
||||||
|
};
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue