mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-11 02:37:22 +03:00
Merge pull request #405 from acelaya-forks/feature/orphan-visits-improvements
Feature/orphan visits improvements
This commit is contained in:
commit
c3d6c83ec4
13 changed files with 239 additions and 66 deletions
|
@ -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.
|
* [#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.
|
* [#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.
|
* [#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.
|
* [#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.
|
* [#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.
|
* [#349](https://github.com/shlinkio/shlink-web-client/issues/349) Added support to export visits to CSV.
|
||||||
|
|
|
@ -7,16 +7,20 @@ export interface DropdownBtnProps {
|
||||||
text: string;
|
text: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
dropdownClassName?: string;
|
||||||
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DropdownBtn: FC<DropdownBtnProps> = ({ text, disabled = false, className = '', children }) => {
|
export const DropdownBtn: FC<DropdownBtnProps> = (
|
||||||
|
{ text, disabled = false, className = '', children, dropdownClassName, right = false },
|
||||||
|
) => {
|
||||||
const [ isOpen, toggle ] = useToggle();
|
const [ isOpen, toggle ] = useToggle();
|
||||||
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
|
const toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled}>
|
<Dropdown isOpen={isOpen} toggle={toggle} disabled={disabled} className={dropdownClassName}>
|
||||||
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
<DropdownToggle caret className={toggleClasses} color="primary">{text}</DropdownToggle>
|
||||||
<DropdownMenu className="w-100">{children}</DropdownMenu>
|
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
||||||
baseUrl={url}
|
baseUrl={url}
|
||||||
settings={settings}
|
settings={settings}
|
||||||
exportCsv={exportCsv}
|
exportCsv={exportCsv}
|
||||||
|
isOrphanVisits
|
||||||
>
|
>
|
||||||
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||||
</VisitsStats>
|
</VisitsStats>
|
||||||
|
|
|
@ -6,6 +6,7 @@ import { faCalendarAlt, faMapMarkedAlt, faList, faChartPie, faFileDownload } fro
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
import { IconDefinition } from '@fortawesome/fontawesome-common-types';
|
||||||
import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
|
import { Route, Switch, NavLink as RouterNavLink, Redirect } from 'react-router-dom';
|
||||||
import { Location } from 'history';
|
import { Location } from 'history';
|
||||||
|
import classNames from 'classnames';
|
||||||
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
import { DateRangeSelector } from '../utils/dates/DateRangeSelector';
|
||||||
import Message from '../utils/Message';
|
import Message from '../utils/Message';
|
||||||
import { formatIsoDate } from '../utils/helpers/date';
|
import { formatIsoDate } from '../utils/helpers/date';
|
||||||
|
@ -18,10 +19,12 @@ import SortableBarGraph from './helpers/SortableBarGraph';
|
||||||
import GraphCard from './helpers/GraphCard';
|
import GraphCard from './helpers/GraphCard';
|
||||||
import LineChartCard from './helpers/LineChartCard';
|
import LineChartCard from './helpers/LineChartCard';
|
||||||
import VisitsTable from './VisitsTable';
|
import VisitsTable from './VisitsTable';
|
||||||
import { NormalizedVisit, Stats, VisitsInfo } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit, OrphanVisitType, VisitsInfo } from './types';
|
||||||
import OpenMapModalBtn from './helpers/OpenMapModalBtn';
|
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 './VisitsStats.scss';
|
||||||
|
import { HighlightableProps, highlightedVisitsToStats, normalizeAndFilterVisits } from './types/helpers';
|
||||||
|
|
||||||
export interface VisitsStatsProps {
|
export interface VisitsStatsProps {
|
||||||
getVisits: (params: Partial<ShlinkVisitsParams>) => void;
|
getVisits: (params: Partial<ShlinkVisitsParams>) => void;
|
||||||
|
@ -31,6 +34,7 @@ export interface VisitsStatsProps {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
exportCsv: (visits: NormalizedVisit[]) => void;
|
exportCsv: (visits: NormalizedVisit[]) => void;
|
||||||
|
isOrphanVisits?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface VisitsNavLinkProps {
|
interface VisitsNavLinkProps {
|
||||||
|
@ -39,7 +43,6 @@ interface VisitsNavLinkProps {
|
||||||
icon: IconDefinition;
|
icon: IconDefinition;
|
||||||
}
|
}
|
||||||
|
|
||||||
type HighlightableProps = 'referer' | 'country' | 'city';
|
|
||||||
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
|
type Section = 'byTime' | 'byContext' | 'byLocation' | 'list';
|
||||||
|
|
||||||
const sections: Record<Section, VisitsNavLinkProps> = {
|
const sections: Record<Section, VisitsNavLinkProps> = {
|
||||||
|
@ -49,18 +52,6 @@ const sections: Record<Section, VisitsNavLinkProps> = {
|
||||||
list: { title: 'List', subPath: '/list', icon: faList },
|
list: { title: 'List', subPath: '/list', icon: faList },
|
||||||
};
|
};
|
||||||
|
|
||||||
const highlightedVisitsToStats = (
|
|
||||||
highlightedVisits: NormalizedVisit[],
|
|
||||||
prop: HighlightableProps,
|
|
||||||
): Stats => highlightedVisits.reduce<Stats>((acc, highlightedVisit) => {
|
|
||||||
if (!acc[highlightedVisit[prop]]) {
|
|
||||||
acc[highlightedVisit[prop]] = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
acc[highlightedVisit[prop]] += 1;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
let selectedBar: string | undefined;
|
let selectedBar: string | undefined;
|
||||||
|
|
||||||
const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => (
|
const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title, icon, to }) => (
|
||||||
|
@ -77,12 +68,13 @@ const VisitsNavLink: FC<VisitsNavLinkProps & { to: string }> = ({ subPath, title
|
||||||
);
|
);
|
||||||
|
|
||||||
const VisitsStats: FC<VisitsStatsProps> = (
|
const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
{ 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 initialInterval: DateInterval = settings.visits?.defaultInterval ?? 'last30Days';
|
||||||
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
|
const [ dateRange, setDateRange ] = useState<DateRange>(intervalToDateRange(initialInterval));
|
||||||
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
|
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
|
||||||
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
|
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
|
||||||
|
const [ orphanVisitType, setOrphanVisitType ] = useState<OrphanVisitType | undefined>();
|
||||||
|
|
||||||
const buildSectionUrl = (subPath?: string) => {
|
const buildSectionUrl = (subPath?: string) => {
|
||||||
const query = domain ? `?domain=${domain}` : '';
|
const query = domain ? `?domain=${domain}` : '';
|
||||||
|
@ -90,8 +82,11 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
|
return !subPath ? `${baseUrl}${query}` : `${baseUrl}${subPath}${query}`;
|
||||||
};
|
};
|
||||||
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
|
const { visits, loading, loadingLarge, error, errorData, progress } = visitsInfo;
|
||||||
const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
|
const normalizedVisits = useMemo(
|
||||||
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
|
() => normalizeAndFilterVisits(visits, orphanVisitType),
|
||||||
|
[ visits, orphanVisitType ],
|
||||||
|
);
|
||||||
|
const { os, browsers, referrers, countries, cities, citiesForMap, visitedUrls } = useMemo(
|
||||||
() => processStatsFromVisits(normalizedVisits),
|
() => processStatsFromVisits(normalizedVisits),
|
||||||
[ normalizedVisits ],
|
[ normalizedVisits ],
|
||||||
);
|
);
|
||||||
|
@ -101,7 +96,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
selectedBar = undefined;
|
selectedBar = undefined;
|
||||||
setHighlightedVisits(selectedVisits);
|
setHighlightedVisits(selectedVisits);
|
||||||
};
|
};
|
||||||
const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => {
|
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
|
||||||
const newSelectedBar = `${prop}_${value}`;
|
const newSelectedBar = `${prop}_${value}`;
|
||||||
|
|
||||||
if (selectedBar === newSelectedBar) {
|
if (selectedBar === newSelectedBar) {
|
||||||
|
@ -109,7 +104,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
setHighlightedLabel(undefined);
|
setHighlightedLabel(undefined);
|
||||||
selectedBar = undefined;
|
selectedBar = undefined;
|
||||||
} else {
|
} else {
|
||||||
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
|
setHighlightedVisits((normalizedVisits as NormalizedOrphanVisit[]).filter(propEq(prop, value)));
|
||||||
setHighlightedLabel(value);
|
setHighlightedLabel(value);
|
||||||
selectedBar = newSelectedBar;
|
selectedBar = newSelectedBar;
|
||||||
}
|
}
|
||||||
|
@ -171,13 +166,13 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byContext.subPath}`}>
|
||||||
<div className="col-xl-4 col-lg-6 mt-4">
|
<div className={classNames('mt-4 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Operating systems" stats={os} />
|
<GraphCard title="Operating systems" stats={os} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-4 col-lg-6 mt-4">
|
<div className={classNames('mt-4 col-lg-6', { 'col-xl-4': !isOrphanVisits })}>
|
||||||
<GraphCard title="Browsers" stats={browsers} />
|
<GraphCard title="Browsers" stats={browsers} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-xl-4 mt-4">
|
<div className={classNames('mt-4', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
||||||
<SortableBarGraph
|
<SortableBarGraph
|
||||||
title="Referrers"
|
title="Referrers"
|
||||||
stats={referrers}
|
stats={referrers}
|
||||||
|
@ -191,6 +186,21 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
onClick={highlightVisitsForProp('referer')}
|
onClick={highlightVisitsForProp('referer')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{isOrphanVisits && (
|
||||||
|
<div className="mt-4 col-lg-6">
|
||||||
|
<SortableBarGraph
|
||||||
|
title="Visited URLs"
|
||||||
|
stats={visitedUrls}
|
||||||
|
highlightedLabel={highlightedLabel}
|
||||||
|
highlightedStats={highlightedVisitsToStats(highlightedVisits, 'visitedUrl')}
|
||||||
|
sortingItems={{
|
||||||
|
visitedUrl: 'Visited URL',
|
||||||
|
amount: 'Visits amount',
|
||||||
|
}}
|
||||||
|
onClick={highlightVisitsForProp('visitedUrl')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
<Route exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
||||||
|
@ -232,6 +242,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
visits={normalizedVisits}
|
visits={normalizedVisits}
|
||||||
selectedVisits={highlightedVisits}
|
selectedVisits={highlightedVisits}
|
||||||
setSelectedVisits={setSelectedVisits}
|
setSelectedVisits={setSelectedVisits}
|
||||||
|
isOrphanVisits={isOrphanVisits}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Route>
|
</Route>
|
||||||
|
@ -250,12 +261,24 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
||||||
<section className="mt-4">
|
<section className="mt-4">
|
||||||
<div className="row flex-md-row-reverse">
|
<div className="row flex-md-row-reverse">
|
||||||
<div className="col-lg-7 col-xl-6">
|
<div className="col-lg-7 col-xl-6">
|
||||||
<DateRangeSelector
|
<div className="d-md-flex">
|
||||||
disabled={loading}
|
<div className="flex-fill">
|
||||||
initialDateRange={initialInterval}
|
<DateRangeSelector
|
||||||
defaultText="All visits"
|
disabled={loading}
|
||||||
onDatesChange={setDateRange}
|
initialDateRange={initialInterval}
|
||||||
/>
|
defaultText="All visits"
|
||||||
|
onDatesChange={setDateRange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{isOrphanVisits && (
|
||||||
|
<OrphanVisitTypeDropdown
|
||||||
|
text="Filter by type"
|
||||||
|
className="ml-0 ml-md-2 mt-4 mt-md-0"
|
||||||
|
selected={orphanVisitType}
|
||||||
|
onChange={setOrphanVisitType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{visits.length > 0 && (
|
{visits.length > 0 && (
|
||||||
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
<div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
|
||||||
|
|
|
@ -12,7 +12,7 @@ import SimplePaginator from '../common/SimplePaginator';
|
||||||
import SearchField from '../utils/SearchField';
|
import SearchField from '../utils/SearchField';
|
||||||
import { determineOrderDir, OrderDir } from '../utils/utils';
|
import { determineOrderDir, OrderDir } from '../utils/utils';
|
||||||
import { prettify } from '../utils/helpers/numbers';
|
import { prettify } from '../utils/helpers/numbers';
|
||||||
import { NormalizedVisit } from './types';
|
import { NormalizedOrphanVisit, NormalizedVisit } from './types';
|
||||||
import './VisitsTable.scss';
|
import './VisitsTable.scss';
|
||||||
|
|
||||||
interface VisitsTableProps {
|
interface VisitsTableProps {
|
||||||
|
@ -20,9 +20,10 @@ interface VisitsTableProps {
|
||||||
selectedVisits?: NormalizedVisit[];
|
selectedVisits?: NormalizedVisit[];
|
||||||
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
setSelectedVisits: (visits: NormalizedVisit[]) => void;
|
||||||
matchMedia?: (query: string) => MediaQueryList;
|
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 {
|
interface Order {
|
||||||
field?: OrderableFields;
|
field?: OrderableFields;
|
||||||
|
@ -30,8 +31,10 @@ interface Order {
|
||||||
}
|
}
|
||||||
|
|
||||||
const PAGE_SIZE = 20;
|
const PAGE_SIZE = 20;
|
||||||
const visitMatchesSearch = ({ browser, os, referer, country, city }: NormalizedVisit, searchTerm: string) =>
|
const visitMatchesSearch = ({ browser, os, referer, country, city, ...rest }: NormalizedVisit, searchTerm: string) =>
|
||||||
`${browser} ${os} ${referer} ${country} ${city}`.toLowerCase().includes(searchTerm.toLowerCase());
|
`${browser} ${os} ${referer} ${country} ${city} ${(rest as NormalizedOrphanVisit).visitedUrl}`.toLowerCase().includes(
|
||||||
|
searchTerm.toLowerCase(),
|
||||||
|
);
|
||||||
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
const searchVisits = (searchTerm: string, visits: NormalizedVisit[]) =>
|
||||||
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
visits.filter((visit) => visitMatchesSearch(visit, searchTerm));
|
||||||
const sortVisits = ({ field, dir }: Order, visits: NormalizedVisit[]) => !field || !dir ? visits : visits.sort(
|
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 greaterThan = dir === 'ASC' ? 1 : -1;
|
||||||
const smallerThan = 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) => {
|
const calculateVisits = (allVisits: NormalizedVisit[], searchTerm: string | undefined, order: Order) => {
|
||||||
|
@ -56,6 +59,7 @@ const VisitsTable = ({
|
||||||
selectedVisits = [],
|
selectedVisits = [],
|
||||||
setSelectedVisits,
|
setSelectedVisits,
|
||||||
matchMedia = window.matchMedia,
|
matchMedia = window.matchMedia,
|
||||||
|
isOrphanVisits = false,
|
||||||
}: VisitsTableProps) => {
|
}: VisitsTableProps) => {
|
||||||
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
|
const headerCellsClass = 'visits-table__header-cell visits-table__sticky';
|
||||||
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
||||||
|
@ -89,11 +93,8 @@ const VisitsTable = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
|
||||||
if (isFirstLoad.current) {
|
!isFirstLoad.current && setSelectedVisits([]);
|
||||||
isFirstLoad.current = false;
|
isFirstLoad.current = false;
|
||||||
} else {
|
|
||||||
setSelectedVisits([]);
|
|
||||||
}
|
|
||||||
}, [ searchTerm ]);
|
}, [ searchTerm ]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -132,22 +133,28 @@ const VisitsTable = ({
|
||||||
Referrer
|
Referrer
|
||||||
{renderOrderIcon('referer')}
|
{renderOrderIcon('referer')}
|
||||||
</th>
|
</th>
|
||||||
|
{isOrphanVisits && (
|
||||||
|
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
|
||||||
|
Visited URL
|
||||||
|
{renderOrderIcon('visitedUrl')}
|
||||||
|
</th>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="p-0">
|
<td colSpan={isOrphanVisits ? 8 : 7} className="p-0">
|
||||||
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
|
{!resultSet.visitsGroups[page - 1]?.length && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="text-center">
|
<td colSpan={isOrphanVisits ? 8 : 7} className="text-center">
|
||||||
No visits found with current filtering
|
No visits found with current filtering
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{resultSet.visitsGroups[page - 1] && resultSet.visitsGroups[page - 1].map((visit, index) => {
|
{resultSet.visitsGroups[page - 1]?.map((visit, index) => {
|
||||||
const isSelected = selectedVisits.includes(visit);
|
const isSelected = selectedVisits.includes(visit);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -170,6 +177,7 @@ const VisitsTable = ({
|
||||||
<td>{visit.browser}</td>
|
<td>{visit.browser}</td>
|
||||||
<td>{visit.os}</td>
|
<td>{visit.os}</td>
|
||||||
<td>{visit.referer}</td>
|
<td>{visit.referer}</td>
|
||||||
|
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
@ -177,7 +185,7 @@ const VisitsTable = ({
|
||||||
{resultSet.total > PAGE_SIZE && (
|
{resultSet.total > PAGE_SIZE && (
|
||||||
<tfoot>
|
<tfoot>
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7} className="visits-table__footer-cell visits-table__sticky">
|
<td colSpan={isOrphanVisits ? 8 : 7} className="visits-table__footer-cell visits-table__sticky">
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<div className="col-md-6">
|
<div className="col-md-6">
|
||||||
<SimplePaginator
|
<SimplePaginator
|
||||||
|
|
|
@ -9,7 +9,7 @@ import {
|
||||||
DropdownItem,
|
DropdownItem,
|
||||||
} from 'reactstrap';
|
} from 'reactstrap';
|
||||||
import { Line } from 'react-chartjs-2';
|
import { Line } from 'react-chartjs-2';
|
||||||
import { always, cond, reverse } from 'ramda';
|
import { always, cond, countBy, reverse } from 'ramda';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
|
||||||
import { NormalizedVisit, Stats } from '../types';
|
import { NormalizedVisit, Stats } from '../types';
|
||||||
|
@ -70,15 +70,9 @@ const determineInitialStep = (oldestVisitDate: string): Step => {
|
||||||
return matcher() ?? 'monthly';
|
return matcher() ?? 'monthly';
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => visits.reduce<Stats>(
|
const groupVisitsByStep = (step: Step, visits: NormalizedVisit[]): Stats => countBy(
|
||||||
(acc, visit) => {
|
(visit) => STEP_TO_DATE_FORMAT[step](visit.date),
|
||||||
const key = STEP_TO_DATE_FORMAT[step](visit.date);
|
visits,
|
||||||
|
|
||||||
acc[key] = (acc[key] || 0) + 1;
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
|
const visitsToDatasetGroups = (step: Step, visits: NormalizedVisit[]) =>
|
||||||
|
|
26
src/visits/helpers/OrphanVisitTypeDropdown.tsx
Normal file
26
src/visits/helpers/OrphanVisitTypeDropdown.tsx
Normal file
|
@ -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) => (
|
||||||
|
<DropdownBtn text={text} dropdownClassName={className} className="mr-3" right>
|
||||||
|
<DropdownItem active={selected === 'base_url'} onClick={() => onChange('base_url')}>
|
||||||
|
Base URL
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={selected === 'invalid_short_url'} onClick={() => onChange('invalid_short_url')}>
|
||||||
|
Invalid short URL
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem active={selected === 'regular_404'} onClick={() => onChange('regular_404')}>
|
||||||
|
Regular 404
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem divider />
|
||||||
|
<DropdownItem onClick={() => onChange(undefined)}><i>Clear selection</i></DropdownItem>
|
||||||
|
</DropdownBtn>
|
||||||
|
);
|
|
@ -2,7 +2,7 @@ import { isNil, map } from 'ramda';
|
||||||
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
|
import { extractDomain, parseUserAgent } from '../../utils/helpers/visits';
|
||||||
import { hasValue } from '../../utils/utils';
|
import { hasValue } from '../../utils/utils';
|
||||||
import { CityStats, NormalizedVisit, Stats, Visit, VisitsStats } from '../types';
|
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) =>
|
const visitHasProperty = (visit: NormalizedVisit, propertyName: keyof NormalizedVisit) =>
|
||||||
!isNil(visit) && hasValue(visit[propertyName]);
|
!isNil(visit) && hasValue(visit[propertyName]);
|
||||||
|
@ -54,6 +54,16 @@ const updateCitiesForMapForVisit = (citiesForMapStats: Record<string, CityStats>
|
||||||
citiesForMapStats[city] = currentCity;
|
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(
|
export const processStatsFromVisits = (visits: NormalizedVisit[]) => visits.reduce(
|
||||||
(stats: VisitsStats, visit: NormalizedVisit) => {
|
(stats: VisitsStats, visit: NormalizedVisit) => {
|
||||||
// We mutate the original object because it has a big performance impact when large data sets are processed
|
// 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);
|
updateCountriesStatsForVisit(stats.countries, visit);
|
||||||
updateCitiesStatsForVisit(stats.cities, visit);
|
updateCitiesStatsForVisit(stats.cities, visit);
|
||||||
updateCitiesForMapForVisit(stats.citiesForMap, visit);
|
updateCitiesForMapForVisit(stats.citiesForMap, visit);
|
||||||
|
updateVisitedUrlsForVisit(stats.visitedUrls, visit);
|
||||||
|
|
||||||
return stats;
|
return stats;
|
||||||
},
|
},
|
||||||
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} },
|
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {}, visitedUrls: {} },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
|
export const normalizeVisits = map((visit: Visit): NormalizedVisit => {
|
||||||
|
|
|
@ -1,8 +1,20 @@
|
||||||
import { groupBy, pipe } from 'ramda';
|
import { countBy, filter, groupBy, pipe, prop } from 'ramda';
|
||||||
import { Visit, OrphanVisit, CreateVisit } from './index';
|
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 isOrphanVisit = (visit: Visit): visit is OrphanVisit => visit.hasOwnProperty('visitedUrl');
|
||||||
|
|
||||||
|
export const isNormalizedOrphanVisit = (visit: NormalizedVisit): visit is NormalizedOrphanVisit =>
|
||||||
|
visit.hasOwnProperty('visitedUrl');
|
||||||
|
|
||||||
export interface GroupedNewVisits {
|
export interface GroupedNewVisits {
|
||||||
orphanVisits: CreateVisit[];
|
orphanVisits: CreateVisit[];
|
||||||
regularVisits: 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
|
// @ts-expect-error Type declaration on groupBy is not correct. It can return undefined props
|
||||||
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
|
(result): GroupedNewVisits => ({ orphanVisits: [], regularVisits: [], ...result }),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type HighlightableProps<T extends NormalizedVisit> = T extends NormalizedOrphanVisit
|
||||||
|
? ('referer' | 'country' | 'city' | 'visitedUrl')
|
||||||
|
: ('referer' | 'country' | 'city');
|
||||||
|
|
||||||
|
export const highlightedVisitsToStats = <T extends NormalizedVisit>(
|
||||||
|
highlightedVisits: T[],
|
||||||
|
property: HighlightableProps<T>,
|
||||||
|
): 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);
|
||||||
|
|
|
@ -20,7 +20,7 @@ export interface VisitsLoadFailedAction extends Action<string> {
|
||||||
errorData?: ProblemDetailsError;
|
errorData?: ProblemDetailsError;
|
||||||
}
|
}
|
||||||
|
|
||||||
type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
export type OrphanVisitType = 'base_url' | 'invalid_short_url' | 'regular_404';
|
||||||
|
|
||||||
interface VisitLocation {
|
interface VisitLocation {
|
||||||
countryCode: string | null;
|
countryCode: string | null;
|
||||||
|
@ -90,4 +90,5 @@ export interface VisitsStats {
|
||||||
countries: Stats;
|
countries: Stats;
|
||||||
cities: Stats;
|
cities: Stats;
|
||||||
citiesForMap: Record<string, CityStats>;
|
citiesForMap: Record<string, CityStats>;
|
||||||
|
visitedUrls: Stats;
|
||||||
}
|
}
|
||||||
|
|
|
@ -10,13 +10,14 @@ describe('<VisitsTable />', () => {
|
||||||
const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
|
const matchMedia = () => Mock.of<MediaQueryList>({ matches: false });
|
||||||
const setSelectedVisits = jest.fn();
|
const setSelectedVisits = jest.fn();
|
||||||
let wrapper: ShallowWrapper;
|
let wrapper: ShallowWrapper;
|
||||||
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = []) => {
|
const createWrapper = (visits: NormalizedVisit[], selectedVisits: NormalizedVisit[] = [], isOrphanVisits = false) => {
|
||||||
wrapper = shallow(
|
wrapper = shallow(
|
||||||
<VisitsTable
|
<VisitsTable
|
||||||
visits={visits}
|
visits={visits}
|
||||||
selectedVisits={selectedVisits}
|
selectedVisits={selectedVisits}
|
||||||
setSelectedVisits={setSelectedVisits}
|
setSelectedVisits={setSelectedVisits}
|
||||||
matchMedia={matchMedia}
|
matchMedia={matchMedia}
|
||||||
|
isOrphanVisits={isOrphanVisits}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -134,4 +135,17 @@ describe('<VisitsTable />', () => {
|
||||||
searchField.simulate('change', '');
|
searchField.simulate('change', '');
|
||||||
expect(wrapper.find('tbody').find('tr')).toHaveLength(7 + 2);
|
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));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
56
test/visits/helpers/OrphanVisitTypeDropdown.test.tsx
Normal file
56
test/visits/helpers/OrphanVisitTypeDropdown.test.tsx
Normal file
|
@ -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('<OrphanVisitTypeDropdown />', () => {
|
||||||
|
let wrapper: ShallowWrapper;
|
||||||
|
const onChange = jest.fn();
|
||||||
|
const createWrapper = (selected?: OrphanVisitType) => {
|
||||||
|
wrapper = shallow(<OrphanVisitTypeDropdown text="The text" selected={selected} onChange={onChange} />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
|
@ -64,7 +64,7 @@ describe('VisitsParser', () => {
|
||||||
}),
|
}),
|
||||||
Mock.of<OrphanVisit>({
|
Mock.of<OrphanVisit>({
|
||||||
type: 'invalid_short_url',
|
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',
|
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',
|
referer: 'https://m.facebook.com',
|
||||||
visitLocation: {
|
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', () => {
|
describe('normalizeVisits', () => {
|
||||||
|
@ -247,7 +256,7 @@ describe('VisitsParser', () => {
|
||||||
latitude: 123.45,
|
latitude: 123.45,
|
||||||
longitude: -543.21,
|
longitude: -543.21,
|
||||||
type: 'invalid_short_url',
|
type: 'invalid_short_url',
|
||||||
visitedUrl: 'baz',
|
visitedUrl: 'bar',
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue