diff --git a/.eslintrc b/.eslintrc index 1349351c..78ada1c2 100644 --- a/.eslintrc +++ b/.eslintrc @@ -14,5 +14,8 @@ "process": true, "setImmediate": true }, - "ignorePatterns": ["src/service*.ts"] + "ignorePatterns": ["src/service*.ts"], + "rules": { + "complexity": "off" + } } diff --git a/CHANGELOG.md b/CHANGELOG.md index ade230a7..1b36d99e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package-lock.json b/package-lock.json index e3daeb0d..8653490d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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==" } } }, diff --git a/package.json b/package.json index d2376a65..900cc2c5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/short-urls/ShortUrlForm.tsx b/src/short-urls/ShortUrlForm.tsx index 368da62e..96fc6eea 100644 --- a/src/short-urls/ShortUrlForm.tsx +++ b/src/short-urls/ShortUrlForm.tsx @@ -43,7 +43,7 @@ const toDate = (date?: string | Date): Date | undefined => typeof date === 'stri export const ShortUrlForm = ( TagsSelector: FC, DomainSelector: FC, -): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { // eslint-disable-line complexity +): FC => ({ mode, saving, onSave, initialState, selectedServer }) => { const [ shortUrlData, setShortUrlData ] = useState(initialState); const isEdit = mode === 'edit'; const changeTags = (tags: string[]) => setShortUrlData({ ...shortUrlData, tags: tags.map(normalizeTag) }); diff --git a/src/utils/helpers/charts.ts b/src/utils/helpers/charts.ts index e47d1b22..e702dc09 100644 --- a/src/utils/helpers/charts.ts +++ b/src/utils/helpers/charts.ts @@ -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, 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) => + `${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) => + `${label}: ${prettify(formattedValue)}`; diff --git a/src/utils/helpers/numbers.ts b/src/utils/helpers/numbers.ts index 559184bf..dbaf6406 100644 --- a/src/utils/helpers/numbers.ts +++ b/src/utils/helpers/numbers.ts @@ -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; diff --git a/src/visits/helpers/DefaultChart.tsx b/src/visits/helpers/DefaultChart.tsx index e68b1e4c..73107748 100644 --- a/src/visits/helpers/DefaultChart.tsx +++ b/src/visits/helpers/DefaultChart.tsx @@ -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 ( -
    - {labels.map((label, index) => ( -
  • -
    - {label} -
  • - ))} -
- ); -}; - -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(); // 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(); + 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) => ( + { + setChartRef(element ?? undefined); + }} + key={`${height}_${customKey}`} + data={chartData} + options={options} + height={height} + getElementAtEvent={chartElementAtEvent(labels, onClick) as any} + /> + ); + return (
- 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')}
{!isBarChart && (
- {chartRef?.chartInstance.generateLegend()} + {chartRef && }
)}
); -}; +}); export default DefaultChart; diff --git a/src/visits/helpers/GraphCard.tsx b/src/visits/helpers/GraphCard.tsx index c0709493..57388835 100644 --- a/src/visits/helpers/GraphCard.tsx +++ b/src/visits/helpers/GraphCard.tsx @@ -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) => ( {typeof title === 'function' ? title() : title} - + {footer && {footer}} diff --git a/src/visits/helpers/LineChartCard.tsx b/src/visits/helpers/LineChartCard.tsx index abc5ffc2..f6a8bcd3 100644 --- a/src/visits/helpers/LineChartCard.tsx +++ b/src/visits/helpers/LineChartCard.tsx @@ -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, 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 = () => ( + + ); return ( @@ -245,11 +253,10 @@ const LineChartCard = ( - + {/* 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()} ); diff --git a/src/visits/helpers/DefaultChart.scss b/src/visits/helpers/PieChartLegend.scss similarity index 64% rename from src/visits/helpers/DefaultChart.scss rename to src/visits/helpers/PieChartLegend.scss index e7a0bd9f..2d4956f6 100644 --- a/src/visits/helpers/DefaultChart.scss +++ b/src/visits/helpers/PieChartLegend.scss @@ -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; diff --git a/src/visits/helpers/PieChartLegend.tsx b/src/visits/helpers/PieChartLegend.tsx new file mode 100644 index 00000000..e6e51d27 --- /dev/null +++ b/src/visits/helpers/PieChartLegend.tsx @@ -0,0 +1,28 @@ +import { FC } from 'react'; +import { Chart } from 'chart.js'; +import './PieChartLegend.scss'; + +interface PieChartLegendProps { + chart: Chart; +} + +export const PieChartLegend: FC = ({ chart }) => { + const { config } = chart; + const { labels = [], datasets = [] } = config.data ?? {}; + const [{ backgroundColor: colors }] = datasets; + const { defaultColor } = config.options ?? {} as any; + + return ( +
    + {(labels as string[]).map((label, index) => ( +
  • +
    + {label} +
  • + ))} +
+ ); +}; diff --git a/src/visits/helpers/SortableBarGraph.tsx b/src/visits/helpers/SortableBarGraph.tsx index 6e2efae5..9a4bd429 100644 --- a/src/visits/helpers/SortableBarGraph.tsx +++ b/src/visits/helpers/SortableBarGraph.tsx @@ -14,6 +14,7 @@ const pickKeyFromPair = ([ key ]: StatsRow) => key; const pickValueFromPair = ([ , value ]: StatsRow) => value; interface SortableBarGraphProps extends DefaultChartProps { + title: Function | string; sortingItems: Record; withPagination?: boolean; extraHeaderContent?: Function; diff --git a/test/visits/helpers/DefaultChart.test.tsx b/test/visits/helpers/DefaultChart.test.tsx index 2e3a2e7b..fc7a6488 100644 --- a/test/visits/helpers/DefaultChart.test.tsx +++ b/test/visits/helpers/DefaultChart.test.tsx @@ -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('', () => { afterEach(() => wrapper?.unmount()); it('renders Doughnut when is not a bar chart', () => { - wrapper = shallow(); + wrapper = shallow(); const doughnut = wrapper.find(Doughnut); - const horizontal = wrapper.find(HorizontalBar); + 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('', () => { '#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(); + wrapper = shallow(); 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('', () => { [{ 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); + wrapper = shallow(); + 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(); diff --git a/test/visits/helpers/LineChartCard.test.tsx b/test/visits/helpers/LineChartCard.test.tsx index f7948102..0ef89866 100644 --- a/test/visits/helpers/LineChartCard.test.tsx +++ b/test/visits/helpers/LineChartCard.test.tsx @@ -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('', () => { let wrapper: ShallowWrapper; @@ -54,23 +55,27 @@ describe('', () => { 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('', () => { ])('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('', () => { Mock.of({ 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); }); }); diff --git a/test/visits/helpers/PieChartLegend.test.tsx b/test/visits/helpers/PieChartLegend.test.tsx new file mode 100644 index 00000000..eebf9365 --- /dev/null +++ b/test/visits/helpers/PieChartLegend.test.tsx @@ -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('', () => { + const labels = [ 'foo', 'bar', 'baz', 'foo2', 'bar2' ]; + const colors = [ 'foo_color', 'bar_color', 'baz_color' ]; + const defaultColor = 'red'; + const datasets = [ Mock.of({ backgroundColor: colors }) ]; + const chart = Mock.of({ + config: { + data: { labels, datasets }, + options: { defaultColor } as any, + }, + }); + + test('renders the expected amount of items with expected colors and labels', () => { + const wrapper = shallow(); + 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); + }); + }); +});