Allowed visits to be selected on charts so that they get highlighted on the rest of the charts

This commit is contained in:
Alejandro Celaya 2020-04-10 11:59:53 +02:00
parent 05deb1aff0
commit 8f42e65ccd
8 changed files with 169 additions and 90 deletions

View file

@ -12,6 +12,7 @@ const propTypes = {
stats: PropTypes.object, stats: PropTypes.object,
max: PropTypes.number, max: PropTypes.number,
highlightedStats: PropTypes.object, highlightedStats: PropTypes.object,
onClick: PropTypes.func,
}; };
const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({ const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
@ -19,6 +20,7 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
datasets: [ datasets: [
{ {
title, title,
label: '',
data, data,
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
'#97BBCD', '#97BBCD',
@ -45,17 +47,20 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label; const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const renderGraph = (title, isBarChart, stats, max, highlightedStats) => { const renderGraph = (title, isBarChart, stats, max, highlightedStats, onClick) => {
const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0;
const Component = isBarChart ? HorizontalBar : Doughnut; const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats).map(dropLabelIfHidden); const labels = keys(stats).map(dropLabelIfHidden);
const data = values(!highlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { const data = values(!hasHighlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
if (acc[highlightedKey]) { if (acc[highlightedKey]) {
acc[highlightedKey] -= highlightedStats[highlightedKey]; acc[highlightedKey] -= highlightedStats[highlightedKey];
} }
return acc; return acc;
}, { ...stats })); }, { ...stats }));
const highlightedData = highlightedStats && values({ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }); const highlightedData = hasHighlightedStats && values(
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
);
const options = { const options = {
legend: isBarChart ? { display: false } : { position: 'right' }, legend: isBarChart ? { display: false } : { position: 'right' },
@ -79,13 +84,30 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats) => {
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null; const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;
// Provide a key based on the height, so that every time the dataset changes, a new graph is rendered // Provide a key based on the height, so that every time the dataset changes, a new graph is rendered
return <Component key={height} data={graphData} options={options} height={height} />; return (
<Component
key={height}
data={graphData}
options={options}
height={height}
getElementAtEvent={([ chart ]) => {
if (!onClick || !chart) {
return;
}
const { _index, _chart: { data } } = chart;
const { labels } = data;
onClick(labels[_index]);
}}
/>
);
}; };
const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats }) => ( const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, onClick }) => (
<Card className="mt-4"> <Card className="mt-4">
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader> <CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats)}</CardBody> <CardBody>{renderGraph(title, isBarChart, stats, max, highlightedStats, onClick)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>} {footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card> </Card>
); );

View file

