diff --git a/CHANGELOG.md b/CHANGELOG.md index a924e1c3..593162a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#385](https://github.com/shlinkio/shlink-web-client/issues/385) Added setting to determine if "validate URL" should be enabled or disabled by default. * [#386](https://github.com/shlinkio/shlink-web-client/issues/386) Added new card in overview section to display amount of orphan visits when using Shlink 2.6.0 or higher. * [#177](https://github.com/shlinkio/shlink-web-client/issues/177) Added dark theme. -* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0. +* [#387](https://github.com/shlinkio/shlink-web-client/issues/387) and [#395](https://github.com/shlinkio/shlink-web-client/issues/395) Added a section to see orphan visits stats, when consuming Shlink >=2.6.0. * [#383](https://github.com/shlinkio/shlink-web-client/issues/383) Added title to short URLs list, displayed when consuming Shlink >=2.6.0. * [#368](https://github.com/shlinkio/shlink-web-client/issues/368) Added new settings to define the default interval for visits pages. * [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV. diff --git a/src/utils/DropdownBtn.tsx b/src/utils/DropdownBtn.tsx index d1cf6f6b..b658c218 100644 --- a/src/utils/DropdownBtn.tsx +++ b/src/utils/DropdownBtn.tsx @@ -7,16 +7,20 @@ export interface DropdownBtnProps { text: string; disabled?: boolean; className?: string; + dropdownClassName?: string; + right?: boolean; } -export const DropdownBtn: FC = ({ text, disabled = false, className = '', children }) => { +export const DropdownBtn: FC = ( + { text, disabled = false, className = '', children, dropdownClassName, right = false }, +) => { const [ isOpen, toggle ] = useToggle(); const toggleClasses = `dropdown-btn__toggle btn-block ${className}`; return ( - + {text} - {children} + {children} ); }; diff --git a/src/visits/OrphanVisits.tsx b/src/visits/OrphanVisits.tsx index 54197a83..b79cfad8 100644 --- a/src/visits/OrphanVisits.tsx +++ b/src/visits/OrphanVisits.tsx @@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure baseUrl={url} settings={settings} exportCsv={exportCsv} + isOrphanVisits > diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 09271d2d..d94d14bb 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -6,6 +6,7 @@ import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } fro import { IconDefinition } from '@fortawesome/fontawesome-common-types'; import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom'; 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'; @@ -18,10 +19,12 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedVisit, Stats, VisitsInfo } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; -import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; +import { processStatsFromVisits } from './services/VisitsParser'; +import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import './VisitsStats.scss'; +import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers'; export interface VisitsStatsProps { getVisits: (params: Partial) => void; @@ -31,6 +34,7 @@ export interface VisitsStatsProps { baseUrl: string; domain?: string; exportCsv: (visits: NormalizedVisit[]) => void; + isOrphanVisits?: boolean; } interface VisitsNavLinkProps { @@ -39,7 +43,6 @@ interface VisitsNavLinkProps { icon: IconDefinition; } -type HighlightableProps = 'referer' | 'country' | 'city'; type Section = 'byTime' | 'byContext' | 'byLocation' | 'list'; const sections: Record = { @@ -49,18 +52,6 @@ const sections: Record = { list: { title: 'List', subPath: '/list', icon: faList }, }; -const highlightedVisitsToStats = ( - highlightedVisits: NormalizedVisit[], - prop: HighlightableProps, -): Stats => highlightedVisits.reduce((acc, highlightedVisit) => { - if (!acc[highlightedVisit[prop]]) { - acc[highlightedVisit[prop]] = 0; - } - - acc[highlightedVisit[prop]] += 1; - - return acc; -}, {}); let selectedBar: string | undefined; const VisitsNavLink: FC = ({ subPath, title, icon, to }) => ( @@ -77,12 +68,13 @@ const VisitsNavLink: FC = ({ subPath, title ); const VisitsStats: FC = ( - { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv }, + { children, visitsInfo, getVisits, cancelGetVisits, baseUrl, domain, settings, exportCsv, isOrphanVisits = false }, ) => { const initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days'; const [ dateRange, setDateRange ] = useState(intervalToDateRange(initialInterval)); const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedLabel, setHighlightedLabel ] = useState(); + const [ orphanVisitType, setOrphanVisitType ] = useState(); const buildSectionUrl = (subPath?: string) => { const query = domain ? `?domain=${domain}` : ''; @@ -90,8 +82,11 @@ const VisitsStats: FC = ( return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`; }; const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo; - const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]); - const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( + const normalizedVisits = useMemo( + () => normalizeAndFilterVisits(visits, orphanVisitType), + [ visits, orphanVisitType ], + ); + const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], ); @@ -101,7 +96,7 @@ const VisitsStats: FC = ( selectedBar = undefined; setHighlightedVisits(selectedVisits); }; - const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => { + const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => { const newSelectedBar = `${prop}_${value}`; if (selectedBar === newSelectedBar) { @@ -109,7 +104,7 @@ const VisitsStats: FC = ( setHighlightedLabel(undefined); selectedBar = undefined; } else { - setHighlightedVisits(normalizedVisits.filter(propEq(prop, value))); + setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value))); setHighlightedLabel(value); selectedBar = newSelectedBar; } @@ -171,13 +166,13 @@ const VisitsStats: FC = ( -
+
-
+
-
+
= ( onClick={highlightVisitsForProp('referer')} />
+ {isOrphanVisits && ( +
+ +
+ )} @@ -232,6 +242,7 @@ const VisitsStats: FC = ( visits={normalizedVisits} selectedVisits={highlightedVisits} setSelectedVisits={setSelectedVisits} + isOrphanVisits={isOrphanVisits} />
@@ -250,12 +261,24 @@ const VisitsStats: FC = (
- +
+
+ +
+ {isOrphanVisits && ( + + )} +
{visits.length > 0 && (
diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index fb99a950..9eb0eef2 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -12,7 +12,7 @@ import SimplePaginator from '../common/SimplePaginator'; import SearchField from '../utils/SearchField'; import { determineOrderDir, OrderDir } from '../utils/utils'; import { prettify } from '../utils/helpers/numbers'; -import { NormalizedVisit } from './types'; +import { NormalizedOrphanVisit, NormalizedVisit } from './types'; import './VisitsTable.scss'; interface VisitsTableProps { @@ -20,9 +20,10 @@ interface VisitsTableProps { selectedVisits?: NormalizedVisit[]; setSelectedVisits: (visits: NormalizedVisit[]) => void; matchMedia?: (query: string) => MediaQueryList; + isOrphanVisits?: boolean; } -type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer'; +type OrderableFields = 'date' | 'country' | 'city' | 'browser' | 'os' | 'referer' | 'visitedUrl'; interface Order { field?: OrderableFields; @@ -30,8 +31,10 @@ interface Order { } const PAGE_SIZE = 20; -const visitMatchesSearch = ({ browser, os, referer, country, city }: NormalizedVisit, searchTerm: string) => - `${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase()); +const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) => + `${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes( + searchTerm.toLowerCase(), + ); const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) => visits.filter((visit) => visitMatchesSearch(visit, searchTerm)); const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort( @@ -39,7 +42,7 @@ const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field const greaterThan = dir === 'ASC' ? 1 : -1; const smallerThan = dir === 'ASC' ? -1 : 1; - return a[field] > b[field] ? greaterThan : smallerThan; + return (a as NormalizedOrphanVisit)[field] > (b as NormalizedOrphanVisit)[field] ? greaterThan : smallerThan; }, ); const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => { @@ -56,6 +59,7 @@ const VisitsTable = ({ selectedVisits = [], setSelectedVisits, matchMedia = window.matchMedia, + isOrphanVisits = false, }: VisitsTableProps) => { const headerCellsClass = 'visits-table__header-cell visits-table__sticky'; const matchMobile = () => matchMedia('(max-width: 767px)').matches; @@ -89,11 +93,8 @@ const VisitsTable = ({ useEffect(() => { setPage(1); - if (isFirstLoad.current) { - isFirstLoad.current = false; - } else { - setSelectedVisits([]); - } + !isFirstLoad.current && setSelectedVisits([]); + isFirstLoad.current = false; }, [ searchTerm ]); return ( @@ -132,22 +133,28 @@ const VisitsTable = ({ Referrer {renderOrderIcon('referer')} + {isOrphanVisits && ( + + Visited URL + {renderOrderIcon('visitedUrl')} + + )} - + - {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( + {!resultSet.visitsGroups[page - 1]?.length && ( - + No visits found with current filtering )} - {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => { + {resultSet.visitsGroups[page - 1]?.map((visit, index) => { const isSelected = selectedVisits.includes(visit); return ( @@ -170,6 +177,7 @@ const VisitsTable = ({ {visit.browser} {visit.os} {visit.referer} + {isOrphanVisits && {(visit as NormalizedOrphanVisit).visitedUrl}} ); })} @@ -177,7 +185,7 @@ const VisitsTable = ({ {resultSet.total > PAGE_SIZE && ( - +
{ return matcher() ?? 'monthly'; }; -const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visits.reduce( - (acc, visit) => { - const key = STEP_TO_DATE_FORMAT[step](visit.date); - - acc[key] = (acc[key] || 0) + 1; - - return acc; - }, - {}, +const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy( + (visit) => STEP_TO_DATE_FORMAT[step](visit.date), + visits, ); const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) => diff --git a/src/visits/helpers/OrphanVisitTypeDropdown.tsx b/src/visits/helpers/OrphanVisitTypeDropdown.tsx new file mode 100644 index 00000000..61273c14 --- /dev/null +++ b/src/visits/helpers/OrphanVisitTypeDropdown.tsx @@ -0,0 +1,26 @@ +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../types'; +import { DropdownBtn } from '../../utils/DropdownBtn'; + +interface OrphanVisitTypeDropdownProps { + onChange: (type: OrphanVisitType | undefined) => void; + selected?: OrphanVisitType | undefined; + className?: string; + text: string; +} + +export const OrphanVisitTypeDropdown = ({ onChange, selected, text, className }: OrphanVisitTypeDropdownProps) => ( + + onChange('base_url')}> + Base URL + + onChange('invalid_short_url')}> + Invalid short URL + + onChange('regular_404')}> + Regular 404 + + + onChange(undefined)}>Clear selection + +); diff --git a/src/visits/services/VisitsParser.ts b/src/visits/services/VisitsParser.ts index 824fb796..9cef1faa 100644 --- a/src/visits/services/VisitsParser.ts +++ b/src/visits/services/VisitsParser.ts @@ -2,7 +2,7 @@ import { isNil, map } from 'ramda'; import { extractDomain, parseUserAgent } from '../../utils/helpers/visits'; import { hasValue } from '../../utils/utils'; import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types'; -import { isOrphanVisit } from '../types/helpers'; +import { isNormalizedOrphanVisit, isOrphanVisit } from '../types/helpers'; const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) => !isNil(visit) && hasValue(visit[propertyName]); @@ -54,6 +54,16 @@ const updateCitiesForMapForVisit = (citiesForMapStats: Record citiesForMapStats[city] = currentCity; }; +const updateVisitedUrlsForVisit = (visitedUrlsStats: Stats, visit: NormalizedVisit) => { + if (!isNormalizedOrphanVisit(visit)) { + return; + } + + const { visitedUrl } = visit; + + visitedUrlsStats[visitedUrl] = (visitedUrlsStats[visitedUrl] || 0) + 1; +}; + export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce( (stats: VisitsStats, visit: NormalizedVisit) => { // We mutate the original object because it has a big performance impact when large data sets are processed @@ -63,10 +73,11 @@ export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.redu updateCountriesStatsForVisit(stats.countries, visit); updateCitiesStatsForVisit(stats.cities, visit); updateCitiesForMapForVisit(stats.citiesForMap, visit); + updateVisitedUrlsForVisit(stats.visitedUrls, visit); return stats; }, - { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }, + { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} }, ); export const normalizeVisits = map((visit: Visit): NormalizedVisit => { diff --git a/src/visits/types/helpers.ts b/src/visits/types/helpers.ts index fdc45733..d2691504 100644 --- a/src/visits/types/helpers.ts +++ b/src/visits/types/helpers.ts @@ -1,8 +1,20 @@ -import { groupBy, pipe } from 'ramda'; -import { Visit, OrphanVisit, CreateVisit } from './index'; +import { countBy, filter, groupBy, pipe, prop } from 'ramda'; +import { normalizeVisits } from '../services/VisitsParser'; +import { + Visit, + OrphanVisit, + CreateVisit, + NormalizedVisit, + NormalizedOrphanVisit, + Stats, + OrphanVisitType, +} from './index'; export const isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl'); +export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit => + visit.hasOwnProperty('visitedUrl'); + export interface GroupedNewVisits { orphanVisits: CreateVisit[]; regularVisits: CreateVisit[]; @@ -13,3 +25,17 @@ export const groupNewVisitsByType = pipe( // @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props (result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }), ); + +export type HighlightableProps = T extends NormalizedOrphanVisit + ? ('referer' | 'country' | 'city' | 'visitedUrl') + : ('referer' | 'country' | 'city'); + +export const highlightedVisitsToStats = ( + highlightedVisits: T[], + property: HighlightableProps, +): Stats => countBy(prop(property) as any, highlightedVisits); + +export const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( + normalizeVisits, + filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), +)(visits); diff --git a/src/visits/types/index.ts b/src/visits/types/index.ts index 813a8767..0e2879d5 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -20,7 +20,7 @@ export interface VisitsLoadFailedAction extends Action { errorData?: ProblemDetailsError; } -type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; +export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404'; interface VisitLocation { countryCode: string | null; @@ -90,4 +90,5 @@ export interface VisitsStats { countries: Stats; cities: Stats; citiesForMap: Record; + visitedUrls: Stats; } diff --git a/test/visits/VisitsTable.test.tsx b/test/visits/VisitsTable.test.tsx index a2d01c4a..393ab64d 100644 --- a/test/visits/VisitsTable.test.tsx +++ b/test/visits/VisitsTable.test.tsx @@ -10,13 +10,14 @@ describe('', () => { const matchMedia = () => Mock.of({ matches: false }); const setSelectedVisits = jest.fn(); let wrapper: ShallowWrapper; - const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => { + const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => { wrapper = shallow( , ); @@ -134,4 +135,17 @@ describe('', () => { searchField.simulate('change', ''); expect(wrapper.find('tbody').find('tr')).toHaveLength(7 + 2); }); + + it.each([ + [ true, 8 ], + [ false, 7 ], + ])('displays proper amount of columns for orphan and non-orphan visits', (isOrphanVisits, expectedCols) => { + const wrapper = createWrapper([], [], isOrphanVisits); + const rowsWithColspan = wrapper.find('[colSpan]'); + const cols = wrapper.find('th'); + + expect(cols).toHaveLength(expectedCols); + expect(rowsWithColspan).toHaveLength(2); + rowsWithColspan.forEach((row) => expect(row.prop('colSpan')).toEqual(expectedCols)); + }); }); diff --git a/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx new file mode 100644 index 00000000..c41b340a --- /dev/null +++ b/test/visits/helpers/OrphanVisitTypeDropdown.test.tsx @@ -0,0 +1,56 @@ +import { shallow, ShallowWrapper } from 'enzyme'; +import { DropdownItem } from 'reactstrap'; +import { OrphanVisitType } from '../../../src/visits/types'; +import { OrphanVisitTypeDropdown } from '../../../src/visits/helpers/OrphanVisitTypeDropdown'; + +describe('', () => { + let wrapper: ShallowWrapper; + const onChange = jest.fn(); + const createWrapper = (selected?: OrphanVisitType) => { + wrapper = shallow(); + + return wrapper; + }; + + beforeEach(jest.clearAllMocks); + afterEach(() => wrapper?.unmount()); + + it('has provided text', () => { + const wrapper = createWrapper(); + + expect(wrapper.prop('text')).toEqual('The text'); + }); + + it.each([ + [ 'base_url' as OrphanVisitType, 0, 1 ], + [ 'invalid_short_url' as OrphanVisitType, 1, 1 ], + [ 'regular_404' as OrphanVisitType, 2, 1 ], + [ undefined, -1, 0 ], + ])('sets expected item as active', (selected, expectedSelectedIndex, expectedActiveItems) => { + const wrapper = createWrapper(selected); + const items = wrapper.find(DropdownItem); + const activeItem = items.filterWhere((item) => !!item.prop('active')); + + expect.assertions(expectedActiveItems + 1); + expect(activeItem).toHaveLength(expectedActiveItems); + items.forEach((item, index) => { + if (item.prop('active')) { + expect(index).toEqual(expectedSelectedIndex); + } + }); + }); + + it.each([ + [ 0, 'base_url' ], + [ 1, 'invalid_short_url' ], + [ 2, 'regular_404' ], + [ 4, undefined ], + ])('invokes onChange with proper type when an item is clicked', (index, expectedType) => { + const wrapper = createWrapper(); + const itemToClick = wrapper.find(DropdownItem).at(index); + + itemToClick.simulate('click'); + + expect(onChange).toHaveBeenCalledWith(expectedType); + }); +}); diff --git a/test/visits/services/VisitsParser.test.ts b/test/visits/services/VisitsParser.test.ts index 96936f44..7edafab8 100644 --- a/test/visits/services/VisitsParser.test.ts +++ b/test/visits/services/VisitsParser.test.ts @@ -64,7 +64,7 @@ describe('VisitsParser', () => { }), Mock.of({ type: 'invalid_short_url', - visitedUrl: 'baz', + visitedUrl: 'bar', userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/51.0.2704.103 Safari/537.36', referer: 'https://m.facebook.com', visitLocation: { @@ -153,6 +153,15 @@ describe('VisitsParser', () => { }, }); }); + + it('properly parses visited URL stats', () => { + const { visitedUrls } = processStatsFromVisits(normalizeVisits(orphanVisits)); + + expect(visitedUrls).toEqual({ + foo: 1, + bar: 2, + }); + }); }); describe('normalizeVisits', () => { @@ -247,7 +256,7 @@ describe('VisitsParser', () => { latitude: 123.45, longitude: -543.21, type: 'invalid_short_url', - visitedUrl: 'baz', + visitedUrl: 'bar', }, ]); });