mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-10-24 05:26:17 +03:00
Allowed visits to be selected on charts so that they get highlighted on the rest of the charts
This commit is contained in:
parent
05deb1aff0
commit
8f42e65ccd
8 changed files with 169 additions and 90 deletions
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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());
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
}));
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
Loading…
Reference in a new issue