Merge pull request #244 from acelaya-forks/feature/chart-visit-highlighting

Feature/chart visit highlighting
This commit is contained in:
Alejandro Celaya 2020-04-10 15:21:07 +02:00 committed by GitHub
commit eb65e99024
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 305 additions and 140 deletions

View File

@ -8,10 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
#### Added #### Added
* [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a pagintaed, sortable and filterable list. * [#199](https://github.com/shlinkio/shlink-web-client/issues/199) Added table to visits page which displays the information in a paginated, sortable and filterable list.
It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts. It also supports selecting multiple visits in the table which makes the corresponding data to be highlighted in the visits charts.
* [#241](https://github.com/shlinkio/shlink-web-client/issues/241) Added support to select charts bars in order to highlight related stats in other charts.
It also selects the visits in the new table, and you can even combine a selection in the chart and in the table.
* [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer. * [#213](https://github.com/shlinkio/shlink-web-client/issues/213) The versions of both shlink-web-client and currently consumed Shlink server are now displayed in the footer.
* [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded. * [#221](https://github.com/shlinkio/shlink-web-client/issues/221) Improved how servers are handled, displaying meaningful errors when a not-found or a not-reachable server is tried to be loaded.
* [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited. * [#226](https://github.com/shlinkio/shlink-web-client/issues/226) Created servers can now be edited.

View File

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap'; import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination'; import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
import './Paginator.scss';
const propTypes = { const propTypes = {
serverId: PropTypes.string.isRequired, serverId: PropTypes.string.isRequired,
@ -36,7 +37,7 @@ const Paginator = ({ paginator = {}, serverId }) => {
)); ));
return ( return (
<Pagination listClassName="flex-wrap justify-content-center"> <Pagination className="short-urls-paginator" listClassName="flex-wrap justify-content-center mb-0">
<PaginationItem disabled={currentPage === 1}> <PaginationItem disabled={currentPage === 1}>
<PaginationLink <PaginationLink
previous previous

View File

@ -0,0 +1,7 @@
.short-urls-paginator {
position: sticky;
bottom: 0;
background-color: rgba(white, .8);
padding: .75rem 0;
border-top: 1px solid rgba(black, .125);
}

View File

@ -25,8 +25,10 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
return ( return (
<React.Fragment> <React.Fragment>
<div className="form-group"><SearchBar /></div> <div className="form-group"><SearchBar /></div>
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} /> <div>
<Paginator paginator={pagination} serverId={serverId} /> <ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
<Paginator paginator={pagination} serverId={serverId} />
</div>
</React.Fragment> </React.Fragment>
); );
}; };

View File

@ -12,6 +12,7 @@ const propTypes = {
isClearable: PropTypes.bool, isClearable: PropTypes.bool,
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]), selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
ref: PropTypes.object, ref: PropTypes.object,
disabled: PropTypes.bool,
}; };
const DateInput = (props) => { const DateInput = (props) => {

View File

@ -7,6 +7,9 @@
.date-input-container__input { .date-input-container__input {
padding-right: 35px !important; padding-right: 35px !important;
}
.date-input-container__input:not(:disabled) {
background-color: #fff !important; background-color: #fff !important;
} }

View File

@ -9,9 +9,10 @@ const propTypes = {
endDate: dateType, endDate: dateType,
onStartDateChange: PropTypes.func.isRequired, onStartDateChange: PropTypes.func.isRequired,
onEndDateChange: PropTypes.func.isRequired, onEndDateChange: PropTypes.func.isRequired,
disabled: PropTypes.bool,
}; };
const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }) => ( const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange, disabled = false }) => (
<div className="row"> <div className="row">
<div className="col-md-6"> <div className="col-md-6">
<DateInput <DateInput
@ -19,6 +20,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
placeholderText="Since" placeholderText="Since"
isClearable isClearable
maxDate={endDate} maxDate={endDate}
disabled={disabled}
onChange={onStartDateChange} onChange={onStartDateChange}
/> />
</div> </div>
@ -29,6 +31,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
placeholderText="Until" placeholderText="Until"
isClearable isClearable
minDate={startDate} minDate={startDate}
disabled={disabled}
onChange={onEndDateChange} onChange={onEndDateChange}
/> />
</div> </div>

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: highlightedData && 'Non-selected',
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' },
@ -74,18 +79,38 @@ const renderGraph = (title, isBarChart, stats, max, highlightedStats) => {
// Do not show tooltip on items with empty label when in a bar chart // Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '', filter: ({ yLabel }) => !isBarChart || yLabel !== '',
}, },
onHover: isBarChart && (({ target }, chartElement) => {
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
}),
}; };
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData); const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
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,8 +1,9 @@
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';
import classNames from 'classnames';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons'; import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
import DateRangeRow from '../utils/DateRangeRow'; import DateRangeRow from '../utils/DateRangeRow';
@ -41,10 +42,9 @@ const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.
return acc; return acc;
}, {}); }, {});
const format = formatDate(); const format = formatDate();
let memoizationId; let selectedBar;
let timeWhenMounted;
const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => { const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
const ShortUrlVisitsComp = ({ const ShortUrlVisitsComp = ({
match, match,
location, location,
@ -62,23 +62,40 @@ 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 setSelectedVisits = (selectedVisits) => {
selectedBar = undefined;
setHighlightedVisits(selectedVisits);
};
const highlightVisitsForProp = (prop) => (value) => {
const newSelectedBar = `${prop}_${value}`;
if (selectedBar === newSelectedBar) {
setHighlightedVisits([]);
selectedBar = undefined;
} else {
setHighlightedVisits(normalizedVisits.filter(propEq(prop, value)));
selectedBar = newSelectedBar;
}
};
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(normalizedVisits),
[ normalizedVisits ]
);
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 +109,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 +128,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 +146,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 +158,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 +174,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
name: 'City name', name: 'City name',
amount: 'Visits amount', amount: 'Visits amount',
}} }}
onClick={highlightVisitsForProp('city')}
/> />
</div> </div>
</div> </div>
@ -175,24 +187,35 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
<section className="mt-4"> <section className="mt-4">
<div className="row flex-md-row-reverse"> <div className="row flex-md-row-reverse">
<div className="col-lg-8 col-xl-6"> <div className="col-lg-7 col-xl-6">
<DateRangeRow <DateRangeRow
disabled={loading}
startDate={startDate} startDate={startDate}
endDate={endDate} endDate={endDate}
onStartDateChange={setStartDate} onStartDateChange={setStartDate}
onEndDateChange={setEndDate} onEndDateChange={setEndDate}
/> />
</div> </div>
<div className="col-lg-4 col-xl-6 mt-4 mt-lg-0"> <div className="col-lg-5 col-xl-6 mt-4 mt-lg-0">
{showTableControls && ( {showTableControls && (
<Button <span className={classNames({ 'row flex-row-reverse': isMobileDevice })}>
outline <span className={classNames({ 'col-6': isMobileDevice })}>
block={isMobileDevice} <Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
onClick={toggleTable} {showTable ? 'Hide' : 'Show'} table
> <FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
{showTable ? 'Hide' : 'Show'} table{' '} </Button>
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} /> </span>
</Button> <span className={classNames({ 'col-6': isMobileDevice, 'ml-2': !isMobileDevice })}>
<Button
outline
disabled={highlightedVisits.length === 0}
block={isMobileDevice}
onClick={() => setSelectedVisits([])}
>
Reset selection
</Button>
</span>
</span>
)} )}
</div> </div>
</div> </div>
@ -201,12 +224,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={setSelectedVisits}
isSticky={tableIsSticky}
/>
</Collapse> </Collapse>
)} )}

