Skip to content

Commit

Permalink
Merge pull request #124 from acelaya/feature/paginated-charts
Browse files Browse the repository at this point in the history
Feature/paginated charts
  • Loading branch information
acelaya committed Mar 16, 2019
2 parents 6057638 + 391424d commit 2ba8676
Show file tree
Hide file tree
Showing 25 changed files with 447 additions and 163 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Expand Up @@ -26,6 +26,7 @@
"no-console": "warn",
"template-curly-spacing": ["error", "never"],
"no-warning-comments": "off",
"no-magic-numbers": "off",
"no-undefined": "off",
"indent": ["error", 2, {
"SwitchCase": 1
Expand Down
3 changes: 2 additions & 1 deletion CHANGELOG.md
Expand Up @@ -4,7 +4,7 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org).

## [Unreleased]
## 2.0.3 - 2019-03-16

#### Added

Expand All @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

* [#120](https://github.com/shlinkio/shlink-web-client/issues/120) Fixed crash when visits page is loaded and there are no visits with known cities.
* [#113](https://github.com/shlinkio/shlink-web-client/issues/113) Ensured visits loading is cancelled when the visits page is unmounted. Requests on flight will still finish.
* [#118](https://github.com/shlinkio/shlink-web-client/issues/118) Fixed chart crashing when trying to render lots of bars by adding pagination.


## 2.0.2 - 2019-03-04
Expand Down
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -38,13 +38,13 @@
"prop-types": "^15.6.2",
"qs": "^6.5.2",
"ramda": "^0.26.1",
"react": "^16.7.0",
"react": "^16.8.0",
"react-autosuggest": "^9.4.0",
"react-chartjs-2": "^2.7.4",
"react-color": "^2.14.1",
"react-copy-to-clipboard": "^5.0.1",
"react-datepicker": "~1.5.0",
"react-dom": "^16.7.0",
"react-dom": "^16.8.0",
"react-leaflet": "^2.2.1",
"react-moment": "^0.7.6",
"react-redux": "^5.0.7",
Expand Down
8 changes: 8 additions & 0 deletions src/index.scss
Expand Up @@ -51,3 +51,11 @@ body,
margin: 0 auto !important;
}
}

.pagination .page-link {
cursor: pointer;
}

.paddingless {
padding: 0;
}
4 changes: 2 additions & 2 deletions src/short-urls/Paginator.js
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { Link } from 'react-router-dom';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import PropTypes from 'prop-types';
import { range } from 'ramda';
import { rangeOf } from '../utils/utils';

const propTypes = {
serverId: PropTypes.string.isRequired,
Expand All @@ -20,7 +20,7 @@ export default function Paginator({ paginator = {}, serverId }) {
}

const renderPages = () =>
range(1, pagesCount + 1).map((pageNumber) => (
rangeOf(pagesCount, (pageNumber) => (
<PaginationItem key={pageNumber} active={currentPage === pageNumber}>
<PaginationLink
tag={Link}
Expand Down
33 changes: 33 additions & 0 deletions src/utils/PaginationDropdown.js
@@ -0,0 +1,33 @@
import React from 'react';
import { DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown } from 'reactstrap';
import * as PropTypes from 'prop-types';

const propTypes = {
toggleClassName: PropTypes.string,
ranges: PropTypes.arrayOf(PropTypes.number).isRequired,
value: PropTypes.number.isRequired,
setValue: PropTypes.func.isRequired,
};

const PaginationDropdown = ({ toggleClassName, ranges, value, setValue }) => (
<UncontrolledDropdown>
<DropdownToggle caret color="link" className={toggleClassName}>
Paginate
</DropdownToggle>
<DropdownMenu right>
{ranges.map((itemsPerPage) => (
<DropdownItem key={itemsPerPage} active={itemsPerPage === value} onClick={() => setValue(itemsPerPage)}>
<b>{itemsPerPage}</b> items per page
</DropdownItem>
))}
<DropdownItem divider />
<DropdownItem disabled={value === Infinity} onClick={() => setValue(Infinity)}>
<i>Clear pagination</i>
</DropdownItem>
</DropdownMenu>
</UncontrolledDropdown>
);

PaginationDropdown.propTypes = propTypes;

export default PaginationDropdown;
2 changes: 1 addition & 1 deletion src/utils/SortingDropdown.js
Expand Up @@ -33,7 +33,7 @@ const SortingDropdown = ({ items, orderField, orderDir, onChange, isButton, righ
<DropdownToggle
caret
color={isButton ? 'secondary' : 'link'}
className={classNames({ 'btn-block': isButton, 'btn-sm sorting-dropdown__paddingless': !isButton })}
className={classNames({ 'btn-block': isButton, 'btn-sm paddingless': !isButton })}
>
Order by
</DropdownToggle>
Expand Down
4 changes: 0 additions & 4 deletions src/utils/SortingDropdown.scss
Expand Up @@ -10,7 +10,3 @@
margin: 3.5px 0 0;
float: right;
}

.sorting-dropdown__paddingless.sorting-dropdown__paddingless {
padding: 0;
}
8 changes: 2 additions & 6 deletions src/utils/services/ColorGenerator.js
@@ -1,15 +1,11 @@
import { range } from 'ramda';
import PropTypes from 'prop-types';
import { rangeOf } from '../utils';

const HEX_COLOR_LENGTH = 6;
const { floor, random } = Math;
const letters = '0123456789ABCDEF';
const buildRandomColor = () =>
`#${
range(0, HEX_COLOR_LENGTH)
.map(() => letters[floor(random() * letters.length)])
.join('')
}`;
`#${rangeOf(HEX_COLOR_LENGTH, () => letters[floor(random() * letters.length)]).join('')}`;
const normalizeKey = (key) => key.toLowerCase().trim();

export default class ColorGenerator {
Expand Down
7 changes: 7 additions & 0 deletions src/utils/utils.js
Expand Up @@ -2,8 +2,11 @@ import L from 'leaflet';
import marker2x from 'leaflet/dist/images/marker-icon-2x.png';
import marker from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
import { range } from 'ramda';

const TEN_ROUNDING_NUMBER = 10;
const DEFAULT_TIMEOUT_DELAY = 2000;
const { ceil } = Math;

export const stateFlagTimeout = (setTimeout) => (
setState,
Expand Down Expand Up @@ -37,3 +40,7 @@ export const fixLeafletIcons = () => {
shadowUrl: markerShadow,
});
};

export const rangeOf = (size, mappingFn, startAt = 1) => range(startAt, size + 1).map(mappingFn);

export const roundTen = (number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;
65 changes: 22 additions & 43 deletions src/visits/GraphCard.js
@@ -1,18 +1,16 @@
import { Card, CardHeader, CardBody } from 'reactstrap';
import { Card, CardHeader, CardBody, CardFooter } from 'reactstrap';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import PropTypes from 'prop-types';
import React from 'react';
import { keys, values } from 'ramda';
import './GraphCard.scss';

const propTypes = {
title: PropTypes.string,
children: PropTypes.node,
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]),
footer: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
isBarChart: PropTypes.bool,
stats: PropTypes.object,
matchMedia: PropTypes.func,
};
const defaultProps = {
matchMedia: global.window ? global.window.matchMedia : () => {},
max: PropTypes.number,
};

const generateGraphData = (title, isBarChart, labels, data) => ({
Expand All @@ -36,62 +34,43 @@ const generateGraphData = (title, isBarChart, labels, data) => ({
],
});

const determineGraphAspectRatio = (barsCount, isBarChart, matchMedia) => {
const determineAspectRationModifier = () => {
switch (true) {
case matchMedia('(max-width: 1200px)').matches:
return 1.5; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 992px)').matches:
return 1.75; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 768px)').matches:
return 2; // eslint-disable-line no-magic-numbers
case matchMedia('(max-width: 576px)').matches:
return 2.25; // eslint-disable-line no-magic-numbers
default:
return 1;
}
};

const MAX_BARS_WITHOUT_HEIGHT = 20;
const DEFAULT_ASPECT_RATION = 2;
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;

return shouldCalculateAspectRatio
? MAX_BARS_WITHOUT_HEIGHT / determineAspectRationModifier() * DEFAULT_ASPECT_RATION / barsCount
: DEFAULT_ASPECT_RATION;
};

const renderGraph = (title, isBarChart, stats, matchMedia) => {
const renderGraph = (title, isBarChart, stats, max) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const labels = keys(stats);
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(stats);
const aspectRatio = determineGraphAspectRatio(labels.length, isBarChart, matchMedia);
const options = {
aspectRatio,
legend: isBarChart ? { display: false } : { position: 'right' },
scales: isBarChart ? {
scales: isBarChart && {
xAxes: [
{
ticks: { beginAtZero: true },
ticks: { beginAtZero: true, max },
},
],
} : null,
},
tooltips: {
intersect: !isBarChart,

// Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
},
};
const graphData = generateGraphData(title, isBarChart, labels, data);
const height = isBarChart && labels.length > 20 ? labels.length * 8 : null;

return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={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} />;
};

const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{children || title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max)}</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>
);

GraphCard.propTypes = propTypes;
GraphCard.defaultProps = defaultProps;

export default GraphCard;
4 changes: 4 additions & 0 deletions src/visits/GraphCard.scss
@@ -0,0 +1,4 @@
.graph-card__footer--sticky {
position: sticky;
bottom: 0;
}
12 changes: 8 additions & 4 deletions src/visits/ShortUrlVisits.js
Expand Up @@ -12,9 +12,11 @@ import VisitsHeader from './VisitsHeader';
import GraphCard from './GraphCard';
import { shortUrlDetailType } from './reducers/shortUrlDetail';
import './ShortUrlVisits.scss';
import OpenMapModalBtn from './helpers/OpenMapModalBtn';

const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits extends React.PureComponent {
const ShortUrlVisits = (
{ processStatsFromVisits },
OpenMapModalBtn
) => class ShortUrlVisits extends React.PureComponent {
static propTypes = {
match: PropTypes.shape({
params: PropTypes.object,
Expand Down Expand Up @@ -94,6 +96,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte
<div className="col-xl-4">
<SortableBarGraph
stats={referrers}
withPagination={false}
title="Referrers"
sortingItems={{
name: 'Referrer name',
Expand All @@ -115,8 +118,9 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte
<SortableBarGraph
stats={cities}
title="Cities"
extraHeaderContent={
[ () => mapLocations.length > 0 && <OpenMapModalBtn modalTitle="Cities" locations={mapLocations} /> ]
extraHeaderContent={(activeCities) =>
mapLocations.length > 0 &&
<OpenMapModalBtn modalTitle="Cities" locations={mapLocations} activeCities={activeCities} />
}
sortingItems={{
name: 'City name',
Expand Down

0 comments on commit 2ba8676

Please sign in to comment.