diff --git a/CHANGELOG.md b/CHANGELOG.md index ee5beec38..d7f654c92 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 diff --git a/src/visits/helpers/DefaultChart.js b/src/visits/helpers/DefaultChart.js new file mode 100644 index 000000000..7d7622101 --- /dev/null +++ b/src/visits/helpers/DefaultChart.js @@ -0,0 +1,159 @@ +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 ]), + 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) { + return 300; + } + + 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 ( + + ); +}; +/* 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; + 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 chartRef = useRef(); + + const options = { + legend: { display: false }, + legendCallback: !isBarChart && renderPieChartLegend, + 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 ( +
+
+ +
+ {!isBarChart && ( +
+ {chartRef.current && chartRef.current.chartInstance.generateLegend()} +
+ )} +
+ ); +}; + +DefaultChart.propTypes = propTypes; + +export default DefaultChart; diff --git a/src/visits/helpers/DefaultChart.scss b/src/visits/helpers/DefaultChart.scss new file mode 100644 index 000000000..e7a0bd9f3 --- /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/src/visits/helpers/GraphCard.js b/src/visits/helpers/GraphCard.js index 155a3d9dc..5fc23d3ff 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/DefaultChart.test.js b/test/visits/helpers/DefaultChart.test.js new file mode 100644 index 000000000..387fd0eda --- /dev/null +++ b/test/visits/helpers/DefaultChart.test.js @@ -0,0 +1,98 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { Doughnut, HorizontalBar } from 'react-chartjs-2'; +import { keys, values } from 'ramda'; +import DefaultChart from '../../../src/visits/helpers/DefaultChart'; + +describe('', () => { + let wrapper; + const stats = { + foo: 123, + bar: 456, + }; + + afterEach(() => wrapper && wrapper.unmount()); + + it('renders Doughnut 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(1); + expect(horizontal).toHaveLength(0); + + const { labels, datasets } = doughnut.prop('data'); + const [{ title, data, backgroundColor, borderColor }] = datasets; + const { legend, legendCallback, scales } = doughnut.prop('options'); + + expect(title).toEqual('The chart'); + expect(labels).toEqual(keys(stats)); + expect(data).toEqual(values(stats)); + expect(datasets).toHaveLength(1); + expect(backgroundColor).toEqual([ + '#97BBCD', + '#F7464A', + '#46BFBD', + '#FDB45C', + '#949FB1', + '#57A773', + '#414066', + '#08B2E3', + '#B6C454', + '#DCDCDC', + '#463730', + ]); + expect(borderColor).toEqual('white'); + 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, 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: [ + { + ticks: { beginAtZero: true, precision: 0 }, + stacked: true, + }, + ], + yAxes: [{ stacked: true }], + }); + expect(cols).toHaveLength(1); + }); + + it.each([ + [{ foo: 23 }, [ 100, 456 ], [ 23, 0 ]], + [{ foo: 50 }, [ 73, 456 ], [ 50, 0 ]], + [{ bar: 45 }, [ 123, 411 ], [ 0, 45 ]], + [{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]], + [ undefined, [ 123, 456 ], undefined ], + ])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => { + wrapper = shallow(); + const horizontal = wrapper.find(HorizontalBar); + + const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data'); + + expect(label).toEqual(highlightedStats ? 'Non-selected' : 'Visits'); + expect(data).toEqual(expectedData); + expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData); + !expectedHighlightedData && expect(highlightedData).toBeUndefined(); + }); +}); diff --git a/test/visits/helpers/GraphCard.test.js b/test/visits/helpers/GraphCard.test.js index b75ce941b..0c0baeb9b 100644 --- a/test/visits/helpers/GraphCard.test.js +++ b/test/visits/helpers/GraphCard.test.js @@ -1,92 +1,49 @@ import React from 'react'; import { shallow } from 'enzyme'; -import { Doughnut, HorizontalBar } from 'react-chartjs-2'; -import { keys, values } from 'ramda'; +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 stats = { - foo: 123, - bar: 456, + const createWrapper = (title = '', footer) => { + wrapper = shallow(); + + return wrapper; }; afterEach(() => wrapper && wrapper.unmount()); - it('renders Doughnut when is not a bar chart', () => { - wrapper = shallow(); - const doughnut = wrapper.find(Doughnut); - const horizontal = wrapper.find(HorizontalBar); - - 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'); - - expect(title).toEqual('The chart'); - expect(labels).toEqual(keys(stats)); - expect(data).toEqual(values(stats)); - expect(datasets).toHaveLength(1); - expect(backgroundColor).toEqual([ - '#97BBCD', - '#F7464A', - '#46BFBD', - '#FDB45C', - '#949FB1', - '#57A773', - '#414066', - '#08B2E3', - '#B6C454', - '#DCDCDC', - '#463730', - ]); - expect(borderColor).toEqual('white'); - expect(legend).toEqual({ position: 'right' }); - expect(scales).toBeUndefined(); + 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('renders HorizontalBar when is not a bar chart', () => { - wrapper = shallow(); - const doughnut = wrapper.find(Doughnut); - const horizontal = wrapper.find(HorizontalBar); - - expect(doughnut).toHaveLength(0); - expect(horizontal).toHaveLength(1); - - const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data'); - const { legend, scales } = horizontal.prop('options'); + 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(backgroundColor).toEqual('rgba(70, 150, 229, 0.4)'); - expect(borderColor).toEqual('rgba(70, 150, 229, 1)'); - expect(legend).toEqual({ display: false }); - expect(scales).toEqual({ - xAxes: [ - { - ticks: { beginAtZero: true, precision: 0 }, - stacked: true, - }, - ], - yAxes: [{ stacked: true }], - }); + expect(header.html()).toContain(expectedTitle); }); - it.each([ - [{ foo: 23 }, [ 100, 456 ], [ 23, 0 ]], - [{ foo: 50 }, [ 73, 456 ], [ 50, 0 ]], - [{ bar: 45 }, [ 123, 411 ], [ 0, 45 ]], - [{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]], - [ undefined, [ 123, 456 ], undefined ], - ])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => { - wrapper = shallow(); - const horizontal = wrapper.find(HorizontalBar); - - const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data'); + it('renders footer only when provided', () => { + const wrapper = createWrapper('', 'the footer'); + const footer = wrapper.find(CardFooter); - expect(label).toEqual(highlightedStats ? 'Non-selected' : 'Visits'); - expect(data).toEqual(expectedData); - expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData); - !expectedHighlightedData && expect(highlightedData).toBeUndefined(); + expect(footer).toHaveLength(1); + expect(footer.html()).toContain('the footer'); }); });