View File

@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda'; import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type, zipObj } from 'ramda';
import SortingDropdown from '../utils/SortingDropdown'; import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown'; import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf } from '../utils/utils'; import { rangeOf } from '../utils/utils';
@ -10,6 +10,7 @@ import GraphCard from './GraphCard';
const { max } = Math; const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value; const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickKeyFromPair = ([ key ]) => key;
const pickValueFromPair = ([ , value ]) => value; const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component { export default class SortableBarGraph extends React.Component {
@ -20,6 +21,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 = {
@ -29,7 +31,7 @@ export default class SortableBarGraph extends React.Component {
itemsPerPage: Infinity, itemsPerPage: Infinity,
}; };
determineStats(stats, sortingItems) { getSortedPairsForStats(stats, sortingItems) {
const pairs = toPairs(stats); const pairs = toPairs(stats);
const sortedPairs = !this.state.orderField ? pairs : sortBy( const sortedPairs = !this.state.orderField ? pairs : sortBy(
pipe( pipe(
@ -38,18 +40,33 @@ export default class SortableBarGraph extends React.Component {
), ),
pairs pairs
); );
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
if (directionalPairs.length <= this.state.itemsPerPage) { return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
return { currentPageStats: fromPairs(directionalPairs) }; }
determineStats(stats, highlightedStats, sortingItems) {
const sortedPairs = this.getSortedPairsForStats(stats, sortingItems);
const sortedKeys = sortedPairs.map(pickKeyFromPair);
// The highlighted stats have to be ordered based on the regular stats, not on its own values
const sortedHighlightedPairs = highlightedStats && toPairs(
{ ...zipObj(sortedKeys, sortedKeys.map(() => 0)), ...highlightedStats }
);
if (sortedPairs.length <= this.state.itemsPerPage) {
return {
currentPageStats: fromPairs(sortedPairs),
currentPageHighlightedStats: sortedHighlightedPairs && fromPairs(sortedHighlightedPairs),
};
} }
const pages = splitEvery(this.state.itemsPerPage, directionalPairs); const pages = splitEvery(this.state.itemsPerPage, sortedPairs);
const highlightedPages = sortedHighlightedPairs && splitEvery(this.state.itemsPerPage, sortedHighlightedPairs);
return { return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)), currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
pagination: this.renderPagination(pages.length), pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))), max: roundTen(max(...sortedPairs.map(pickValueFromPair))),
}; };
} }
@ -74,8 +91,20 @@ export default class SortableBarGraph extends React.Component {
} }
render() { render() {
const { stats, sortingItems, title, extraHeaderContent, highlightedStats, withPagination = true } = this.props; const {
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems); stats,
highlightedStats,
sortingItems,
title,
extraHeaderContent,
withPagination = true,
...rest
} = this.props;
const { currentPageStats, currentPageHighlightedStats, pagination, max } = this.determineStats(
stats,
highlightedStats && keys(highlightedStats).length > 0 ? highlightedStats : undefined,
sortingItems
);
const activeCities = keys(currentPageStats); const activeCities = keys(currentPageStats);
const computeTitle = () => ( const computeTitle = () => (
<React.Fragment> <React.Fragment>
@ -113,9 +142,10 @@ export default class SortableBarGraph extends React.Component {
isBarChart isBarChart
title={computeTitle} title={computeTitle}
stats={currentPageStats} stats={currentPageStats}
highlightedStats={currentPageHighlightedStats}
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,60 +1,52 @@
import { isEmpty, isNil, memoizeWith, prop } from 'ramda'; import { isNil, map } from 'ramda';
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits'; import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../../utils/helpers/visits';
import { hasValue } from '../../utils/utils';
const visitLocationHasProperty = (visitLocation, propertyName) => const visitHasProperty = (visit, propertyName) => !isNil(visit) && hasValue(visit[propertyName]);
!isNil(visitLocation)
&& !isNil(visitLocation[propertyName])
&& !isEmpty(visitLocation[propertyName]);
const updateOsStatsForVisit = (osStats, { userAgent }) => {
const os = osFromUserAgent(userAgent);
const updateOsStatsForVisit = (osStats, { os }) => {
osStats[os] = (osStats[os] || 0) + 1; osStats[os] = (osStats[os] || 0) + 1;
}; };
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => { const updateBrowsersStatsForVisit = (browsersStats, { browser }) => {
const browser = browserFromUserAgent(userAgent);
browsersStats[browser] = (browsersStats[browser] || 0) + 1; browsersStats[browser] = (browsersStats[browser] || 0) + 1;
}; };
const updateReferrersStatsForVisit = (referrersStats, { referer }) => { const updateReferrersStatsForVisit = (referrersStats, { referer: domain }) => {
const domain = extractDomain(referer);
referrersStats[domain] = (referrersStats[domain] || 0) + 1; referrersStats[domain] = (referrersStats[domain] || 0) + 1;
}; };
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => { const updateLocationsStatsForVisit = (propertyName) => (stats, visit) => {
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName); const hasLocationProperty = visitHasProperty(visit, propertyName);
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown'; const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
stats[value] = (stats[value] || 0) + 1; stats[value] = (stats[value] || 0) + 1;
}; };
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName'); const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName'); const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => { const updateCitiesForMapForVisit = (citiesForMapStats, visit) => {
if (!visitLocationHasProperty(visitLocation, 'cityName')) { if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
return; return;
} }
const { cityName, latitude, longitude } = visitLocation; const { city, latitude, longitude } = visit;
const currentCity = citiesForMapStats[cityName] || { const currentCity = citiesForMapStats[city] || {
cityName, cityName: city,
count: 0, count: 0,
latLong: [ parseFloat(latitude), parseFloat(longitude) ], latLong: [ parseFloat(latitude), parseFloat(longitude) ],
}; };
currentCity.count++; currentCity.count++;
citiesForMapStats[cityName] = currentCity; citiesForMapStats[city] = currentCity;
}; };
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) => export const processStatsFromVisits = (normalizedVisits) =>
visits.reduce( normalizedVisits.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 performance impact when large data sets are processed
updateOsStatsForVisit(stats.os, visit); updateOsStatsForVisit(stats.os, visit);
updateBrowsersStatsForVisit(stats.browsers, visit); updateBrowsersStatsForVisit(stats.browsers, visit);
updateReferrersStatsForVisit(stats.referrers, visit); updateReferrersStatsForVisit(stats.referrers, visit);
@ -65,4 +57,15 @@ 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',
latitude: visitLocation && visitLocation.latitude,
longitude: visitLocation && visitLocation.longitude,
}));

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 = [
@ -8,8 +8,8 @@ describe('VisitsParser', () => {
visitLocation: { visitLocation: {
countryName: 'Spain', countryName: 'Spain',
cityName: 'Zaragoza', cityName: 'Zaragoza',
latitude: '123.45', latitude: 123.45,
longitude: '-543.21', longitude: -543.21,
}, },
}, },
{ {
@ -18,8 +18,8 @@ describe('VisitsParser', () => {
visitLocation: { visitLocation: {
countryName: 'United States', countryName: 'United States',
cityName: 'New York', cityName: 'New York',
latitude: '1029', latitude: 1029,
longitude: '6758', longitude: 6758,
}, },
}, },
{ {
@ -34,8 +34,8 @@ describe('VisitsParser', () => {
visitLocation: { visitLocation: {
countryName: 'Spain', countryName: 'Spain',
cityName: 'Zaragoza', cityName: 'Zaragoza',
latitude: '123.45', latitude: 123.45,
longitude: '-543.21', longitude: -543.21,
}, },
}, },
{ {
@ -47,7 +47,7 @@ describe('VisitsParser', () => {
let stats; let stats;
beforeAll(() => { beforeAll(() => {
stats = processStatsFromVisits({ id: 'id', visits }); stats = processStatsFromVisits(normalizeVisits(visits));
}); });
it('properly parses OS stats', () => { it('properly parses OS stats', () => {
@ -121,4 +121,61 @@ 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,
latitude: 123.45,
longitude: -543.21,
},
{
browser: 'Firefox',
os: 'MacOS',
referer: 'google.com',
country: 'United States',
city: 'New York',
date: undefined,
latitude: 1029,
longitude: 6758,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'Direct',
country: 'Spain',
city: 'Unknown',
date: undefined,
latitude: undefined,
longitude: undefined,
},
{
browser: 'Chrome',
os: 'Linux',
referer: 'm.facebook.com',
country: 'Spain',
city: 'Zaragoza',
date: undefined,
latitude: 123.45,
longitude: -543.21,
},
{
browser: 'Opera',
os: 'Linux',
referer: 'Direct',
country: 'Unknown',
city: 'Unknown',
date: undefined,
latitude: undefined,
longitude: undefined,
},
]);
});
});
}); });