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 (
+
+ {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;
+ 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');
});
});