diff --git a/CHANGELOG.md b/CHANGELOG.md index eaad77d3e..e8d1ccdc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Not released +- Implement ComparativeFormulaWidgetUI [#504](https://github.com/CartoDB/carto-react/pull/504) +- Implement ComparativeCategoryWidgetUI [#505](https://github.com/CartoDB/carto-react/pull/505) +- AnimatedNumber component with hook wrapping `animateValue` [#509](https://github.com/CartoDB/carto-react/pull/509) - Fix `executeModel` through **POST** request [#525](https://github.com/CartoDB/carto-react/pull/525) ## 1.5 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(); + }) + }) +}) diff --git a/packages/react-ui/src/index.d.ts b/packages/react-ui/src/index.d.ts index eea9cbb02..f3b7a4d1c 100644 --- a/packages/react-ui/src/index.d.ts +++ b/packages/react-ui/src/index.d.ts @@ -22,7 +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 ComparativeFormulaWidgetUI from './widgets/comparative/ComparativeFormulaWidgetUI'; +import ComparativeCategoryWidgetUI from './widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI'; export { cartoThemeOptions, @@ -39,11 +40,12 @@ export { useTimeSeriesInteractivity, TimeSeriesProvider, CHART_TYPES as TIME_SERIES_CHART_TYPES, - FeatureSelectionWidgetUI, TableWidgetUI, LegendWidgetUI, RangeWidgetUI, + FeatureSelectionWidgetUI, ComparativeFormulaWidgetUI, + ComparativeCategoryWidgetUI, LEGEND_TYPES, NoDataAlert, LegendCategories, diff --git a/packages/react-ui/src/index.js b/packages/react-ui/src/index.js index 78bce6907..28534c5b8 100644 --- a/packages/react-ui/src/index.js +++ b/packages/react-ui/src/index.js @@ -14,7 +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 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'; @@ -41,13 +42,14 @@ export { BarWidgetUI, PieWidgetUI, ScatterPlotWidgetUI, - TimeSeriesWidgetUI, FeatureSelectionWidgetUI, - ComparativeFormulaWidgetUI, + TimeSeriesWidgetUI, CHART_TYPES as TIME_SERIES_CHART_TYPES, TableWidgetUI, LegendWidgetUI, RangeWidgetUI, + ComparativeFormulaWidgetUI, + ComparativeCategoryWidgetUI, LEGEND_TYPES, NoDataAlert, featureSelectionIcons, diff --git a/packages/react-ui/src/types.d.ts b/packages/react-ui/src/types.d.ts index 3e932533c..9e6d08d02 100644 --- a/packages/react-ui/src/types.d.ts +++ b/packages/react-ui/src/types.d.ts @@ -222,3 +222,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 ComparativeCategoryWidgetUI = { + 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/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/comparative/ComparativeCategoryWidgetUI/CategoryItem.js b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js new file mode 100644 index 000000000..2aa5786e8 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategoryItem.js @@ -0,0 +1,181 @@ +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.slice(1).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..db607a27e --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/CategorySkeleton.js @@ -0,0 +1,39 @@ +import React from 'react' +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 new file mode 100644 index 000000000..070bbadf7 --- /dev/null +++ b/packages/react-ui/src/widgets/comparative/ComparativeCategoryWidgetUI/ComparativeCategoryWidgetUI.js @@ -0,0 +1,372 @@ +import { + Box, + Button, + Divider, + InputAdornment, + Link, + SvgIcon, + TextField, + Typography, + useTheme +} from '@material-ui/core'; +import React, { useMemo, useState } from 'react'; +import PropTypes from 'prop-types'; +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; +export const OTHERS_KEY = 'others'; + +function SearchIcon() { + return ( + + + + ); +} + +/** Renders a `` widget + * + * + */ +function ComparativeCategoryWidgetUI({ + 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 = useCategoryStyles(); + 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?.length ? colors : [ + theme.palette.secondary.main, + theme.palette.primary.main, + theme.palette.info.main + ]; + return transposeCategoryData(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} +
+ ))} +
+
+ ); +} + +ComparativeCategoryWidgetUI.displayName = 'ComparativeCategoryWidgetUI'; +ComparativeCategoryWidgetUI.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 +}; +ComparativeCategoryWidgetUI.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 ComparativeCategoryWidgetUI; 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) + } +})); 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 8ee653093..7fe4ede4b 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 new file mode 100644 index 000000000..27ed40eea --- /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/comparative/ComparativeCategoryWidgetUI/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 +}; 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 = {