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, "process": true,
"setImmediate": 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] ## [Unreleased]
### Added ### 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. * [#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/pull/460) Added dynamic title on hover for tags with a very long title. * [#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/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. * [#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/pull/463) The strategy to determine which tags to suggest in the TagsSelector during short URL creation, can now be configured: * [#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. * `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. * `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. * [#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/pull/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher. * [#469](https://github.com/shlinkio/shlink-web-client/issues/469) Added support `errorCorrection` in QR codes, when consuming Shlink 2.8 or higher.
### Changed ### Changed
* *Nothing* * [#408](https://github.com/shlinkio/shlink-web-client/issues/408) Updated to Chart.js 3.5
### Deprecated ### Deprecated
* *Nothing* * *Nothing*
@ -44,9 +44,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing* * *Nothing*
### Fixed ### 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. * [#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/pull/480) Fixed servers import on Chromium-based browsers when using windows. * [#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/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). * [#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 ## [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. * `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. * [#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. * [#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/pull/431) Added support to filter out visits from potential bots in visits sections, 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/pull/430) Added support to set new and existing short URLs as crawlable, 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/pull/450) Improved landing page design. * [#450](https://github.com/shlinkio/shlink-web-client/issues/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. * [#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 ### Changed
* [#442](https://github.com/shlinkio/shlink-web-client/pull/442) Visits filtering now goes through the corresponding reducer. * [#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/pull/337) Replaced moment.js with date-fns. * [#337](https://github.com/shlinkio/shlink-web-client/issues/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`. * [#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 ### Deprecated
* *Nothing* * *Nothing*
@ -76,7 +76,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
* *Nothing* * *Nothing*
### Fixed ### 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 ## [3.1.2] - 2021-06-06

63
package-lock.json generated
View file

@ -6363,15 +6363,6 @@
"@babel/types": "^7.3.0" "@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": { "@types/cheerio": {
"version": "0.22.22", "version": "0.22.22",
"resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz", "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.22.tgz",
@ -10578,30 +10569,9 @@
"dev": true "dev": true
}, },
"chart.js": { "chart.js": {
"version": "2.9.4", "version": "3.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-2.9.4.tgz", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-3.5.1.tgz",
"integrity": "sha512-B07aAzxcrikjAPyV+01j7BmOpxtQETxTSlQ26BEYJ+3iUkbNKaOJ/nDbT6JjyqYxseM0ON12COHYdU2cTIjC7A==", "integrity": "sha512-m5kzt72I1WQ9LILwQC4syla/LD/N413RYv2Dx2nnTkRS9iv/ey1xLTt0DnPc/eWV4zI+BgEgDYBIzbQhZHc/PQ=="
"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"
}
}, },
"check-types": { "check-types": {
"version": "11.1.2", "version": "11.1.2",
@ -10957,6 +10927,7 @@
"version": "1.9.3", "version": "1.9.3",
"resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz", "resolved": "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz",
"integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=", "integrity": "sha1-u3GFBpDh8TZWfeYp0tVHHe2kweg=",
"dev": true,
"requires": { "requires": {
"color-name": "1.1.3" "color-name": "1.1.3"
}, },
@ -10964,14 +10935,16 @@
"color-name": { "color-name": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz", "resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=" "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=",
"dev": true
} }
} }
}, },
"color-name": { "color-name": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz", "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": { "color-string": {
"version": "1.5.4", "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": { "moo": {
"version": "0.5.1", "version": "0.5.1",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz", "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.1.tgz",
@ -24570,18 +24538,17 @@
} }
}, },
"react-chartjs-2": { "react-chartjs-2": {
"version": "2.11.1", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-2.11.1.tgz", "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-3.0.4.tgz",
"integrity": "sha512-G7cNq/n2Bkh/v4vcI+GKx7Q1xwZexKYhOSj2HmrFXlvNeaURWXun6KlOUpEQwi1cv9Tgs4H3kGywDWMrX2kxfA==", "integrity": "sha512-pcbFNpkPMTkGXXJ7k7hnukbRD0ZV01qB6JQY1ontITc2IYvhGlK6BBDy28VeydYs1Dl/c5ZpRgRVEtT5GUnxcQ==",
"requires": { "requires": {
"lodash": "^4.17.19", "lodash": "^4.17.19"
"prop-types": "^15.7.2"
}, },
"dependencies": { "dependencies": {
"lodash": { "lodash": {
"version": "4.17.20", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==" "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
} }
} }
}, },

