Updated charts to allow optional pagination

This commit is contained in:
Alejandro Celaya
2019-03-10 08:28:14 +01:00
parent c094a27c97
commit 61480abd2e
10 changed files with 180 additions and 96 deletions

View File

@@ -4,7 +4,9 @@ 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,
@@ -40,3 +42,5 @@ export const fixLeafletIcons = () => {
};
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;

View File

@@ -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';
const propTypes = {
title: PropTypes.string,
children: PropTypes.node,
title: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]),
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,
redraw: PropTypes.bool,
};
const generateGraphData = (title, isBarChart, labels, data) => ({
@@ -36,62 +34,42 @@ 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 dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label;
const MAX_BARS_WITHOUT_HEIGHT = 20;
const DEFAULT_ASPECT_RATION = 2;
const shouldCalculateAspectRatio = isBarChart && barsCount > MAX_BARS_WITHOUT_HEIGHT;
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, redraw) => {
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 ? {
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 = labels.length < 20 ? null : labels.length * 8;
return <Component data={generateGraphData(title, isBarChart, labels, data)} options={options} height={null} />;
return <Component data={graphData} options={options} height={height} redraw={redraw} />;
};
const GraphCard = ({ title, children, isBarChart, stats, matchMedia }) => (
const GraphCard = ({ title, footer, isBarChart, stats, max, redraw = false }) => (
<Card className="mt-4">
<CardHeader className="graph-card__header">{children || title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, matchMedia)}</CardBody>
<CardHeader className="graph-card__header">{title}</CardHeader>
<CardBody>{renderGraph(title, isBarChart, stats, max, redraw)}</CardBody>
{footer && <CardFooter>{footer}</CardFooter>}
</Card>
);
GraphCard.propTypes = propTypes;
GraphCard.defaultProps = defaultProps;
export default GraphCard;

View File

@@ -94,6 +94,7 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte
<div className="col-xl-4">
<SortableBarGraph
stats={referrers}
supportPagination={false}
title="Referrers"
sortingItems={{
name: 'Referrer name',
@@ -115,9 +116,9 @@ const ShortUrlVisits = ({ processStatsFromVisits }) => class ShortUrlVisits exte
<SortableBarGraph
stats={cities}
title="Cities"
extraHeaderContent={
[ () => mapLocations.length > 0 && <OpenMapModalBtn modalTitle="Cities" locations={mapLocations} /> ]
}
extraHeaderContent={(
mapLocations.length > 0 && <OpenMapModalBtn modalTitle="Cities" locations={mapLocations} />
)}
sortingItems={{
name: 'City name',
amount: 'Visits amount',

View File

@@ -1,61 +1,139 @@
import React from 'react';
import PropTypes from 'prop-types';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, toLower, toPairs, type } from 'ramda';
import { fromPairs, head, keys, pipe, prop, reverse, sortBy, splitEvery, toLower, toPairs, type } from 'ramda';
import { Pagination, PaginationItem, PaginationLink } from 'reactstrap';
import SortingDropdown from '../utils/SortingDropdown';
import PaginationDropdown from '../utils/PaginationDropdown';
import { rangeOf, roundTen } from '../utils/utils';
import GraphCard from './GraphCard';
const { max } = Math;
const toLowerIfString = (value) => type(value) === 'String' ? toLower(value) : value;
const pickValueFromPair = ([ , value ]) => value;
export default class SortableBarGraph extends React.Component {
static propTypes = {
stats: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
sortingItems: PropTypes.object.isRequired,
extraHeaderContent: PropTypes.arrayOf(PropTypes.func),
extraHeaderContent: PropTypes.node,
supportPagination: PropTypes.bool,
};
state = {
orderField: undefined,
orderDir: undefined,
currentPage: 1,
itemsPerPage: Infinity,
};
redraw = false;
render() {
const { stats, sortingItems, title, extraHeaderContent } = this.props;
const sortStats = () => {
if (!this.state.orderField) {
return stats;
}
doRedraw() {
const prev = this.redraw;
const sortedPairs = sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
toPairs(stats)
);
this.redraw = false;
return fromPairs(this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs));
return prev;
}
determineStats(stats, sortingItems) {
const sortedPairs = !this.state.orderField ? toPairs(stats) : sortBy(
pipe(
prop(this.state.orderField === head(keys(sortingItems)) ? 0 : 1),
toLowerIfString
),
toPairs(stats)
);
const directionalPairs = !this.state.orderDir || this.state.orderDir === 'ASC' ? sortedPairs : reverse(sortedPairs);
if (directionalPairs.length <= this.state.itemsPerPage) {
return { currentPageStats: fromPairs(directionalPairs) };
}
const pages = splitEvery(this.state.itemsPerPage, directionalPairs);
return {
currentPageStats: fromPairs(this.determineCurrentPagePairs(pages)),
pagination: this.renderPagination(pages.length),
max: roundTen(max(...directionalPairs.map(pickValueFromPair))),
};
}
determineCurrentPagePairs(pages) {
const page = pages[this.state.currentPage - 1];
if (this.state.currentPage < pages.length) {
return page;
}
const firstPageLength = pages[0].length;
// Using the "hidden" key, the chart will just replace the label by an empty string
return [ ...page, ...rangeOf(firstPageLength - page.length, (i) => [ `hidden_${i}`, 0 ]) ];
}
renderPagination(pagesCount) {
const { currentPage } = this.state;
return (
<GraphCard stats={sortStats()} isBarChart>
<Pagination listClassName="flex-wrap mb-0">
<PaginationItem disabled={currentPage === 1}>
<PaginationLink previous tag="span" onClick={() => this.setState({ currentPage: currentPage - 1 })} />
</PaginationItem>
{rangeOf(pagesCount, (page) => (
<PaginationItem key={page} active={page === currentPage}>
<PaginationLink tag="span" onClick={() => this.setState({ currentPage: page })}>{page}</PaginationLink>
</PaginationItem>
))}
<PaginationItem disabled={currentPage >= pagesCount}>
<PaginationLink next tag="span" onClick={() => this.setState({ currentPage: currentPage + 1 })} />
</PaginationItem>
</Pagination>
);
}
render() {
const { stats, sortingItems, title, extraHeaderContent, supportPagination = true } = this.props;
const computedTitle = (
<React.Fragment>
{title}
<div className="float-right">
<SortingDropdown
isButton={false}
right
items={sortingItems}
orderField={this.state.orderField}
orderDir={this.state.orderDir}
items={sortingItems}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir })}
onChange={(orderField, orderDir) => this.setState({ orderField, orderDir, currentPage: 1 })}
/>
</div>
{extraHeaderContent && extraHeaderContent.map((content, index) => (
<div key={index} className="float-right">
{content()}
{supportPagination && (
<div className="float-right">
<PaginationDropdown
toggleClassName="btn-sm paddingless mr-3"
ranges={[ 50, 100, 200, 500 ]}
value={this.state.itemsPerPage}
setValue={(itemsPerPage) => {
this.redraw = true;
this.setState({ itemsPerPage, currentPage: 1 });
}}
/>
</div>
))}
</GraphCard>
)}
{extraHeaderContent && <div className="float-right">{extraHeaderContent}</div>}
</React.Fragment>
);
const { currentPageStats, pagination, max } = this.determineStats(stats, sortingItems);
return (
<GraphCard
isBarChart
title={computedTitle}
stats={currentPageStats}
footer={pagination}
max={max}
redraw={this.doRedraw()}
/>
);
}
}