diff --git a/CHANGELOG.md b/CHANGELOG.md index ba0e8b7d..d27862e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7. ### Changed +* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer. * [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns. * [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`. diff --git a/src/api/types/index.ts b/src/api/types/index.ts index b9da9fa6..acd0d4f7 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -55,6 +55,7 @@ export interface ShlinkVisitsParams { itemsPerPage?: number; startDate?: string; endDate?: string; + excludeBots?: boolean; } export interface ShlinkShortUrlData extends ShortUrlMeta { diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 8a252e95..8184e687 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -4,12 +4,13 @@ import { ShlinkVisitsParams } from '../api/types'; import { Topics } from '../mercure/helpers/Topics'; import VisitsStats from './VisitsStats'; import { OrphanVisitsHeader } from './OrphanVisitsHeader'; -import { NormalizedVisit, VisitsInfo } from './types'; +import { NormalizedVisit, OrphanVisitType, VisitsInfo, VisitsParams } from './types'; import { VisitsExporter } from './services/VisitsExporter'; import { CommonVisitsProps } from './types/CommonVisitsProps'; +import { toApiParams } from './types/helpers'; export interface OrphanVisitsProps extends CommonVisitsProps, RouteComponentProps { - getOrphanVisits: (params: ShlinkVisitsParams) => void; + getOrphanVisits: (params?: ShlinkVisitsParams, orphanVisitsType?: OrphanVisitType) => void; orphanVisits: VisitsInfo; cancelGetOrphanVisits: () => void; } @@ -24,10 +25,11 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure selectedServer, }: OrphanVisitsProps) => { const exportCsv = (visits: NormalizedVisit[]) => exportVisits('orphan_visits.csv', visits); + const loadVisits = (params: VisitsParams) => getOrphanVisits(toApiParams(params), params.filter?.orphanVisitsType); return ( { getShortUrlVisits: (shortCode: string, query?: ShlinkVisitsParams) => void; @@ -34,7 +35,7 @@ const ShortUrlVisits = ({ exportVisits }: VisitsExporter) => boundToMercureHub(( }: ShortUrlVisitsProps) => { const { shortCode } = params; const { domain } = parseQuery<{ domain?: string }>(search); - const loadVisits = (params: Partial) => getShortUrlVisits(shortCode, { ...params, domain }); + const loadVisits = (params: VisitsParams) => getShortUrlVisits(shortCode, { ...toApiParams(params), domain }); const exportCsv = (visits: NormalizedVisit[]) => exportVisits( `short-url_${shortUrlDetail.shortUrl?.shortUrl.replace(/https?:\/\//g, '')}_visits.csv`, visits, diff --git a/src/visits/TagVisits.tsx b/src/visits/TagVisits.tsx index d4d47eaa..4a80519f 100644 --- a/src/visits/TagVisits.tsx +++ b/src/visits/TagVisits.tsx @@ -9,9 +9,10 @@ import VisitsStats from './VisitsStats'; import { VisitsExporter } from './services/VisitsExporter'; import { NormalizedVisit } from './types'; import { CommonVisitsProps } from './types/CommonVisitsProps'; +import { toApiParams } from './types/helpers'; export interface TagVisitsProps extends CommonVisitsProps, RouteComponentProps<{ tag: string }> { - getTagVisits: (tag: string, query: any) => void; + getTagVisits: (tag: string, query?: ShlinkVisitsParams) => void; tagVisits: TagVisitsState; cancelGetTagVisits: () => void; } @@ -26,7 +27,7 @@ const TagVisits = (colorGenerator: ColorGenerator, { exportVisits }: VisitsExpor selectedServer, }: TagVisitsProps) => { const { tag } = params; - const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, params); + const loadVisits = (params: ShlinkVisitsParams) => getTagVisits(tag, toApiParams(params)); const exportCsv = (visits: NormalizedVisit[]) => exportVisits(`tag_${tag}_visits.csv`, visits); return ( diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index feb354f1..533658fe 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -9,8 +9,6 @@ import { Location } from 'history'; import classNames from 'classnames'; import { DateRangeSelector } from '../utils/dates/DateRangeSelector'; import Message from '../utils/Message'; -import { formatIsoDate } from '../utils/helpers/date'; -import { ShlinkVisitsParams } from '../api/types'; import { DateInterval, DateRange, intervalToDateRange } from '../utils/dates/types'; import { Result } from '../utils/Result'; import { ShlinkApiError } from '../api/ShlinkApiError'; @@ -21,15 +19,15 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedOrphanVisit, NormalizedVisit, VisitsInfo } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, VisitsFilter, VisitsInfo, VisitsParams } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; -import { processStatsFromVisits } from './services/VisitsParser'; -import { VisitsFilter, VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; -import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; +import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; +import { VisitsFilterDropdown } from './helpers/VisitsFilterDropdown'; +import { HighlightableProps, highlightedVisitsToStats } from './types/helpers'; import './VisitsStats.scss'; export interface VisitsStatsProps { - getVisits: (params: Partial) => void; + getVisits: (params: VisitsParams) => void; visitsInfo: VisitsInfo; settings: Settings; selectedServer: SelectedServer; @@ -95,7 +93,7 @@ const VisitsStats: FC = ({ return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; - const normalizedVisits = useMemo(() => normalizeAndFilterVisits(visits, visitsFilter), [ visits, visitsFilter ]); + const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], @@ -122,10 +120,8 @@ const VisitsStats: FC = ({ useEffect(() => cancelGetVisits, []); useEffect(() => { - const { startDate, endDate } = dateRange; - - getVisits({ startDate: formatIsoDate(startDate) ?? undefined, endDate: formatIsoDate(endDate) ?? undefined }); - }, [ dateRange ]); + getVisits({ dateRange, filter: visitsFilter }); + }, [ dateRange, visitsFilter ]); const renderVisitsContent = () => { if (loadingLarge) { diff --git a/src/visits/helpers/VisitsFilterDropdown.tsx b/src/visits/helpers/VisitsFilterDropdown.tsx index 6cbdcdc9..27936a90 100644 --- a/src/visits/helpers/VisitsFilterDropdown.tsx +++ b/src/visits/helpers/VisitsFilterDropdown.tsx @@ -1,13 +1,8 @@ import { DropdownItem, DropdownItemProps } from 'reactstrap'; // eslint-disable-line import/named -import { OrphanVisitType } from '../types'; +import { OrphanVisitType, VisitsFilter } from '../types'; import { DropdownBtn } from '../../utils/DropdownBtn'; import { hasValue } from '../../utils/utils'; -export interface VisitsFilter { - orphanVisitsType?: OrphanVisitType | undefined; - excludeBots?: boolean; -} - interface VisitsFilterDropdownProps { onChange: (filters: VisitsFilter) => void; selected?: VisitsFilter; @@ -26,7 +21,7 @@ export const VisitsFilterDropdown = ( const { orphanVisitsType, excludeBots = false } = selected; const propsForOrphanVisitsTypeItem = (type: OrphanVisitType): DropdownItemProps => ({ active: orphanVisitsType === type, - onClick: () => onChange({ ...selected, orphanVisitsType: type }), + onClick: () => onChange({ ...selected, orphanVisitsType: type === selected?.orphanVisitsType ? undefined : type }), }); const onBotsClick = () => onChange({ ...selected, excludeBots: !selected?.excludeBots }); diff --git a/src/visits/reducers/orphanVisits.ts b/src/visits/reducers/orphanVisits.ts index 11b90836..ac77a5c5 100644 --- a/src/visits/reducers/orphanVisits.ts +++ b/src/visits/reducers/orphanVisits.ts @@ -1,8 +1,17 @@ import { Action, Dispatch } from 'redux'; -import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAction } from '../types'; +import { + OrphanVisit, + OrphanVisitType, + Visit, + VisitsInfo, + VisitsLoadFailedAction, + VisitsLoadProgressChangedAction, +} from '../types'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; +import { ShlinkVisitsParams } from '../../api/types'; +import { isOrphanVisit } from '../types/helpers'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -48,12 +57,20 @@ export default buildReducer({ }, }, initialState); -export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (query = {}) => async ( - dispatch: Dispatch, - getState: GetState, -) => { +const matchesType = (visit: OrphanVisit, orphanVisitsType?: OrphanVisitType) => + !orphanVisitsType || orphanVisitsType === visit.type; + +export const getOrphanVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + query: ShlinkVisitsParams = {}, + orphanVisitsType?: OrphanVisitType, +) => async (dispatch: Dispatch, getState: GetState) => { const { getOrphanVisits } = buildShlinkApiClient(getState); - const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }); + const visitsLoader = async (page: number, itemsPerPage: number) => getOrphanVisits({ ...query, page, itemsPerPage }) + .then((result) => { + const visits = result.data.filter((visit) => isOrphanVisit(visit) && matchesType(visit, orphanVisitsType)); + + return { ...result, data: visits }; + }); const shouldCancel = () => getState().orphanVisits.cancelLoad; const actionMap = { start: GET_ORPHAN_VISITS_START, diff --git a/src/visits/reducers/shortUrlVisits.ts b/src/visits/reducers/shortUrlVisits.ts index 6641c1e8..2018a66a 100644 --- a/src/visits/reducers/shortUrlVisits.ts +++ b/src/visits/reducers/shortUrlVisits.ts @@ -5,7 +5,7 @@ import { ShortUrlIdentifier } from '../../short-urls/data'; import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; -import { OptionalString } from '../../utils/utils'; +import { ShlinkVisitsParams } from '../../api/types'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -64,7 +64,7 @@ export default buildReducer({ export const getShortUrlVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( shortCode: string, - query: { domain?: OptionalString } = {}, + query: ShlinkVisitsParams = {}, ) => async (dispatch: Dispatch, getState: GetState) => { const { getShortUrlVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getShortUrlVisits( diff --git a/src/visits/reducers/tagVisits.ts b/src/visits/reducers/tagVisits.ts index 6bbff367..77cf31b3 100644 --- a/src/visits/reducers/tagVisits.ts +++ b/src/visits/reducers/tagVisits.ts @@ -3,6 +3,7 @@ import { Visit, VisitsInfo, VisitsLoadFailedAction, VisitsLoadProgressChangedAct import { buildActionCreator, buildReducer } from '../../utils/helpers/redux'; import { ShlinkApiClientBuilder } from '../../api/services/ShlinkApiClientBuilder'; import { GetState } from '../../container/types'; +import { ShlinkVisitsParams } from '../../api/types'; import { getVisitsWithLoader } from './common'; import { CREATE_VISITS, CreateVisitsAction } from './visitCreation'; @@ -56,10 +57,10 @@ export default buildReducer({ }, }, initialState); -export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => (tag: string, query = {}) => async ( - dispatch: Dispatch, - getState: GetState, -) => { +export const getTagVisits = (buildShlinkApiClient: ShlinkApiClientBuilder) => ( + tag: string, + query: ShlinkVisitsParams = {}, +) => async (dispatch: Dispatch, getState: GetState) => { const { getTagVisits } = buildShlinkApiClient(getState); const visitsLoader = async (page: number, itemsPerPage: number) => getTagVisits( tag, diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts index 00d248d8..a12f093a 100644 --- a/src/visits/types/helpers.ts +++ b/src/visits/types/helpers.ts @@ -1,8 +1,7 @@ -import { countBy, filter, groupBy, pipe, prop } from 'ramda'; -import { normalizeVisits } from '../services/VisitsParser'; -import { VisitsFilter } from '../helpers/VisitsFilterDropdown'; -import { hasValue } from '../../utils/utils'; -import { Visit, OrphanVisit, CreateVisit, NormalizedVisit, NormalizedOrphanVisit, Stats } from './index'; +import { countBy, groupBy, pipe, prop } from 'ramda'; +import { formatIsoDate } from '../../utils/helpers/date'; +import { ShlinkVisitsParams } from '../../api/types'; +import { CreateVisit, NormalizedOrphanVisit, NormalizedVisit, OrphanVisit, Stats, Visit, VisitsParams } from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); @@ -29,19 +28,10 @@ export const highlightedVisitsToStats = ( property: HighlightableProps, ): Stats => countBy(prop(property) as any, highlightedVisits); -export const normalizeAndFilterVisits = (visits: Visit[], filters: VisitsFilter) => pipe( - normalizeVisits, - filter((normalizedVisit: NormalizedVisit) => { - if (!hasValue(filters)) { - return true; - } +export const toApiParams = ({ page, itemsPerPage, filter, dateRange }: VisitsParams): ShlinkVisitsParams => { + const startDate = (dateRange?.startDate && formatIsoDate(dateRange?.startDate)) ?? undefined; + const endDate = (dateRange?.endDate && formatIsoDate(dateRange?.endDate)) ?? undefined; + const excludeBots = filter?.excludeBots || undefined; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing - const { orphanVisitsType, excludeBots } = filters; - - if (orphanVisitsType && orphanVisitsType !== (normalizedVisit as NormalizedOrphanVisit).type) { - return false; - } - - return !(excludeBots && normalizedVisit.potentialBot); - }), -)(visits); + return { page, itemsPerPage, startDate, endDate, excludeBots }; +}; diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index ffaccb47..60c64a14 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -1,6 +1,7 @@ import { Action } from 'redux'; import { ShortUrl } from '../../short-urls/data'; import { ProblemDetailsError } from '../../api/types'; +import { DateRange } from '../../utils/dates/types'; export interface VisitsInfo { visits: Visit[]; @@ -94,3 +95,15 @@ export interface VisitsStats { citiesForMap: Record; visitedUrls: Stats; } + +export interface VisitsFilter { + orphanVisitsType?: OrphanVisitType | undefined; + excludeBots?: boolean; +} + +export interface VisitsParams { + page?: number; + itemsPerPage?: number; + dateRange?: DateRange; + filter?: VisitsFilter; +} diff --git a/test/visits/OrphanVisits.test.tsx b/test/visits/OrphanVisits.test.tsx index e980c4fa..99fef93e 100644 --- a/test/visits/OrphanVisits.test.tsx +++ b/test/visits/OrphanVisits.test.tsx @@ -37,7 +37,6 @@ describe('', () => { expect(stats).toHaveLength(1); expect(header).toHaveLength(1); - expect(stats.prop('getVisits')).toEqual(getOrphanVisits); expect(stats.prop('cancelGetVisits')).toEqual(cancelGetOrphanVisits); expect(stats.prop('visitsInfo')).toEqual(orphanVisits); expect(stats.prop('baseUrl')).toEqual('the_base_url'); diff --git a/test/visits/helpers/VisitsFilterDropdown.test.tsx b/test/visits/helpers/VisitsFilterDropdown.test.tsx index 9476ac9c..10cbaecc 100644 --- a/test/visits/helpers/VisitsFilterDropdown.test.tsx +++ b/test/visits/helpers/VisitsFilterDropdown.test.tsx @@ -1,7 +1,7 @@ import { shallow, ShallowWrapper } from 'enzyme'; import { DropdownItem } from 'reactstrap'; -import { OrphanVisitType } from '../../../src/visits/types'; -import { VisitsFilter, VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown'; +import { OrphanVisitType, VisitsFilter } from '../../../src/visits/types'; +import { VisitsFilterDropdown } from '../../../src/visits/helpers/VisitsFilterDropdown'; describe('', () => { let wrapper: ShallowWrapper; diff --git a/test/visits/reducers/orphanVisits.test.ts b/test/visits/reducers/orphanVisits.test.ts index 2dddf73c..75fe7253 100644 --- a/test/visits/reducers/orphanVisits.test.ts +++ b/test/visits/reducers/orphanVisits.test.ts @@ -110,9 +110,9 @@ describe('orphanVisitsReducer', () => { [ undefined ], [{}], ])('dispatches start and success when promise is resolved', async (query) => { - const visits = visitsMocks; + const visits = visitsMocks.map((visit) => ({ ...visit, visitedUrl: '' })); const ShlinkApiClient = buildApiClientMock(Promise.resolve({ - data: visitsMocks, + data: visits, pagination: { currentPage: 1, pagesCount: 1,