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 ? (
+
}
+ onClick={enableSearchMode}
+ >
+ Search in {otherCount} elements
+
+ ) : 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 = {