diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d04a56f..c0f8da11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/jest.config.js b/jest.config.js index 1e37de5d..cb2b1a57 100644 --- a/jest.config.js +++ b/jest.config.js @@ -17,6 +17,7 @@ module.exports = { }, setupFilesAfterEnv: ['/config/jest/setupTests.ts'], testMatch: ['/test/**/*.test.{ts,tsx}'], + modulePathIgnorePatterns: ['/.stryker-tmp'], testEnvironment: 'jsdom', testEnvironmentOptions: { url: 'http://localhost', diff --git a/src/settings/reducers/settings.ts b/src/settings/reducers/settings.ts index 7f66a67c..b707160c 100644 --- a/src/settings/reducers/settings.ts +++ b/src/settings/reducers/settings.ts @@ -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'; diff --git a/src/short-urls/ShortUrlsFilteringBar.tsx b/src/short-urls/ShortUrlsFilteringBar.tsx index 205c62ef..1a5ec29d 100644 --- a/src/short-urls/ShortUrlsFilteringBar.tsx +++ b/src/short-urls/ShortUrlsFilteringBar.tsx @@ -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, TagsSelector: FC, @@ -74,10 +71,7 @@ export const ShortUrlsFilteringBar = (
diff --git a/src/short-urls/helpers/hooks.ts b/src/short-urls/helpers/hooks.ts index 3f1c165c..29fe8516 100644 --- a/src/short-urls/helpers/hooks.ts +++ b/src/short-urls/helpers/hooks.ts @@ -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) => void; - interface ShortUrlsQueryCommon { search?: string; startDate?: string; @@ -25,35 +23,36 @@ interface ShortUrlsFiltering extends ShortUrlsQueryCommon { tags: string[]; } +type ToFirstPage = (extra: Partial) => 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(location.search), + () => parseQuery(search), ({ orderBy, tags, ...rest }: ShortUrlsQuery): ShortUrlsFiltering => { const parsedOrderBy = orderBy ? stringToOrder(orderBy) : undefined; const parsedTags = tags?.split(',') ?? []; - return { ...rest, orderBy: parsedOrderBy, tags: parsedTags }; }, ), - [location.search], + [search], ); const toFirstPageWithExtra = (extra: Partial) => { - 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]; }; diff --git a/src/utils/dates/DateIntervalDropdownItems.tsx b/src/utils/dates/DateIntervalDropdownItems.tsx index 57099496..bd2b8de4 100644 --- a/src/utils/dates/DateIntervalDropdownItems.tsx +++ b/src/utils/dates/DateIntervalDropdownItems.tsx @@ -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; diff --git a/src/utils/dates/DateIntervalSelector.tsx b/src/utils/dates/DateIntervalSelector.tsx index 047fccf0..3f4bccb3 100644 --- a/src/utils/dates/DateIntervalSelector.tsx +++ b/src/utils/dates/DateIntervalSelector.tsx @@ -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 = ({ onChange, active, allText }) => ( diff --git a/src/utils/dates/DateRangeRow.tsx b/src/utils/dates/DateRangeRow.tsx index d2cf1b8d..f5e33ff4 100644 --- a/src/utils/dates/DateRangeRow.tsx +++ b/src/utils/dates/DateRangeRow.tsx @@ -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; diff --git a/src/utils/dates/DateRangeSelector.tsx b/src/utils/dates/DateRangeSelector.tsx index 1b2a89cd..652d3c11 100644 --- a/src/utils/dates/DateRangeSelector.tsx +++ b/src/utils/dates/DateRangeSelector.tsx @@ -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( + initialIntervalIsRange ? initialDateRange : undefined, + ); const [activeDateRange, setActiveDateRange] = useState(initialIntervalIsRange ? undefined : initialDateRange); const updateDateRange = (dateRange: DateRange) => { diff --git a/src/utils/helpers/date.ts b/src/utils/helpers/date.ts index 9e0a73b5..e9489193 100644 --- a/src/utils/helpers/date.ts +++ b/src/utils/helpers/date.ts @@ -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) }); diff --git a/src/utils/dates/types/index.ts b/src/utils/helpers/dateIntervals.ts similarity index 75% rename from src/utils/dates/types/index.ts rename to src/utils/helpers/dateIntervals.ts index d7d81d78..f81addb8 100644 --- a/src/utils/dates/types/index.ts +++ b/src/utils/helpers/dateIntervals.ts @@ -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 = { +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 = { 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; +}; diff --git a/src/utils/helpers/ordering.ts b/src/utils/helpers/ordering.ts index d79a9898..dafe7b69 100644 --- a/src/utils/helpers/ordering.ts +++ b/src/utils/helpers/ordering.ts @@ -36,6 +36,6 @@ export const orderToString = (order: Order): string | undefined => ( ); export const stringToOrder = (order: string): Order => { - const [field, dir] = order.split('-') as [ T | undefined, OrderDir | undefined ]; + const [field, dir] = order.split('-') as [T | undefined, OrderDir | undefined]; return { field, dir }; }; diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 2f22f5e7..a6110bc4 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/helpers/dateIntervals'; export type VisitsStatsProps = PropsWithChildren<{ getVisits: (params: VisitsParams, doIntervalFallback?: boolean) => void; @@ -68,19 +69,26 @@ 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 [highlightedVisits, setHighlightedVisits] = useState([]); const [highlightedLabel, setHighlightedLabel] = useState(); - const [visitsFilter, setVisitsFilter] = useState({}); 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 = ({ 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..af19d9c8 --- /dev/null +++ b/src/visits/helpers/hooks.ts @@ -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) => void; + +export const useVisitsQuery = (): [VisitsFiltering, UpdateFiltering] => { + const navigate = useNavigate(); + const { search } = useLocation(); + + const { filtering, domain: theDomain } = useMemo( + pipe( + () => parseQuery(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) => { + 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]; +}; diff --git a/src/visits/reducers/common.ts b/src/visits/reducers/common.ts index a5ae208f..f64078cd 100644 --- a/src/visits/reducers/common.ts +++ b/src/visits/reducers/common.ts @@ -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'; diff --git a/src/visits/reducers/types/index.ts b/src/visits/reducers/types/index.ts index b9dd1adf..f3e0e353 100644 --- a/src/visits/reducers/types/index.ts +++ b/src/visits/reducers/types/index.ts @@ -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'; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index cb0499aa..14b844f1 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -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'; diff --git a/test/short-urls/ShortUrlsFilteringBar.test.tsx b/test/short-urls/ShortUrlsFilteringBar.test.tsx index 10adb9fc..d8f30942 100644 --- a/test/short-urls/ShortUrlsFilteringBar.test.tsx +++ b/test/short-urls/ShortUrlsFilteringBar.test.tsx @@ -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'; diff --git a/test/utils/dates/DateIntervalDropdownItems.test.tsx b/test/utils/dates/DateIntervalDropdownItems.test.tsx index 0e9ec52f..66a909aa 100644 --- a/test/utils/dates/DateIntervalDropdownItems.test.tsx +++ b/test/utils/dates/DateIntervalDropdownItems.test.tsx @@ -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'; diff --git a/test/utils/dates/DateIntervalSelector.test.tsx b/test/utils/dates/DateIntervalSelector.test.tsx index ebff24a1..975159c7 100644 --- a/test/utils/dates/DateIntervalSelector.test.tsx +++ b/test/utils/dates/DateIntervalSelector.test.tsx @@ -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'; diff --git a/test/utils/dates/DateRangeSelector.test.tsx b/test/utils/dates/DateRangeSelector.test.tsx index c776cec9..858d06c0 100644 --- a/test/utils/dates/DateRangeSelector.test.tsx +++ b/test/utils/dates/DateRangeSelector.test.tsx @@ -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('', () => { diff --git a/test/utils/dates/types/index.test.ts b/test/utils/helpers/dateIntervals.test.ts similarity index 80% rename from test/utils/dates/types/index.test.ts rename to test/utils/helpers/dateIntervals.test.ts index 7d436bae..37ebe6ff 100644 --- a/test/utils/dates/types/index.test.ts +++ b/test/utils/helpers/dateIntervals.test.ts @@ -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); + }); + }); }); diff --git a/test/visits/VisitsStats.test.tsx b/test/visits/VisitsStats.test.tsx index ce503fac..7498ddec 100644 --- a/test/visits/VisitsStats.test.tsx +++ b/test/visits/VisitsStats.test.tsx @@ -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('', () => { const history = createMemoryHistory(); history.push(activeRoute); - return renderWithEvents( - - (visitsInfo)} - cancelGetVisits={() => {}} - settings={Mock.all()} - exportCsv={exportCsv} - selectedServer={Mock.all()} - /> - , - ); + return { + history, + ...renderWithEvents( + + (visitsInfo)} + cancelGetVisits={() => {}} + settings={Mock.all()} + exportCsv={exportCsv} + selectedServer={Mock.of({ version: '3.0.0' })} + /> + , + ), + }; }; it('renders a preloader when visits are loading', () => { @@ -81,4 +84,24 @@ describe('', () => { 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']); + }); }); 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 diff --git a/test/visits/reducers/domainVisits.test.ts b/test/visits/reducers/domainVisits.test.ts index da683d16..db52ba06 100644 --- a/test/visits/reducers/domainVisits.test.ts +++ b/test/visits/reducers/domainVisits.test.ts @@ -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'; diff --git a/test/visits/reducers/nonOrphanVisits.test.ts b/test/visits/reducers/nonOrphanVisits.test.ts index 5ba44eed..e62a6d10 100644 --- a/test/visits/reducers/nonOrphanVisits.test.ts +++ b/test/visits/reducers/nonOrphanVisits.test.ts @@ -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'; diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 0a58c3b9..0b728929 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -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'; diff --git a/test/visits/reducers/shortUrlVisits.test.ts b/test/visits/reducers/shortUrlVisits.test.ts index 01f87e30..4b65d351 100644 --- a/test/visits/reducers/shortUrlVisits.test.ts +++ b/test/visits/reducers/shortUrlVisits.test.ts @@ -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', () => { diff --git a/test/visits/reducers/tagVisits.test.ts b/test/visits/reducers/tagVisits.test.ts index 0b3573ca..fc94e980 100644 --- a/test/visits/reducers/tagVisits.test.ts +++ b/test/visits/reducers/tagVisits.test.ts @@ -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', () => {