From ccd1eda1668aab71ad7ab07ac0bca0a1e066443c Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:04:47 +0200 Subject: [PATCH 01/23] feat(ui): implement ComparativeFormulaWidgetUI --- packages/react-ui/package.json | 3 +- .../src/custom-components/AnimatedNumber.js | 43 ++++++ packages/react-ui/src/index.d.ts | 2 + packages/react-ui/src/index.js | 2 + packages/react-ui/src/types.d.ts | 36 +++++ .../src/widgets/ComparativeFormulaWidgetUI.js | 142 ++++++++++++++++++ yarn.lock | 5 + 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 packages/react-ui/src/custom-components/AnimatedNumber.js create mode 100644 packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index c021e6a5f..76863ff0f 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -72,7 +72,8 @@ "webpack-cli": "^4.5.0" }, "dependencies": { - "@babel/runtime": "^7.13.9" + "@babel/runtime": "^7.13.9", + "use-animate-number": "^1.0.5" }, "peerDependencies": { "@carto/react-core": "^1.5.0-alpha.4", diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js new file mode 100644 index 000000000..61f5e0167 --- /dev/null +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import useAnimateNumber from 'use-animate-number'; + +function countDecimals(n) { + if (Math.floor(n) === n) return 0; + return String(n).split('.')[1]?.length || 0; +} + +function AnimatedNumber({ enabled, value, options, formatter }) { + const defaultOptions = { + direct: true, + decimals: countDecimals(value), + disabled: enabled === false || value === null || value === undefined + }; + const [animated] = useAnimateNumber(value, { ...defaultOptions, ...options }); + return {formatter ? formatter(animated) : animated}; +} + +AnimatedNumber.displayName = 'AnimatedNumber'; +AnimatedNumber.defaultProps = { + enabled: true, + value: 0, + options: {}, + formatter: null +}; + +export const animationOptionsPropTypes = PropTypes.shape({ + duration: PropTypes.number, + enterance: PropTypes.bool, + direct: PropTypes.bool, + disabled: PropTypes.bool, + decimals: PropTypes.number +}); + +AnimatedNumber.propTypes = { + enabled: PropTypes.bool, + value: PropTypes.number.isRequired, + options: animationOptionsPropTypes, + formatter: PropTypes.func +}; + +export default AnimatedNumber; diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index 10060bdd7..eea9cbb02 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -22,6 +22,7 @@ import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; export { cartoThemeOptions, @@ -42,6 +43,7 @@ export { TableWidgetUI, LegendWidgetUI, RangeWidgetUI, + ComparativeFormulaWidgetUI, LEGEND_TYPES, NoDataAlert, LegendCategories, diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 23679104d..78bce6907 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -14,6 +14,7 @@ import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI'; import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; import RangeWidgetUI from './widgets/RangeWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; @@ -42,6 +43,7 @@ export { ScatterPlotWidgetUI, TimeSeriesWidgetUI, FeatureSelectionWidgetUI, + ComparativeFormulaWidgetUI, CHART_TYPES as TIME_SERIES_CHART_TYPES, TableWidgetUI, LegendWidgetUI, diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 89fd53651..a42095aea 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -188,3 +188,39 @@ export type LegendRamp = { colors?: string | string[] | number[][]; }; }; + +export type AnimationOptions = Partial<{ + duration: number; + enterance: boolean; + direct: boolean; + disabled: boolean; + decimals: number; +}>; + +type AnimatedNumber = { + enabled: boolean; + value: number; + options?: AnimationOptions; + formatter: (n: number) => React.ReactNode; +}; + +export type FormulaLabels = { + prefix?: React.ReactNode; + suffix?: React.ReactNode; + note?: React.ReactNode; +}; + +export type FormulaColors = { + [key in keyof FormulaLabels]?: string; +} & { + value?: string; +}; + +export type ComparativeFormulaWidgetUI = { + data: number[]; + labels?: FormulaLabels[]; + colors?: FormulaColors[]; + animated?: boolean; + animationOptions?: AnimationOptions; + formatter?: (n: number) => React.ReactNode; +}; diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js new file mode 100644 index 000000000..e87e3ba52 --- /dev/null +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Box, makeStyles, Typography } from '@material-ui/core'; +import { useTheme } from '@material-ui/core'; +import AnimatedNumber, { + animationOptionsPropTypes +} from '../custom-components/AnimatedNumber'; + +const IDENTITY_FN = (v) => v; +const EMPTY_ARRAY = []; + +const useStyles = makeStyles((theme) => ({ + formulaChart: {}, + formulaGroup: { + '& + $formulaGroup': { + marginTop: theme.spacing(2) + } + }, + firstLine: { + margin: 0, + ...theme.typography.h5, + fontWeight: Number(theme.typography.fontWeightMedium), + color: theme.palette.text.primary, + display: 'flex' + }, + unit: { + marginLeft: theme.spacing(0.5) + }, + unitBefore: { + marginLeft: 0, + marginRight: theme.spacing(0.5) + }, + note: { + display: 'inline-block', + marginTop: theme.spacing(0.5) + } +})); + +function ComparativeFormulaWidgetUI({ + data = EMPTY_ARRAY, + labels = EMPTY_ARRAY, + colors = EMPTY_ARRAY, + animated = true, + animationOptions, + formatter = IDENTITY_FN +}) { + const theme = useTheme(); + const classes = useStyles(); + + function getColor(index) { + return colors[index] || {}; + } + function getLabel(index) { + return labels[index] || {}; + } + + return ( +
+ {data + .filter((n) => n !== undefined) + .map((d, i) => ( +
+
+ {getLabel(i).prefix ? ( + + + {getLabel(i).prefix} + + + ) : null} + + + + {getLabel(i).suffix ? ( + + + {getLabel(i).suffix} + + + ) : null} +
+ {getLabel(i).note ? ( + + + {getLabel(i).note} + + + ) : null} +
+ ))} +
+ ); +} + +ComparativeFormulaWidgetUI.displayName = 'ComparativeFormulaWidgetUI'; +ComparativeFormulaWidgetUI.defaultProps = { + data: EMPTY_ARRAY, + labels: EMPTY_ARRAY, + colors: EMPTY_ARRAY, + animated: true, + animationOptions: {}, + formatter: IDENTITY_FN +}; + +const formulaLabelsPropTypes = PropTypes.shape({ + prefix: PropTypes.string, + suffix: PropTypes.string, + note: PropTypes.string +}); + +const formulaColorsPropTypes = PropTypes.shape({ + prefix: PropTypes.string, + suffix: PropTypes.string, + note: PropTypes.string, + value: PropTypes.string +}); + +ComparativeFormulaWidgetUI.propTypes = { + data: PropTypes.arrayOf(PropTypes.number), + labels: PropTypes.arrayOf(formulaLabelsPropTypes), + colors: PropTypes.arrayOf(formulaColorsPropTypes), + animated: PropTypes.bool, + animationOptions: animationOptionsPropTypes, + formatter: PropTypes.func +}; + +export default ComparativeFormulaWidgetUI; diff --git a/yarn.lock b/yarn.lock index d78b5ca7a..a0be4be35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17667,6 +17667,11 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" +use-animate-number@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/use-animate-number/-/use-animate-number-1.0.5.tgz#c224fd3ce81d0b563a5215d714aaa98ad8c67470" + integrity sha512-zAavKn83Z6V6wlatpEp+fmbAS6vMA3tISudw04eSeyXc0AZG98JDDK1U+gmMxV/oATcub+ijgrENMPqHue+SiA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 2ef27fe3d21bfe589d3b668adbc9d3f45a41c2d1 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:17:19 +0200 Subject: [PATCH 02/23] add storybook for ComparativeFormulaWidgetUI --- .../ComparativeFormulaWidgetUI.stories.js | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js new file mode 100644 index 000000000..e4bd611be --- /dev/null +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js @@ -0,0 +1,21 @@ +import React from 'react'; +import ComparativeFormulaWidgetUI from '../../../src/widgets/ComparativeFormulaWidgetUI'; + +const options = { + title: 'Custom Components/ComparativeFormulaWidgetUI', + component: ComparativeFormulaWidgetUI +}; + +export default options; + +const Template = (args) => ; + +export const Default = Template.bind({}); +Default.args = { + data: [1245, 3435.9], + labels: [ + { prefix: '$', suffix: ' sales', note: 'label 1' }, + { prefix: '$', suffix: ' sales', note: 'label 2' } + ], + colors: [{ note: '#ff9900' }, { note: '#6732a8' }] +}; From f76ccb7e12794b20effbae09714101c0c494dd2f Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 14:39:38 +0200 Subject: [PATCH 03/23] add changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1a209809..0a471910b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- Implement ComparativeFormulaWidgetUI [#504](https://github.com/CartoDB/carto-react/pull/504) + ## 1.5 ### 1.5.0-alpha.4 (2022-10-14) From faca867ec8333c39fa604eaec713c5b694068aa6 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:30:13 +0200 Subject: [PATCH 04/23] add JSDoc for ComparativeFormulaWidgetUI --- .../src/widgets/ComparativeFormulaWidgetUI.js | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index e87e3ba52..b3440f31d 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -36,6 +36,16 @@ const useStyles = makeStyles((theme) => ({ } })); +/** + * Renders a widget + * @param {Object} props + * @param {number[]} props.data + * @param {{ prefix?: string; suffix?: string; note?: string }[]} [props.labels] + * @param {{ prefix?: string; suffix?: string; note?: string; value?: string }[]} [props.colors] + * @param {boolean} [props.animated] + * @param {Object} [props.animationOptions] + * @param {(v: number) => React.ReactNode} [props.formatter] + */ function ComparativeFormulaWidgetUI({ data = EMPTY_ARRAY, labels = EMPTY_ARRAY, @@ -131,7 +141,7 @@ const formulaColorsPropTypes = PropTypes.shape({ }); ComparativeFormulaWidgetUI.propTypes = { - data: PropTypes.arrayOf(PropTypes.number), + data: PropTypes.arrayOf(PropTypes.number).isRequired, labels: PropTypes.arrayOf(formulaLabelsPropTypes), colors: PropTypes.arrayOf(formulaColorsPropTypes), animated: PropTypes.bool, From c711fc388211888fba79f6eedf2f03d1522e463a Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:31:14 +0200 Subject: [PATCH 05/23] add JSDoc for AnimatedNumber --- packages/react-ui/src/custom-components/AnimatedNumber.js | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js index 61f5e0167..fd21022b4 100644 --- a/packages/react-ui/src/custom-components/AnimatedNumber.js +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -7,6 +7,14 @@ function countDecimals(n) { return String(n).split('.')[1]?.length || 0; } +/** + * Renders a widget + * @param {Object} props + * @param {boolean} props.enabled + * @param {number} props.value + * @param {{ duration?: number; enterance?: boolean; direct?: boolean; disabled?: boolean; decimals?: number; }} [props.options] + * @param {(n: number) => React.ReactNode} [props.formatter] + */ function AnimatedNumber({ enabled, value, options, formatter }) { const defaultOptions = { direct: true, From 831f9cfe520d86b86325fed402d4632f4b7332b2 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:31:35 +0200 Subject: [PATCH 06/23] export type not exported --- packages/react-ui/src/types.d.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index a42095aea..994155168 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -197,7 +197,7 @@ export type AnimationOptions = Partial<{ decimals: number; }>; -type AnimatedNumber = { +export type AnimatedNumber = { enabled: boolean; value: number; options?: AnimationOptions; From 0cfe6fa1dbd234f0e13b93486b672ff2c28e6d78 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 24 Oct 2022 18:38:52 +0200 Subject: [PATCH 07/23] implement comparative category widget UI --- .../widgets/ComparativeCategoryWidgetUI.js | 697 ++++++++++++++++++ 1 file changed, 697 insertions(+) create mode 100644 packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js new file mode 100644 index 000000000..f921eaaba --- /dev/null +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -0,0 +1,697 @@ +import { + Box, + Button, + Checkbox, + darken, + Divider, + InputAdornment, + lighten, + Link, + makeStyles, + SvgIcon, + TextField, + Tooltip, + Typography, + useTheme, + withStyles +} from '@material-ui/core'; +import { Skeleton } from '@material-ui/lab'; +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +import AnimatedNumber, { + animationOptionsPropTypes +} from '../custom-components/AnimatedNumber'; + +/** + * Enum for ComparativeCategoryWidgetUI order types. 'RANKING' orders the data by value and 'FIXED' keep the order present in the original data + * @enum {string} + */ +export const ORDER_TYPES = { + RANKING: 'RANKING', + FIXED: 'FIXED' +}; + +const IDENTITY_FN = (v) => v; +const EMPTY_ARRAY = []; + +/** transpose incoming data to group items by column, apply colors and labels + * @param {{ name: string; value: number }[][]} data + * @param {string[]} colors + * @param {string[]} labels + * @param {string[]} selectedCategories + * @param {ORDER_TYPES} order + * + * @returns {{ label: string; key: string; data: { color: string; value: number }[] }[]} + */ +function transposeData(data, colors, labels, selectedCategories, order) { + const reference = data[0] || []; + const transposed = reference.map((item, itemIndex) => { + const isDisabled = + selectedCategories.length > 0 && selectedCategories.indexOf(item.name) === -1; + + const label = labels[itemIndex] || item.name; + const indexData = data.map((group, groupIndex) => ({ + color: isDisabled ? lighten(colors[groupIndex], 0.8) : colors[groupIndex], + value: group[itemIndex].value + })); + + return { + label, + key: item.name, + data: indexData + }; + }); + + // only sort the list if order type is 'RANKING' + // if order type is 'FIXED' keep the sort order from data + if (order === ORDER_TYPES.RANKING) { + transposed.sort((a, b) => { + const aMax = Math.max(...a.data.map((d) => d.value)); + const bMax = Math.max(...b.data.map((d) => d.value)); + return bMax - aMax; + }); + } + + return transposed; +} + +const useStyles = makeStyles((theme) => ({ + wrapper: { + borderTop: `1px solid ${theme.palette.divider}`, + padding: theme.spacing(2, 0), + ...theme.typography.body2 + }, + categoriesList: { + overflow: 'auto', + maxHeight: theme.spacing(40), + paddingRight: theme.spacing(1), + margin: theme.spacing(0.5, 0) + }, + label: {}, + positive: {}, + negative: {}, + progressbar: { + height: theme.spacing(0.5), + width: '100%', + margin: theme.spacing(0.5, 0, 1, 0), + borderRadius: theme.spacing(0.5), + backgroundColor: theme.palette.action.disabledBackground, + + '& div': { + width: 0, + height: '100%', + borderRadius: theme.spacing(0.5), + transition: `background-color ${theme.transitions.easing.sharp} ${theme.transitions.duration.shortest}ms, + width ${theme.transitions.easing.sharp} ${theme.transitions.duration.complex}ms` + } + }, + toolbar: { + marginBottom: theme.spacing(2), + paddingRight: theme.spacing(1), + + '& .MuiTypography-caption': { + color: theme.palette.text.secondary + }, + + '& .MuiButton-label': { + ...theme.typography.caption + }, + + '& a': { + cursor: 'pointer' + } + }, + searchInput: { + marginTop: theme.spacing(-0.5) + }, + categoryGroup: { + '& $progressbar div': { + backgroundColor: 'var(--color)' + } + }, + categoryGroupHover: { + cursor: 'pointer', + '&:hover $progressbar div': { + backgroundColor: 'var(--hover-color)' + }, + '& $progressbar div': { + backgroundColor: 'var(--color)' + } + }, + bullet: { + width: theme.spacing(1), + height: theme.spacing(1), + borderRadius: theme.spacing(1) + } +})); + +const OTHERS_KEY = 'others'; + +function SearchIcon() { + return ( + + + + ); +} + +/** Renders a widget + * @param {Object} props + * @param {string[]} props.names + * @param {{ name: string; value: number }[][]} props.data + * @param {string[]} [props.labels] + * @param {string[]} [props.colors] + * @param {number} [props.maxItems] + * @param {ORDER_TYPES} [props.order] + * @param {boolean} [props.animation] + * @param {Object} [props.animationOptions] + * @param {boolean} [props.searchable] + * @param {boolean} [props.searchable] + * @param {boolean} [props.filterable] + * @param {string[]} [props.selectedCategories] + * @param {(categories: string[]) => any} [props.onSelectedCategoriesChange] + * @param {(v: any) => any} [props.formatter] + */ +function MultipleCategoryWidgetUI({ + names = EMPTY_ARRAY, + data = EMPTY_ARRAY, + labels = EMPTY_ARRAY, + colors, + maxItems = 5, + order = ORDER_TYPES.FIXED, + animation = true, + animationOptions, + searchable = true, + filterable = true, + selectedCategories = EMPTY_ARRAY, + onSelectedCategoriesChange = IDENTITY_FN, + formatter = IDENTITY_FN +}) { + const classes = useStyles(); + const theme = useTheme(); + const [searchActive, setSearchActive] = useState(false); + const [blockingActive, setBlockingActive] = useState(false); + const [tempSelection, setTempSelection] = useState(selectedCategories); + const [searchValue, setSearchValue] = useState(''); + + // process incoming data to group items by column, apply colors and labels + const processedData = useMemo(() => { + const _colors = colors || [ + theme.palette.secondary.main, + theme.palette.primary.main, + theme.palette.info.main + ]; + return transposeData(data, _colors, labels, selectedCategories, order); + }, [data, colors, labels, theme, selectedCategories, order]); + + const maxValue = useMemo(() => { + return Math.max(...data.map((group) => group.map((g) => g.value)).flat()); + }, [data]); + + // cut the list created in processedData according to maxItems prop and create the 'Others' category with the rest + const compressedData = useMemo(() => { + if (maxItems >= processedData.length) { + return processedData; + } + + const visibleItems = processedData.slice(0, maxItems); + const otherItems = processedData.slice(maxItems); + + const otherSum = []; + for (const item of otherItems) { + item.data.forEach((d, i) => { + otherSum[i] = otherSum[i] || 0; + otherSum[i] += d.value; + }); + } + + const combinedOther = { + key: OTHERS_KEY, + label: searchable ? 'Others' : `Others (${processedData.length - maxItems})`, + data: otherSum.map((sum) => ({ + value: sum, + color: theme.palette.divider + })) + }; + + return [...visibleItems, combinedOther]; + }, [processedData, searchable, maxItems, theme.palette.divider]); + + // filter the list created in processedData using selected categories + const blockedData = useMemo(() => { + return processedData.filter((c) => selectedCategories.indexOf(c.key) !== -1); + }, [processedData, selectedCategories]); + + const filteredData = useMemo(() => { + if (!searchValue) { + return processedData; + } + + return processedData.filter((el) => { + const key = (el.key || '').toLowerCase(); + const label = (el.label || '').toLowerCase(); + + const keyMatches = key && key.indexOf(searchValue.toLowerCase()) !== -1; + const labelMatches = label && label.indexOf(searchValue.toLowerCase()) !== -1; + + return keyMatches || labelMatches; + }); + }, [processedData, searchValue]); + + const otherCount = processedData.length - compressedData.length + 1; + const showSearchToggle = searchable && !searchActive && maxItems < processedData.length; + + if (processedData.length === 0) { + return ; + } + + const list = searchActive + ? filteredData + : blockingActive + ? blockedData + : compressedData; + + function applyTempSelection() { + setBlockingActive(true); + onSelectedCategoriesChange([...tempSelection]); + disableSearchMode(); + } + + function disableBlocking() { + setBlockingActive(false); + } + + function clearSelection() { + onSelectedCategoriesChange([]); + } + + function enableBlocking() { + setBlockingActive(true); + } + + function enableSearchMode() { + setSearchActive(true); + setTempSelection([...selectedCategories]); + } + + function disableSearchMode() { + setSearchActive(false); + setTempSelection([]); + } + + function selectCategory(category) { + const isSelected = selectedCategories.indexOf(category) !== -1; + const set = new Set(selectedCategories); + if (isSelected) { + set.delete(category); + } else { + set.add(category); + } + + let newCategories = Array.from(set); + if (newCategories.length === processedData.length) { + newCategories = []; + } + + onSelectedCategoriesChange(newCategories); + } + + function selectTempCategory(category) { + const isSelected = tempSelection.indexOf(category) !== -1; + const set = new Set(tempSelection); + if (isSelected) { + set.delete(category); + } else { + set.add(category); + } + + let newCategories = Array.from(set); + if (newCategories.length === processedData.length) { + newCategories = []; + } + + setTempSelection(newCategories); + } + + const clickHandler = filterable + ? searchActive + ? selectTempCategory + : selectCategory + : undefined; + + return ( +
+ {filterable ? ( + + + {selectedCategories.length ? selectedCategories.length : 'All'} + {' selected'} + + + {searchActive ? ( + Apply + ) : blockingActive ? ( + Unlock + ) : selectedCategories.length ? ( + + Lock + + Clear + + ) : null} + + + ) : null} + {searchActive ? ( + + setSearchValue(ev.currentTarget.value)} + onFocus={(ev) => ev.currentTarget.scrollIntoView()} + className={classes.searchInput} + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + + ) : null} + + {list.length === 0 ? ( + <> + No results + + Your search "{searchValue}" didn't match with any value. + + + ) : null} + {list.map((d) => ( + + ))} + + {showSearchToggle ? ( + + ) : null} + {searchActive ? ( + + ) : null} + + {names.map((name, i) => ( + +
+ {name} +
+ ))} +
+
+ ); +} + +MultipleCategoryWidgetUI.displayName = 'MultipleCategoryWidgetUI'; +MultipleCategoryWidgetUI.defaultProps = { + names: EMPTY_ARRAY, + data: EMPTY_ARRAY, + labels: EMPTY_ARRAY, + colors: EMPTY_ARRAY, + maxItems: 5, + order: ORDER_TYPES.FIXED, + animation: true, + animationOptions: {}, + searchable: true, + filterable: true, + selectedCategories: [], + onSelectedCategoriesChange: IDENTITY_FN, + formatter: IDENTITY_FN +}; +MultipleCategoryWidgetUI.propTypes = { + names: PropTypes.arrayOf(PropTypes.string).isRequired, + data: PropTypes.arrayOf( + PropTypes.arrayOf( + PropTypes.shape({ + name: PropTypes.string.isRequired, + value: PropTypes.number.isRequired + }) + ) + ).isRequired, + labels: PropTypes.arrayOf(PropTypes.string), + colors: PropTypes.arrayOf(PropTypes.string), + maxItems: PropTypes.number, + order: PropTypes.oneOf([ORDER_TYPES.FIXED, ORDER_TYPES.RANKING]), + animation: PropTypes.bool, + animationOptions: animationOptionsPropTypes, + searchable: PropTypes.bool, + filterable: PropTypes.bool, + selectedCategories: PropTypes.arrayOf(PropTypes.string), + onSelectedCategoriesChange: PropTypes.func, + formatter: PropTypes.func +}; + +export default MultipleCategoryWidgetUI; + +const transposedCategoryItemPropTypes = PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + data: PropTypes.arrayOf( + PropTypes.shape({ + color: PropTypes.string.isRequired, + value: PropTypes.number.isRequired + }) + ).isRequired +}).isRequired; + +function MultipleCategoryUISkeleton() { + const classes = useStyles(); + return ( +
+ + + + + + + {[...Array(4)].map((_, i) => ( + + + + + + + + + + {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ))} +
+
+ ); +} + +function MultipleCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { + const theme = useTheme(); + const classes = useStyles(); + + return ( +
+ + {item.label} + + + {item.data.map((d, i) => ( + +
+ + {names[i]} + + + + {formatter(d.value)} + +
+ ))} +
+
+ ); +} + +MultipleCategoryTooltip.displayName = 'MultipleCategoryTooltip'; +MultipleCategoryTooltip.defaultProps = { + names: EMPTY_ARRAY, + formatter: IDENTITY_FN +}; +MultipleCategoryTooltip.propTypes = { + item: transposedCategoryItemPropTypes, + names: PropTypes.arrayOf(PropTypes.string).isRequired, + formatter: PropTypes.func +}; + +const StyledTooltip = withStyles((theme) => ({ + tooltip: { + color: theme.palette.common.white, + maxWidth: 260 + } +}))(Tooltip); + +function CategoryItem({ + item, + animation, + animationOptions, + maxValue, + showCheckbox, + checkboxChecked, + className, + formatter, + onClick = IDENTITY_FN, + names +}) { + const classes = useStyles(); + const theme = useTheme(); + const compareValue = useMemo(() => { + const reference = item.data[0].value; + const max = Math.max(...item.data.map((d) => d.value)); + return reference - max; + }, [item]); + + const valueColor = + Math.sign(compareValue) === -1 + ? theme.palette.error.main + : theme.palette.success.main; + + const numberColor = item.key === OTHERS_KEY ? theme.palette.text.disabled : valueColor; + + function getProgressbarLength(value) { + return `${Math.min(100, ((value || 0) / maxValue) * 100)}%`; + } + + const tooltipContent = ( + + ); + + return ( + + onClick(item.key)} + className={className} + > + {showCheckbox ? : null} + + + + + {item.label} + + + + + + + {item.data.map((d, i) => ( +
+
+
+ ))} +
+
+
+ ); +} + +CategoryItem.displayName = 'CategoryItem'; +CategoryItem.defaultProps = { + animation: true, + animationOptions: {}, + className: '', + formatter: IDENTITY_FN, + onClick: IDENTITY_FN +}; +CategoryItem.propTypes = { + item: transposedCategoryItemPropTypes, + animation: PropTypes.bool, + animationOptions: animationOptionsPropTypes, + maxValue: PropTypes.number.isRequired, + showCheckbox: PropTypes.bool.isRequired, + checkboxChecked: PropTypes.bool.isRequired, + className: PropTypes.string, + formatter: PropTypes.func, + onClick: PropTypes.func, + names: PropTypes.arrayOf(PropTypes.string).isRequired +}; From 67918b69cf424909c9d2075e1d77be6e669cd905 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Tue, 25 Oct 2022 12:46:42 +0200 Subject: [PATCH 08/23] finish comparative category widget ui addons - typings - jsdoc - exports - storybook --- packages/react-ui/src/index.d.ts | 3 ++ packages/react-ui/src/index.js | 5 ++ packages/react-ui/src/types.d.ts | 26 ++++++++++ .../widgets/ComparativeCategoryWidgetUI.js | 13 +++-- .../ComparativeCategoryWidgetUI.stories.js | 47 +++++++++++++++++++ 5 files changed, 87 insertions(+), 7 deletions(-) create mode 100644 packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index eea9cbb02..d69528de6 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -23,6 +23,7 @@ import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; +import ComparativeCategoryWidgetUI, { ORDER_TYPES } from './widgets/ComparativeCategoryWidgetUI'; export { cartoThemeOptions, @@ -44,6 +45,8 @@ export { LegendWidgetUI, RangeWidgetUI, ComparativeFormulaWidgetUI, + ComparativeCategoryWidgetUI, + ORDER_TYPES as CATEGORY_ORDER_TYPES, LEGEND_TYPES, NoDataAlert, LegendCategories, diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 78bce6907..4d5cb3236 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -15,6 +15,9 @@ import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI' import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; import RangeWidgetUI from './widgets/RangeWidgetUI'; import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; +import ComparativeCategoryWidgetUI, { + ORDER_TYPES +} from './widgets/ComparativeCategoryWidgetUI'; import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; @@ -44,6 +47,8 @@ export { TimeSeriesWidgetUI, FeatureSelectionWidgetUI, ComparativeFormulaWidgetUI, + ComparativeCategoryWidgetUI, + ORDER_TYPES as CATEGORY_ORDER_TYPES, CHART_TYPES as TIME_SERIES_CHART_TYPES, TableWidgetUI, LegendWidgetUI, diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 994155168..75ca11bf6 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -224,3 +224,29 @@ export type ComparativeFormulaWidgetUI = { animationOptions?: AnimationOptions; formatter?: (n: number) => React.ReactNode; }; + +export enum ORDER_TYPES { + RANKING = 'ranking', + FIXED = 'fixed', +} + +type CategoryData = { + name: string; + value: number; +}; + +export type MultipleCategoryWidgetUI = { + names: string[]; + data: CategoryData[][]; + labels?: string[]; + colors?: string[]; + maxItems?: number; + order?: ORDER_TYPES; + animation?: boolean; + animationOptions?: AnimationOptions; + searchable?: boolean; + filterable?: boolean; + selectedCategories?: string[]; + onSelectedCategoriesChange?: (categories: string[]) => any; + formatter?: (v: any) => string; +}; diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index f921eaaba..cb7b729d0 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -77,7 +77,6 @@ function transposeData(data, colors, labels, selectedCategories, order) { const useStyles = makeStyles((theme) => ({ wrapper: { - borderTop: `1px solid ${theme.palette.divider}`, padding: theme.spacing(2, 0), ...theme.typography.body2 }, @@ -159,7 +158,7 @@ function SearchIcon() { ); } -/** Renders a widget +/** Renders a widget * @param {Object} props * @param {string[]} props.names * @param {{ name: string; value: number }[][]} props.data @@ -176,7 +175,7 @@ function SearchIcon() { * @param {(categories: string[]) => any} [props.onSelectedCategoriesChange] * @param {(v: any) => any} [props.formatter] */ -function MultipleCategoryWidgetUI({ +function ComparativeCategoryWidgetUI({ names = EMPTY_ARRAY, data = EMPTY_ARRAY, labels = EMPTY_ARRAY, @@ -451,8 +450,8 @@ function MultipleCategoryWidgetUI({ ); } -MultipleCategoryWidgetUI.displayName = 'MultipleCategoryWidgetUI'; -MultipleCategoryWidgetUI.defaultProps = { +ComparativeCategoryWidgetUI.displayName = 'ComparativeCategoryWidgetUI'; +ComparativeCategoryWidgetUI.defaultProps = { names: EMPTY_ARRAY, data: EMPTY_ARRAY, labels: EMPTY_ARRAY, @@ -467,7 +466,7 @@ MultipleCategoryWidgetUI.defaultProps = { onSelectedCategoriesChange: IDENTITY_FN, formatter: IDENTITY_FN }; -MultipleCategoryWidgetUI.propTypes = { +ComparativeCategoryWidgetUI.propTypes = { names: PropTypes.arrayOf(PropTypes.string).isRequired, data: PropTypes.arrayOf( PropTypes.arrayOf( @@ -490,7 +489,7 @@ MultipleCategoryWidgetUI.propTypes = { formatter: PropTypes.func }; -export default MultipleCategoryWidgetUI; +export default ComparativeCategoryWidgetUI; const transposedCategoryItemPropTypes = PropTypes.shape({ key: PropTypes.string.isRequired, diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js new file mode 100644 index 000000000..9722c4847 --- /dev/null +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js @@ -0,0 +1,47 @@ +import React from 'react'; +import ComparativeCategoryWidgetUI from '../../../src/widgets/ComparativeCategoryWidgetUI'; + +const options = { + title: 'Custom Components/ComparativeCategoryWidgetUI', + component: ComparativeCategoryWidgetUI +}; + +export default options; + +const Template = (args) => ; + +const categoryData = [ + [ + { name: 'data 1', value: 245 }, + { name: 'data 2', value: 354 }, + { name: 'data 3', value: 245 }, + { name: 'data 4', value: 354 }, + { name: 'data 5', value: 245 }, + { name: 'data 6', value: 354 } + ], + [ + { name: 'data 1', value: 454 }, + { name: 'data 2', value: 346 }, + { name: 'data 3', value: 454 }, + { name: 'data 4', value: 346 }, + { name: 'data 5', value: 454 }, + { name: 'data 6', value: 346 } + ], + [ + { name: 'data 1', value: 532 }, + { name: 'data 2', value: 754 }, + { name: 'data 3', value: 532 }, + { name: 'data 4', value: 754 }, + { name: 'data 5', value: 532 }, + { name: 'data 6', value: 754 } + ] +]; + +export const Default = Template.bind({}); +Default.args = { + data: categoryData, + names: ['serie 1', 'serie 2', 'serie 3'], + labels: ['label 1', 'label 2', 'label 3', 'label 4', 'label 5', 'label 6'], + colors: ['#f27', '#fa0', '#32a852'], + maxItems: 3 +}; From 99d15f17dd90ee0e7b7ca65cfc5b8e6f33e83b79 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Tue, 25 Oct 2022 13:01:23 +0200 Subject: [PATCH 09/23] add changelog entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a471910b..ccce4123d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Not released - Implement ComparativeFormulaWidgetUI [#504](https://github.com/CartoDB/carto-react/pull/504) +- Implement ComparativeCategoryWidgetUI [#505](https://github.com/CartoDB/carto-react/pull/505) ## 1.5 From 67e294ddca5483943b11f01169875236f0c525fa Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:01:13 +0200 Subject: [PATCH 10/23] implement custom animation hook to replace npm lib --- packages/react-ui/package.json | 3 +- .../src/custom-components/AnimatedNumber.js | 19 +++------ .../react-ui/src/hooks/useAnimatedNumber.js | 39 +++++++++++++++++++ .../src/widgets/ComparativeFormulaWidgetUI.js | 2 +- yarn.lock | 5 --- 5 files changed, 46 insertions(+), 22 deletions(-) create mode 100644 packages/react-ui/src/hooks/useAnimatedNumber.js diff --git a/packages/react-ui/package.json b/packages/react-ui/package.json index 76863ff0f..c021e6a5f 100644 --- a/packages/react-ui/package.json +++ b/packages/react-ui/package.json @@ -72,8 +72,7 @@ "webpack-cli": "^4.5.0" }, "dependencies": { - "@babel/runtime": "^7.13.9", - "use-animate-number": "^1.0.5" + "@babel/runtime": "^7.13.9" }, "peerDependencies": { "@carto/react-core": "^1.5.0-alpha.4", diff --git a/packages/react-ui/src/custom-components/AnimatedNumber.js b/packages/react-ui/src/custom-components/AnimatedNumber.js index fd21022b4..24c794023 100644 --- a/packages/react-ui/src/custom-components/AnimatedNumber.js +++ b/packages/react-ui/src/custom-components/AnimatedNumber.js @@ -1,27 +1,21 @@ import React from 'react'; import PropTypes from 'prop-types'; -import useAnimateNumber from 'use-animate-number'; - -function countDecimals(n) { - if (Math.floor(n) === n) return 0; - return String(n).split('.')[1]?.length || 0; -} +import useAnimatedNumber from '../hooks/useAnimatedNumber'; /** * Renders a widget * @param {Object} props * @param {boolean} props.enabled * @param {number} props.value - * @param {{ duration?: number; enterance?: boolean; direct?: boolean; disabled?: boolean; decimals?: number; }} [props.options] + * @param {{ duration?: number; animateOnMount?: boolean; }} [props.options] * @param {(n: number) => React.ReactNode} [props.formatter] */ function AnimatedNumber({ enabled, value, options, formatter }) { const defaultOptions = { - direct: true, - decimals: countDecimals(value), + animateOnMount: true, disabled: enabled === false || value === null || value === undefined }; - const [animated] = useAnimateNumber(value, { ...defaultOptions, ...options }); + const animated = useAnimatedNumber(value, { ...defaultOptions, ...options }); return {formatter ? formatter(animated) : animated}; } @@ -35,10 +29,7 @@ AnimatedNumber.defaultProps = { export const animationOptionsPropTypes = PropTypes.shape({ duration: PropTypes.number, - enterance: PropTypes.bool, - direct: PropTypes.bool, - disabled: PropTypes.bool, - decimals: PropTypes.number + animateOnMount: PropTypes.bool }); AnimatedNumber.propTypes = { diff --git a/packages/react-ui/src/hooks/useAnimatedNumber.js b/packages/react-ui/src/hooks/useAnimatedNumber.js new file mode 100644 index 000000000..c6c77bf83 --- /dev/null +++ b/packages/react-ui/src/hooks/useAnimatedNumber.js @@ -0,0 +1,39 @@ +import { useEffect, useRef, useState } from "react"; +import { animateValue } from '../widgets/utils/animations'; + +/** + * React hook to handle animating value changes over time, abstracting the necesary state, refs and effects + * @param {number} value + * @param {{ disabled?: boolean; duration?: number; animateOnMount?: boolean }} [options] + */ +export default function useAnimatedNumber(value, options = {}) { + const { disabled, duration, animateOnMount } = options; + + // starting with a -1 to supress a typescript warning + const requestAnimationFrameRef = useRef(-1); + + // if we want to run the animation on mount, we set the start value as 0 and animate to the start value + const [animatedValue, setAnimatedValue] = useState(() => animateOnMount ? 0 : value); + + useEffect(() => { + if (!disabled) { + animateValue({ + start: animatedValue || 0, + end: value, + duration: duration || 500, // 500ms + drawFrame: (val) => setAnimatedValue(val), + requestRef: requestAnimationFrameRef + }); + } else { + setAnimatedValue(value) + } + + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + cancelAnimationFrame(requestAnimationFrameRef.current); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value, disabled, duration]); + + return animatedValue; +}; diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js index b3440f31d..50ca4045c 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js @@ -43,7 +43,7 @@ const useStyles = makeStyles((theme) => ({ * @param {{ prefix?: string; suffix?: string; note?: string }[]} [props.labels] * @param {{ prefix?: string; suffix?: string; note?: string; value?: string }[]} [props.colors] * @param {boolean} [props.animated] - * @param {Object} [props.animationOptions] + * @param {{ duration?: number; animateOnMount?: boolean; }} [props.animationOptions] * @param {(v: number) => React.ReactNode} [props.formatter] */ function ComparativeFormulaWidgetUI({ diff --git a/yarn.lock b/yarn.lock index a0be4be35..d78b5ca7a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17667,11 +17667,6 @@ url@^0.11.0: punycode "1.3.2" querystring "0.2.0" -use-animate-number@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/use-animate-number/-/use-animate-number-1.0.5.tgz#c224fd3ce81d0b563a5215d714aaa98ad8c67470" - integrity sha512-zAavKn83Z6V6wlatpEp+fmbAS6vMA3tISudw04eSeyXc0AZG98JDDK1U+gmMxV/oATcub+ijgrENMPqHue+SiA== - use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f" From 6a84ad4bc59f0b0d8cff82e39d79775e4d3b9c62 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:11:48 +0200 Subject: [PATCH 11/23] fix animation options exported type --- packages/react-ui/src/types.d.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 994155168..0bcc626d1 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -189,13 +189,10 @@ export type LegendRamp = { }; }; -export type AnimationOptions = Partial<{ - duration: number; - enterance: boolean; - direct: boolean; - disabled: boolean; - decimals: number; -}>; +export type AnimationOptions = { + duration?: number; + animateOnMount?: boolean; +}; export type AnimatedNumber = { enabled: boolean; From 83b0adb5c2f4ee1be9c70d7ab99e842088eff219 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 26 Oct 2022 14:17:34 +0200 Subject: [PATCH 12/23] fix types and naming for comparative category widget --- packages/react-ui/src/types.d.ts | 2 +- .../src/widgets/ComparativeCategoryWidgetUI.js | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 72af4acaf..fdd3f02df 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -232,7 +232,7 @@ type CategoryData = { value: number; }; -export type MultipleCategoryWidgetUI = { +export type ComparativeCategoryWidgetUI = { names: string[]; data: CategoryData[][]; labels?: string[]; diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index cb7b729d0..90db2087b 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -167,7 +167,7 @@ function SearchIcon() { * @param {number} [props.maxItems] * @param {ORDER_TYPES} [props.order] * @param {boolean} [props.animation] - * @param {Object} [props.animationOptions] + * @param {{ duration?: number; animateOnMount?: boolean; }} [props.animationOptions] * @param {boolean} [props.searchable] * @param {boolean} [props.searchable] * @param {boolean} [props.filterable] @@ -265,7 +265,7 @@ function ComparativeCategoryWidgetUI({ const showSearchToggle = searchable && !searchActive && maxItems < processedData.length; if (processedData.length === 0) { - return ; + return ; } const list = searchActive @@ -502,7 +502,7 @@ const transposedCategoryItemPropTypes = PropTypes.shape({ ).isRequired }).isRequired; -function MultipleCategoryUISkeleton() { +function ComparativeCategoryUISkeleton() { const classes = useStyles(); return (
@@ -537,7 +537,7 @@ function MultipleCategoryUISkeleton() { ); } -function MultipleCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { +function ComparativeCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { const theme = useTheme(); const classes = useStyles(); @@ -575,12 +575,12 @@ function MultipleCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { ); } -MultipleCategoryTooltip.displayName = 'MultipleCategoryTooltip'; -MultipleCategoryTooltip.defaultProps = { +ComparativeCategoryTooltip.displayName = 'ComparativeCategoryTooltip'; +ComparativeCategoryTooltip.defaultProps = { names: EMPTY_ARRAY, formatter: IDENTITY_FN }; -MultipleCategoryTooltip.propTypes = { +ComparativeCategoryTooltip.propTypes = { item: transposedCategoryItemPropTypes, names: PropTypes.arrayOf(PropTypes.string).isRequired, formatter: PropTypes.func @@ -625,7 +625,7 @@ function CategoryItem({ } const tooltipContent = ( - + ); return ( From 207056ece8601012e227d484e34ef982c1ee5e78 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Fri, 28 Oct 2022 16:43:35 +0200 Subject: [PATCH 13/23] fix storybook for comparative category widget --- packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index 90db2087b..329e2d606 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -158,7 +158,9 @@ function SearchIcon() { ); } -/** Renders a widget +/** Renders a `` widget + * + * */ function ComparativeCategoryWidgetUI({ names = EMPTY_ARRAY, From 5036fcd14930f135f15262a0a4e4ad8a82794de9 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 12:17:05 +0100 Subject: [PATCH 14/23] fix bug for empty comparative data --- packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index 329e2d606..fb3247d83 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -52,7 +52,7 @@ function transposeData(data, colors, labels, selectedCategories, order) { const label = labels[itemIndex] || item.name; const indexData = data.map((group, groupIndex) => ({ color: isDisabled ? lighten(colors[groupIndex], 0.8) : colors[groupIndex], - value: group[itemIndex].value + value: group[itemIndex] ? group[itemIndex].value : 0 })); return { From fc2c1ab5b92eac37ce15f0ece67b511f11ca7ac9 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 16:08:23 +0100 Subject: [PATCH 15/23] fix layout bug in bullets --- packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index fb3247d83..d3b3251aa 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -138,6 +138,7 @@ const useStyles = makeStyles((theme) => ({ } }, bullet: { + flexShrink: 0, width: theme.spacing(1), height: theme.spacing(1), borderRadius: theme.spacing(1) From 7ab9e50da90a876d352816415b5c0e2451c7a857 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 17:14:49 +0100 Subject: [PATCH 16/23] reuse ORDER_TYPES from CategoryWidgetUI in ComparativeCategoryWidgetUI --- packages/react-ui/src/widgets/CategoryWidgetUI.js | 4 ++++ .../src/widgets/ComparativeCategoryWidgetUI.js | 15 ++++----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/packages/react-ui/src/widgets/CategoryWidgetUI.js b/packages/react-ui/src/widgets/CategoryWidgetUI.js index 72bd6990d..6fc1f5c4e 100644 --- a/packages/react-ui/src/widgets/CategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/CategoryWidgetUI.js @@ -597,6 +597,10 @@ function CategoryWidgetUI(props) { ); } +/** + * Enum for CategoryWidgetUI order types. 'RANKING' orders the data by value and 'FIXED' keeps the order present in the original data + * @enum {string} + */ CategoryWidgetUI.ORDER_TYPES = { RANKING: 'ranking', FIXED: 'fixed' diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js index d3b3251aa..27d354e52 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js @@ -21,25 +21,18 @@ import PropTypes from 'prop-types'; import AnimatedNumber, { animationOptionsPropTypes } from '../custom-components/AnimatedNumber'; - -/** - * Enum for ComparativeCategoryWidgetUI order types. 'RANKING' orders the data by value and 'FIXED' keep the order present in the original data - * @enum {string} - */ -export const ORDER_TYPES = { - RANKING: 'RANKING', - FIXED: 'FIXED' -}; +import CategoryWidgetUI from '../widgets/CategoryWidgetUI'; const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; +const ORDER_TYPES = CategoryWidgetUI.ORDER_TYPES; /** transpose incoming data to group items by column, apply colors and labels * @param {{ name: string; value: number }[][]} data * @param {string[]} colors * @param {string[]} labels * @param {string[]} selectedCategories - * @param {ORDER_TYPES} order + * @param {CategoryWidgetUI.ORDER_TYPES} order * * @returns {{ label: string; key: string; data: { color: string; value: number }[] }[]} */ @@ -168,7 +161,7 @@ function SearchIcon() { * @param {string[]} [props.labels] * @param {string[]} [props.colors] * @param {number} [props.maxItems] - * @param {ORDER_TYPES} [props.order] + * @param {CategoryWidgetUI.ORDER_TYPES} [props.order] * @param {boolean} [props.animation] * @param {{ duration?: number; animateOnMount?: boolean; }} [props.animationOptions] * @param {boolean} [props.searchable] From 3a1f9c05284471a05b83c5fa0a69c42d4022f844 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 17:34:27 +0100 Subject: [PATCH 17/23] move comparative widgets to their own folder --- packages/react-ui/src/index.d.ts | 5 ++--- packages/react-ui/src/index.js | 7 ++----- .../ComparativeCategoryWidgetUI.js | 4 ++-- .../{ => comparative}/ComparativeFormulaWidgetUI.js | 2 +- .../widgetsUI/ComparativeCategoryWidgetUI.stories.js | 2 +- .../widgetsUI/ComparativeFormulaWidgetUI.stories.js | 2 +- 6 files changed, 9 insertions(+), 13 deletions(-) rename packages/react-ui/src/widgets/{ => comparative/ComparativeCategoryWidgetUI}/ComparativeCategoryWidgetUI.js (99%) rename packages/react-ui/src/widgets/{ => comparative}/ComparativeFormulaWidgetUI.js (98%) diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index 3dfef57c7..f3b7a4d1c 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -22,8 +22,8 @@ import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; -import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; -import ComparativeCategoryWidgetUI, { ORDER_TYPES } from './widgets/ComparativeCategoryWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/comparative/ComparativeFormulaWidgetUI'; +import ComparativeCategoryWidgetUI from './widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI'; export { cartoThemeOptions, @@ -46,7 +46,6 @@ export { FeatureSelectionWidgetUI, ComparativeFormulaWidgetUI, ComparativeCategoryWidgetUI, - ORDER_TYPES as CATEGORY_ORDER_TYPES, LEGEND_TYPES, NoDataAlert, LegendCategories, diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index dc3252b8d..28534c5b8 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -14,10 +14,8 @@ import ScatterPlotWidgetUI from './widgets/ScatterPlotWidgetUI'; import TimeSeriesWidgetUI from './widgets/TimeSeriesWidgetUI/TimeSeriesWidgetUI'; import FeatureSelectionWidgetUI from './widgets/FeatureSelectionWidgetUI'; import RangeWidgetUI from './widgets/RangeWidgetUI'; -import ComparativeFormulaWidgetUI from './widgets/ComparativeFormulaWidgetUI'; -import ComparativeCategoryWidgetUI, { - ORDER_TYPES -} from './widgets/ComparativeCategoryWidgetUI'; +import ComparativeFormulaWidgetUI from './widgets/comparative/ComparativeFormulaWidgetUI'; +import ComparativeCategoryWidgetUI from './widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI'; import { CHART_TYPES } from './widgets/TimeSeriesWidgetUI/utils/constants'; import TableWidgetUI from './widgets/TableWidgetUI/TableWidgetUI'; import NoDataAlert from './widgets/NoDataAlert'; @@ -52,7 +50,6 @@ export { RangeWidgetUI, ComparativeFormulaWidgetUI, ComparativeCategoryWidgetUI, - ORDER_TYPES as CATEGORY_ORDER_TYPES, LEGEND_TYPES, NoDataAlert, featureSelectionIcons, diff --git a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js similarity index 99% rename from packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js rename to packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js index 27d354e52..acbb641a8 100644 --- a/packages/react-ui/src/widgets/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js @@ -20,8 +20,8 @@ import React, { useMemo, useState } from 'react'; import PropTypes from 'prop-types'; import AnimatedNumber, { animationOptionsPropTypes -} from '../custom-components/AnimatedNumber'; -import CategoryWidgetUI from '../widgets/CategoryWidgetUI'; +} from '../../../custom-components/AnimatedNumber'; +import CategoryWidgetUI from '../../CategoryWidgetUI'; const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; diff --git a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js similarity index 98% rename from packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js rename to packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js index 414549439..29cc643a7 100644 --- a/packages/react-ui/src/widgets/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js @@ -4,7 +4,7 @@ import { Box, makeStyles, Typography } from '@material-ui/core'; import { useTheme } from '@material-ui/core'; import AnimatedNumber, { animationOptionsPropTypes -} from '../custom-components/AnimatedNumber'; +} from '../../custom-components/AnimatedNumber'; const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js index 9722c4847..27ed40eea 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeCategoryWidgetUI.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import ComparativeCategoryWidgetUI from '../../../src/widgets/ComparativeCategoryWidgetUI'; +import ComparativeCategoryWidgetUI from '../../../src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI'; const options = { title: 'Custom Components/ComparativeCategoryWidgetUI', diff --git a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js index 3389ea4d2..dea95b87d 100644 --- a/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js +++ b/packages/react-ui/storybook/stories/widgetsUI/ComparativeFormulaWidgetUI.stories.js @@ -1,5 +1,5 @@ import React from 'react'; -import ComparativeFormulaWidgetUI from '../../../src/widgets/ComparativeFormulaWidgetUI'; +import ComparativeFormulaWidgetUI from '../../../src/widgets/comparative/ComparativeFormulaWidgetUI'; import { buildReactPropsAsString } from '../../utils' const options = { From 7e09a14d276fc3432a3f78ee695c1e435a14df2d Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 18:18:58 +0100 Subject: [PATCH 18/23] split comparative category widgets in many files --- .../CategoryItem.js | 180 +++++++++ .../CategorySkeleton.js | 38 ++ .../ComparativeCategoryWidgetUI.js | 341 +----------------- .../transposeCategoryData.js | 57 +++ .../useCategoryStyles.js | 68 ++++ 5 files changed, 353 insertions(+), 331 deletions(-) create mode 100644 packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js create mode 100644 packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js create mode 100644 packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/transposeCategoryData.js create mode 100644 packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/useCategoryStyles.js diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js new file mode 100644 index 000000000..bb4a94267 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js @@ -0,0 +1,180 @@ +import { + Box, + Checkbox, + darken, + Tooltip, + Typography, + useTheme, + withStyles +} from '@material-ui/core'; +import React, { useMemo } from 'react'; +import PropTypes from 'prop-types'; +import AnimatedNumber, { + animationOptionsPropTypes +} from '../../../custom-components/AnimatedNumber'; +import { transposedCategoryItemPropTypes } from './transposeCategoryData'; +import { OTHERS_KEY } from './ComparativeCategoryWidgetUI'; +import { useCategoryStyles } from './useCategoryStyles'; + +const IDENTITY_FN = (v) => v; +const EMPTY_ARRAY = []; + +function ComparativeCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { + const theme = useTheme(); + const classes = useCategoryStyles(); + + return ( +
+ + {item.label} + + + {item.data.map((d, i) => ( + +
+ + {names[i]} + + + + {formatter(d.value)} + +
+ ))} +
+
+ ); +} + +ComparativeCategoryTooltip.displayName = 'ComparativeCategoryTooltip'; +ComparativeCategoryTooltip.defaultProps = { + names: EMPTY_ARRAY, + formatter: IDENTITY_FN +}; +ComparativeCategoryTooltip.propTypes = { + item: transposedCategoryItemPropTypes, + names: PropTypes.arrayOf(PropTypes.string).isRequired, + formatter: PropTypes.func +}; + +const StyledTooltip = withStyles((theme) => ({ + tooltip: { + color: theme.palette.common.white, + maxWidth: 260 + } +}))(Tooltip); + +function CategoryItem({ + item, + animation, + animationOptions, + maxValue, + showCheckbox, + checkboxChecked, + className, + formatter, + onClick = IDENTITY_FN, + names +}) { + const classes = useCategoryStyles(); + const theme = useTheme(); + const compareValue = useMemo(() => { + const reference = item.data[0].value; + const max = Math.max(...item.data.map((d) => d.value)); + return reference - max; + }, [item]); + + const valueColor = + Math.sign(compareValue) === -1 + ? theme.palette.error.main + : theme.palette.success.main; + + const numberColor = item.key === OTHERS_KEY ? theme.palette.text.disabled : valueColor; + + function getProgressbarLength(value) { + return `${Math.min(100, ((value || 0) / maxValue) * 100)}%`; + } + + const tooltipContent = ( + + ); + + return ( + + onClick(item.key)} + className={className} + > + {showCheckbox ? : null} + + + + + {item.label} + + + + + + + {item.data.map((d, i) => ( +
+
+
+ ))} +
+
+
+ ); +} + +CategoryItem.displayName = 'CategoryItem'; +CategoryItem.defaultProps = { + animation: true, + animationOptions: {}, + className: '', + formatter: IDENTITY_FN, + onClick: IDENTITY_FN +}; +CategoryItem.propTypes = { + item: transposedCategoryItemPropTypes, + animation: PropTypes.bool, + animationOptions: animationOptionsPropTypes, + maxValue: PropTypes.number.isRequired, + showCheckbox: PropTypes.bool.isRequired, + checkboxChecked: PropTypes.bool.isRequired, + className: PropTypes.string, + formatter: PropTypes.func, + onClick: PropTypes.func, + names: PropTypes.arrayOf(PropTypes.string).isRequired +}; + +export default CategoryItem; diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js new file mode 100644 index 000000000..54dbd61a7 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js @@ -0,0 +1,38 @@ +import { Box, Typography } from "@material-ui/core"; +import { Skeleton } from "@material-ui/lab"; +import { useCategoryStyles } from "./useCategoryStyles"; + +export default function CategorySkeleton() { + const classes = useCategoryStyles(); + return ( +
+ + + + + + + {[...Array(4)].map((_, i) => ( + + + + + + + + + + {[...Array(3)].map((_, i) => ( +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js index acbb641a8..d123b42e4 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js @@ -1,144 +1,27 @@ import { Box, Button, - Checkbox, - darken, Divider, InputAdornment, - lighten, Link, - makeStyles, SvgIcon, TextField, - Tooltip, Typography, - useTheme, - withStyles + useTheme } from '@material-ui/core'; -import { Skeleton } from '@material-ui/lab'; import React, { useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import AnimatedNumber, { - animationOptionsPropTypes -} from '../../../custom-components/AnimatedNumber'; +import { animationOptionsPropTypes } from '../../../custom-components/AnimatedNumber'; import CategoryWidgetUI from '../../CategoryWidgetUI'; +import { transposeCategoryData } from './transposeCategoryData'; +import { useCategoryStyles } from './useCategoryStyles'; +import CategoryItem from './CategoryItem'; +import CategorySkeleton from './CategorySkeleton'; const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; const ORDER_TYPES = CategoryWidgetUI.ORDER_TYPES; - -/** transpose incoming data to group items by column, apply colors and labels - * @param {{ name: string; value: number }[][]} data - * @param {string[]} colors - * @param {string[]} labels - * @param {string[]} selectedCategories - * @param {CategoryWidgetUI.ORDER_TYPES} order - * - * @returns {{ label: string; key: string; data: { color: string; value: number }[] }[]} - */ -function transposeData(data, colors, labels, selectedCategories, order) { - const reference = data[0] || []; - const transposed = reference.map((item, itemIndex) => { - const isDisabled = - selectedCategories.length > 0 && selectedCategories.indexOf(item.name) === -1; - - const label = labels[itemIndex] || item.name; - const indexData = data.map((group, groupIndex) => ({ - color: isDisabled ? lighten(colors[groupIndex], 0.8) : colors[groupIndex], - value: group[itemIndex] ? group[itemIndex].value : 0 - })); - - return { - label, - key: item.name, - data: indexData - }; - }); - - // only sort the list if order type is 'RANKING' - // if order type is 'FIXED' keep the sort order from data - if (order === ORDER_TYPES.RANKING) { - transposed.sort((a, b) => { - const aMax = Math.max(...a.data.map((d) => d.value)); - const bMax = Math.max(...b.data.map((d) => d.value)); - return bMax - aMax; - }); - } - - return transposed; -} - -const useStyles = makeStyles((theme) => ({ - wrapper: { - padding: theme.spacing(2, 0), - ...theme.typography.body2 - }, - categoriesList: { - overflow: 'auto', - maxHeight: theme.spacing(40), - paddingRight: theme.spacing(1), - margin: theme.spacing(0.5, 0) - }, - label: {}, - positive: {}, - negative: {}, - progressbar: { - height: theme.spacing(0.5), - width: '100%', - margin: theme.spacing(0.5, 0, 1, 0), - borderRadius: theme.spacing(0.5), - backgroundColor: theme.palette.action.disabledBackground, - - '& div': { - width: 0, - height: '100%', - borderRadius: theme.spacing(0.5), - transition: `background-color ${theme.transitions.easing.sharp} ${theme.transitions.duration.shortest}ms, - width ${theme.transitions.easing.sharp} ${theme.transitions.duration.complex}ms` - } - }, - toolbar: { - marginBottom: theme.spacing(2), - paddingRight: theme.spacing(1), - - '& .MuiTypography-caption': { - color: theme.palette.text.secondary - }, - - '& .MuiButton-label': { - ...theme.typography.caption - }, - - '& a': { - cursor: 'pointer' - } - }, - searchInput: { - marginTop: theme.spacing(-0.5) - }, - categoryGroup: { - '& $progressbar div': { - backgroundColor: 'var(--color)' - } - }, - categoryGroupHover: { - cursor: 'pointer', - '&:hover $progressbar div': { - backgroundColor: 'var(--hover-color)' - }, - '& $progressbar div': { - backgroundColor: 'var(--color)' - } - }, - bullet: { - flexShrink: 0, - width: theme.spacing(1), - height: theme.spacing(1), - borderRadius: theme.spacing(1) - } -})); - -const OTHERS_KEY = 'others'; +export const OTHERS_KEY = 'others'; function SearchIcon() { return ( @@ -187,7 +70,7 @@ function ComparativeCategoryWidgetUI({ onSelectedCategoriesChange = IDENTITY_FN, formatter = IDENTITY_FN }) { - const classes = useStyles(); + const classes = useCategoryStyles(); const theme = useTheme(); const [searchActive, setSearchActive] = useState(false); const [blockingActive, setBlockingActive] = useState(false); @@ -201,7 +84,7 @@ function ComparativeCategoryWidgetUI({ theme.palette.primary.main, theme.palette.info.main ]; - return transposeData(data, _colors, labels, selectedCategories, order); + return transposeCategoryData(data, _colors, labels, selectedCategories, order); }, [data, colors, labels, theme, selectedCategories, order]); const maxValue = useMemo(() => { @@ -262,7 +145,7 @@ function ComparativeCategoryWidgetUI({ const showSearchToggle = searchable && !searchActive && maxItems < processedData.length; if (processedData.length === 0) { - return ; + return ; } const list = searchActive @@ -487,207 +370,3 @@ ComparativeCategoryWidgetUI.propTypes = { }; export default ComparativeCategoryWidgetUI; - -const transposedCategoryItemPropTypes = PropTypes.shape({ - key: PropTypes.string.isRequired, - label: PropTypes.string.isRequired, - data: PropTypes.arrayOf( - PropTypes.shape({ - color: PropTypes.string.isRequired, - value: PropTypes.number.isRequired - }) - ).isRequired -}).isRequired; - -function ComparativeCategoryUISkeleton() { - const classes = useStyles(); - return ( -
- - - - - - - {[...Array(4)].map((_, i) => ( - - - - - - - - - - {[...Array(3)].map((_, i) => ( -
- ))} -
- ))} -
-
- ); -} - -function ComparativeCategoryTooltip({ item, names, formatter = IDENTITY_FN }) { - const theme = useTheme(); - const classes = useStyles(); - - return ( -
- - {item.label} - - - {item.data.map((d, i) => ( - -
- - {names[i]} - - - - {formatter(d.value)} - -
- ))} -
-
- ); -} - -ComparativeCategoryTooltip.displayName = 'ComparativeCategoryTooltip'; -ComparativeCategoryTooltip.defaultProps = { - names: EMPTY_ARRAY, - formatter: IDENTITY_FN -}; -ComparativeCategoryTooltip.propTypes = { - item: transposedCategoryItemPropTypes, - names: PropTypes.arrayOf(PropTypes.string).isRequired, - formatter: PropTypes.func -}; - -const StyledTooltip = withStyles((theme) => ({ - tooltip: { - color: theme.palette.common.white, - maxWidth: 260 - } -}))(Tooltip); - -function CategoryItem({ - item, - animation, - animationOptions, - maxValue, - showCheckbox, - checkboxChecked, - className, - formatter, - onClick = IDENTITY_FN, - names -}) { - const classes = useStyles(); - const theme = useTheme(); - const compareValue = useMemo(() => { - const reference = item.data[0].value; - const max = Math.max(...item.data.map((d) => d.value)); - return reference - max; - }, [item]); - - const valueColor = - Math.sign(compareValue) === -1 - ? theme.palette.error.main - : theme.palette.success.main; - - const numberColor = item.key === OTHERS_KEY ? theme.palette.text.disabled : valueColor; - - function getProgressbarLength(value) { - return `${Math.min(100, ((value || 0) / maxValue) * 100)}%`; - } - - const tooltipContent = ( - - ); - - return ( - - onClick(item.key)} - className={className} - > - {showCheckbox ? : null} - - - - - {item.label} - - - - - - - {item.data.map((d, i) => ( -
-
-
- ))} -
-
-
- ); -} - -CategoryItem.displayName = 'CategoryItem'; -CategoryItem.defaultProps = { - animation: true, - animationOptions: {}, - className: '', - formatter: IDENTITY_FN, - onClick: IDENTITY_FN -}; -CategoryItem.propTypes = { - item: transposedCategoryItemPropTypes, - animation: PropTypes.bool, - animationOptions: animationOptionsPropTypes, - maxValue: PropTypes.number.isRequired, - showCheckbox: PropTypes.bool.isRequired, - checkboxChecked: PropTypes.bool.isRequired, - className: PropTypes.string, - formatter: PropTypes.func, - onClick: PropTypes.func, - names: PropTypes.arrayOf(PropTypes.string).isRequired -}; diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/transposeCategoryData.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/transposeCategoryData.js new file mode 100644 index 000000000..f505056d6 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/transposeCategoryData.js @@ -0,0 +1,57 @@ +import { lighten } from '@material-ui/core'; +import CategoryWidgetUI from '../../CategoryWidgetUI'; +import PropTypes from 'prop-types'; + +const ORDER_TYPES = CategoryWidgetUI.ORDER_TYPES; + +/** transpose incoming data to group items by column, apply colors and labels + * @param {{ name: string; value: number }[][]} data + * @param {string[]} colors + * @param {string[]} labels + * @param {string[]} selectedCategories + * @param {CategoryWidgetUI.ORDER_TYPES} order + * + * @returns {{ label: string; key: string; data: { color: string; value: number }[] }[]} + */ +export function transposeCategoryData(data, colors, labels, selectedCategories, order) { + const reference = data[0] || []; + const transposed = reference.map((item, itemIndex) => { + const isDisabled = + selectedCategories.length > 0 && selectedCategories.indexOf(item.name) === -1; + + const label = labels[itemIndex] || item.name; + const indexData = data.map((group, groupIndex) => ({ + color: isDisabled ? lighten(colors[groupIndex], 0.8) : colors[groupIndex], + value: group[itemIndex] ? group[itemIndex].value : 0 + })); + + return { + label, + key: item.name, + data: indexData + }; + }); + + // only sort the list if order type is 'RANKING' + // if order type is 'FIXED' keep the sort order from data + if (order === ORDER_TYPES.RANKING) { + transposed.sort((a, b) => { + const aMax = Math.max(...a.data.map((d) => d.value)); + const bMax = Math.max(...b.data.map((d) => d.value)); + return bMax - aMax; + }); + } + + return transposed; +} + +export const transposedCategoryItemPropTypes = PropTypes.shape({ + key: PropTypes.string.isRequired, + label: PropTypes.string.isRequired, + data: PropTypes.arrayOf( + PropTypes.shape({ + color: PropTypes.string.isRequired, + value: PropTypes.number.isRequired + }) + ).isRequired +}).isRequired; diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/useCategoryStyles.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/useCategoryStyles.js new file mode 100644 index 000000000..f5ddadd76 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/useCategoryStyles.js @@ -0,0 +1,68 @@ +import { makeStyles } from "@material-ui/core"; + +export const useCategoryStyles = makeStyles((theme) => ({ + wrapper: { + padding: theme.spacing(2, 0), + ...theme.typography.body2 + }, + categoriesList: { + overflow: 'auto', + maxHeight: theme.spacing(40), + paddingRight: theme.spacing(1), + margin: theme.spacing(0.5, 0) + }, + progressbar: { + height: theme.spacing(0.5), + width: '100%', + margin: theme.spacing(0.5, 0, 1, 0), + borderRadius: theme.spacing(0.5), + backgroundColor: theme.palette.action.disabledBackground, + + '& div': { + width: 0, + height: '100%', + borderRadius: theme.spacing(0.5), + transition: `background-color ${theme.transitions.easing.sharp} ${theme.transitions.duration.shortest}ms, + width ${theme.transitions.easing.sharp} ${theme.transitions.duration.complex}ms` + } + }, + toolbar: { + marginBottom: theme.spacing(2), + paddingRight: theme.spacing(1), + + '& .MuiTypography-caption': { + color: theme.palette.text.secondary + }, + + '& .MuiButton-label': { + ...theme.typography.caption + }, + + '& a': { + cursor: 'pointer' + } + }, + searchInput: { + marginTop: theme.spacing(-0.5) + }, + categoryGroup: { + '& $progressbar div': { + backgroundColor: 'var(--color)' + } + }, + categoryGroupHover: { + cursor: 'pointer', + '&:hover $progressbar div': { + backgroundColor: 'var(--hover-color)' + }, + '& $progressbar div': { + backgroundColor: 'var(--color)' + } + }, + bullet: { + flexShrink: 0, + width: theme.spacing(1), + height: theme.spacing(1), + borderRadius: theme.spacing(1) + } +})); From 74de18096c65db120979a6ecb4d2b1bc7ed51a0b Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Mon, 31 Oct 2022 18:20:26 +0100 Subject: [PATCH 19/23] remove unused class --- .../src/widgets/comparative/ComparativeFormulaWidgetUI.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js index 29cc643a7..7fe4ede4b 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeFormulaWidgetUI.js @@ -10,7 +10,6 @@ const IDENTITY_FN = (v) => v; const EMPTY_ARRAY = []; const useStyles = makeStyles((theme) => ({ - formulaChart: {}, formulaGroup: { '& + $formulaGroup': { marginTop: theme.spacing(2) @@ -67,7 +66,7 @@ function ComparativeFormulaWidgetUI({ } return ( -
+
{data .filter((n) => n !== undefined) .map((d, i) => ( From e483417efa36f02caf289fc1cdc1a6581da39fd2 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Thu, 3 Nov 2022 13:32:41 +0100 Subject: [PATCH 20/23] fix layout for long overflowing category labels --- .../comparative/ComparativeCategoryWidgetUI/CategoryItem.js | 5 +++-- .../ComparativeCategoryWidgetUI/CategorySkeleton.js | 3 ++- .../ComparativeCategoryWidgetUI.js | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js index bb4a94267..79c9b75fd 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js @@ -116,13 +116,14 @@ function CategoryItem({ display='flex' alignItems='center' flexWrap='nowrap' + overflow='hidden' gridGap={theme.spacing(1)} onClick={() => onClick(item.key)} className={className} > {showCheckbox ? : null} - - + + {item.label} diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js index 54dbd61a7..db607a27e 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js @@ -1,3 +1,4 @@ +import React from 'react' import { Box, Typography } from "@material-ui/core"; import { Skeleton } from "@material-ui/lab"; import { useCategoryStyles } from "./useCategoryStyles"; @@ -16,7 +17,7 @@ export default function CategorySkeleton() { - + {[...Array(4)].map((_, i) => ( diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js index d123b42e4..8b7176b28 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js @@ -268,7 +268,7 @@ function ComparativeCategoryWidgetUI({ /> ) : null} - + {list.length === 0 ? ( <> No results From 5b9b3270e04f3766350661f2dcddd1a64e1b7ed1 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Thu, 3 Nov 2022 13:33:55 +0100 Subject: [PATCH 21/23] fix compare value case for small reference value --- .../comparative/ComparativeCategoryWidgetUI/CategoryItem.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js index 79c9b75fd..2aa5786e8 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js @@ -91,7 +91,7 @@ function CategoryItem({ const theme = useTheme(); const compareValue = useMemo(() => { const reference = item.data[0].value; - const max = Math.max(...item.data.map((d) => d.value)); + const max = Math.max(...item.data.slice(1).map((d) => d.value)); return reference - max; }, [item]); From d68dac7ae6820cb0b0e5d84ff5a13a4e0ece9442 Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 9 Nov 2022 17:12:48 +0100 Subject: [PATCH 22/23] fix edge case for empty color array --- .../ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js index 8b7176b28..070bbadf7 100644 --- a/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js @@ -79,7 +79,7 @@ function ComparativeCategoryWidgetUI({ // process incoming data to group items by column, apply colors and labels const processedData = useMemo(() => { - const _colors = colors || [ + const _colors = colors?.length ? colors : [ theme.palette.secondary.main, theme.palette.primary.main, theme.palette.info.main From d011829864747f52751c6507b814d62cb99f7ddf Mon Sep 17 00:00:00 2001 From: "Juan D. Jara" Date: Wed, 9 Nov 2022 17:12:58 +0100 Subject: [PATCH 23/23] add tests for comparative category widget --- .../ComparativeCategoryWidgetUI.test.js | 241 ++++++++++++++++++ 1 file changed, 241 insertions(+) create mode 100644 packages/react-ui/__tests__/widgets/ComparativeCategoryWidgetUI.test.js diff --git a/packages/react-ui/__tests__/widgets/ComparativeCategoryWidgetUI.test.js b/packages/react-ui/__tests__/widgets/ComparativeCategoryWidgetUI.test.js new file mode 100644 index 000000000..520b6a318 --- /dev/null +++ b/packages/react-ui/__tests__/widgets/ComparativeCategoryWidgetUI.test.js @@ -0,0 +1,241 @@ +import React from 'react' +import ComparativeCategoryWidgetUI from '../../src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI'; +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const SAMPLE_DATA = [ + [ + { name: 'data 1', value: 245 }, + { name: 'data 2', value: 354 }, + { name: 'data 3', value: 245 }, + { name: 'data 4', value: 354 }, + { name: 'data 5', value: 245 }, + { name: 'data 6', value: 354 } + ], + [ + { name: 'data 1', value: 454 }, + { name: 'data 2', value: 346 }, + { name: 'data 3', value: 454 }, + { name: 'data 4', value: 346 }, + { name: 'data 5', value: 454 }, + { name: 'data 6', value: 346 } + ], + [ + { name: 'data 1', value: 532 }, + { name: 'data 2', value: 758 }, + { name: 'data 3', value: 532 }, + { name: 'data 4', value: 760 }, + { name: 'data 5', value: 532 }, + { name: 'data 6', value: 754 } + ] +]; + +const SAMPLE_NAMES = ['serie 1', 'serie 2', 'serie 3'] + +describe('ComparativeCategoryWidgetUI', () => { + test('item skeleton should display', () => { + const { container } = render(); + expect(container.querySelector('.MuiSkeleton-root')).toBeInTheDocument(); + }); + + test('simple', () => { + render( + + ); + + expect(screen.getByText(/data 1/)).toBeInTheDocument(); + + const [ref, ...others] = SAMPLE_DATA.map(s => s[0]); + const max = Math.max(...others.map((o) => o.value)); + const value = ref.value - max + + expect(screen.getAllByText(new RegExp(value))[0]).toBeInTheDocument() + }); + + test('with one selected category', () => { + render( + + ); + + expect(screen.getByText(/1 selected/)).toBeInTheDocument(); + }); + + describe('order', () => { + test('fixed', () => { + render( + + ); + + const renderedCategories = screen.getAllByText(/data \d/); + expect(renderedCategories[0].textContent).toBe('data 1'); + }) + test('ranking', () => { + render( + + ); + + const renderedCategories = screen.getAllByText(/data \d/); + expect(renderedCategories[0].textContent).toBe('data 4'); + }) + }) + + describe('events', () => { + test('category change', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/data 1/)); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + }) + + test('clear', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/Clear/)); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + }) + + test('lock', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/data 1/)); + expect(screen.getByText(/Lock/)).toBeInTheDocument(); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + }) + + test('unlock', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/data 1/)); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByText(/Lock/)); + expect(screen.queryByText(/Unlock/)).toBeInTheDocument(); + expect(screen.queryByText(/Lock/)).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText(/Unlock/)); + expect(screen.queryByText(/Lock/)).toBeInTheDocument(); + expect(screen.queryByText(/Unlock/)).not.toBeInTheDocument(); + }); + + test('search cycle', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/Search in 1 elements/)); + fireEvent.click(screen.getByText(/data 1/)); + fireEvent.click(screen.getByText(/Apply/)); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + }) + + test('search category', () => { + HTMLElement.prototype.scrollIntoView = jest.fn(); + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + fireEvent.click(screen.getByText(/Search in 1 elements/)); + userEvent.type(screen.getByRole('textbox'), 'data 1'); + fireEvent.click(screen.getByText(/data 1/)); + fireEvent.click(screen.getByText(/Apply/)); + expect(mockOnSelectedCategoriesChange).toHaveBeenCalledTimes(1); + }) + + test('search cancel', () => { + const mockOnSelectedCategoriesChange = jest.fn(); + render( + + ); + + expect(screen.getByText(/Search in 1 elements/)).toBeInTheDocument(); + fireEvent.click(screen.getByText(/Search in 1 elements/)); + fireEvent.click(screen.getByText(/Cancel/)); + expect(screen.getByText(/Search in 1 elements/)).toBeInTheDocument(); + }) + + test('searchable props', () => { + render( + + ); + + expect(screen.queryByText('Search in 1 elements')).not.toBeInTheDocument(); + expect(screen.getByText('Others (1)')).toBeInTheDocument(); + }) + }) +})