From 770cc59448dba615587fbf715de40a059aad3364 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Jun 2020 10:35:13 +0200 Subject: [PATCH 1/5] Extracted logic to render graph from GraphCard to DefatlChart component --- src/visits/helpers/DefaultChart.js | 121 ++++++++++++++++++ src/visits/helpers/GraphCard.js | 112 +--------------- .../{GraphCard.test.js => DefaultChart.js} | 10 +- 3 files changed, 131 insertions(+), 112 deletions(-) create mode 100644 src/visits/helpers/DefaultChart.js rename test/visits/helpers/{GraphCard.test.js => DefaultChart.js} (87%) diff --git a/src/visits/helpers/DefaultChart.js b/src/visits/helpers/DefaultChart.js new file mode 100644 index 00000000..40fbc0da --- /dev/null +++ b/src/visits/helpers/DefaultChart.js @@ -0,0 +1,121 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Doughnut, HorizontalBar } from 'react-chartjs-2'; +import { keys, values } from 'ramda'; +import { fillTheGaps } from '../../utils/helpers/visits'; + +const propTypes = { + title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), + isBarChart: PropTypes.bool, + stats: PropTypes.object, + max: PropTypes.number, + highlightedStats: PropTypes.object, + highlightedLabel: PropTypes.string, + onClick: PropTypes.func, +}; + +const generateGraphData = (title, isBarChart, labels, data, highlightedData, highlightedLabel) => ({ + labels, + datasets: [ + { + title, + label: highlightedData ? 'Non-selected' : 'Visits', + data, + backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ + '#97BBCD', + '#F7464A', + '#46BFBD', + '#FDB45C', + '#949FB1', + '#57A773', + '#414066', + '#08B2E3', + '#B6C454', + '#DCDCDC', + '#463730', + ], + borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', + borderWidth: 2, + }, + highlightedData && { + title, + label: highlightedLabel || 'Selected', + data: highlightedData, + backgroundColor: 'rgba(247, 127, 40, 0.4)', + borderColor: '#F77F28', + borderWidth: 2, + }, + ].filter(Boolean), +}); + +const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label; + +const determineHeight = (isBarChart, labels) => { + if (!isBarChart && labels.length > 8) { + return 200; + } + + return isBarChart && labels.length > 20 ? labels.length * 8 : null; +}; + +const DefaultChart = ({ title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => { + const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0; + const Component = isBarChart ? HorizontalBar : Doughnut; + const labels = keys(stats).map(dropLabelIfHidden); + const data = values(!hasHighlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { + if (acc[highlightedKey]) { + acc[highlightedKey] -= highlightedStats[highlightedKey]; + } + + return acc; + }, { ...stats })); + const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels); + + const options = { + legend: isBarChart ? { display: false } : { position: 'right' }, + scales: isBarChart && { + xAxes: [ + { + ticks: { beginAtZero: true, precision: 0, max }, + stacked: true, + }, + ], + yAxes: [{ stacked: true }], + }, + tooltips: { + intersect: !isBarChart, + + // 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, highlightedLabel); + const height = determineHeight(isBarChart, labels); + + // Provide a key based on the height, so that every time the dataset changes, a new graph is rendered + return ( + { + if (!onClick || !chart) { + return; + } + + const { _index, _chart: { data } } = chart; + const { labels } = data; + + onClick(labels[_index]); + }} + /> + ); +}; + +DefaultChart.propTypes = propTypes; + +export default DefaultChart; diff --git a/src/visits/helpers/GraphCard.js b/src/visits/helpers/GraphCard.js index 155a3d9d..5fc23d3f 100644 --- a/src/visits/helpers/GraphCard.js +++ b/src/visits/helpers/GraphCard.js @@ -1,9 +1,7 @@ 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 { fillTheGaps } from '../../utils/helpers/visits'; +import DefaultChart from './DefaultChart'; import './GraphCard.scss'; const propTypes = { @@ -17,112 +15,12 @@ const propTypes = { onClick: PropTypes.func, }; -const generateGraphData = (title, isBarChart, labels, data, highlightedData, highlightedLabel) => ({ - labels, - datasets: [ - { - title, - label: highlightedData ? 'Non-selected' : 'Visits', - data, - backgroundColor: isBarChart ? 'rgba(70, 150, 229, 0.4)' : [ - '#97BBCD', - '#F7464A', - '#46BFBD', - '#FDB45C', - '#949FB1', - '#57A773', - '#414066', - '#08B2E3', - '#B6C454', - '#DCDCDC', - '#463730', - ], - borderColor: isBarChart ? 'rgba(70, 150, 229, 1)' : 'white', - borderWidth: 2, - }, - highlightedData && { - title, - label: highlightedLabel || 'Selected', - data: highlightedData, - backgroundColor: 'rgba(247, 127, 40, 0.4)', - borderColor: '#F77F28', - borderWidth: 2, - }, - ].filter(Boolean), -}); - -const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label; - -const determineHeight = (isBarChart, labels) => { - if (!isBarChart && labels.length > 8) { - return 200; - } - - return isBarChart && labels.length > 20 ? labels.length * 8 : null; -}; - -const renderGraph = (title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick) => { - const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0; - const Component = isBarChart ? HorizontalBar : Doughnut; - const labels = keys(stats).map(dropLabelIfHidden); - const data = values(!hasHighlightedStats ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { - if (acc[highlightedKey]) { - acc[highlightedKey] -= highlightedStats[highlightedKey]; - } - - return acc; - }, { ...stats })); - const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels); - - const options = { - legend: isBarChart ? { display: false } : { position: 'right' }, - scales: isBarChart && { - xAxes: [ - { - ticks: { beginAtZero: true, precision: 0, max }, - stacked: true, - }, - ], - yAxes: [{ stacked: true }], - }, - tooltips: { - intersect: !isBarChart, - - // 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, highlightedLabel); - const height = determineHeight(isBarChart, labels); - - // Provide a key based on the height, so that every time the dataset changes, a new graph is rendered - return ( - { - if (!onClick || !chart) { - return; - } - - const { _index, _chart: { data } } = chart; - const { labels } = data; - - onClick(labels[_index]); - }} - /> - ); -}; - -const GraphCard = ({ title, footer, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => ( +const GraphCard = ({ title, footer, ...rest }) => ( {typeof title === 'function' ? title() : title} - {renderGraph(title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick)} + + + {footer && {footer}} ); diff --git a/test/visits/helpers/GraphCard.test.js b/test/visits/helpers/DefaultChart.js similarity index 87% rename from test/visits/helpers/GraphCard.test.js rename to test/visits/helpers/DefaultChart.js index b75ce941..7bff35e9 100644 --- a/test/visits/helpers/GraphCard.test.js +++ b/test/visits/helpers/DefaultChart.js @@ -2,9 +2,9 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { keys, values } from 'ramda'; -import GraphCard from '../../../src/visits/helpers/GraphCard'; +import DefaultChart from '../../../src/visits/helpers/DefaultChart'; -describe('', () => { +describe('', () => { let wrapper; const stats = { foo: 123, @@ -14,7 +14,7 @@ describe('', () => { afterEach(() => wrapper && wrapper.unmount()); it('renders Doughnut when is not a bar chart', () => { - wrapper = shallow(); + wrapper = shallow(); const doughnut = wrapper.find(Doughnut); const horizontal = wrapper.find(HorizontalBar); @@ -48,7 +48,7 @@ describe('', () => { }); it('renders HorizontalBar when is not a bar chart', () => { - wrapper = shallow(); + wrapper = shallow(); const doughnut = wrapper.find(Doughnut); const horizontal = wrapper.find(HorizontalBar); @@ -79,7 +79,7 @@ describe('', () => { [{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]], [ undefined, [ 123, 456 ], undefined ], ])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => { - wrapper = shallow(); + wrapper = shallow(); const horizontal = wrapper.find(HorizontalBar); const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data'); From 949e0da10517a5a4438b94473b5b2128f835beb2 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Jun 2020 11:58:25 +0200 Subject: [PATCH 2/5] Added custom responsive legend to doughnut charts --- src/visits/helpers/DefaultChart.js | 74 ++++++++++++++----- src/visits/helpers/DefaultChart.scss | 29 ++++++++ .../{DefaultChart.js => DefaultChart.test.js} | 12 ++- 3 files changed, 94 insertions(+), 21 deletions(-) create mode 100644 src/visits/helpers/DefaultChart.scss rename test/visits/helpers/{DefaultChart.js => DefaultChart.test.js} (86%) diff --git a/src/visits/helpers/DefaultChart.js b/src/visits/helpers/DefaultChart.js index 40fbc0da..3fbf150c 100644 --- a/src/visits/helpers/DefaultChart.js +++ b/src/visits/helpers/DefaultChart.js @@ -1,8 +1,10 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { keys, values } from 'ramda'; +import classNames from 'classnames'; import { fillTheGaps } from '../../utils/helpers/visits'; +import './DefaultChart.scss'; const propTypes = { title: PropTypes.oneOfType([ PropTypes.string, PropTypes.func ]), @@ -58,6 +60,39 @@ const determineHeight = (isBarChart, labels) => { return isBarChart && labels.length > 20 ? labels.length * 8 : null; }; +/* eslint-disable react/prop-types */ +const renderPieChartLegend = ({ config }) => { + const { labels, datasets } = config.data; + const { defaultColor } = config.options; + const [{ backgroundColor: colors }] = datasets; + + return ( +
    + {labels.map((label, index) => ( +
  • +
    + {label} +
  • + ))} +
+ ); +}; +/* eslint-enable react/prop-types */ + +const chartElementAtEvent = (onClick) => ([ chart ]) => { + if (!onClick || !chart) { + return; + } + + const { _index, _chart: { data } } = chart; + const { labels } = data; + + onClick(labels[_index]); +}; + const DefaultChart = ({ title, isBarChart, stats, max, highlightedStats, highlightedLabel, onClick }) => { const hasHighlightedStats = highlightedStats && Object.keys(highlightedStats).length > 0; const Component = isBarChart ? HorizontalBar : Doughnut; @@ -70,9 +105,11 @@ const DefaultChart = ({ title, isBarChart, stats, max, highlightedStats, highlig return acc; }, { ...stats })); const highlightedData = hasHighlightedStats && fillTheGaps(highlightedStats, labels); + const chartRef = useRef(); const options = { - legend: isBarChart ? { display: false } : { position: 'right' }, + legend: { display: false }, + legendCallback: !isBarChart && renderPieChartLegend, scales: isBarChart && { xAxes: [ { @@ -97,22 +134,23 @@ const DefaultChart = ({ title, isBarChart, stats, max, highlightedStats, highlig // Provide a key based on the height, so that every time the dataset changes, a new graph is rendered return ( - { - if (!onClick || !chart) { - return; - } - - const { _index, _chart: { data } } = chart; - const { labels } = data; - - onClick(labels[_index]); - }} - /> +
+
+ +
+ {!isBarChart && ( +
+ {chartRef.current && chartRef.current.chartInstance.generateLegend()} +
+ )} +
); }; diff --git a/src/visits/helpers/DefaultChart.scss b/src/visits/helpers/DefaultChart.scss new file mode 100644 index 00000000..e7a0bd9f --- /dev/null +++ b/src/visits/helpers/DefaultChart.scss @@ -0,0 +1,29 @@ +@import '../../utils/base'; + +.default-chart__pie-chart-legend { + list-style-type: none; + padding: 0; + margin: 0; + + @media (max-width: $smMax) { + margin-top: 1rem; + } +} + +.default-chart__pie-chart-legend-item:not(:first-child) { + margin-top: .3rem; +} + +.default-chart__pie-chart-legend-item-color { + width: 20px; + min-width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 10px; +} + +.default-chart__pie-chart-legend-item-text { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/test/visits/helpers/DefaultChart.js b/test/visits/helpers/DefaultChart.test.js similarity index 86% rename from test/visits/helpers/DefaultChart.js rename to test/visits/helpers/DefaultChart.test.js index 7bff35e9..387fd0ed 100644 --- a/test/visits/helpers/DefaultChart.js +++ b/test/visits/helpers/DefaultChart.test.js @@ -17,13 +17,14 @@ describe('', () => { wrapper = shallow(); const doughnut = wrapper.find(Doughnut); const horizontal = wrapper.find(HorizontalBar); + const cols = wrapper.find('.col-sm-12'); expect(doughnut).toHaveLength(1); expect(horizontal).toHaveLength(0); const { labels, datasets } = doughnut.prop('data'); const [{ title, data, backgroundColor, borderColor }] = datasets; - const { legend, scales } = doughnut.prop('options'); + const { legend, legendCallback, scales } = doughnut.prop('options'); expect(title).toEqual('The chart'); expect(labels).toEqual(keys(stats)); @@ -43,24 +44,28 @@ describe('', () => { '#463730', ]); expect(borderColor).toEqual('white'); - expect(legend).toEqual({ position: 'right' }); + expect(legend).toEqual({ display: false }); + expect(typeof legendCallback).toEqual('function'); expect(scales).toBeUndefined(); + expect(cols).toHaveLength(2); }); it('renders HorizontalBar when is not a bar chart', () => { wrapper = shallow(); const doughnut = wrapper.find(Doughnut); const horizontal = wrapper.find(HorizontalBar); + const cols = wrapper.find('.col-sm-12'); expect(doughnut).toHaveLength(0); expect(horizontal).toHaveLength(1); const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data'); - const { legend, scales } = horizontal.prop('options'); + const { legend, legendCallback, scales } = horizontal.prop('options'); expect(backgroundColor).toEqual('rgba(70, 150, 229, 0.4)'); expect(borderColor).toEqual('rgba(70, 150, 229, 1)'); expect(legend).toEqual({ display: false }); + expect(legendCallback).toEqual(false); expect(scales).toEqual({ xAxes: [ { @@ -70,6 +75,7 @@ describe('', () => { ], yAxes: [{ stacked: true }], }); + expect(cols).toHaveLength(1); }); it.each([ From cb761dea8fe6a610c917bd6d1af3dd9b2e5e2b18 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Jun 2020 12:08:21 +0200 Subject: [PATCH 3/5] Increased default height for doughnut charts --- src/visits/helpers/DefaultChart.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/visits/helpers/DefaultChart.js b/src/visits/helpers/DefaultChart.js index 3fbf150c..7d762210 100644 --- a/src/visits/helpers/DefaultChart.js +++ b/src/visits/helpers/DefaultChart.js @@ -53,8 +53,8 @@ const generateGraphData = (title, isBarChart, labels, data, highlightedData, hig const dropLabelIfHidden = (label) => label.startsWith('hidden') ? '' : label; const determineHeight = (isBarChart, labels) => { - if (!isBarChart && labels.length > 8) { - return 200; + if (!isBarChart) { + return 300; } return isBarChart && labels.length > 20 ? labels.length * 8 : null; From e31e70039d874efb5fa43e5ddd90e7dceaad5aa6 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Jun 2020 12:16:19 +0200 Subject: [PATCH 4/5] Created GraphCard test --- test/visits/helpers/GraphCard.test.js | 49 +++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/visits/helpers/GraphCard.test.js diff --git a/test/visits/helpers/GraphCard.test.js b/test/visits/helpers/GraphCard.test.js new file mode 100644 index 00000000..0c0baeb9 --- /dev/null +++ b/test/visits/helpers/GraphCard.test.js @@ -0,0 +1,49 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Card, CardBody, CardHeader, CardFooter } from 'reactstrap'; +import GraphCard from '../../../src/visits/helpers/GraphCard'; +import DefaultChart from '../../../src/visits/helpers/DefaultChart'; + +describe('', () => { + let wrapper; + const createWrapper = (title = '', footer) => { + wrapper = shallow(); + + return wrapper; + }; + + afterEach(() => wrapper && wrapper.unmount()); + + it('renders expected components', () => { + const wrapper = createWrapper(); + const card = wrapper.find(Card); + const header = wrapper.find(CardHeader); + const body = wrapper.find(CardBody); + const chart = wrapper.find(DefaultChart); + const footer = wrapper.find(CardFooter); + + expect(card).toHaveLength(1); + expect(header).toHaveLength(1); + expect(body).toHaveLength(1); + expect(chart).toHaveLength(1); + expect(footer).toHaveLength(0); + }); + + it.each([ + [ 'the title', 'the title' ], + [ () => 'the title from func', 'the title from func' ], + ])('properly renders title by parsing provided value', (title, expectedTitle) => { + const wrapper = createWrapper(title); + const header = wrapper.find(CardHeader); + + expect(header.html()).toContain(expectedTitle); + }); + + it('renders footer only when provided', () => { + const wrapper = createWrapper('', 'the footer'); + const footer = wrapper.find(CardFooter); + + expect(footer).toHaveLength(1); + expect(footer.html()).toContain('the footer'); + }); +}); From fa54aa31288e879f78c1f1f09df6ec93dc9fb8e5 Mon Sep 17 00:00:00 2001 From: Alejandro Celaya Date: Sat, 6 Jun 2020 12:17:45 +0200 Subject: [PATCH 5/5] Updated changelog --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5beec3..d7f654c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.5.1 - 2020-06-06 #### Added @@ -26,6 +26,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), * [#276](https://github.com/shlinkio/shlink-web-client/issues/276) Fixed default grouping used for visits line chart, making it be dynamic depending on how old the short URL is. * [#280](https://github.com/shlinkio/shlink-web-client/issues/280) Fixed shlink-web-client version not being properly passed when building stable tags of the docker image. +* [#269](https://github.com/shlinkio/shlink-web-client/issues/269) Fixed doughnut chart legends getting to big and hiding charts on mobile devices. ## 2.5.0 - 2020-05-31