Merge pull request #487 from acelaya-forks/feature/chartjs-3

Feature/chartjs 3
This commit is contained in:
Alejandro Celaya 2021-09-18 13:26:41 +02:00 committed by GitHub
commit 27c4bd792b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 322 additions and 291 deletions

View file

@ -14,5 +14,8 @@
"process": true,
"setImmediate": true
},
"ignorePatterns": ["src/service*.ts"]
"ignorePatterns": ["src/service*.ts"],
"rules": {
"complexity": "off"
}
}

View file

@ -6,19 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
## [Unreleased]
### Added
* [#465](https://github.com/shlinkio/shlink-web-client/pull/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
* [#460](https://github.com/shlinkio/shlink-web-client/pull/460) Added dynamic title on hover for tags with a very long title.
* [#462](https://github.com/shlinkio/shlink-web-client/pull/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
* [#463](https://github.com/shlinkio/shlink-web-client/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
* [#465](https://github.com/shlinkio/shlink-web-client/issues/465) Added new page to manage domains and their redirects, when consuming Shlink 2.8 or higher.
* [#460](https://github.com/shlinkio/shlink-web-client/issues/460) Added dynamic title on hover for tags with a very long title.
* [#462](https://github.com/shlinkio/shlink-web-client/issues/462) Now it is possible to paste multiple comma-separated tags in the tags selector, making all of them to be added as individual tags.
* [#463](https://github.com/shlinkio/shlink-web-client/issues/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured:
* `startsWith`: Suggests tags that start with the input. This is the default behavior for keep it as it was so far.
* `includes`: Suggests tags that contain the input.
* [#464](https://github.com/shlinkio/shlink-web-client/pull/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
* [#469](https://github.com/shlinkio/shlink-web-client/pull/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
* [#464](https://github.com/shlinkio/shlink-web-client/issues/464) Added support to download QR codes. This feature requires an unreleased version of Shlink, so it comes disabled, and will get enabled as soon as Shlink v2.9 is released.
* [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
### Changed
* *Nothing*
* [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
### Deprecated
* *Nothing*
@ -44,9 +44,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing*
### Fixed
* [#478](https://github.com/shlinkio/shlink-web-client/pull/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
* [#480](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed servers import on Chromium-based browsers when using windows.
* [#482](https://github.com/shlinkio/shlink-web-client/pull/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
* [#478](https://github.com/shlinkio/shlink-web-client/issues/478) Fixed tags including special chars not being properly URL encoded before using them as query params.
* [#480](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed servers import on Chromium-based browsers when using windows.
* [#482](https://github.com/shlinkio/shlink-web-client/issues/480) Fixed end date not being set to the end of the day when filtering visits using a "smart filter" (last 7 days, last 30 days, etc).
## [3.2.0] - 2021-07-12
@ -58,16 +58,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* `SHLINK_SERVER_NAME`: A name you want to give to this server. Defaults to *Shlink* if not provided.
* [#432](https://github.com/shlinkio/shlink-web-client/pull/432) Added support to provide the `servers.json` file inside a `conf.d` folder.
* [#440](https://github.com/shlinkio/shlink-web-client/pull/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
* [#431](https://github.com/shlinkio/shlink-web-client/pull/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
* [#430](https://github.com/shlinkio/shlink-web-client/pull/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
* [#450](https://github.com/shlinkio/shlink-web-client/pull/450) Improved landing page design.
* [#449](https://github.com/shlinkio/shlink-web-client/pull/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
* [#440](https://github.com/shlinkio/shlink-web-client/issues/440) Added hint of what visits come potentially from a bot, in the visits table, when consuming Shlink >=2.7.
* [#431](https://github.com/shlinkio/shlink-web-client/issues/431) Added support to filter out visits from potential bots in visits sections, when consuming Shlink >=2.7.
* [#430](https://github.com/shlinkio/shlink-web-client/issues/430) Added support to set new and existing short URLs as crawlable, when consuming Shlink >=2.7.
* [#450](https://github.com/shlinkio/shlink-web-client/issues/450) Improved landing page design.
* [#449](https://github.com/shlinkio/shlink-web-client/issues/449) Improved PWA update banner, allowing to restart the app directly from it without having to close the tab.
### Changed
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer.
* [#337](https://github.com/shlinkio/shlink-web-client/pull/337) Replaced moment.js with date-fns.
* [#360](https://github.com/shlinkio/shlink-web-client/pull/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
* [#442](https://github.com/shlinkio/shlink-web-client/issues/442) Visits filtering now goes through the corresponding reducer.
* [#337](https://github.com/shlinkio/shlink-web-client/issues/337) Replaced moment.js with date-fns.
* [#360](https://github.com/shlinkio/shlink-web-client/issues/360) Changed component used to generate a tags selector, switching from `react-tagsinput`, which is no longer maintained, to `react-tag-autocomplete`.
### Deprecated
* *Nothing*
@ -76,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing*
### Fixed
* [#438](https://github.com/shlinkio/shlink-web-client/pull/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
* [#438](https://github.com/shlinkio/shlink-web-client/issues/438) Fixed horizontal scrolling in short URLs list on mobile devices when the long URL didn't have words to break.
## [3.1.2] - 2021-06-06

63
package-lock.json generated
View file

@ -6363,15 +6363,6 @@
"@babel/types": "^7.3.0"
}
},
"@types/chart.js": {
"version": "2.9.31",
"resolved": "https://registry.npmjs.org/@types/chart.js/-/chart.js-2.9.31.tgz",
"integrity": "sha512-hzS6phN/kx3jClk3iYqEHNnYIRSi4RZrIGJ8CDLjgatpHoftCezvC44uqB3o3OUm9ftU1m7sHG8+RLyPTlACrA==",
"dev": true,
"requires": {
"moment": "^2.10.2"
}
},
"@types/cheerio": {
"version": "0.22.22",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
@ -10578,30 +10569,9 @@
"dev": true
},
"chart.js": {
"version": "2.9.4",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==",
"requires": {
"chartjs-color": "^2.1.0",
"moment": "^2.10.2"
}
},
"chartjs-color": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/chartjs-color/-/chartjs-color-2.4.1.tgz",
"integrity": "sha512-haqOg1+Yebys/Ts/9bLo/BqUcONQOdr/hoEr2LLTRl6C5LXctUdHxsCYfvQVg5JIxITrfCNUDr4ntqmQk9+/0w==",
"requires": {
"chartjs-color-string": "^0.6.0",
"color-convert": "^1.9.3"
}
},
"chartjs-color-string": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/chartjs-color-string/-/chartjs-color-string-0.6.0.tgz",
"integrity": "sha512-TIB5OKn1hPJvO7JcteW4WY/63v6KwEdt6udfnDE9iCAZgy+V4SrbSxoIbTw/xkUIapjEI4ExGtD0+6D3KyFd7A==",
"requires": {
"color-name": "^1.0.0"
}
"version": "3.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
"integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
},
"check-types": {
"version": "11.1.2",
@ -10957,6 +10927,7 @@
"version": "1.9.3",
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
"dev": true,
"requires": {
"color-name": "1.1.3"
},
@ -10964,14 +10935,16 @@
"color-name": {
"version": "1.1.3",
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
}
}
},
"color-name": {
"version": "1.1.4",
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI="
"integrity": "sha1-wqCah6y95pVD3m9j+jmVyCbFNqI=",
"dev": true
},
"color-string": {
"version": "1.5.4",
@ -19000,11 +18973,6 @@
}
}
},
"moment": {
"version": "2.29.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity": "sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
"moo": {
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
@ -24570,18 +24538,17 @@
}
},
"react-chartjs-2": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz",
"integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==",
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
"integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
"requires": {
"lodash": "^4.17.19",
"prop-types": "^15.7.2"
"lodash": "^4.17.19"
},
"dependencies": {
"lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA=="
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
}
}
},

View file

@ -30,7 +30,7 @@
"bootstrap": "^4.6.0",
"bottlejs": "^2.0.0",
"bowser": "^2.11.0",
"chart.js": "^2.9.4",
"chart.js": "^3.5.1",
"classnames": "^2.2.6",
"compare-versions": "^3.6.0",
"csvjson": "^5.1.0",
@ -41,7 +41,7 @@
"qs": "^6.9.6",
"ramda": "^0.27.1",
"react": "^17.0.1",
"react-chartjs-2": "^2.11.1",
"react-chartjs-2": "^3.0.4",
"react-color": "^2.19.3",
"react-copy-to-clipboard": "^5.0.2",
"react-datepicker": "^3.6.0",
@ -72,7 +72,6 @@
"@stryker-mutator/jest-runner": "^5.0.0",
"@stryker-mutator/typescript-checker": "^5.0.0",
"@svgr/webpack": "^5.5.0",
"@types/chart.js": "^2.9.31",
"@types/classnames": "^2.2.11",
"@types/enzyme": "^3.10.8",
"@types/jest": "^26.0.20",

View file

@ -43,7 +43,7 @@ const toDate = (date?: string | Date): Date | undefined => typeof date === 'stri
export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>,
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity
): FC<ShortUrlFormProps> => ({ mode, saving, onSave, initialState, selectedServer }) => {
const [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });

View file

@ -1,30 +1,18 @@
import { ChangeEvent, FC } from 'react';
import { ChartData, ChartTooltipItem } from 'chart.js';
import { ActiveElement, ChartEvent, ChartType, TooltipItem } from 'chart.js';
import { prettify } from './numbers';
export const pointerOnHover = ({ target }: ChangeEvent<HTMLElement>, chartElement: FC[]) => {
target.style.cursor = chartElement[0] ? 'pointer' : 'default';
export const pointerOnHover = ({ native }: ChartEvent, [ firstElement ]: ActiveElement[]) => {
if (!native?.target) {
return;
}
const canvas = native.target as HTMLCanvasElement;
canvas.style.cursor = firstElement ? 'pointer' : 'default';
};
export const renderNonDoughnutChartLabel = (labelToPick: 'yLabel' | 'xLabel') => (
item: ChartTooltipItem,
{ datasets }: ChartData,
) => {
const { datasetIndex } = item;
const value = item[labelToPick];
const datasetLabel = datasetIndex !== undefined && datasets?.[datasetIndex]?.label || '';
export const renderChartLabel = ({ dataset, formattedValue }: TooltipItem<ChartType>) =>
`${dataset.label}: ${prettify(formattedValue)}`;
return `${datasetLabel}: ${prettify(Number(value))}`;
};
export const renderDoughnutChartLabel = (
{ datasetIndex, index }: ChartTooltipItem,
{ labels, datasets }: ChartData,
) => {
const datasetLabel = index !== undefined && labels?.[index] || '';
const value = datasetIndex !== undefined && index !== undefined
&& datasets?.[datasetIndex]?.data?.[index]
|| '';
return `${datasetLabel}: ${prettify(Number(value))}`; // eslint-disable-line @typescript-eslint/no-base-to-string
};
export const renderPieChartLabel = ({ label, formattedValue }: TooltipItem<ChartType>) =>
`${label}: ${prettify(formattedValue)}`;

View file

@ -2,6 +2,6 @@ const TEN_ROUNDING_NUMBER = 10;
const { ceil } = Math;
const formatter = new Intl.NumberFormat('en-US');
export const prettify = (number: number) => formatter.format(number);
export const prettify = (number: number | string) => formatter.format(Number(number));
export const roundTen = (number: number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View file

@ -1,12 +1,12 @@
import { useState } from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { useState, memo } from 'react';
import { Doughnut, Bar } from 'react-chartjs-2';
import { keys, values } from 'ramda';
import classNames from 'classnames';
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { Chart, ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { fillTheGaps } from '../../utils/helpers/visits';
import { Stats } from '../types';
import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
import { pointerOnHover, renderChartLabel, renderPieChartLabel } from '../../utils/helpers/charts';
import {
HIGHLIGHTED_COLOR,
HIGHLIGHTED_COLOR_ALPHA,
@ -16,10 +16,9 @@ import {
PRIMARY_DARK_COLOR,
PRIMARY_LIGHT_COLOR,
} from '../../utils/theme';
import './DefaultChart.scss';
import { PieChartLegend } from './PieChartLegend';
export interface DefaultChartProps {
title: Function | string;
stats: Stats;
isBarChart?: boolean;
max?: number;
@ -28,45 +27,56 @@ export interface DefaultChartProps {
onClick?: (label: string) => void;
}
const generateGraphData = (
title: Function | string,
const generateChartDatasets = (
isBarChart: boolean,
data: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartDataset[] => {
const mainDataset: ChartDataset = {
label: highlightedLabel ? 'Non-selected' : 'Visits',
data,
backgroundColor: isBarChart ? MAIN_COLOR_ALPHA : [
'#97BBCD',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
],
borderColor: isBarChart ? MAIN_COLOR : isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
borderWidth: 2,
};
if (!isBarChart || highlightedData.every((value) => value === 0)) {
return [ mainDataset ];
}
const highlightedDataset: ChartDataset = {
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
borderColor: HIGHLIGHTED_COLOR,
borderWidth: 2,
};
return [ mainDataset, highlightedDataset ];
};
const generateChartData = (
isBarChart: boolean,
labels: string[],
data: number[],
highlightedData?: number[],
highlightedData: number[],
highlightedLabel?: string,
): ChartData => ({
labels,
datasets: [
{
title,
label: highlightedData ? 'Non-selected' : 'Visits',
data,
backgroundColor: isBarChart ? MAIN_COLOR_ALPHA : [
'#97BBCD',
'#F7464A',
'#46BFBD',
'#FDB45C',
'#949FB1',
'#57A773',
'#414066',
'#08B2E3',
'#B6C454',
'#DCDCDC',
'#463730',
],
borderColor: isBarChart ? MAIN_COLOR : isDarkThemeEnabled() ? PRIMARY_DARK_COLOR : PRIMARY_LIGHT_COLOR,
borderWidth: 2,
},
highlightedData && {
title,
label: highlightedLabel ?? 'Selected',
data: highlightedData,
backgroundColor: HIGHLIGHTED_COLOR_ALPHA,
borderColor: HIGHLIGHTED_COLOR,
borderWidth: 2,
},
].filter(Boolean) as ChartDataSets[],
datasets: generateChartDatasets(isBarChart, data, highlightedData, highlightedLabel),
});
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label;
@ -79,43 +89,24 @@ const determineHeight = (isBarChart: boolean, labels: string[]): number | undefi
return isBarChart && labels.length > 20 ? labels.length * 8 : undefined;
};
const renderPieChartLegend = ({ config }: Chart) => {
const { labels = [], datasets = [] } = config.data ?? {};
const { defaultColor } = config.options ?? {} as any;
const [{ backgroundColor: colors }] = datasets;
return (
<ul className="default-chart__pie-chart-legend">
{labels.map((label, index) => (
<li key={label as string} className="default-chart__pie-chart-legend-item d-flex">
<div
className="default-chart__pie-chart-legend-item-color"
style={{ backgroundColor: (colors as string[])[index] || defaultColor }}
/>
<small className="default-chart__pie-chart-legend-item-text flex-fill">{label}</small>
</li>
))}
</ul>
);
};
const chartElementAtEvent = (onClick?: (label: string) => void) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
const chartElementAtEvent = (
labels: string[],
onClick?: (label: string) => void,
) => ([ chart ]: [{ index: number }]) => {
if (!onClick || !chart) {
return;
}
const { _index, _chart: { data } } = chart;
const { labels } = data;
onClick(labels?.[_index] as string);
onClick(labels[chart.index]);
};
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
const DefaultChart = (
{ title, isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps,
const DefaultChart = memo((
{ isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps,
) => {
const Component = isBarChart ? HorizontalBar : Doughnut;
const Component = isBarChart ? Bar : Doughnut;
const [ chartRef, setChartRef ] = useState<Chart | undefined>(); // Cannot use useRef here
const labels = keys(stats).map(dropLabelIfHidden);
const data = values(
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
@ -126,59 +117,68 @@ const DefaultChart = (
return acc;
}, { ...stats }),
);
const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined;
const [ chartRef, setChartRef ] = useState<HorizontalBar | Doughnut | undefined>();
const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
const options: ChartOptions = {
legend: { display: false },
legendCallback: !isBarChart && renderPieChartLegend as any,
scales: !isBarChart ? undefined : {
xAxes: [
{
ticks: {
beginAtZero: true,
precision: 0,
callback: prettify,
max,
},
stacked: true,
plugins: {
legend: { display: false },
tooltip: {
intersect: !isBarChart,
mode: isBarChart ? 'y' : 'index',
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ label }) => !isBarChart || label !== '',
callbacks: {
label: isBarChart ? renderChartLabel : renderPieChartLabel,
},
],
yAxes: [{ stacked: true }],
},
tooltips: {
intersect: !isBarChart,
// Do not show tooltip on items with empty label when in a bar chart
filter: ({ yLabel }) => !isBarChart || yLabel !== '',
callbacks: {
label: isBarChart ? renderNonDoughnutChartLabel('xLabel') : renderDoughnutChartLabel,
},
},
onHover: !isBarChart ? undefined : (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
scales: !isBarChart ? undefined : {
x: {
beginAtZero: true,
stacked: true,
max,
ticks: {
precision: 0,
callback: prettify,
},
},
y: { stacked: true },
},
onHover: isBarChart ? pointerOnHover : undefined,
indexAxis: isBarChart ? 'y' : 'x',
};
const graphData = generateGraphData(title, isBarChart, labels, data, highlightedData, highlightedLabel);
const chartData = generateChartData(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
// Provide a key based on the height, to force re-render every time the dataset changes (example, due to pagination)
const renderChartComponent = (customKey: string) => (
<Component
ref={(element) => {
setChartRef(element ?? undefined);
}}
key={`${height}_${customKey}`}
data={chartData}
options={options}
height={height}
getElementAtEvent={chartElementAtEvent(labels, onClick) as any}
/>
);
return (
<div className="row">
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
<Component
ref={(element) => setChartRef(element ?? undefined)}
key={height}
data={graphData}
options={options}
height={height}
getElementAtEvent={chartElementAtEvent(onClick)}
/>
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedStats !== undefined && renderChartComponent('with_stats')}
{highlightedStats === undefined && renderChartComponent('without_stats')}
</div>
{!isBarChart && (
<div className="col-sm-12 col-md-5">
{chartRef?.chartInstance.generateLegend()}
{chartRef && <PieChartLegend chart={chartRef} />}
</div>
)}
</div>
);
};
});
export default DefaultChart;

View file

@ -4,6 +4,7 @@ import DefaultChart, { DefaultChartProps } from './DefaultChart';
import './GraphCard.scss';
interface GraphCardProps extends DefaultChartProps {
title: Function | string;
footer?: ReactNode;
}
@ -11,7 +12,7 @@ const GraphCard = ({ title, footer, ...rest }: GraphCardProps) => (
<Card>
<CardHeader className="graph-card__header">{typeof title === 'function' ? title() : title}</CardHeader>
<CardBody>
<DefaultChart title={title} {...rest} />
<DefaultChart {...rest} />
</CardBody>
{footer && <CardFooter className="graph-card__footer--sticky">{footer}</CardFooter>}
</Card>

View file

@ -21,14 +21,14 @@ import {
startOfISOWeek,
endOfISOWeek,
} from 'date-fns';
import Chart, { ChartData, ChartDataSets, ChartOptions } from 'chart.js';
import { ChartData, ChartDataset, ChartOptions } from 'chart.js';
import { NormalizedVisit, Stats } from '../types';
import { fillTheGaps } from '../../utils/helpers/visits';
import { useToggle } from '../../utils/helpers/hooks';
import { rangeOf } from '../../utils/utils';
import ToggleSwitch from '../../utils/ToggleSwitch';
import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderNonDoughnutChartLabel } from '../../utils/helpers/charts';
import { pointerOnHover, renderChartLabel } from '../../utils/helpers/charts';
import { HIGHLIGHTED_COLOR, MAIN_COLOR } from '../../utils/theme';
import './LineChartCard.scss';
@ -134,11 +134,11 @@ const generateLabelsAndGroupedVisits = (
return [ labels, fillTheGaps(groupedVisitsWithGaps, labels) ];
};
const generateDataset = (data: number[], label: string, color: string): ChartDataSets => ({
const generateDataset = (data: number[], label: string, color: string): ChartDataset => ({
label,
data,
fill: false,
lineTension: 0.2,
tension: 0.2,
borderColor: color,
backgroundColor: color,
});
@ -146,15 +146,15 @@ const generateDataset = (data: number[], label: string, color: string): ChartDat
let selectedLabel: string | null = null;
const chartElementAtEvent = (
labels: string[],
datasetsByPoint: Record<string, NormalizedVisit[]>,
setSelectedVisits?: (visits: NormalizedVisit[]) => void,
) => ([ chart ]: [{ _index: number; _chart: Chart }]) => {
) => ([ chart ]: [{ index: number }]) => {
if (!setSelectedVisits || !chart) {
return;
}
const { _index: index, _chart: { data } } = chart;
const { labels } = data as { labels: string[] };
const { index } = chart;
if (selectedLabel === labels[index]) {
setSelectedVisits([]);
@ -183,42 +183,50 @@ const LineChartCard = (
() => fillTheGaps(groupVisitsByStep(step, reverse(highlightedVisits)), labels),
[ highlightedVisits, step, labels ],
);
const generateChartDatasets = (): ChartDataset[] => {
const mainDataset = generateDataset(groupedVisits, 'Visits', MAIN_COLOR);
const data: ChartData = {
labels,
datasets: [
generateDataset(groupedVisits, 'Visits', MAIN_COLOR),
highlightedVisits.length > 0 && generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR),
].filter(Boolean) as ChartDataSets[],
if (highlightedVisits.length === 0) {
return [ mainDataset ];
}
const highlightedDataset = generateDataset(groupedHighlighted, highlightedLabel, HIGHLIGHTED_COLOR);
return [ mainDataset, highlightedDataset ];
};
const generateChartData = (): ChartData => ({ labels, datasets: generateChartDatasets() });
const options: ChartOptions = {
maintainAspectRatio: false,
legend: { display: false },
scales: {
yAxes: [
{
ticks: {
beginAtZero: true,
precision: 0,
callback: prettify,
},
},
],
xAxes: [
{
scaleLabel: { display: true, labelString: STEPS_MAP[step] },
},
],
},
tooltips: {
intersect: false,
axis: 'x',
callbacks: {
label: renderNonDoughnutChartLabel('yLabel'),
plugins: {
legend: { display: false },
tooltip: {
intersect: false,
axis: 'x',
callbacks: { label: renderChartLabel },
},
},
onHover: (pointerOnHover) as any, // TODO Types seem to be incorrectly defined in @types/chart.js
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
callback: prettify,
},
},
x: {
title: { display: true, text: STEPS_MAP[step] },
},
},
onHover: pointerOnHover,
};
const renderLineChart = () => (
<Line
data={generateChartData()}
options={options}
getElementAtEvent={chartElementAtEvent(labels, datasetsByPoint, setSelectedVisits) as any}
/>
);
return (
<Card>
@ -245,11 +253,10 @@ const LineChartCard = (
</div>
</CardHeader>
<CardBody className="line-chart-card__body">
<Line
data={data}
options={options}
getElementAtEvent={chartElementAtEvent(datasetsByPoint, setSelectedVisits)}
/>
{/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
{/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
{highlightedVisits.length > 0 && renderLineChart()}
{highlightedVisits.length === 0 && renderLineChart()}
</CardBody>
</Card>
);

View file

@ -1,6 +1,6 @@
@import '../../utils/base';
.default-chart__pie-chart-legend {
.pie-chart-legend {
list-style-type: none;
padding: 0;
margin: 0;
@ -10,11 +10,11 @@
}
}
.default-chart__pie-chart-legend-item:not(:first-child) {
.pie-chart-legend__item:not(:first-child) {
margin-top: .3rem;
}
.default-chart__pie-chart-legend-item-color {
.pie-chart-legend__item-color {
width: 20px;
min-width: 20px;
height: 20px;
@ -22,7 +22,7 @@
border-radius: 10px;
}
.default-chart__pie-chart-legend-item-text {
.pie-chart-legend__item-text {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View file

@ -0,0 +1,28 @@
import { FC } from 'react';
import { Chart } from 'chart.js';
import './PieChartLegend.scss';
interface PieChartLegendProps {
chart: Chart;
}
export const PieChartLegend: FC<PieChartLegendProps> = ({ chart }) => {
const { config } = chart;
const { labels = [], datasets = [] } = config.data ?? {};
const [{ backgroundColor: colors }] = datasets;
const { defaultColor } = config.options ?? {} as any;
return (
<ul className="pie-chart-legend">
{(labels as string[]).map((label, index) => (
<li key={label} className="pie-chart-legend__item d-flex">
<div
className="pie-chart-legend__item-color"
style={{ backgroundColor: (colors as string[])[index] ?? defaultColor }}
/>
<small className="pie-chart-legend__item-text flex-fill">{label}</small>
</li>
))}
</ul>
);
};

View file

@ -14,6 +14,7 @@ const pickKeyFromPair = ([ key ]: StatsRow) => key;
const pickValueFromPair = ([ , value ]: StatsRow) => value;
interface SortableBarGraphProps extends DefaultChartProps {
title: Function | string;
sortingItems: Record<string, string>;
withPagination?: boolean;
extraHeaderContent?: Function;

View file

@ -1,5 +1,5 @@
import { shallow, ShallowWrapper } from 'enzyme';
import { Doughnut, HorizontalBar } from 'react-chartjs-2';
import { Doughnut, Bar } from 'react-chartjs-2';
import { keys, values } from 'ramda';
import DefaultChart from '../../../src/visits/helpers/DefaultChart';
import { prettify } from '../../../src/utils/helpers/numbers';
@ -15,19 +15,18 @@ describe('<DefaultChart />', () => {
afterEach(() => wrapper?.unmount());
it('renders Doughnut when is not a bar chart', () => {
wrapper = shallow(<DefaultChart title="The chart" stats={stats} />);
wrapper = shallow(<DefaultChart stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
const horizontal = wrapper.find(Bar);
const cols = wrapper.find('.col-sm-12');
expect(doughnut).toHaveLength(1);
expect(horizontal).toHaveLength(0);
const { labels, datasets } = doughnut.prop('data') as any;
const [{ title, data, backgroundColor, borderColor }] = datasets;
const { legend, scales, ...options } = doughnut.prop('options') ?? {};
const { labels, datasets } = doughnut.prop('data');
const [{ data, backgroundColor, borderColor }] = datasets;
const { plugins, scales } = doughnut.prop('options') ?? {};
expect(title).toEqual('The chart');
expect(labels).toEqual(keys(stats));
expect(data).toEqual(values(stats));
expect(datasets).toHaveLength(1);
@ -45,36 +44,36 @@ describe('<DefaultChart />', () => {
'#463730',
]);
expect(borderColor).toEqual('white');
expect(legend).toEqual({ display: false });
expect(typeof options.legendCallback).toEqual('function');
expect(plugins.legend).toEqual({ display: false });
expect(scales).toBeUndefined();
expect(cols).toHaveLength(2);
});
it('renders HorizontalBar when is not a bar chart', () => {
wrapper = shallow(<DefaultChart isBarChart title="The chart" stats={stats} />);
wrapper = shallow(<DefaultChart isBarChart stats={stats} />);
const doughnut = wrapper.find(Doughnut);
const horizontal = wrapper.find(HorizontalBar);
const horizontal = wrapper.find(Bar);
const cols = wrapper.find('.col-sm-12');
expect(doughnut).toHaveLength(0);
expect(horizontal).toHaveLength(1);
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data') as any;
const { legend, scales, ...options } = horizontal.prop('options') ?? {};
const { datasets: [{ backgroundColor, borderColor }] } = horizontal.prop('data');
const { plugins, scales } = horizontal.prop('options') ?? {};
expect(backgroundColor).toEqual(MAIN_COLOR_ALPHA);
expect(borderColor).toEqual(MAIN_COLOR);
expect(legend).toEqual({ display: false });
expect(typeof options.legendCallback).toEqual('boolean');
expect(plugins.legend).toEqual({ display: false });
expect(scales).toEqual({
xAxes: [
{
ticks: { beginAtZero: true, precision: 0, callback: prettify },
stacked: true,
x: {
beginAtZero: true,
stacked: true,
ticks: {
precision: 0,
callback: prettify,
},
],
yAxes: [{ stacked: true }],
},
y: { stacked: true },
});
expect(cols).toHaveLength(1);
});
@ -86,12 +85,12 @@ describe('<DefaultChart />', () => {
[{ bar: 20, foo: 13 }, [ 110, 436 ], [ 13, 20 ]],
[ undefined, [ 123, 456 ], undefined ],
])('splits highlighted data from regular data', (highlightedStats, expectedData, expectedHighlightedData) => {
wrapper = shallow(<DefaultChart isBarChart title="The chart" stats={stats} highlightedStats={highlightedStats} />);
const horizontal = wrapper.find(HorizontalBar);
wrapper = shallow(<DefaultChart isBarChart stats={stats} highlightedStats={highlightedStats} />);
const horizontal = wrapper.find(Bar);
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data') as any;
const { datasets: [{ data, label }, highlightedData ] } = horizontal.prop('data');
expect(label).toEqual(highlightedStats ? 'Non-selected' : 'Visits');
expect(label).toEqual('Visits');
expect(data).toEqual(expectedData);
expectedHighlightedData && expect(highlightedData.data).toEqual(expectedHighlightedData);
!expectedHighlightedData && expect(highlightedData).toBeUndefined();

View file

@ -7,6 +7,7 @@ import LineChartCard from '../../../src/visits/helpers/LineChartCard';
import ToggleSwitch from '../../../src/utils/ToggleSwitch';
import { NormalizedVisit } from '../../../src/visits/types';
import { prettify } from '../../../src/utils/helpers/numbers';
import { pointerOnHover, renderChartLabel } from '../../../src/utils/helpers/charts';
describe('<LineChartCard />', () => {
let wrapper: ShallowWrapper;
@ -54,23 +55,27 @@ describe('<LineChartCard />', () => {
expect(chart.prop('options')).toEqual(expect.objectContaining({
maintainAspectRatio: false,
legend: { display: false },
scales: {
yAxes: [
{
ticks: { beginAtZero: true, precision: 0, callback: prettify },
},
],
xAxes: [
{
scaleLabel: { display: true, labelString: 'Month' },
},
],
plugins: {
legend: { display: false },
tooltip: {
intersect: false,
axis: 'x',
callbacks: { label: renderChartLabel },
},
},
tooltips: expect.objectContaining({
intersect: false,
axis: 'x',
}),
scales: {
y: {
beginAtZero: true,
ticks: {
precision: 0,
callback: prettify,
},
},
x: {
title: { display: true, text: 'Month' },
},
},
onHover: pointerOnHover,
}));
});
@ -80,7 +85,7 @@ describe('<LineChartCard />', () => {
])('renders chart with expected data', (visits, highlightedVisits, expectedLines) => {
const wrapper = createWrapper(visits, highlightedVisits);
const chart = wrapper.find(Line);
const { datasets } = chart.prop('data') as any;
const { datasets } = chart.prop('data');
expect(datasets).toHaveLength(expectedLines);
});
@ -91,8 +96,8 @@ describe('<LineChartCard />', () => {
Mock.of<NormalizedVisit>({ date: '2016-01-01' }),
]);
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(2);
expect(wrapper.find(Line).prop('data').labels).toHaveLength(2);
wrapper.find(ToggleSwitch).simulate('change');
expect((wrapper.find(Line).prop('data') as any).labels).toHaveLength(4);
expect(wrapper.find(Line).prop('data').labels).toHaveLength(4);
});
});

View file

@ -0,0 +1,33 @@
import { shallow } from 'enzyme';
import { Mock } from 'ts-mockery';
import { Chart, ChartDataset } from 'chart.js';
import { PieChartLegend } from '../../../src/visits/helpers/PieChartLegend';
describe('<PieChartLegend />', () => {
const labels = [ 'foo', 'bar', 'baz', 'foo2', 'bar2' ];
const colors = [ 'foo_color', 'bar_color', 'baz_color' ];
const defaultColor = 'red';
const datasets = [ Mock.of<ChartDataset>({ backgroundColor: colors }) ];
const chart = Mock.of<Chart>({
config: {
data: { labels, datasets },
options: { defaultColor } as any,
},
});
test('renders the expected amount of items with expected colors and labels', () => {
const wrapper = shallow(<PieChartLegend chart={chart} />);
const items = wrapper.find('li');
expect.assertions(labels.length * 2 + 1);
expect(items).toHaveLength(labels.length);
labels.forEach((label, index) => {
const item = items.at(index);
expect(item.find('.pie-chart-legend__item-color').prop('style')).toEqual({
backgroundColor: colors[index] ?? defaultColor,
});
expect(item.find('.pie-chart-legend__item-text').text()).toEqual(label);
});
});
});