Update bar chart design (#15372)

- Implement new dynamic color palettes
- Create custom bar component with rounded edges



https://github.com/user-attachments/assets/8246f16d-0239-4807-bb4a-9647367575f2
This commit is contained in:
Raphaël Bosi
2025-10-27 17:46:06 +01:00
committed by GitHub
parent e975fbe217
commit 01d8b99c38
29 changed files with 593 additions and 333 deletions
@@ -1,45 +0,0 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { calculateBarChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates';
import { type ComputedBarDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
type BarChartEndLinesProps = {
bars: readonly ComputedBarDatum<BarChartDataItem>[];
enrichedKeysMap: Map<string, BarChartEnrichedKey>;
layout: 'vertical' | 'horizontal';
};
export const BarChartEndLines = ({
bars,
enrichedKeysMap,
layout,
}: BarChartEndLinesProps) => {
return (
<g>
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
const enrichedKey = enrichedKeysMap.get(String(bar.data.id));
if (!isDefined(enrichedKey)) {
return null;
}
const lineColor = enrichedKey.colorScheme.solid;
const { x1, y1, x2, y2 } = calculateBarChartEndLineCoordinates(
bar,
layout,
);
return (
<line
key={`${bar.data.id}-${bar.data.indexValue}-endline-${index}`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lineColor}
strokeWidth={1}
/>
);
})}
</g>
);
};
@@ -0,0 +1,211 @@
import { BAR_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartHoverBrightness';
import { type BarDatum, type BarItemProps } from '@nivo/bar';
import { Text } from '@nivo/text';
import { useTheme } from '@nivo/theming';
import { useTooltip } from '@nivo/tooltip';
import { animated, to } from '@react-spring/web';
import { isNumber } from '@sniptt/guards';
import { createElement, useCallback, useMemo, type MouseEvent } from 'react';
import styled from 'styled-components';
import { isDefined } from 'twenty-shared/utils';
type CustomBarItemProps<D extends BarDatum> = BarItemProps<D> & {
keys?: string[];
groupMode?: 'grouped' | 'stacked';
data?: readonly D[];
indexBy?: string;
layout?: 'vertical' | 'horizontal';
};
const StyledBarRect = styled(animated.rect)<{ $isInteractive?: boolean }>`
cursor: ${({ $isInteractive }) => ($isInteractive ? 'pointer' : 'default')};
transition: filter 0.15s ease-in-out;
&:hover {
filter: ${({ $isInteractive }) =>
$isInteractive ? `brightness(${BAR_CHART_HOVER_BRIGHTNESS})` : 'none'};
}
`;
// This is a copy of the BarItem component from @nivo/bar with some design modifications
export const CustomBarItem = <D extends BarDatum>({
bar: { data: barData, ...bar },
style: {
borderColor,
color,
height,
labelColor,
labelOpacity,
labelX,
labelY,
transform,
width,
textAnchor,
},
borderRadius,
borderWidth,
label,
shouldRenderLabel,
isInteractive,
onClick,
onMouseEnter,
onMouseLeave,
tooltip,
isFocusable,
ariaLabel,
ariaLabelledBy,
ariaDescribedBy,
ariaDisabled,
ariaHidden,
keys,
groupMode = 'grouped',
data: chartData,
indexBy,
layout = 'vertical',
}: CustomBarItemProps<D>) => {
const theme = useTheme();
const { showTooltipFromEvent, showTooltipAt, hideTooltip } = useTooltip();
const renderTooltip = useMemo(
() => () => createElement(tooltip, { ...bar, ...barData }),
[tooltip, bar, barData],
);
const handleClick = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onClick?.({ color: bar.color, ...barData }, event);
},
[bar, barData, onClick],
);
const handleTooltip = useCallback(
(event: MouseEvent<SVGRectElement>) =>
showTooltipFromEvent(renderTooltip(), event),
[showTooltipFromEvent, renderTooltip],
);
const handleMouseEnter = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onMouseEnter?.(barData, event);
showTooltipFromEvent(renderTooltip(), event);
},
[barData, onMouseEnter, showTooltipFromEvent, renderTooltip],
);
const handleMouseLeave = useCallback(
(event: MouseEvent<SVGRectElement>) => {
onMouseLeave?.(barData, event);
hideTooltip();
},
[barData, hideTooltip, onMouseLeave],
);
const handleFocus = useCallback(() => {
showTooltipAt(renderTooltip(), [bar.absX + bar.width / 2, bar.absY]);
}, [showTooltipAt, renderTooltip, bar]);
const handleBlur = useCallback(() => {
hideTooltip();
}, [hideTooltip]);
const isTopBar = useMemo(() => {
const isStackedAndValid =
groupMode === 'stacked' &&
isDefined(keys) &&
keys.length > 0 &&
isDefined(chartData) &&
isDefined(indexBy);
if (!isStackedAndValid) {
return true;
}
const dataPoint = chartData.find(
(data) => data[indexBy] === barData.indexValue,
);
if (!isDefined(dataPoint)) {
return true;
}
const currentKeyIndex = keys.findIndex((key) => key === barData.id);
if (currentKeyIndex === -1) {
return true;
}
const keysAboveCurrentKey = keys.slice(currentKeyIndex + 1);
const hasBarAbove = keysAboveCurrentKey.some((key) => {
const value = dataPoint[key];
return isNumber(value) && value > 0;
});
return !hasBarAbove;
}, [groupMode, keys, barData, chartData, indexBy]);
const isHorizontal = layout === 'horizontal';
return (
<animated.g transform={transform}>
{isTopBar && (
<defs>
<clipPath id={`round-corner-${barData.index}`}>
<animated.rect
x={isHorizontal ? -borderRadius : 0}
y={0}
rx={borderRadius}
ry={borderRadius}
width={to(width, (value) =>
Math.max(value + (isHorizontal ? borderRadius : 0), 0),
)}
height={to(height, (value) =>
Math.max(value + (isHorizontal ? 0 : borderRadius), 0),
)}
/>
</clipPath>
</defs>
)}
<StyledBarRect
$isInteractive={isInteractive}
clipPath={isTopBar ? `url(#round-corner-${barData.index})` : undefined}
width={to(width, (value) => Math.max(value, 0))}
height={to(height, (value) => Math.max(value, 0))}
fill={color}
strokeWidth={borderWidth}
stroke={borderColor}
focusable={isFocusable}
tabIndex={isFocusable ? 0 : undefined}
aria-label={ariaLabel ? ariaLabel(barData) : undefined}
aria-labelledby={ariaLabelledBy ? ariaLabelledBy(barData) : undefined}
aria-describedby={
ariaDescribedBy ? ariaDescribedBy(barData) : undefined
}
aria-disabled={ariaDisabled ? ariaDisabled(barData) : undefined}
aria-hidden={ariaHidden ? ariaHidden(barData) : undefined}
onMouseEnter={isInteractive ? handleMouseEnter : undefined}
onMouseMove={isInteractive ? handleTooltip : undefined}
onMouseLeave={isInteractive ? handleMouseLeave : undefined}
onClick={isInteractive ? handleClick : undefined}
onFocus={isInteractive && isFocusable ? handleFocus : undefined}
onBlur={isInteractive && isFocusable ? handleBlur : undefined}
data-testid={`bar.item.${barData.id}.${barData.index}`}
/>
{shouldRenderLabel && (
<Text
x={labelX}
y={labelY}
textAnchor={textAnchor}
dominantBaseline="central"
fillOpacity={labelOpacity}
style={{
...theme.labels.text,
// We don't want the label to intercept mouse events
pointerEvents: 'none',
fill: labelColor,
}}
>
{label}
</Text>
)}
</animated.g>
);
};
@@ -1,7 +1,7 @@
import { GraphWidgetChartContainer } from '@/page-layout/widgets/graph/components/GraphWidgetChartContainer';
import { GraphWidgetLegend } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { GraphWidgetTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
import { BarChartEndLines } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/BarChartEndLines';
import { CustomBarItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/CustomBarItem';
import { BAR_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMargins';
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers';
@@ -19,8 +19,8 @@ import {
import { NodeDimensionEffect } from '@/ui/utilities/dimensions/components/NodeDimensionEffect';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveBar, type BarCustomLayerProps } from '@nivo/bar';
import { useId, useRef, useState } from 'react';
import { ResponsiveBar } from '@nivo/bar';
import { useMemo, useRef, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
const LEGEND_THRESHOLD = 10;
@@ -76,7 +76,6 @@ export const GraphWidgetBarChart = ({
customFormatter,
}: GraphWidgetBarChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const [chartWidth, setChartWidth] = useState<number>(0);
const [chartHeight, setChartHeight] = useState<number>(0);
@@ -98,17 +97,13 @@ export const GraphWidgetBarChart = ({
const chartTheme = useBarChartTheme();
const { barConfigs, enrichedKeys, enrichedKeysMap, defs } = useBarChartData({
const { barConfigs, enrichedKeys } = useBarChartData({
data,
indexBy,
keys,
series,
colorRegistry,
id,
instanceId,
seriesLabels,
hoveredBar,
layout,
});
const { renderTooltip: getTooltipData } = useBarChartTooltip({
@@ -152,22 +147,27 @@ export const GraphWidgetBarChart = ({
);
};
const barEndLinesLayer = (props: BarCustomLayerProps<BarChartDataItem>) => {
return (
<BarChartEndLines
bars={props.bars}
enrichedKeysMap={enrichedKeysMap}
const BarItemWithContext = useMemo(
() => (props: any) => (
<CustomBarItem
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
keys={keys}
groupMode={groupMode}
data={data}
indexBy={indexBy}
layout={layout}
/>
);
};
),
[keys, groupMode, data, indexBy, layout],
);
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
ref={containerRef}
$isClickable={hasClickableItems}
$cursorSelector='svg g[transform] rect[fill^="url(#gradient-"]'
$cursorSelector="svg g[transform] rect[fill]"
>
<NodeDimensionEffect
elementRef={containerRef}
@@ -177,6 +177,7 @@ export const GraphWidgetBarChart = ({
}}
/>
<ResponsiveBar
barComponent={BarItemWithContext}
data={data}
keys={keys}
indexBy={indexBy}
@@ -192,15 +193,7 @@ export const GraphWidgetBarChart = ({
}}
indexScale={{ type: 'band', round: true }}
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
defs={defs}
layers={[
'grid',
'axes',
'bars',
barEndLinesLayer,
'markers',
'legends',
]}
layers={['grid', 'axes', 'bars', 'markers', 'legends']}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
@@ -226,6 +219,7 @@ export const GraphWidgetBarChart = ({
}}
onMouseLeave={() => setHoveredBar(null)}
theme={chartTheme}
borderRadius={parseInt(theme.border.radius.sm)}
/>
</GraphWidgetChartContainer>
<GraphWidgetLegend
@@ -0,0 +1 @@
export const BAR_CHART_HOVER_BRIGHTNESS = 0.85;
@@ -47,10 +47,6 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
@@ -66,10 +62,6 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
@@ -77,14 +69,18 @@ describe('useBarChartData', () => {
expect(result.current.barConfigs[0]).toMatchObject({
key: 'sales',
indexValue: 'Jan',
gradientId: 'gradient-test-chart-instance-1-sales-0-0',
colorScheme: mockColorRegistry.green,
colorScheme: {
name: 'green',
gradient: mockColorRegistry.green.gradient,
},
});
expect(result.current.barConfigs[1]).toMatchObject({
key: 'costs',
indexValue: 'Jan',
gradientId: 'gradient-test-chart-instance-1-costs-0-1',
colorScheme: mockColorRegistry.purple,
colorScheme: {
name: 'purple',
gradient: mockColorRegistry.purple.gradient,
},
});
});
@@ -96,25 +92,28 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.enrichedKeys).toEqual([
{
key: 'sales',
colorScheme: mockColorRegistry.green,
label: 'Sales',
expect(result.current.enrichedKeys).toHaveLength(2);
expect(result.current.enrichedKeys[0]).toMatchObject({
key: 'sales',
label: 'Sales',
colorScheme: {
name: 'green',
gradient: mockColorRegistry.green.gradient,
},
{
key: 'costs',
colorScheme: mockColorRegistry.purple,
label: 'Costs',
});
expect(result.current.enrichedKeys[0].colorScheme.solid).toBeDefined();
expect(result.current.enrichedKeys[1]).toMatchObject({
key: 'costs',
label: 'Costs',
colorScheme: {
name: 'purple',
gradient: mockColorRegistry.purple.gradient,
},
]);
});
expect(result.current.enrichedKeys[1].colorScheme.solid).toBeDefined();
});
it('should use series labels when series config is not provided', () => {
@@ -125,11 +124,7 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: undefined,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
seriesLabels: { sales: 'Revenue', costs: 'Expenses' },
hoveredBar: null,
layout: 'vertical',
}),
);
@@ -137,70 +132,6 @@ describe('useBarChartData', () => {
expect(result.current.enrichedKeys[1].label).toBe('Expenses');
});
it('should handle hover state for bars', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: { key: 'sales', indexValue: 'Feb' },
layout: 'vertical',
}),
);
const hoveredDef = result.current.defs.find(
(def) => def.id === 'gradient-test-chart-instance-1-sales-1-0',
);
expect(hoveredDef?.colors).toEqual([
{ offset: 0, color: 'green4' },
{ offset: 100, color: 'green3' },
]);
});
it('should generate vertical gradients for vertical layout', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
const def = result.current.defs[0];
expect(def.y1).toBe('0%');
expect(def.y2).toBe('100%');
});
it('should generate horizontal gradients for horizontal layout', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'horizontal',
}),
);
const def = result.current.defs[0];
expect(def.x1).toBe('0%');
expect(def.x2).toBe('100%');
});
it('should handle empty data', () => {
const { result } = renderHook(() =>
useBarChartData({
@@ -209,15 +140,10 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.barConfigs).toEqual([]);
expect(result.current.defs).toEqual([]);
});
it('should handle empty keys', () => {
@@ -228,16 +154,11 @@ describe('useBarChartData', () => {
keys: [],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.barConfigs).toEqual([]);
expect(result.current.enrichedKeys).toEqual([]);
expect(result.current.defs).toEqual([]);
});
it('should fall back to key name when no label is provided', () => {
@@ -248,40 +169,11 @@ describe('useBarChartData', () => {
keys: ['sales', 'costs'],
series: undefined,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
seriesLabels: undefined,
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.enrichedKeys[0].label).toBe('sales');
expect(result.current.enrichedKeys[1].label).toBe('costs');
});
it('should recalculate when instanceId changes', () => {
const { result, rerender } = renderHook(
({ instanceId }) =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId,
hoveredBar: null,
layout: 'vertical',
}),
{ initialProps: { instanceId: 'instance-1' } },
);
const firstBarConfigs = result.current.barConfigs;
rerender({ instanceId: 'instance-2' });
expect(result.current.barConfigs).not.toBe(firstBarConfigs);
expect(result.current.barConfigs[0].gradientId).toContain('instance-2');
});
});
@@ -3,7 +3,6 @@ import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBa
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { createGradientDef } from '@/page-layout/widgets/graph/utils/createGradientDef';
import { getColorScheme } from '@/page-layout/widgets/graph/utils/getColorScheme';
import { useMemo } from 'react';
@@ -13,11 +12,8 @@ type UseBarChartDataProps = {
keys: string[];
series?: BarChartSeries[];
colorRegistry: GraphColorRegistry;
id: string;
instanceId: string;
seriesLabels?: Record<string, string>;
hoveredBar: { key: string; indexValue: string | number } | null;
layout: 'vertical' | 'horizontal';
groupMode?: 'grouped' | 'stacked';
};
export const useBarChartData = ({
@@ -26,11 +22,7 @@ export const useBarChartData = ({
keys,
series,
colorRegistry,
id,
instanceId,
seriesLabels,
hoveredBar,
layout,
}: UseBarChartDataProps) => {
const seriesConfigMap = useMemo(
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
@@ -38,35 +30,35 @@ export const useBarChartData = ({
);
const barConfigs = useMemo((): BarChartConfig[] => {
return data.flatMap((dataPoint, dataIndex) => {
return data.flatMap((dataPoint) => {
const indexValue = dataPoint[indexBy];
return keys.map((key, keyIndex): BarChartConfig => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
keyIndex,
);
const sanitizedKey = key.replace(/\s+/g, '-');
const gradientId = `gradient-${id}-${instanceId}-${sanitizedKey}-${dataIndex}-${keyIndex}`;
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: seriesConfig?.color,
fallbackIndex: keyIndex,
totalGroups: keys.length,
});
return {
key,
indexValue,
gradientId,
colorScheme,
};
});
});
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
}, [data, indexBy, keys, colorRegistry, seriesConfigMap]);
const enrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
index,
);
const colorScheme = getColorScheme({
registry: colorRegistry,
colorName: seriesConfig?.color,
fallbackIndex: index,
totalGroups: keys.length,
});
return {
key,
colorScheme,
@@ -74,28 +66,9 @@ export const useBarChartData = ({
};
});
const enrichedKeysMap = useMemo(
() => new Map(enrichedKeys.map((item) => [item.key, item])),
[enrichedKeys],
);
const defs = barConfigs.map((bar) => {
const isHovered =
hoveredBar?.key === bar.key && hoveredBar?.indexValue === bar.indexValue;
return createGradientDef(
bar.colorScheme,
bar.gradientId,
isHovered,
layout === 'horizontal' ? 0 : 90,
true,
);
});
return {
seriesConfigMap,
barConfigs,
enrichedKeys,
enrichedKeysMap,
defs,
};
};
@@ -3,6 +3,5 @@ import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphCo
export type BarChartConfig = {
key: string;
indexValue: string | number;
gradientId: string;
colorScheme: GraphColorScheme;
};
@@ -14,5 +14,5 @@ export const getBarChartColor = (
if (!isDefined(bar)) {
return theme.border.color.light;
}
return `url(#${bar.gradientId})`;
return bar.colorScheme.solid;
};