From d2ebc880a01356f33dc3f8e5eae07ddd834d6026 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 3 Dec 2022 12:15:36 +0100 Subject: [PATCH] Created new hook to handle visits filtering via query string --- src/short-urls/helpers/hooks.ts | 1 - src/utils/dates/types/index.ts | 13 ++++- src/visits/VisitsStats.tsx | 36 +++++++------ src/visits/helpers/MapModal.tsx | 2 +- src/visits/helpers/VisitsFilterDropdown.tsx | 7 ++- src/visits/helpers/hooks.ts | 53 +++++++++++++++++++ .../helpers/VisitsFilterDropdown.test.tsx | 2 +- .../__snapshots__/MapModal.test.tsx.snap | 2 +- .../OpenMapModalBtn.test.tsx.snap | 4 +- 9 files changed, 96 insertions(+), 24 deletions(-) create mode 100644 src/visits/helpers/hooks.ts diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index 03b2323f..29fe8516 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -36,7 +36,6 @@ export const useShortUrlsQuery = (): [ShortUrlsFiltering, ToFirstPage] => { ({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; const parsedTags = tags?.split(',') ?? []; - return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; }, ), diff --git a/src/utils/dates/types/index.ts b/src/utils/dates/types/index.ts index 04e4eef3..905fbe8d 100644 --- a/src/utils/dates/types/index.ts +++ b/src/utils/dates/types/index.ts @@ -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 = { 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; +}; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 5976d223..f25dbfba 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -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 = ({ isOrphanVisits = false, }) => { const { visits, loading, loadingLarge, error, errorData, progress, fallbackInterval } = visitsInfo; - const [initialInterval, setInitialInterval] = useState( - 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 ?? fallbackInterval ?? settings.visits?.defaultInterval ?? 'last30Days', ); - const [dateRange, setDateRange] = useState(intervalToDateRange(initialInterval)); - const [visitsFilter, setVisitsFilter] = useState({}); const [highlightedVisits, setHighlightedVisits] = useState([]); const [highlightedLabel, setHighlightedLabel] = useState(); const botsSupported = supportsBotVisits(selectedServer); @@ -80,7 +89,6 @@ export const VisitsStats: FC = ({ 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 = ({ 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 = ({ = ({ isOrphanVisits={isOrphanVisits} botsSupported={botsSupported} selected={visitsFilter} - onChange={setVisitsFilter} + onChange={(newVisitsFilter) => updateFiltering({ visitsFilter: newVisitsFilter })} /> diff --git a/src/visits/helpers/MapModal.tsx b/src/visits/helpers/MapModal.tsx index 27296ff9..c2118031 100644 --- a/src/visits/helpers/MapModal.tsx +++ b/src/visits/helpers/MapModal.tsx @@ -14,7 +14,7 @@ interface MapModalProps { const OpenStreetMapTile: FC = () => ( ); diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx index 19824a4b..58bb4ff5 100644 --- a/src/visits/helpers/VisitsFilterDropdown.tsx +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -46,7 +46,12 @@ export const VisitsFilterDropdown = ( )} - onChange({})}>Clear filters + onChange({ excludeBots: false, orphanVisitsType: undefined })} + > + Clear filters + ); }; diff --git a/src/visits/helpers/hooks.ts b/src/visits/helpers/hooks.ts new file mode 100644 index 00000000..6fd3375a --- /dev/null +++ b/src/visits/helpers/hooks.ts @@ -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) => void; + +export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => { + const navigate = useNavigate(); + const { search } = useLocation(); + + const filtering = useMemo( + pipe( + () => parseQuery(search), + ({ startDate, endDate, orphanVisitsType, excludeBots }: VisitsQuery): VisitsFiltering => ({ + dateRange: startDate || endDate ? datesToDateRange(startDate, endDate) : undefined, + visitsFilter: { orphanVisitsType, excludeBots: excludeBots === 'true' }, + }), + ), + [search], + ); + const updateFiltering = (extra: DeepPartial) => { + 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]; +}; diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx index eb3f38a2..a3729dca 100644 --- a/test/visits/helpers/VisitsFilterDropdown.test.tsx +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -60,7 +60,7 @@ describe('', () => { [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); diff --git a/test/visits/helpers/__snapshots__/MapModal.test.tsx.snap b/test/visits/helpers/__snapshots__/MapModal.test.tsx.snap index 8a7ea0a4..9a95d69d 100644 --- a/test/visits/helpers/__snapshots__/MapModal.test.tsx.snap +++ b/test/visits/helpers/__snapshots__/MapModal.test.tsx.snap @@ -167,7 +167,7 @@ exports[` renders expected map 1`] = ` © OpenStreetMap diff --git a/test/visits/helpers/__snapshots__/OpenMapModalBtn.test.tsx.snap b/test/visits/helpers/__snapshots__/OpenMapModalBtn.test.tsx.snap index 148a44d5..079cd283 100644 --- a/test/visits/helpers/__snapshots__/OpenMapModalBtn.test.tsx.snap +++ b/test/visits/helpers/__snapshots__/OpenMapModalBtn.test.tsx.snap @@ -167,7 +167,7 @@ exports[` filters out non-active cities from list of location © OpenStreetMap @@ -335,7 +335,7 @@ exports[` filters out non-active cities from list of location © OpenStreetMap