View file

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

View file

@ -43,7 +43,7 @@ const toDate = (date?: string | Date): Date | undefined => typeof date === 'stri
export const ShortUrlForm = ( export const ShortUrlForm = (
TagsSelector: FC<TagsSelectorProps>, TagsSelector: FC<TagsSelectorProps>,
DomainSelector: FC<DomainSelectorProps>, 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 [ shortUrlData, setShortUrlData ] = useState(initialState);
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) });

View file

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

View file

@ -2,6 +2,6 @@ const TEN_ROUNDING_NUMBER = 10;
const { ceil } = Math; const { ceil } = Math;
const formatter = new Intl.NumberFormat('en-US'); 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; export const roundTen = (number: number) => ceil(number / TEN_ROUNDING_NUMBER) * TEN_ROUNDING_NUMBER;

View file

@ -1,12 +1,12 @@
import { useState } from 'react'; import { useState, memo } from 'react';
import { Doughnut, HorizontalBar } from 'react-chartjs-2'; import { Doughnut, Bar } from 'react-chartjs-2';
import { keys, values } from 'ramda'; import { keys, values } from 'ramda';
import classNames from 'classnames'; 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 { fillTheGaps } from '../../utils/helpers/visits';
import { Stats } from '../types'; import { Stats } from '../types';
import { prettify } from '../../utils/helpers/numbers'; import { prettify } from '../../utils/helpers/numbers';
import { pointerOnHover, renderDoughnutChartLabel, renderNonDoughnutChartLabel } from '../../utils/helpers/charts'; import { pointerOnHover, renderChartLabel, renderPieChartLabel } from '../../utils/helpers/charts';
import { import {
HIGHLIGHTED_COLOR, HIGHLIGHTED_COLOR,
HIGHLIGHTED_COLOR_ALPHA, HIGHLIGHTED_COLOR_ALPHA,
@ -16,10 +16,9 @@ import {
PRIMARY_DARK_COLOR, PRIMARY_DARK_COLOR,
PRIMARY_LIGHT_COLOR, PRIMARY_LIGHT_COLOR,
} from '../../utils/theme'; } from '../../utils/theme';
import './DefaultChart.scss'; import { PieChartLegend } from './PieChartLegend';
export interface DefaultChartProps { export interface DefaultChartProps {
title: Function | string;
stats: Stats; stats: Stats;
isBarChart?: boolean; isBarChart?: boolean;
max?: number; max?: number;
@ -28,45 +27,56 @@ export interface DefaultChartProps {
onClick?: (label: string) => void; onClick?: (label: string) => void;
} }
const generateGraphData = ( const generateChartDatasets = (
title: Function | string, 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, isBarChart: boolean,
labels: string[], labels: string[],
data: number[], data: number[],
highlightedData?: number[], highlightedData: number[],
highlightedLabel?: string, highlightedLabel?: string,
): ChartData => ({ ): ChartData => ({
labels, labels,
datasets: [ datasets: generateChartDatasets(isBarChart, data, highlightedData, highlightedLabel),
{
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[],
}); });
const dropLabelIfHidden = (label: string) => label.startsWith('hidden') ? '' : label; 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; return isBarChart && labels.length > 20 ? labels.length * 8 : undefined;
}; };
const renderPieChartLegend = ({ config }: Chart) => { const chartElementAtEvent = (
const { labels = [], datasets = [] } = config.data ?? {}; labels: string[],
const { defaultColor } = config.options ?? {} as any; onClick?: (label: string) => void,
const [{ backgroundColor: colors }] = datasets; ) => ([ chart ]: [{ index: number }]) => {
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 }]) => {
if (!onClick || !chart) { if (!onClick || !chart) {
return; return;
} }
const { _index, _chart: { data } } = chart; onClick(labels[chart.index]);
const { labels } = data;
onClick(labels?.[_index] as string);
}; };
const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0; const statsAreDefined = (stats: Stats | undefined): stats is Stats => !!stats && Object.keys(stats).length > 0;
const DefaultChart = ( const DefaultChart = memo((
{ title, isBarChart = false, stats, max, highlightedStats, highlightedLabel, onClick }: DefaultChartProps, { 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 labels = keys(stats).map(dropLabelIfHidden);
const data = values( const data = values(
!statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => { !statsAreDefined(highlightedStats) ? stats : keys(highlightedStats).reduce((acc, highlightedKey) => {
@ -126,59 +117,68 @@ const DefaultChart = (
return acc; return acc;
}, { ...stats }), }, { ...stats }),
); );
const highlightedData = statsAreDefined(highlightedStats) ? fillTheGaps(highlightedStats, labels) : undefined; const highlightedData = fillTheGaps(highlightedStats ?? {}, labels);
const [ chartRef, setChartRef ] = useState<HorizontalBar | Doughnut | undefined>();
const options: ChartOptions = { const options: ChartOptions = {
legend: { display: false }, plugins: {
legendCallback: !isBarChart && renderPieChartLegend as any, legend: { display: false },
scales: !isBarChart ? undefined : { tooltip: {
xAxes: [ intersect: !isBarChart,
{ mode: isBarChart ? 'y' : 'index',
ticks: { // Do not show tooltip on items with empty label when in a bar chart
beginAtZero: true, filter: ({ label }) => !isBarChart || label !== '',
precision: 0, callbacks: {
callback: prettify, label: isBarChart ? renderChartLabel : renderPieChartLabel,
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 !== '',
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); 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 ( return (
<div className="row"> <div className="row">
<div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}> <div className={classNames('col-sm-12', { 'col-md-7': !isBarChart })}>
<Component {/* It's VERY IMPORTANT to render two different components here, as one has 1 dataset and the other has 2 */}
ref={(element) => setChartRef(element ?? undefined)} {/* Using the same component causes a crash when switching from 1 to 2 datasets, and then back to 1 dataset */}
key={height} {highlightedStats !== undefined && renderChartComponent('with_stats')}
data={graphData} {highlightedStats === undefined && renderChartComponent('without_stats')}
options={options}
height={height}
getElementAtEvent={chartElementAtEvent(onClick)}
/>
</div> </div>
{!isBarChart && ( {!isBarChart && (
<div className="col-sm-12 col-md-5"> <div className="col-sm-12 col-md-5">
{chartRef?.chartInstance.generateLegend()} {chartRef && <PieChartLegend chart={chartRef} />}
</div> </div>
)} )}
</div> </div>
); );
}; });
export default DefaultChart; export default DefaultChart;

View file

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

View file

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

View file

@ -1,6 +1,6 @@
@import '../../utils/base'; @import '../../utils/base';
.default-chart__pie-chart-legend { .pie-chart-legend {
list-style-type: none; list-style-type: none;
padding: 0; padding: 0;
margin: 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; margin-top: .3rem;
} }
.default-chart__pie-chart-legend-item-color { .pie-chart-legend__item-color {
width: 20px; width: 20px;
min-width: 20px; min-width: 20px;
height: 20px; height: 20px;
@ -22,7 +22,7 @@
border-radius: 10px; border-radius: 10px;
} }
.default-chart__pie-chart-legend-item-text { .pie-chart-legend__item-text {
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; 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; const pickValueFromPair = ([ , value ]: StatsRow) => value;
interface SortableBarGraphProps extends DefaultChartProps { interface SortableBarGraphProps extends DefaultChartProps {
title: Function | string;
sortingItems: Record<string, string>; sortingItems: Record<string, string>;
withPagination?: boolean; withPagination?: boolean;
extraHeaderContent?: Function; extraHeaderContent?: Function;

View file

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

View file

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