mirror of
https://github.com/shlinkio/shlink-web-client.git
synced 2024-12-23 09:30:31 +03:00
Merge pull request #487 from acelaya-forks/feature/chartjs-3
Feature/chartjs 3
This commit is contained in:
commit
27c4bd792b
16 changed files with 322 additions and 291 deletions
|
@ -14,5 +14,8 @@
|
||||||
"process": true,
|
"process": true,
|
||||||
"setImmediate": true
|
"setImmediate": true
|
||||||
},
|
},
|
||||||
"ignorePatterns": ["src/service*.ts"]
|
"ignorePatterns": ["src/service*.ts"],
|
||||||
|
"rules": {
|
||||||
|
"complexity": "off"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
38
CHANGELOG.md
38
CHANGELOG.md
|
@ -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
63
package-lock.json
generated
|
@ -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=="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) });
|
||||||
|
|
|
@ -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
|
|
||||||
};
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
28
src/visits/helpers/PieChartLegend.tsx
Normal file
28
src/visits/helpers/PieChartLegend.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
|
@ -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;
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
33
test/visits/helpers/PieChartLegend.test.tsx
Normal file
33
test/visits/helpers/PieChartLegend.test.tsx
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
Loading…
Reference in a new issue