mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +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.
|
||||
* [#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.
|
||||
|
|
|
@ -7,16 +7,20 @@ export interface DropdownBtnProps {
|
|||
text: string;
|
||||
disabled?: boolean;
|
||||
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 toggleClasses = `dropdown-btn__toggle btn-block ${className}`;
|
||||
|
||||
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>
|
||||
<DropdownMenu className="w-100">{children}</DropdownMenu>
|
||||
<DropdownMenu className="w-100" right={right}>{children}</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
|
|||
baseUrl={url}
|
||||
settings={settings}
|
||||
exportCsv={exportCsv}
|
||||
isOrphanVisits
|
||||
>
|
||||
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
|
||||
</VisitsStats>
|
||||
|
|
|
@ -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<ShlinkVisitsParams>) => 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<Section, VisitsNavLinkProps> = {
|
||||
|
@ -49,18 +52,6 @@ const sections: Record<Section, VisitsNavLinkProps> = {
|
|||
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;
|
||||
|
||||
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> = (
|
||||
{ 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<DateRange>(intervalToDateRange(initialInterval));
|
||||
const [ highlightedVisits, setHighlightedVisits ] = useState<NormalizedVisit[]>([]);
|
||||
const [ highlightedLabel, setHighlightedLabel ] = useState<string | undefined>();
|
||||
const [ orphanVisitType, setOrphanVisitType ] = useState<OrphanVisitType | undefined>();
|
||||
|
||||
const buildSectionUrl = (subPath?: string) => {
|
||||
const query = domain ? `?domain=${domain}` : '';
|
||||
|
@ -90,8 +82,11 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
|||
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<VisitsStatsProps> = (
|
|||
selectedBar = undefined;
|
||||
setHighlightedVisits(selectedVisits);
|
||||
};
|
||||
const highlightVisitsForProp = (prop: HighlightableProps) => (value: string) => {
|
||||
const highlightVisitsForProp = (prop: HighlightableProps<NormalizedOrphanVisit>) => (value: string) => {
|
||||
const newSelectedBar = `${prop}_${value}`;
|
||||
|
||||
if (selectedBar === newSelectedBar) {
|
||||
|
@ -109,7 +104,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
|||
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<VisitsStatsProps> = (
|
|||
</Route>
|
||||
|
||||
<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} />
|
||||
</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} />
|
||||
</div>
|
||||
<div className="col-xl-4 mt-4">
|
||||
<div className={classNames('mt-4', { 'col-xl-4': !isOrphanVisits, 'col-lg-6': isOrphanVisits })}>
|
||||
<SortableBarGraph
|
||||
title="Referrers"
|
||||
stats={referrers}
|
||||
|
@ -191,6 +186,21 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
|||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</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 exact path={`${baseUrl}${sections.byLocation.subPath}`}>
|
||||
|
@ -232,6 +242,7 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
|||
visits={normalizedVisits}
|
||||
selectedVisits={highlightedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
isOrphanVisits={isOrphanVisits}
|
||||
/>
|
||||
</div>
|
||||
</Route>
|
||||
|
@ -250,12 +261,24 @@ const VisitsStats: FC<VisitsStatsProps> = (
|
|||
<section className="mt-4">
|
||||
<div className="row flex-md-row-reverse">
|
||||
<div className="col-lg-7 col-xl-6">
|
||||
<DateRangeSelector
|
||||
disabled={loading}
|
||||
initialDateRange={initialInterval}
|
||||
defaultText="All visits"
|
||||
onDatesChange={setDateRange}
|
||||
/>
|
||||
<div className="d-md-flex">
|
||||
<div className="flex-fill">
|
||||
<DateRangeSelector
|
||||
disabled={loading}
|
||||
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>
|
||||
{visits.length > 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 { 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')}
|
||||
</th>
|
||||
{isOrphanVisits && (
|
||||
<th className={headerCellsClass} onClick={orderByColumn('visitedUrl')}>
|
||||
Visited URL
|
||||
{renderOrderIcon('visitedUrl')}
|
||||
</th>
|
||||
)}
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={7} className="p-0">
|
||||
<td colSpan={isOrphanVisits ? 8 : 7} className="p-0">
|
||||
<SearchField noBorder large={false} onChange={setSearchTerm} />
|
||||
</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(!resultSet.visitsGroups[page - 1] || resultSet.visitsGroups[page - 1].length === 0) && (
|
||||
{!resultSet.visitsGroups[page - 1]?.length && (
|
||||
<tr>
|
||||
<td colSpan={7} className="text-center">
|
||||
<td colSpan={isOrphanVisits ? 8 : 7} className="text-center">
|
||||
No visits found with current filtering
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{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 = ({
|
|||
<td>{visit.browser}</td>
|
||||
<td>{visit.os}</td>
|
||||
<td>{visit.referer}</td>
|
||||
{isOrphanVisits && <td>{(visit as NormalizedOrphanVisit).visitedUrl}</td>}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
|
@ -177,7 +185,7 @@ const VisitsTable = ({
|
|||
{resultSet.total > PAGE_SIZE && (
|
||||
<tfoot>
|
||||
<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="col-md-6">
|
||||
<SimplePaginator
|
||||
|
|
|
@ -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<Stats>(
|
||||
(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[]) =>
|
||||
|
|
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 { 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<string, CityStats>
|
|||
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 => {
|
||||
|
|
|
@ -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 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;
|
||||
}
|
||||
|
||||
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<string, CityStats>;
|
||||
visitedUrls: Stats;
|
||||
}
|
||||
|
|
|
@ -10,13 +10,14 @@ describe('<VisitsTable />', () => {
|
|||
const matchMedia = () => Mock.of<MediaQueryList>({ 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(
|
||||
<VisitsTable
|
||||
visits={visits}
|
||||
selectedVisits={selectedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
matchMedia={matchMedia}
|
||||
isOrphanVisits={isOrphanVisits}
|
||||
/>,
|
||||
);
|
||||
|
||||
|
@ -134,4 +135,17 @@ describe('<VisitsTable />', () => {
|
|||
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));
|
||||
});
|
||||
});
|
||||
|
|
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>({
|
||||
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',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue