Merge pull request #405 from acelaya-forks/feature/orphan-visits-improvements

Feature/orphan visits improvements
This commit is contained in:
Alejandro Celaya 2021-03-28 21:07:52 +02:00 committed by GitHub
commit c3d6c83ec4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 239 additions and 66 deletions

View file

@ -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.

View file

@ -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>
);
};

View file

@ -33,6 +33,7 @@ export const OrphanVisits = ({ exportVisits }: VisitsExporter) => boundToMercure
baseUrl={url}
settings={settings}
exportCsv={exportCsv}
isOrphanVisits
>
<OrphanVisitsHeader orphanVisits={orphanVisits} goBack={goBack} />
</VisitsStats>

View file

@ -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">

View file

@ -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

View file

@ -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[]) =>

View 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>
);

View file

@ -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 => {

View file

@ -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);

View file

@ -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;
}

View file

@ -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));
});
});

View 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);
});
});

View file

@ -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',
},
]);
});