@ -1,5 +1,5 @@
import { isEmpty, values } from 'ramda'; import { isEmpty, propEq, values } from 'ramda';
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useMemo } from 'react';
import { Button, Card, Collapse } from 'reactstrap'; import { Button, Card, Collapse } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import qs from 'qs'; import qs from 'qs';
@ -41,10 +41,8 @@ const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.
return acc; return acc;
}, {}); }, {});
const format = formatDate(); const format = formatDate();
let memoizationId;
let timeWhenMounted;
const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
const ShortUrlVisitsComp = ({ const ShortUrlVisitsComp = ({
match, match,
location, location,
@ -62,23 +60,28 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
const [ highlightedVisits, setHighlightedVisits ] = useState([]); const [ highlightedVisits, setHighlightedVisits ] = useState([]);
const [ isMobileDevice, setIsMobileDevice ] = useState(false); const [ isMobileDevice, setIsMobileDevice ] = useState(false);
const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches); const determineIsMobileDevice = () => setIsMobileDevice(matchMedia('(max-width: 991px)').matches);
const highlightVisitsForProp = (prop) => (value) => setHighlightedVisits(
highlightedVisits.length === 0 ? normalizedVisits.filter(propEq(prop, value)) : []
);
const { params } = match; const { params } = match;
const { shortCode } = params; const { shortCode } = params;
const { search } = location; const { search } = location;
const { domain } = qs.parse(search, { ignoreQueryPrefix: true }); const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
const loadVisits = () => { const { visits, loading, loadingLarge, error } = shortUrlVisits;
const start = format(startDate); const showTableControls = !loading && visits.length > 0;
const end = format(endDate); const normalizedVisits = useMemo(() => normalizeVisits(visits), [ visits ]);
const { os, browsers, referrers, countries, cities, citiesForMap } = useMemo(
() => processStatsFromVisits(visits),
[ visits ]
);
const mapLocations = values(citiesForMap);
// While the "page" is loaded, use the timestamp + filtering dates as memoization IDs for stats calculations const loadVisits = () =>
memoizationId = `${timeWhenMounted}_${shortCode}_${start}_${end}`; getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
getShortUrlVisits(shortCode, { startDate: start, endDate: end, domain });
};
useEffect(() => { useEffect(() => {
timeWhenMounted = new Date().getTime();
getShortUrlDetail(shortCode, domain); getShortUrlDetail(shortCode, domain);
determineIsMobileDevice(); determineIsMobileDevice();
window.addEventListener('resize', determineIsMobileDevice); window.addEventListener('resize', determineIsMobileDevice);
@ -92,9 +95,6 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
loadVisits(); loadVisits();
}, [ startDate, endDate ]); }, [ startDate, endDate ]);
const { visits, loading, loadingLarge, error } = shortUrlVisits;
const showTableControls = !loading && visits.length > 0;
const renderVisitsContent = () => { const renderVisitsContent = () => {
if (loading) { if (loading) {
const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...'; const message = loadingLarge ? 'This is going to take a while... :S' : 'Loading...';
@ -114,11 +114,6 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
return <Message>There are no visits matching current filter :(</Message>; return <Message>There are no visits matching current filter :(</Message>;
} }
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
{ id: memoizationId, visits }
);
const mapLocations = values(citiesForMap);
return ( return (
<div className="row"> <div className="row">
<div className="col-xl-4 col-lg-6"> <div className="col-xl-4 col-lg-6">
@ -137,6 +132,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
name: 'Referrer name', name: 'Referrer name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('referer')}
/> />
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
@ -148,6 +144,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
name: 'Country name', name: 'Country name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('country')}
/> />
</div> </div>
<div className="col-lg-6"> <div className="col-lg-6">
@ -163,6 +160,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
name: 'City name', name: 'City name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('city')}
/> />
</div> </div>
</div> </div>
@ -185,11 +183,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
</div> </div>
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0"> <div className="col-lg-4 col-xl-6 mt-4 mt-lg-0">
{showTableControls && ( {showTableControls && (
<Button <Button outline block={isMobileDevice} onClick={toggleTable}>
outline
block={isMobileDevice}
onClick={toggleTable}
>
{showTable ? 'Hide' : 'Show'} table{' '} {showTable ? 'Hide' : 'Show'} table{' '}
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} /> <FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} />
</Button> </Button>
@ -201,12 +195,16 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
{showTableControls && ( {showTableControls && (
<Collapse <Collapse
isOpen={showTable} isOpen={showTable}
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects // Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
onEntered={setSticky} onEntered={setSticky}
onExiting={unsetSticky} onExiting={unsetSticky}
> >
<VisitsTable visits={visits} isSticky={tableIsSticky} onVisitsSelected={setHighlightedVisits} /> <VisitsTable
visits={normalizedVisits}
selectedVisits={highlightedVisits}
setSelectedVisits={setHighlightedVisits}
isSticky={tableIsSticky}
/>
</Collapse> </Collapse>
)} )}

View file

@ -20,6 +20,7 @@ export default class SortableBarGraph extends React.Component {
sortingItems: PropTypes.object.isRequired, sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.func, extraHeaderContent: PropTypes.func,
withPagination: PropTypes.bool, withPagination: PropTypes.bool,
onClick: PropTypes.func,
}; };
state = { state = {
@ -74,7 +75,7 @@ export default class SortableBarGraph extends React.Component {
} }
render() { render() {
const { stats, sortingItems, title, extraHeaderContent, highlightedStats, withPagination = true } = this.props; const { stats, sortingItems, title, extraHeaderContent, withPagination = true, ...rest } = this.props;
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems); const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
const activeCities = keys(currentPageStats); const activeCities = keys(currentPageStats);
const computeTitle = () => ( const computeTitle = () => (
@ -115,7 +116,7 @@ export default class SortableBarGraph extends React.Component {
stats={currentPageStats} stats={currentPageStats}
footer={pagination} footer={pagination}
max={max} max={max}
highlightedStats={highlightedStats} {...rest}
/> />
); );
} }

View file

@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Moment from 'react-moment'; import Moment from 'react-moment';
import classNames from 'classnames'; import classNames from 'classnames';
import { map, min, splitEvery } from 'ramda'; import { min, splitEvery } from 'ramda';
import { import {
faCaretDown as caretDownIcon, faCaretDown as caretDownIcon,
faCaretUp as caretUpIcon, faCaretUp as caretUpIcon,
@ -11,15 +11,18 @@ import {
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import SimplePaginator from '../common/SimplePaginator'; import SimplePaginator from '../common/SimplePaginator';
import SearchField from '../utils/SearchField'; import SearchField from '../utils/SearchField';
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../utils/helpers/visits';
import { determineOrderDir } from '../utils/utils'; import { determineOrderDir } from '../utils/utils';
import { prettify } from '../utils/helpers/numbers'; import { prettify } from '../utils/helpers/numbers';
import { visitType } from './reducers/shortUrlVisits';
import './VisitsTable.scss'; import './VisitsTable.scss';
const NormalizedVisitType = PropTypes.shape({
});
const propTypes = { const propTypes = {
visits: PropTypes.arrayOf(visitType).isRequired, visits: PropTypes.arrayOf(NormalizedVisitType).isRequired,
onVisitsSelected: PropTypes.func, selectedVisits: PropTypes.arrayOf(NormalizedVisitType),
setSelectedVisits: PropTypes.func.isRequired,
isSticky: PropTypes.bool, isSticky: PropTypes.bool,
matchMedia: PropTypes.func, matchMedia: PropTypes.func,
}; };
@ -35,34 +38,30 @@ const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => {
return a[field] > b[field] ? greaterThan : smallerThan; return a[field] > b[field] ? greaterThan : smallerThan;
}); });
const calculateVisits = (allVisits, searchTerm, order) => { const calculateVisits = (allVisits, searchTerm, order) => {
const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : allVisits; const filteredVisits = searchTerm ? searchVisits(searchTerm, allVisits) : [ ...allVisits ];
const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits; const sortedVisits = order.dir ? sortVisits(order, filteredVisits) : filteredVisits;
const total = sortedVisits.length; const total = sortedVisits.length;
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits); const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
return { visitsGroups, total }; return { visitsGroups, total };
}; };
const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({
date,
browser: browserFromUserAgent(userAgent),
os: osFromUserAgent(userAgent),
referer: extractDomain(referer),
country: (visitLocation && visitLocation.countryName) || 'Unknown',
city: (visitLocation && visitLocation.cityName) || 'Unknown',
}));
const VisitsTable = ({ visits, onVisitsSelected, isSticky = false, matchMedia = window.matchMedia }) => { const VisitsTable = ({
const allVisits = normalizeVisits(visits); visits,
selectedVisits = [],
setSelectedVisits,
isSticky = false,
matchMedia = window.matchMedia,
}) => {
const headerCellsClass = classNames('visits-table__header-cell', { const headerCellsClass = classNames('visits-table__header-cell', {
'visits-table__sticky': isSticky, 'visits-table__sticky': isSticky,
}); });
const matchMobile = () => matchMedia('(max-width: 767px)').matches; const matchMobile = () => matchMedia('(max-width: 767px)').matches;
const [ selectedVisits, setSelectedVisits ] = useState([]);
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile()); const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
const [ searchTerm, setSearchTerm ] = useState(undefined); const [ searchTerm, setSearchTerm ] = useState(undefined);
const [ order, setOrder ] = useState({ field: undefined, dir: undefined }); const [ order, setOrder ] = useState({ field: undefined, dir: undefined });
const resultSet = useMemo(() => calculateVisits(allVisits, searchTerm, order), [ searchTerm, order ]); const resultSet = useMemo(() => calculateVisits(visits, searchTerm, order), [ searchTerm, order ]);
const [ page, setPage ] = useState(1); const [ page, setPage ] = useState(1);
const end = page * PAGE_SIZE; const end = page * PAGE_SIZE;
@ -76,9 +75,6 @@ const VisitsTable = ({ visits, onVisitsSelected, isSticky = false, matchMedia =
/> />
); );
useEffect(() => {
onVisitsSelected && onVisitsSelected(selectedVisits);
}, [ selectedVisits ]);
useEffect(() => { useEffect(() => {
const listener = () => setIsMobileDevice(matchMobile()); const listener = () => setIsMobileDevice(matchMobile());

View file

@ -1,4 +1,4 @@
import { isEmpty, isNil, memoizeWith, prop } from 'ramda'; import { isEmpty, isNil, map } from 'ramda';
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits'; import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits';
const visitLocationHasProperty = (visitLocation, propertyName) => const visitLocationHasProperty = (visitLocation, propertyName) =>
@ -51,7 +51,7 @@ const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => {
citiesForMapStats[cityName] = currentCity; citiesForMapStats[cityName] = currentCity;
}; };
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => export const processStatsFromVisits = (visits) =>
visits.reduce( visits.reduce(
(stats, visit) => { (stats, visit) => {
// We mutate the original object because it has a big side effect when large data sets are processed // We mutate the original object because it has a big side effect when large data sets are processed
@ -65,4 +65,13 @@ export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
return stats; return stats;
}, },
{ os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} } { os: {}, browsers: {}, referrers: {}, countries: {}, cities: {}, citiesForMap: {} }
)); );
export const normalizeVisits = map(({ userAgent, date, referer, visitLocation }) => ({
date,
browser: browserFromUserAgent(userAgent),
os: osFromUserAgent(userAgent),
referer: extractDomain(referer),
country: (visitLocation && visitLocation.countryName) || 'Unknown',
city: (visitLocation && visitLocation.cityName) || 'Unknown',
}));

View file

@ -20,7 +20,7 @@ describe('<ShortUrlVisits />', () => {
const location = { search: '' }; const location = { search: '' };
const createComponent = (shortUrlVisits) => { const createComponent = (shortUrlVisits) => {
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }, () => ''); const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => '');
wrapper = shallow( wrapper = shallow(
<ShortUrlVisits <ShortUrlVisits

View file

@ -7,14 +7,25 @@ import SearchField from '../../src/utils/SearchField';
describe('<VisitsTable />', () => { describe('<VisitsTable />', () => {
const matchMedia = () => ({ matches: false }); const matchMedia = () => ({ matches: false });
const setSelectedVisits = jest.fn();
let wrapper; let wrapper;
const createWrapper = (visits) => { const createWrapper = (visits, selectedVisits = []) => {
wrapper = shallow(<VisitsTable visits={visits} matchMedia={matchMedia} />); wrapper = shallow(
<VisitsTable
visits={visits}
selectedVisits={selectedVisits}
setSelectedVisits={setSelectedVisits}
matchMedia={matchMedia}
/>
);
return wrapper; return wrapper;
}; };
afterEach(() => wrapper && wrapper.unmount()); afterEach(() => {
jest.resetAllMocks();
wrapper && wrapper.unmount();
});
it('renders columns as expected', () => { it('renders columns as expected', () => {
const wrapper = createWrapper([]); const wrapper = createWrapper([]);
@ -44,7 +55,7 @@ describe('<VisitsTable />', () => {
[ 60, 3 ], [ 60, 3 ],
[ 115, 6 ], [ 115, 6 ],
])('renders the expected amount of pages', (visitsCount, expectedAmountOfPages) => { ])('renders the expected amount of pages', (visitsCount, expectedAmountOfPages) => {
const wrapper = createWrapper(rangeOf(visitsCount, () => ({ userAgent: '', date: '', referer: '' }))); const wrapper = createWrapper(rangeOf(visitsCount, () => ({ browser: '', date: '', referer: '' })));
const tr = wrapper.find('tbody').find('tr'); const tr = wrapper.find('tbody').find('tr');
const paginator = wrapper.find(SimplePaginator); const paginator = wrapper.find(SimplePaginator);
@ -55,7 +66,7 @@ describe('<VisitsTable />', () => {
it.each( it.each(
rangeOf(20, (value) => [ value ]) rangeOf(20, (value) => [ value ])
)('does not render footer when there is only one page to render', (visitsCount) => { )('does not render footer when there is only one page to render', (visitsCount) => {
const wrapper = createWrapper(rangeOf(visitsCount, () => ({ userAgent: '', date: '', referer: '' }))); const wrapper = createWrapper(rangeOf(visitsCount, () => ({ browser: '', date: '', referer: '' })));
const tr = wrapper.find('tbody').find('tr'); const tr = wrapper.find('tbody').find('tr');
const paginator = wrapper.find(SimplePaginator); const paginator = wrapper.find(SimplePaginator);
@ -64,39 +75,34 @@ describe('<VisitsTable />', () => {
}); });
it('selected rows are highlighted', () => { it('selected rows are highlighted', () => {
const wrapper = createWrapper(rangeOf(10, () => ({ userAgent: '', date: '', referer: '' }))); const visits = rangeOf(10, () => ({ browser: '', date: '', referer: '' }));
const wrapper = createWrapper(
visits,
[ visits[1], visits[2] ],
);
expect(wrapper.find('.text-primary')).toHaveLength(0);
expect(wrapper.find('.table-primary')).toHaveLength(0);
wrapper.find('tr').at(5).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(2);
expect(wrapper.find('.table-primary')).toHaveLength(1);
wrapper.find('tr').at(3).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(3); expect(wrapper.find('.text-primary')).toHaveLength(3);
expect(wrapper.find('.table-primary')).toHaveLength(2); expect(wrapper.find('.table-primary')).toHaveLength(2);
// Select one extra
wrapper.find('tr').at(5).simulate('click');
expect(setSelectedVisits).toHaveBeenCalledWith([ visits[1], visits[2], visits[4] ]);
// Deselect one
wrapper.find('tr').at(3).simulate('click'); wrapper.find('tr').at(3).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(2); expect(setSelectedVisits).toHaveBeenCalledWith([ visits[1] ]);
expect(wrapper.find('.table-primary')).toHaveLength(1);
// Select all // Select all
wrapper.find('thead').find('th').at(0).simulate('click'); wrapper.find('thead').find('th').at(0).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(11); expect(setSelectedVisits).toHaveBeenCalledWith(visits);
expect(wrapper.find('.table-primary')).toHaveLength(10);
// Select none
wrapper.find('thead').find('th').at(0).simulate('click');
expect(wrapper.find('.text-primary')).toHaveLength(0);
expect(wrapper.find('.table-primary')).toHaveLength(0);
}); });
it('orders visits when column is clicked', () => { it('orders visits when column is clicked', () => {
const wrapper = createWrapper(rangeOf(9, (index) => ({ const wrapper = createWrapper(rangeOf(9, (index) => ({
userAgent: '', browser: '',
date: `${9 - index}`, date: `${9 - index}`,
referer: `${index}`, referer: `${index}`,
visitLocation: { country: `Country_${index}`,
countryName: `Country_${index}`,
},
}))); })));
expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_1'); expect(wrapper.find('tbody').find('tr').at(0).find('td').at(2).text()).toContain('Country_1');
@ -112,8 +118,8 @@ describe('<VisitsTable />', () => {
it('filters list when writing in search box', () => { it('filters list when writing in search box', () => {
const wrapper = createWrapper([ const wrapper = createWrapper([
...rangeOf(7, () => ({ userAgent: 'aaa', date: 'aaa', referer: 'aaa' })), ...rangeOf(7, () => ({ browser: 'aaa', date: 'aaa', referer: 'aaa' })),
...rangeOf(2, () => ({ userAgent: 'bbb', date: 'bbb', referer: 'bbb' })), ...rangeOf(2, () => ({ browser: 'bbb', date: 'bbb', referer: 'bbb' })),
]); ]);
const searchField = wrapper.find(SearchField); const searchField = wrapper.find(SearchField);

View file

@ -1,4 +1,4 @@
import { processStatsFromVisits } from '../../../src/visits/services/VisitsParser'; import { processStatsFromVisits, normalizeVisits } from '../../../src/visits/services/VisitsParser';
describe('VisitsParser', () => { describe('VisitsParser', () => {
const visits = [ const visits = [
@ -47,7 +47,7 @@ describe('VisitsParser', () => {
let stats; let stats;
beforeAll(() => { beforeAll(() => {
stats = processStatsFromVisits({ id: 'id', visits }); stats = processStatsFromVisits(visits);
}); });
it('properly parses OS stats', () => { it('properly parses OS stats', () => {
@ -121,4 +121,51 @@ describe('VisitsParser', () => {
}); });
}); });
}); });
describe('normalizeVisits', () => {
it('properly parses the list of visits', () => {
expect(normalizeVisits(visits)).toEqual([
{
browser: 'Firefox',
os: 'Windows',
referer: 'google.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
},
{
browser: 'Firefox',
os: 'MacOS',
referer: 'google.com',
country: 'United States',
city: 'New York',
date: undefined,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'Direct',
country: 'Spain',
city: 'Unknown',
date: undefined,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'm.facebook.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
},
{
browser: 'Opera',
os: 'Linux',
referer: 'Direct',
country: 'Unknown',
city: 'Unknown',
date: undefined,
},
]);
});
});
}); });