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 => {
|
||||
const parsedOrderBy = orderBy ? stringToOrder<ShortUrlsOrderableFields>(orderBy) : undefined;
|
||||
const parsedTags = tags?.split(',') ?? [];
|
||||
|
||||
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 { dateOrNull, DateOrString, formatInternational, isBeforeOrEqual, parseISO } from '../../helpers/date';
|
||||
|
||||
// TODO Rename this to src/utils/helpers/dateIntervals.ts
|
||||
|
||||
export interface DateRange {
|
||||
startDate?: 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
|
||||
|| isEmpty(filter(Boolean, dateRange as any));
|
||||
|
||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval =>
|
||||
typeof range === 'string';
|
||||
export const rangeIsInterval = (range?: DateRange | DateInterval): range is DateInterval => typeof range === 'string';
|
||||
|
||||
const INTERVAL_TO_STRING_MAP: Record<DateInterval, string | undefined> = {
|
||||
today: 'Today',
|
||||
|
@ -103,3 +104,11 @@ export const dateToMatchingInterval = (date: DateOrString): DateInterval => {
|
|||
[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 { 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/dates/types';
|
||||
|
||||
export type VisitsStatsProps = PropsWithChildren<{
|
||||
getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void;
|
||||
|
@ -68,11 +69,19 @@ 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 [visitsFilter, setVisitsFilter] = useState<VisitsFilter>({});
|
||||
const [highlightedVisits, setHighlightedVisits] = useState<NormalizedVisit[]>([]);
|
||||
const [highlightedLabel, setHighlightedLabel] = useState<string | undefined>();
|
||||
const botsSupported = supportsBotVisits(selectedServer);
|
||||
|
@ -80,7 +89,6 @@ export const VisitsStats: FC<VisitsStatsProps> = ({
|
|||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
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' }, {}],
|
||||
[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>
|
||||
|
|
Loading…
Reference in a new issue