From eabd7d9ecb538d362f99c25f9873372d514e122d Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 15:57:22 +0200 Subject: [PATCH 1/7] Added visited URL column on visits table for orphan visits --- src/visits/OrphanVisits.tsx | 1 + src/visits/VisitsStats.tsx | 23 +++++++++++++++++++---- src/visits/VisitsTable.tsx | 27 +++++++++++++++++++-------- 3 files changed, 39 insertions(+), 12 deletions(-) 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..73e6d533 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'; @@ -31,6 +32,7 @@ export interface VisitsStatsProps { baseUrl: string; domain?: string; exportCsv: (visits: NormalizedVisit[]) => void; + isOrphanVisits?: boolean; } interface VisitsNavLinkProps { @@ -77,7 +79,7 @@ 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)); @@ -171,13 +173,13 @@ const VisitsStats: FC = ( -
+
-
+
-
+
= ( onClick={highlightVisitsForProp('referer')} />
+ {isOrphanVisits && ( +
+ +
+ )} @@ -232,6 +246,7 @@ const VisitsStats: FC = ( visits={normalizedVisits} selectedVisits={highlightedVisits} setSelectedVisits={setSelectedVisits} + isOrphanVisits={isOrphanVisits} />
diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index fb99a950..0e7bcb71 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; @@ -132,9 +136,15 @@ const VisitsTable = ({ Referrer {renderOrderIcon('referer')} + {isOrphanVisits && ( + + Visited URL + {renderOrderIcon('visitedUrl')} + + )} - + @@ -142,7 +152,7 @@ const VisitsTable = ({ {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( - + No visits found with current filtering @@ -170,6 +180,7 @@ const VisitsTable = ({ {visit.browser} {visit.os} {visit.referer} + {isOrphanVisits && {(visit as NormalizedOrphanVisit).visitedUrl}} ); })} @@ -177,7 +188,7 @@ const VisitsTable = ({ {resultSet.total > PAGE_SIZE && ( - +
Date: Sun, 28 Mar 2021 16:06:37 +0200 Subject: [PATCH 2/7] Improved VisitsTable test --- src/visits/VisitsTable.tsx | 2 +- test/visits/VisitsTable.test.tsx | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index 0e7bcb71..e5bcd849 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -150,7 +150,7 @@ const VisitsTable = ({ - {(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && ( + {!resultSet.visitsGroups[page - 1]?.length && ( No visits found with current filtering 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)); + }); }); From 6d887ec4a8f6979ef6d7aa90860ef6cdd0e92848 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 16:27:31 +0200 Subject: [PATCH 3/7] Replaced custom reducers with ramda's countBy --- src/visits/VisitsStats.tsx | 15 +++------------ src/visits/helpers/LineChartCard.tsx | 14 ++++---------- 2 files changed, 7 insertions(+), 22 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 73e6d533..57c10e4b 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,4 +1,4 @@ -import { isEmpty, propEq, values } from 'ramda'; +import { countBy, isEmpty, prop, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC } from 'react'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -51,18 +51,9 @@ 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; - } +const highlightedVisitsToStats = (highlightedVisits: NormalizedVisit[], property: HighlightableProps): Stats => + countBy(prop(property), highlightedVisits); - acc[highlightedVisit[prop]] += 1; - - return acc; -}, {}); let selectedBar: string | undefined; const VisitsNavLink: FC = ({ subPath, title, icon, to }) => ( diff --git a/src/visits/helpers/LineChartCard.tsx b/src/visits/helpers/LineChartCard.tsx index 2190b4bf..cb180dca 100644 --- a/src/visits/helpers/LineChartCard.tsx +++ b/src/visits/helpers/LineChartCard.tsx @@ -9,7 +9,7 @@ import { DropdownItem, } from 'reactstrap'; import { Line } from 'react-chartjs-2'; -import { always, cond, reverse } from 'ramda'; +import { always, cond, countBy, reverse } from 'ramda'; import moment from 'moment'; import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js'; import { NormalizedVisit, Stats } from '../types'; @@ -70,15 +70,9 @@ const determineInitialStep = (oldestVisitDate: string): Step => { 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[]) => From d6bb7186721326be416e1165601aede13c9f01c2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 17:45:47 +0200 Subject: [PATCH 4/7] Added filtering by type to orphan visits --- src/utils/DropdownBtn.tsx | 10 +++- src/visits/VisitsStats.tsx | 40 ++++++++++--- .../helpers/OrphanVisitTypeDropdown.tsx | 26 +++++++++ src/visits/types/index.ts | 2 +- .../helpers/OrphanVisitTypeDropdown.test.tsx | 56 +++++++++++++++++++ 5 files changed, 121 insertions(+), 13 deletions(-) create mode 100644 src/visits/helpers/OrphanVisitTypeDropdown.tsx create mode 100644 test/visits/helpers/OrphanVisitTypeDropdown.test.tsx 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/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 57c10e4b..8e5da643 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,4 +1,4 @@ -import { countBy, isEmpty, prop, propEq, values } from 'ramda'; +import { countBy, filter, isEmpty, pipe, prop, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC } from 'react'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -19,9 +19,10 @@ 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, Stats, Visit, VisitsInfo } from './types'; import OpenMapModalBtn from './helpers/OpenMapModalBtn'; import { normalizeVisits, processStatsFromVisits } from './services/VisitsParser'; +import { OrphanVisitTypeDropdown } from './helpers/OrphanVisitTypeDropdown'; import './VisitsStats.scss'; export interface VisitsStatsProps { @@ -54,6 +55,11 @@ const sections: Record = { const highlightedVisitsToStats = (highlightedVisits: NormalizedVisit[], property: HighlightableProps): Stats => countBy(prop(property), highlightedVisits); +const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( + normalizeVisits, + filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), +)(visits); + let selectedBar: string | undefined; const VisitsNavLink: FC = ({ subPath, title, icon, to }) => ( @@ -76,6 +82,7 @@ const VisitsStats: FC = ( 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}` : ''; @@ -83,7 +90,10 @@ 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 normalizedVisits = useMemo( + () => normalizeAndFilterVisits(visits, orphanVisitType), + [ visits, orphanVisitType ], + ); const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], @@ -256,12 +266,24 @@ const VisitsStats: FC = (
- +
+
+ +
+ {isOrphanVisits && ( + + )} +
{visits.length > 0 && (
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/types/index.ts b/src/visits/types/index.ts index 813a8767..bb8f7f68 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; 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); + }); +}); From f0a04ced7562a388bfaf52aaa85067fc22934e99 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 20:56:16 +0200 Subject: [PATCH 5/7] Added graph with orphan visits grouped by visited URL --- src/visits/VisitsStats.tsx | 27 +++++++++++--------------- src/visits/VisitsTable.tsx | 9 +++------ src/visits/services/VisitsParser.ts | 15 +++++++++++++-- src/visits/types/helpers.ts | 30 +++++++++++++++++++++++++++-- src/visits/types/index.ts | 1 + 5 files changed, 56 insertions(+), 26 deletions(-) diff --git a/src/visits/VisitsStats.tsx b/src/visits/VisitsStats.tsx index 8e5da643..d94d14bb 100644 --- a/src/visits/VisitsStats.tsx +++ b/src/visits/VisitsStats.tsx @@ -1,4 +1,4 @@ -import { countBy, filter, isEmpty, pipe, prop, propEq, values } from 'ramda'; +import { isEmpty, propEq, values } from 'ramda'; import { useState, useEffect, useMemo, FC } from 'react'; import { Button, Card, Nav, NavLink, Progress, Row } from 'reactstrap'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -19,11 +19,12 @@ import SortableBarGraph from './helpers/SortableBarGraph'; import GraphCard from './helpers/GraphCard'; import LineChartCard from './helpers/LineChartCard'; import VisitsTable from './VisitsTable'; -import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, Stats, Visit, 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; @@ -42,7 +43,6 @@ interface VisitsNavLinkProps { icon: IconDefinition; } -type HighlightableProps = 'referer' | 'country' | 'city'; type Section = 'byTime' | 'byContext' | 'byLocation' | 'list'; const sections: Record = { @@ -52,14 +52,6 @@ const sections: Record = { list: { title: 'List', subPath: '/list', icon: faList }, }; -const highlightedVisitsToStats = (highlightedVisits: NormalizedVisit[], property: HighlightableProps): Stats => - countBy(prop(property), highlightedVisits); - -const normalizeAndFilterVisits = (visits: Visit[], type: OrphanVisitType | undefined) => pipe( - normalizeVisits, - filter((normalizedVisit) => type === undefined || (normalizedVisit as NormalizedOrphanVisit).type === type), -)(visits); - let selectedBar: string | undefined; const VisitsNavLink: FC = ({ subPath, title, icon, to }) => ( @@ -94,7 +86,7 @@ const VisitsStats: FC = ( () => normalizeAndFilterVisits(visits, orphanVisitType), [ visits, orphanVisitType ], ); - const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo( + const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo( () => processStatsFromVisits(normalizedVisits), [ normalizedVisits ], ); @@ -104,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) { @@ -112,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; } @@ -198,11 +190,14 @@ const VisitsStats: FC = (
)} diff --git a/src/visits/VisitsTable.tsx b/src/visits/VisitsTable.tsx index e5bcd849..9eb0eef2 100644 --- a/src/visits/VisitsTable.tsx +++ b/src/visits/VisitsTable.tsx @@ -93,11 +93,8 @@ const VisitsTable = ({ useEffect(() => { setPage(1); - if (isFirstLoad.current) { - isFirstLoad.current = false; - } else { - setSelectedVisits([]); - } + !isFirstLoad.current && setSelectedVisits([]); + isFirstLoad.current = false; }, [ searchTerm ]); return ( @@ -157,7 +154,7 @@ const VisitsTable = ({ )} - {resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => { + {resultSet.visitsGroups[page - 1]?.map((visit, index) => { const isSelected = selectedVisits.includes(visit); return ( 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 bb8f7f68..0e2879d5 100644 --- a/src/visits/types/index.ts +++ b/src/visits/types/index.ts @@ -90,4 +90,5 @@ export interface VisitsStats { countries: Stats; cities: Stats; citiesForMap: Record; + visitedUrls: Stats; } From db778a73f72184153338d946338c596c15129837 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 20:57:19 +0200 Subject: [PATCH 6/7] Updated changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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. From c3e38fd580fda1bf6ddfdfdd353cffe37a273206 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sun, 28 Mar 2021 21:03:46 +0200 Subject: [PATCH 7/7] Improved VisitsParser test --- test/visits/services/VisitsParser.test.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) 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', }, ]); });