mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2025-01-11 02:37:22 +03:00
Merge pull request #244 from acelaya-forks/feature/chart-visit-highlighting
Feature/chart visit highlighting
This commit is contained in:
commit
eb65e99024
15 changed files with 305 additions and 140 deletions
|
@ -8,10 +8,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|||
|
||||
#### 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.
|
||||
|
||||
* [#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.
|
||||
* [#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.
|
||||
|
|
|
@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
|
|||
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isPageDisabled, keyForPage, progressivePagination } from '../utils/helpers/pagination';
|
||||
import './Paginator.scss';
|
||||
|
||||
const propTypes = {
|
||||
serverId: PropTypes.string.isRequired,
|
||||
|
@ -36,7 +37,7 @@ const Paginator = ({ paginator = {}, serverId }) => {
|
|||
));
|
||||
|
||||
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}>
|
||||
<PaginationLink
|
||||
previous
|
||||
|
|
7
src/short-urls/Paginator.scss
Normal file
7
src/short-urls/Paginator.scss
Normal 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);
|
||||
}
|
|
@ -25,8 +25,10 @@ const ShortUrls = (SearchBar, ShortUrlsList) => {
|
|||
return (
|
||||
<React.Fragment>
|
||||
<div className="form-group"><SearchBar /></div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
<div>
|
||||
<ShortUrlsList {...props} shortUrlsList={data} key={urlsListKey} />
|
||||
<Paginator paginator={pagination} serverId={serverId} />
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,6 +12,7 @@ const propTypes = {
|
|||
isClearable: PropTypes.bool,
|
||||
selected: PropTypes.oneOfType([ PropTypes.string, PropTypes.object ]),
|
||||
ref: PropTypes.object,
|
||||
disabled: PropTypes.bool,
|
||||
};
|
||||
|
||||
const DateInput = (props) => {
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
.date-input-container__input {
|
||||
padding-right: 35px !important;
|
||||
}
|
||||
|
||||
.date-input-container__input:not(:disabled) {
|
||||
background-color: #fff !important;
|
||||
}
|
||||
|
||||
|
|
|
@ -9,9 +9,10 @@ const propTypes = {
|
|||
endDate: dateType,
|
||||
onStartDateChange: 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="col-md-6">
|
||||
<DateInput
|
||||
|
@ -19,6 +20,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
|
|||
placeholderText="Since"
|
||||
isClearable
|
||||
maxDate={endDate}
|
||||
disabled={disabled}
|
||||
onChange={onStartDateChange}
|
||||
/>
|
||||
</div>
|
||||
|
@ -29,6 +31,7 @@ const DateRangeRow = ({ startDate, endDate, onStartDateChange, onEndDateChange }
|
|||
placeholderText="Until"
|
||||
isClearable
|
||||
minDate={startDate}
|
||||
disabled={disabled}
|
||||
onChange={onEndDateChange}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
@ -12,6 +12,7 @@ const propTypes = {
|
|||
stats: PropTypes.object,
|
||||
max: PropTypes.number,
|
||||
highlightedStats: PropTypes.object,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
const generateGraphData = (title, isBarChart, labels, data, highlightedData) => ({
|
||||
|
@ -19,6 +20,7 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
|
|||
datasets: [
|
||||
{
|
||||
title,
|
||||
label: highlightedData && 'Non-selected',
|
||||
data,
|
||||
backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [
|
||||
'#97BBCD',
|
||||
|
@ -45,17 +47,20 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData) =>
|
|||
|
||||
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 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]) {
|
||||
acc[highlightedKey] -= highlightedStats[highlightedKey];
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, { ...stats }));
|
||||
const highlightedData = highlightedStats && values({ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats });
|
||||
const highlightedData = hasHighlightedStats && values(
|
||||
{ ...zipObj(labels, labels.map(() => 0)), ...highlightedStats }
|
||||
);
|
||||
|
||||
const options = {
|
||||
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
|
||||
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
|
||||
},
|
||||
onHover: isBarChart && (({ target }, chartElement) => {
|
||||
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
|
||||
}),
|
||||
};
|
||||
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData);
|
||||
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
|
||||
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">
|
||||
<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>}
|
||||
</Card>
|
||||
);
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { isEmpty, values } from 'ramda';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { isEmpty, propEq, values } from 'ramda';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Button, Card, Collapse } from 'reactstrap';
|
||||
import PropTypes from 'prop-types';
|
||||
import qs from 'qs';
|
||||
import classNames from 'classnames';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronDown as chevronDown } from '@fortawesome/free-solid-svg-icons';
|
||||
import DateRangeRow from '../utils/DateRangeRow';
|
||||
|
@ -41,10 +42,9 @@ const highlightedVisitsToStats = (highlightedVisits, prop) => highlightedVisits.
|
|||
return acc;
|
||||
}, {});
|
||||
const format = formatDate();
|
||||
let memoizationId;
|
||||
let timeWhenMounted;
|
||||
let selectedBar;
|
||||
|
||||
const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
||||
const ShortUrlVisits = ({ processStatsFromVisits, normalizeVisits }, OpenMapModalBtn) => {
|
||||
const ShortUrlVisitsComp = ({
|
||||
match,
|
||||
location,
|
||||
|
@ -62,23 +62,40 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
const [ highlightedVisits, setHighlightedVisits ] = useState([]);
|
||||
const [ isMobileDevice, setIsMobileDevice ] = useState(false);
|
||||
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 { shortCode } = params;
|
||||
const { search } = location;
|
||||
const { domain } = qs.parse(search, { ignoreQueryPrefix: true });
|
||||
|
||||
const loadVisits = () => {
|
||||
const start = format(startDate);
|
||||
const end = format(endDate);
|
||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
||||
const showTableControls = !loading && visits.length > 0;
|
||||
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
|
||||
memoizationId = `${timeWhenMounted}_${shortCode}_${start}_${end}`;
|
||||
getShortUrlVisits(shortCode, { startDate: start, endDate: end, domain });
|
||||
};
|
||||
const loadVisits = () =>
|
||||
getShortUrlVisits(shortCode, { startDate: format(startDate), endDate: format(endDate), domain });
|
||||
|
||||
useEffect(() => {
|
||||
timeWhenMounted = new Date().getTime();
|
||||
getShortUrlDetail(shortCode, domain);
|
||||
determineIsMobileDevice();
|
||||
window.addEventListener('resize', determineIsMobileDevice);
|
||||
|
@ -92,9 +109,6 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
loadVisits();
|
||||
}, [ startDate, endDate ]);
|
||||
|
||||
const { visits, loading, loadingLarge, error } = shortUrlVisits;
|
||||
const showTableControls = !loading && visits.length > 0;
|
||||
|
||||
const renderVisitsContent = () => {
|
||||
if (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>;
|
||||
}
|
||||
|
||||
const { os, browsers, referrers, countries, cities, citiesForMap } = processStatsFromVisits(
|
||||
{ id: memoizationId, visits }
|
||||
);
|
||||
const mapLocations = values(citiesForMap);
|
||||
|
||||
return (
|
||||
<div className="row">
|
||||
<div className="col-xl-4 col-lg-6">
|
||||
|
@ -137,6 +146,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
name: 'Referrer name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('referer')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
|
@ -148,6 +158,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
name: 'Country name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('country')}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
|
@ -163,6 +174,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
name: 'City name',
|
||||
amount: 'Visits amount',
|
||||
}}
|
||||
onClick={highlightVisitsForProp('city')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -175,24 +187,35 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
|
||||
<section className="mt-4">
|
||||
<div className="row flex-md-row-reverse">
|
||||
<div className="col-lg-8 col-xl-6">
|
||||
<div className="col-lg-7 col-xl-6">
|
||||
<DateRangeRow
|
||||
disabled={loading}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
</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 && (
|
||||
<Button
|
||||
outline
|
||||
block={isMobileDevice}
|
||||
onClick={toggleTable}
|
||||
>
|
||||
{showTable ? 'Hide' : 'Show'} table{' '}
|
||||
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} />
|
||||
</Button>
|
||||
<span className={classNames({ 'row flex-row-reverse': isMobileDevice })}>
|
||||
<span className={classNames({ 'col-6': isMobileDevice })}>
|
||||
<Button outline color="primary" block={isMobileDevice} onClick={toggleTable}>
|
||||
{showTable ? 'Hide' : 'Show'} table
|
||||
<FontAwesomeIcon icon={chevronDown} rotation={showTable ? 180 : undefined} className="ml-2" />
|
||||
</Button>
|
||||
</span>
|
||||
<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>
|
||||
|
@ -201,12 +224,16 @@ const ShortUrlVisits = ({ processStatsFromVisits }, OpenMapModalBtn) => {
|
|||
{showTableControls && (
|
||||
<Collapse
|
||||
isOpen={showTable}
|
||||
|
||||
// Enable stickiness only when there's no CSS animation, to avoid weird rendering effects
|
||||
onEntered={setSticky}
|
||||
onExiting={unsetSticky}
|
||||
>
|
||||
<VisitsTable visits={visits} isSticky={tableIsSticky} onVisitsSelected={setHighlightedVisits} />
|
||||
<VisitsTable
|
||||
visits={normalizedVisits}
|
||||
selectedVisits={highlightedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
isSticky={tableIsSticky}
|
||||
/>
|
||||
</Collapse>
|
||||
)}
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import React from 'react';
|
||||
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 PaginationDropdown from '../utils/PaginationDropdown';
|
||||
import { rangeOf } from '../utils/utils';
|
||||
|
@ -10,6 +10,7 @@ import GraphCard from './GraphCard';
|
|||
|
||||
const { max } = Math;
|
||||
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
|
||||
const pickKeyFromPair = ([ key ]) => key;
|
||||
const pickValueFromPair = ([ , value ]) => value;
|
||||
|
||||
export default class SortableBarGraph extends React.Component {
|
||||
|
@ -20,6 +21,7 @@ export default class SortableBarGraph extends React.Component {
|
|||
sortingItems: PropTypes.object.isRequired,
|
||||
extraHeaderContent: PropTypes.func,
|
||||
withPagination: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
};
|
||||
|
||||
state = {
|
||||
|
@ -29,7 +31,7 @@ export default class SortableBarGraph extends React.Component {
|
|||
itemsPerPage: Infinity,
|
||||
};
|
||||
|
||||
determineStats(stats, sortingItems) {
|
||||
getSortedPairsForStats(stats, sortingItems) {
|
||||
const pairs = toPairs(stats);
|
||||
const sortedPairs = !this.state.orderField ? pairs : sortBy(
|
||||
pipe(
|
||||
|
@ -38,18 +40,33 @@ export default class SortableBarGraph extends React.Component {
|
|||
),
|
||||
pairs
|
||||
);
|
||||
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
|
||||
if (directionalPairs.length <= this.state.itemsPerPage) {
|
||||
return { currentPageStats: fromPairs(directionalPairs) };
|
||||
return !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
|
||||
}
|
||||
|
||||
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 {
|
||||
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
|
||||
currentPageHighlightedStats: highlightedPages && fromPairs(this.determineCurrentPagePairs(highlightedPages)),
|
||||
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() {
|
||||
const { stats, sortingItems, title, extraHeaderContent, highlightedStats, withPagination = true } = this.props;
|
||||
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
|
||||
const {
|
||||
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 computeTitle = () => (
|
||||
<React.Fragment>
|
||||
|
@ -113,9 +142,10 @@ export default class SortableBarGraph extends React.Component {
|
|||
isBarChart
|
||||
title={computeTitle}
|
||||
stats={currentPageStats}
|
||||
highlightedStats={currentPageHighlightedStats}
|
||||
footer={pagination}
|
||||
max={max}
|
||||
highlightedStats={highlightedStats}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useState } from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
import Moment from 'react-moment';
|
||||
import classNames from 'classnames';
|
||||
import { map, min, splitEvery } from 'ramda';
|
||||
import { min, splitEvery } from 'ramda';
|
||||
import {
|
||||
faCaretDown as caretDownIcon,
|
||||
faCaretUp as caretUpIcon,
|
||||
|
@ -11,15 +11,18 @@ import {
|
|||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import SimplePaginator from '../common/SimplePaginator';
|
||||
import SearchField from '../utils/SearchField';
|
||||
import { browserFromUserAgent, extractDomain, osFromUserAgent } from '../utils/helpers/visits';
|
||||
import { determineOrderDir } from '../utils/utils';
|
||||
import { prettify } from '../utils/helpers/numbers';
|
||||
import { visitType } from './reducers/shortUrlVisits';
|
||||
import './VisitsTable.scss';
|
||||
|
||||
const NormalizedVisitType = PropTypes.shape({
|
||||
|
||||
});
|
||||
|
||||
const propTypes = {
|
||||
visits: PropTypes.arrayOf(visitType).isRequired,
|
||||
onVisitsSelected: PropTypes.func,
|
||||
visits: PropTypes.arrayOf(NormalizedVisitType).isRequired,
|
||||
selectedVisits: PropTypes.arrayOf(NormalizedVisitType),
|
||||
setSelectedVisits: PropTypes.func.isRequired,
|
||||
isSticky: PropTypes.bool,
|
||||
matchMedia: PropTypes.func,
|
||||
};
|
||||
|
@ -35,34 +38,30 @@ const sortVisits = ({ field, dir }, visits) => visits.sort((a, b) => {
|
|||
return a[field] > b[field] ? greaterThan : smallerThan;
|
||||
});
|
||||
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 total = sortedVisits.length;
|
||||
const visitsGroups = splitEvery(PAGE_SIZE, sortedVisits);
|
||||
|
||||
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 allVisits = normalizeVisits(visits);
|
||||
const VisitsTable = ({
|
||||
visits,
|
||||
selectedVisits = [],
|
||||
setSelectedVisits,
|
||||
isSticky = false,
|
||||
matchMedia = window.matchMedia,
|
||||
}) => {
|
||||
const headerCellsClass = classNames('visits-table__header-cell', {
|
||||
'visits-table__sticky': isSticky,
|
||||
});
|
||||
const matchMobile = () => matchMedia('(max-width: 767px)').matches;
|
||||
|
||||
const [ selectedVisits, setSelectedVisits ] = useState([]);
|
||||
const [ isMobileDevice, setIsMobileDevice ] = useState(matchMobile());
|
||||
const [ searchTerm, setSearchTerm ] = useState(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 end = page * PAGE_SIZE;
|
||||
|
@ -76,9 +75,6 @@ const VisitsTable = ({ visits, onVisitsSelected, isSticky = false, matchMedia =
|
|||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
onVisitsSelected && onVisitsSelected(selectedVisits);
|
||||
}, [ selectedVisits ]);
|
||||
useEffect(() => {
|
||||
const listener = () => setIsMobileDevice(matchMobile());
|
||||
|
||||
|
|
|
@ -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 { hasValue } from '../../utils/utils';
|
||||
|
||||
const visitLocationHasProperty = (visitLocation, propertyName) =>
|
||||
!isNil(visitLocation)
|
||||
&& !isNil(visitLocation[propertyName])
|
||||
&& !isEmpty(visitLocation[propertyName]);
|
||||
|
||||
const updateOsStatsForVisit = (osStats, { userAgent }) => {
|
||||
const os = osFromUserAgent(userAgent);
|
||||
const visitHasProperty = (visit, propertyName) => !isNil(visit) && hasValue(visit[propertyName]);
|
||||
|
||||
const updateOsStatsForVisit = (osStats, { os }) => {
|
||||
osStats[os] = (osStats[os] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateBrowsersStatsForVisit = (browsersStats, { userAgent }) => {
|
||||
const browser = browserFromUserAgent(userAgent);
|
||||
|
||||
const updateBrowsersStatsForVisit = (browsersStats, { browser }) => {
|
||||
browsersStats[browser] = (browsersStats[browser] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateReferrersStatsForVisit = (referrersStats, { referer }) => {
|
||||
const domain = extractDomain(referer);
|
||||
|
||||
const updateReferrersStatsForVisit = (referrersStats, { referer: domain }) => {
|
||||
referrersStats[domain] = (referrersStats[domain] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateLocationsStatsForVisit = (propertyName) => (stats, { visitLocation }) => {
|
||||
const hasLocationProperty = visitLocationHasProperty(visitLocation, propertyName);
|
||||
const value = hasLocationProperty ? visitLocation[propertyName] : 'Unknown';
|
||||
const updateLocationsStatsForVisit = (propertyName) => (stats, visit) => {
|
||||
const hasLocationProperty = visitHasProperty(visit, propertyName);
|
||||
const value = hasLocationProperty ? visit[propertyName] : 'Unknown';
|
||||
|
||||
stats[value] = (stats[value] || 0) + 1;
|
||||
};
|
||||
|
||||
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('countryName');
|
||||
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('cityName');
|
||||
const updateCountriesStatsForVisit = updateLocationsStatsForVisit('country');
|
||||
const updateCitiesStatsForVisit = updateLocationsStatsForVisit('city');
|
||||
|
||||
const updateCitiesForMapForVisit = (citiesForMapStats, { visitLocation }) => {
|
||||
if (!visitLocationHasProperty(visitLocation, 'cityName')) {
|
||||
const updateCitiesForMapForVisit = (citiesForMapStats, visit) => {
|
||||
if (!visitHasProperty(visit, 'city') || visit.city === 'Unknown') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { cityName, latitude, longitude } = visitLocation;
|
||||
const currentCity = citiesForMapStats[cityName] || {
|
||||
cityName,
|
||||
const { city, latitude, longitude } = visit;
|
||||
const currentCity = citiesForMapStats[city] || {
|
||||
cityName: city,
|
||||
count: 0,
|
||||
latLong: [ parseFloat(latitude), parseFloat(longitude) ],
|
||||
};
|
||||
|
||||
currentCity.count++;
|
||||
|
||||
citiesForMapStats[cityName] = currentCity;
|
||||
citiesForMapStats[city] = currentCity;
|
||||
};
|
||||
|
||||
export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
|
||||
visits.reduce(
|
||||
export const processStatsFromVisits = (normalizedVisits) =>
|
||||
normalizedVisits.reduce(
|
||||
(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);
|
||||
updateBrowsersStatsForVisit(stats.browsers, visit);
|
||||
updateReferrersStatsForVisit(stats.referrers, visit);
|
||||
|
@ -65,4 +57,15 @@ export const processStatsFromVisits = memoizeWith(prop('id'), ({ visits }) =>
|
|||
return stats;
|
||||
},
|
||||
{ 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,
|
||||
}));
|
||||
|
|
|
@ -20,7 +20,7 @@ describe('<ShortUrlVisits />', () => {
|
|||
const location = { search: '' };
|
||||
|
||||
const createComponent = (shortUrlVisits) => {
|
||||
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits }, () => '');
|
||||
const ShortUrlVisits = createShortUrlVisits({ processStatsFromVisits, normalizeVisits: identity }, () => '');
|
||||
|
||||
wrapper = shallow(
|
||||
<ShortUrlVisits
|
||||
|
|
|
@ -7,14 +7,25 @@ import SearchField from '../../src/utils/SearchField';
|
|||
|
||||
describe('<VisitsTable />', () => {
|
||||
const matchMedia = () => ({ matches: false });
|
||||
const setSelectedVisits = jest.fn();
|
||||
let wrapper;
|
||||
const createWrapper = (visits) => {
|
||||
wrapper = shallow(<VisitsTable visits={visits} matchMedia={matchMedia} />);
|
||||
const createWrapper = (visits, selectedVisits = []) => {
|
||||
wrapper = shallow(
|
||||
<VisitsTable
|
||||
visits={visits}
|
||||
selectedVisits={selectedVisits}
|
||||
setSelectedVisits={setSelectedVisits}
|
||||
matchMedia={matchMedia}
|
||||
/>
|
||||
);
|
||||
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
afterEach(() => wrapper && wrapper.unmount());
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
wrapper && wrapper.unmount();
|
||||
});
|
||||
|
||||
it('renders columns as expected', () => {
|
||||
const wrapper = createWrapper([]);
|
||||
|
@ -44,7 +55,7 @@ describe('<VisitsTable />', () => {
|
|||
[ 60, 3 ],
|
||||
[ 115, 6 ],
|
||||
])('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 paginator = wrapper.find(SimplePaginator);
|
||||
|
||||
|
@ -55,7 +66,7 @@ describe('<VisitsTable />', () => {
|
|||
it.each(
|
||||
rangeOf(20, (value) => [ value ])
|
||||
)('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 paginator = wrapper.find(SimplePaginator);
|
||||
|
||||
|
@ -64,39 +75,34 @@ describe('<VisitsTable />', () => {
|
|||
});
|
||||
|
||||
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('.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');
|
||||
expect(wrapper.find('.text-primary')).toHaveLength(2);
|
||||
expect(wrapper.find('.table-primary')).toHaveLength(1);
|
||||
expect(setSelectedVisits).toHaveBeenCalledWith([ visits[1] ]);
|
||||
|
||||
// Select all
|
||||
wrapper.find('thead').find('th').at(0).simulate('click');
|
||||
expect(wrapper.find('.text-primary')).toHaveLength(11);
|
||||
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);
|
||||
expect(setSelectedVisits).toHaveBeenCalledWith(visits);
|
||||
});
|
||||
|
||||
it('orders visits when column is clicked', () => {
|
||||
const wrapper = createWrapper(rangeOf(9, (index) => ({
|
||||
userAgent: '',
|
||||
browser: '',
|
||||
date: `${9 - index}`,
|
||||
referer: `${index}`,
|
||||
visitLocation: {
|
||||
countryName: `Country_${index}`,
|
||||
},
|
||||
country: `Country_${index}`,
|
||||
})));
|
||||
|
||||
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', () => {
|
||||
const wrapper = createWrapper([
|
||||
...rangeOf(7, () => ({ userAgent: 'aaa', date: 'aaa', referer: 'aaa' })),
|
||||
...rangeOf(2, () => ({ userAgent: 'bbb', date: 'bbb', referer: 'bbb' })),
|
||||
...rangeOf(7, () => ({ browser: 'aaa', date: 'aaa', referer: 'aaa' })),
|
||||
...rangeOf(2, () => ({ browser: 'bbb', date: 'bbb', referer: 'bbb' })),
|
||||
]);
|
||||
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', () => {
|
||||
const visits = [
|
||||
|
@ -8,8 +8,8 @@ describe('VisitsParser', () => {
|
|||
visitLocation: {
|
||||
countryName: 'Spain',
|
||||
cityName: 'Zaragoza',
|
||||
latitude: '123.45',
|
||||
longitude: '-543.21',
|
||||
latitude: 123.45,
|
||||
longitude: -543.21,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -18,8 +18,8 @@ describe('VisitsParser', () => {
|
|||
visitLocation: {
|
||||
countryName: 'United States',
|
||||
cityName: 'New York',
|
||||
latitude: '1029',
|
||||
longitude: '6758',
|
||||
latitude: 1029,
|
||||
longitude: 6758,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -34,8 +34,8 @@ describe('VisitsParser', () => {
|
|||
visitLocation: {
|
||||
countryName: 'Spain',
|
||||
cityName: 'Zaragoza',
|
||||
latitude: '123.45',
|
||||
longitude: '-543.21',
|
||||
latitude: 123.45,
|
||||
longitude: -543.21,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -47,7 +47,7 @@ describe('VisitsParser', () => {
|
|||
let stats;
|
||||
|
||||
beforeAll(() => {
|
||||
stats = processStatsFromVisits({ id: 'id', visits });
|
||||
stats = processStatsFromVisits(normalizeVisits(visits));
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue