From 61480abd2ecbaddb5e7765d50efae8144cfdf13e Mon Sep 17 00:00:00 2001 From: Alejandro Celaya <alejandro@alejandrocelaya.com> Date: Sun, 10 Mar 2019 08:28:14 +0100 Subject: [PATCH] Updated charts to allow optional pagination --- .eslintrc | 1 + package.json | 4 +- src/utils/utils.js | 4 + src/visits/GraphCard.js | 60 +++------ src/visits/ShortUrlVisits.js | 7 +- src/visits/SortableBarGraph.js | 126 +++++++++++++++---- test/short-urls/helpers/ShortUrlsRow.test.js | 11 +- test/utils/utils.test.js | 18 +++ test/visits/SortableBarGraph.test.js | 19 ++- yarn.lock | 26 ++-- 10 files changed, 180 insertions(+), 96 deletions(-) diff --git a/.eslintrc b/.eslintrc index 7ac3f4ec..b14ea6b0 100644 --- a/.eslintrc +++ b/.eslintrc @@ -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 diff --git a/package.json b/package.json index e9c6f15e..457521b4 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/utils/utils.js b/src/utils/utils.js index 60f33c16..a49cc604 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -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; diff --git a/src/visits/GraphCard.js b/src/visits/GraphCard.js index 2cbbda75..44facee7 100644 --- a/src/visits/GraphCard.js +++ b/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'; 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; diff --git a/src/visits/ShortUrlVisits.js b/src/visits/ShortUrlVisits.js index 6c2f9bf8..0d880d9f 100644 --- a/src/visits/ShortUrlVisits.js +++ b/src/visits/ShortUrlVisits.js @@ -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', diff --git a/src/visits/SortableBarGraph.js b/src/visits/SortableBarGraph.js index 1a30218b..9e03a6a9 100644 --- a/src/visits/SortableBarGraph.js +++ b/src/visits/SortableBarGraph.js @@ -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()} + /> ); } } diff --git a/test/short-urls/helpers/ShortUrlsRow.test.js b/test/short-urls/helpers/ShortUrlsRow.test.js index d8978788..48c32b5e 100644 --- a/test/short-urls/helpers/ShortUrlsRow.test.js +++ b/test/short-urls/helpers/ShortUrlsRow.test.js @@ -53,7 +53,7 @@ describe('<ShortUrlsRow />', () => { }); it('renders long URL in third row', () => { - const col = wrapper.find('td').at(2); // eslint-disable-line no-magic-numbers + const col = wrapper.find('td').at(2); const link = col.find(ExternalLink); expect(link.prop('href')).toEqual(shortUrl.longUrl); @@ -61,7 +61,7 @@ describe('<ShortUrlsRow />', () => { describe('renders list of tags in fourth row', () => { it('with tags', () => { - const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers + const col = wrapper.find('td').at(3); const tags = col.find(Tag); expect(tags).toHaveLength(shortUrl.tags.length); @@ -75,20 +75,20 @@ describe('<ShortUrlsRow />', () => { it('without tags', () => { wrapper.setProps({ shortUrl: assoc('tags', [], shortUrl) }); - const col = wrapper.find('td').at(3); // eslint-disable-line no-magic-numbers + const col = wrapper.find('td').at(3); expect(col.text()).toContain('No tags'); }); }); it('renders visits count in fifth row', () => { - const col = wrapper.find('td').at(4); // eslint-disable-line no-magic-numbers + const col = wrapper.find('td').at(4); expect(col.text()).toEqual(toString(shortUrl.visitsCount)); }); it('updates state when copied to clipboard', () => { - const col = wrapper.find('td').at(5); // eslint-disable-line no-magic-numbers + const col = wrapper.find('td').at(5); const menu = col.find(ShortUrlsRowMenu); expect(menu).toHaveLength(1); @@ -98,7 +98,6 @@ describe('<ShortUrlsRow />', () => { }); it('shows copy hint when state prop is true', () => { - // eslint-disable-next-line no-magic-numbers const isHidden = () => wrapper.find('td').at(5).find('.short-urls-row__copy-hint').prop('hidden'); expect(isHidden()).toEqual(true); diff --git a/test/utils/utils.test.js b/test/utils/utils.test.js index e4c47a90..3a9c507a 100644 --- a/test/utils/utils.test.js +++ b/test/utils/utils.test.js @@ -8,6 +8,7 @@ import { determineOrderDir, fixLeafletIcons, rangeOf, + roundTen, } from '../../src/utils/utils'; describe('utils', () => { @@ -87,4 +88,21 @@ describe('utils', () => { ]); }); }); + + describe('roundTen', () => { + it('rounds provided number to the next multiple of ten', () => { + const expectationsPairs = [ + [ 10, 10 ], + [ 12, 20 ], + [ 158, 160 ], + [ 5, 10 ], + [ -42, -40 ], + ]; + + expect.assertions(expectationsPairs.length); + expectationsPairs.forEach(([ number, expected ]) => { + expect(roundTen(number)).toEqual(expected); + }); + }); + }); }); diff --git a/test/visits/SortableBarGraph.test.js b/test/visits/SortableBarGraph.test.js index 3dbcc337..02090ce7 100644 --- a/test/visits/SortableBarGraph.test.js +++ b/test/visits/SortableBarGraph.test.js @@ -15,7 +15,7 @@ describe('<SortableBarGraph />', () => { Foo: 100, Bar: 50, }; - const createWrapper = (extraHeaderContent = []) => { + const createWrapper = (extraHeaderContent) => { wrapper = shallow( <SortableBarGraph title="Foo" stats={stats} sortingItems={sortingItems} extraHeaderContent={extraHeaderContent} /> ); @@ -53,24 +53,19 @@ describe('<SortableBarGraph />', () => { }; }); - // eslint-disable-next-line no-magic-numbers it('name - ASC', (done) => assert('name', 'ASC', [ 'Bar', 'Foo' ], [ 50, 100 ], done)); - - // eslint-disable-next-line no-magic-numbers it('name - DESC', (done) => assert('name', 'DESC', [ 'Foo', 'Bar' ], [ 100, 50 ], done)); - - // eslint-disable-next-line no-magic-numbers it('value - ASC', (done) => assert('value', 'ASC', [ 'Bar', 'Foo' ], [ 50, 100 ], done)); - - // eslint-disable-next-line no-magic-numbers it('value - DESC', (done) => assert('value', 'DESC', [ 'Foo', 'Bar' ], [ 100, 50 ], done)); }); it('renders extra header functions', () => { - const wrapper = createWrapper([ - () => <span className="foo-span">Foo</span>, - () => <span className="bar-span">Bar</span>, - ]); + const wrapper = createWrapper(( + <React.Fragment> + <span className="foo-span">Foo</span> + <span className="bar-span">Bar</span> + </React.Fragment> + )); expect(wrapper.find('.foo-span')).toHaveLength(1); expect(wrapper.find('.bar-span')).toHaveLength(1); diff --git a/yarn.lock b/yarn.lock index 491235a6..44d72b70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8064,14 +8064,15 @@ react-dev-utils@^7.0.1: strip-ansi "4.0.0" text-table "0.2.0" -react-dom@^16.7.0: - version "16.7.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.7.0.tgz#a17b2a7ca89ee7390bc1ed5eb81783c7461748b8" +react-dom@^16.8.0: + version "16.8.4" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.8.4.tgz#1061a8e01a2b3b0c8160037441c3bf00a0e3bc48" + integrity sha512-Ob2wK7XG2tUDt7ps7LtLzGYYB6DXMCLj0G5fO6WeEICtT4/HdpOi7W/xLzZnR6RCG1tYza60nMdqtxzA8FaPJQ== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.12.0" + scheduler "^0.13.4" react-error-overlay@^5.1.2: version "5.1.2" @@ -8187,14 +8188,15 @@ react-transition-group@^2.3.1: prop-types "^15.6.2" react-lifecycles-compat "^3.0.4" -react@^16.7.0: - version "16.7.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.7.0.tgz#b674ec396b0a5715873b350446f7ea0802ab6381" +react@^16.8.0: + version "16.8.4" + resolved "https://registry.yarnpkg.com/react/-/react-16.8.4.tgz#fdf7bd9ae53f03a9c4cd1a371432c206be1c4768" + integrity sha512-0GQ6gFXfUH7aZcjGVymlPOASTuSjlQL4ZtVC5YKH+3JL6bBLCVO21DknzmaPlI90LN253ojj02nsapy+j7wIjg== dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - scheduler "^0.12.0" + scheduler "^0.13.4" reactcss@^1.2.0: version "1.2.3" @@ -8731,6 +8733,14 @@ scheduler@^0.12.0: loose-envify "^1.1.0" object-assign "^4.1.1" +scheduler@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.13.4.tgz#8fef05e7a3580c76c0364d2df5e550e4c9140298" + integrity sha512-cvSOlRPxOHs5dAhP9yiS/6IDmVAVxmk33f0CtTJRkmUWcb1Us+t7b1wqdzoC0REw2muC9V5f1L/w5R5uKGaepA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + schema-utils@^0.4.4: version "0.4.7" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-0.4.7.tgz#ba74f597d2be2ea880131746ee17d0a093c68187"