Created new hook to handle visits filtering via query string

This commit is contained in:
Alejandro Celaya 2022-12-03 12:15:36 +01:00
parent 165afa436d
commit d2ebc880a0
9 changed files with 96 additions and 24 deletions

View file

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

View file

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

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

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

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>