[Dashboards] - refactor - bar chart (#14496)

This is the first PR from the split of #14458 - refactoring only the
BarChart widget.
This commit is contained in:
nitin
2025-09-15 17:52:40 +05:30
committed by GitHub
parent 06605b2f36
commit d1cb046ebb
19 changed files with 1009 additions and 450 deletions
@@ -1,444 +0,0 @@
import styled from '@emotion/styled';
import {
ResponsiveBar,
type BarCustomLayerProps,
type BarDatum,
type ComputedBarDatum,
type ComputedDatum,
} from '@nivo/bar';
import { useId, useMemo, useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { useTheme } from '@emotion/react';
import { type GraphColor } from '../types/GraphColor';
import { type GraphColorScheme } from '../types/GraphColorScheme';
import { createGradientDef } from '../utils/createGradientDef';
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
import { getColorScheme } from '../utils/getColorScheme';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '../utils/graphFormatters';
import { GraphWidgetLegend } from './GraphWidgetLegend';
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
type BarChartDataItem = BarDatum & {
to?: string;
};
type BarChartSeries = {
key: string;
label?: string;
color?: GraphColor;
};
type BarConfig = {
key: string;
indexValue: string | number;
gradientId: string;
colorScheme: GraphColorScheme;
};
type GraphWidgetBarChartProps = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series?: BarChartSeries[];
showLegend?: boolean;
showGrid?: boolean;
showValues?: boolean;
xAxisLabel?: string;
yAxisLabel?: string;
id: string;
layout?: 'vertical' | 'horizontal';
groupMode?: 'grouped' | 'stacked';
seriesLabels?: Record<string, string>;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
flex: 1;
position: relative;
width: 100%;
${({ $isClickable }) =>
$isClickable &&
`
svg g[transform] rect[fill^="url(#gradient-"] {
cursor: pointer;
}
`}
`;
const getAxisBottomConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
};
};
const getAxisLeftConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
};
};
const calculateBarEndLineCoordinates = (
bar: ComputedBarDatum<BarChartDataItem>,
layout: 'vertical' | 'horizontal',
) => {
if (layout === 'vertical') {
return {
x1: bar.x,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y,
};
} else {
return {
x1: bar.x + bar.width,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y + bar.height,
};
}
};
export const GraphWidgetBarChart = ({
data,
indexBy,
keys,
series,
showLegend = true,
showGrid = true,
showValues = false,
xAxisLabel,
yAxisLabel,
id,
layout = 'vertical',
groupMode = 'grouped',
seriesLabels,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetBarChartProps) => {
const theme = useTheme();
const instanceId = useId();
const [hoveredBar, setHoveredBar] = useState<{
key: string;
indexValue: string | number;
} | null>(null);
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const seriesConfigMap = useMemo(() => {
const map = new Map<string, BarChartSeries>();
series?.forEach((s) => map.set(s.key, s));
return map;
}, [series]);
const barConfigs = useMemo((): BarConfig[] => {
return data.flatMap((dataPoint, dataIndex) => {
const indexValue = dataPoint[indexBy];
return keys.map((key, keyIndex): BarConfig => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
keyIndex,
);
const gradientId = `gradient-${id}-${instanceId}-${key}-${dataIndex}-${keyIndex}`;
return {
key,
indexValue,
gradientId,
colorScheme,
};
});
});
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
const enrichedKeys = useMemo(() => {
return keys.map((key, index) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
index,
);
return {
key,
colorScheme,
label: seriesConfig?.label || seriesLabels?.[key] || key,
};
});
}, [keys, colorRegistry, seriesConfigMap, seriesLabels]);
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,
);
});
const getBarColor = (datum: ComputedDatum<BarDatum>) => {
const bar = barConfigs.find(
(b) => b.key === datum.id && b.indexValue === datum.indexValue,
);
if (!bar) {
return theme.border.color.light;
}
return `url(#${bar.gradientId})`;
};
const handleBarClick = (datum: ComputedDatum<BarDatum>) => {
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
if (isDefined(dataItem?.to)) {
window.location.href = dataItem.to;
}
};
const renderTooltip = (datum: ComputedDatum<BarDatum>) => {
const hoveredKey = hoveredBar?.key;
if (!isDefined(hoveredKey)) return null;
const enrichedKey = enrichedKeys.find((item) => item.key === hoveredKey);
if (!enrichedKey) return null;
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
const seriesValue = Number(datum.data[hoveredKey] || 0);
const tooltipItem = {
label: enrichedKey.label,
formattedValue: formatGraphValue(seriesValue, formatOptions),
dotColor: enrichedKey.colorScheme.solid,
};
return (
<GraphWidgetTooltip
items={[tooltipItem]}
showClickHint={isDefined(dataItem?.to)}
/>
);
};
const axisBottomConfig = getAxisBottomConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const axisLeftConfig = getAxisLeftConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const hasClickableItems = data.some((item) => isDefined(item.to));
const renderBarEndLines = (props: BarCustomLayerProps<BarChartDataItem>) => {
const { bars } = props;
if (!bars || bars.length === 0) {
return null;
}
return (
<g>
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
const enrichedKey = enrichedKeys.find((k) => k.key === bar.data.id);
if (!enrichedKey) {
return null;
}
const lineColor = enrichedKey.colorScheme.solid;
const { x1, y1, x2, y2 } = calculateBarEndLineCoordinates(
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>
);
};
return (
<StyledContainer id={id}>
<StyledChartContainer $isClickable={hasClickableItems}>
<ResponsiveBar
data={data}
keys={keys}
indexBy={indexBy}
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
padding={0.3}
groupMode={groupMode}
layout={layout}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={getBarColor}
defs={defs}
layers={[
'grid',
'axes',
'bars',
renderBarEndLines,
'markers',
'legends',
]}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
axisLeft={axisLeftConfig}
enableGridX={layout === 'horizontal' && showGrid}
enableGridY={layout === 'vertical' && showGrid}
gridXValues={layout === 'horizontal' ? 5 : undefined}
gridYValues={layout === 'vertical' ? 5 : undefined}
enableLabel={showValues}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={theme.font.color.primary}
label={(d) => formatGraphValue(Number(d.value), formatOptions)}
tooltip={(props) => renderTooltip(props)}
onClick={handleBarClick}
onMouseEnter={(datum) => {
if (isDefined(datum.id) && isDefined(datum.indexValue)) {
setHoveredBar({
key: String(datum.id),
indexValue: datum.indexValue,
});
}
}}
onMouseLeave={() => setHoveredBar(null)}
theme={{
axis: {
domain: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
},
},
ticks: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
},
text: {
fill: theme.font.color.secondary,
fontSize: 11,
},
},
legend: {
text: {
fill: theme.font.color.light,
fontSize: 12,
fontWeight: theme.font.weight.medium,
},
},
},
grid: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
strokeDasharray: '4 4',
},
},
labels: {
text: {
fontSize: 11,
fontWeight: theme.font.weight.medium,
},
},
}}
/>
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedKeys.map((item) => {
const total = data.reduce(
(sum, d) => sum + Number(d[item.key] || 0),
0,
);
return {
id: item.key,
label: item.label,
formattedValue: formatGraphValue(total, formatOptions),
color: item.colorScheme.solid,
};
})}
/>
</StyledContainer>
);
};
@@ -6,11 +6,11 @@ import { type GraphWidget } from '@/page-layout/widgets/graph/types/GraphWidget'
import { lazy, Suspense } from 'react';
const GraphWidgetBarChart = lazy(() =>
import('@/page-layout/widgets/graph/components/GraphWidgetBarChart').then(
(module) => ({
default: module.GraphWidgetBarChart,
}),
),
import(
'@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart'
).then((module) => ({
default: module.GraphWidgetBarChart,
})),
);
const GraphWidgetLineChart = lazy(() =>
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetBarChart } from '../GraphWidgetBarChart';
import { GraphWidgetBarChart } from '../../graphWidgetBarChart/components/GraphWidgetBarChart';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
@@ -0,0 +1,45 @@
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>[];
enrichedKeys: BarChartEnrichedKey[];
layout: 'vertical' | 'horizontal';
};
export const BarChartEndLines = ({
bars,
enrichedKeys,
layout,
}: BarChartEndLinesProps) => {
return (
<g>
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
const enrichedKey = enrichedKeys.find((k) => k.key === 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,227 @@
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 { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
import { useBarChartHandlers } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartHandlers';
import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme';
import { useBarChartTooltip } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTooltip';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { getBarChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisBottomConfig';
import { getBarChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartAxisLeftConfig';
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { ResponsiveBar, type BarCustomLayerProps } from '@nivo/bar';
import { useId } from 'react';
import { isDefined } from 'twenty-shared/utils';
type GraphWidgetBarChartProps = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series?: BarChartSeries[];
showLegend?: boolean;
showGrid?: boolean;
showValues?: boolean;
xAxisLabel?: string;
yAxisLabel?: string;
id: string;
layout?: 'vertical' | 'horizontal';
groupMode?: 'grouped' | 'stacked';
seriesLabels?: Record<string, string>;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
const StyledChartContainer = styled.div<{ $isClickable?: boolean }>`
flex: 1;
position: relative;
width: 100%;
${({ $isClickable }) =>
$isClickable &&
`
svg g[transform] rect[fill^="url(#gradient-"] {
cursor: pointer;
}
`}
`;
export const GraphWidgetBarChart = ({
data,
indexBy,
keys,
series,
showLegend = true,
showGrid = true,
showValues = false,
xAxisLabel,
yAxisLabel,
id,
layout = 'vertical',
groupMode = 'grouped',
seriesLabels,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetBarChartProps) => {
const theme = useTheme();
const instanceId = useId();
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const { hoveredBar, setHoveredBar, handleBarClick, hasClickableItems } =
useBarChartHandlers({
data,
indexBy,
});
const chartTheme = useBarChartTheme();
const { barConfigs, enrichedKeys, defs } = useBarChartData({
data,
indexBy,
keys,
series,
colorRegistry,
id,
instanceId,
seriesLabels,
hoveredBar,
layout,
});
const { renderTooltip: getTooltipData } = useBarChartTooltip({
hoveredBar,
enrichedKeys,
data,
indexBy,
formatOptions,
});
const axisBottomConfig = getBarChartAxisBottomConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const axisLeftConfig = getBarChartAxisLeftConfig(
layout,
xAxisLabel,
yAxisLabel,
formatOptions,
);
const renderTooltip = (datum: Parameters<typeof getTooltipData>[0]) => {
const tooltipData = getTooltipData(datum);
if (!isDefined(tooltipData)) return null;
return (
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
/>
);
};
const barEndLinesLayer = (props: BarCustomLayerProps<BarChartDataItem>) => {
return (
<BarChartEndLines
bars={props.bars}
enrichedKeys={enrichedKeys}
layout={layout}
/>
);
};
return (
<StyledContainer id={id}>
<StyledChartContainer $isClickable={hasClickableItems}>
<ResponsiveBar
data={data}
keys={keys}
indexBy={indexBy}
margin={{ top: 20, right: 20, bottom: 60, left: 70 }}
padding={0.3}
groupMode={groupMode}
layout={layout}
valueScale={{ type: 'linear' }}
indexScale={{ type: 'band', round: true }}
colors={(datum) => getBarChartColor(datum, barConfigs, theme)}
defs={defs}
layers={[
'grid',
'axes',
'bars',
barEndLinesLayer,
'markers',
'legends',
]}
axisTop={null}
axisRight={null}
axisBottom={axisBottomConfig}
axisLeft={axisLeftConfig}
enableGridX={layout === 'horizontal' && showGrid}
enableGridY={layout === 'vertical' && showGrid}
gridXValues={layout === 'horizontal' ? 5 : undefined}
gridYValues={layout === 'vertical' ? 5 : undefined}
enableLabel={showValues}
labelSkipWidth={12}
labelSkipHeight={12}
labelTextColor={theme.font.color.primary}
label={(d) => formatGraphValue(Number(d.value), formatOptions)}
tooltip={(props) => renderTooltip(props)}
onClick={handleBarClick}
onMouseEnter={(datum) => {
if (isDefined(datum.id) && isDefined(datum.indexValue)) {
setHoveredBar({
key: String(datum.id),
indexValue: datum.indexValue,
});
}
}}
onMouseLeave={() => setHoveredBar(null)}
theme={chartTheme}
/>
</StyledChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedKeys.map((item) => {
const total = data.reduce(
(sum, d) => sum + Number(d[item.key] || 0),
0,
);
return {
id: item.key,
label: item.label,
formattedValue: formatGraphValue(total, formatOptions),
color: item.colorScheme.solid,
};
})}
/>
</StyledContainer>
);
};
@@ -0,0 +1,287 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { useBarChartData } from '../useBarChartData';
describe('useBarChartData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockColorRegistry: GraphColorRegistry = {
green: {
name: 'green',
gradient: {
normal: ['green1', 'green2'],
hover: ['green3', 'green4'],
},
solid: 'greenSolid',
},
purple: {
name: 'purple',
gradient: {
normal: ['purple1', 'purple2'],
hover: ['purple3', 'purple4'],
},
solid: 'purpleSolid',
},
};
const mockData: BarChartDataItem[] = [
{ month: 'Jan', sales: 100, costs: 80 },
{ month: 'Feb', sales: 120, costs: 90 },
{ month: 'Mar', sales: 150, costs: 100 },
];
const mockSeries: BarChartSeries[] = [
{ key: 'sales', label: 'Sales', color: 'green' },
{ key: 'costs', label: 'Costs', color: 'purple' },
];
it('should create series config map', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.seriesConfigMap.get('sales')).toEqual(mockSeries[0]);
expect(result.current.seriesConfigMap.get('costs')).toEqual(mockSeries[1]);
});
it('should generate bar configs for each data point and key', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: mockSeries,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.barConfigs).toHaveLength(6);
expect(result.current.barConfigs[0]).toMatchObject({
key: 'sales',
indexValue: 'Jan',
gradientId: 'gradient-test-chart-instance-1-sales-0-0',
colorScheme: mockColorRegistry.green,
});
expect(result.current.barConfigs[1]).toMatchObject({
key: 'costs',
indexValue: 'Jan',
gradientId: 'gradient-test-chart-instance-1-costs-0-1',
colorScheme: mockColorRegistry.purple,
});
});
it('should create enriched keys with labels', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
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',
},
{
key: 'costs',
colorScheme: mockColorRegistry.purple,
label: 'Costs',
},
]);
});
it('should use series labels when series config is not provided', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
keys: ['sales', 'costs'],
series: undefined,
colorRegistry: mockColorRegistry,
id: 'test-chart',
instanceId: 'instance-1',
seriesLabels: { sales: 'Revenue', costs: 'Expenses' },
hoveredBar: null,
layout: 'vertical',
}),
);
expect(result.current.enrichedKeys[0].label).toBe('Revenue');
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({
data: [],
indexBy: 'month',
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', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
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', () => {
const { result } = renderHook(() =>
useBarChartData({
data: mockData,
indexBy: 'month',
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');
});
});
@@ -0,0 +1,93 @@
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
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';
type UseBarChartDataProps = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series?: BarChartSeries[];
colorRegistry: GraphColorRegistry;
id: string;
instanceId: string;
seriesLabels?: Record<string, string>;
hoveredBar: { key: string; indexValue: string | number } | null;
layout: 'vertical' | 'horizontal';
};
export const useBarChartData = ({
data,
indexBy,
keys,
series,
colorRegistry,
id,
instanceId,
seriesLabels,
hoveredBar,
layout,
}: UseBarChartDataProps) => {
const seriesConfigMap = useMemo(
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
[series],
);
const barConfigs = useMemo((): BarChartConfig[] => {
return data.flatMap((dataPoint, dataIndex) => {
const indexValue = dataPoint[indexBy];
return keys.map((key, keyIndex): BarChartConfig => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
keyIndex,
);
const gradientId = `gradient-${id}-${instanceId}-${key}-${dataIndex}-${keyIndex}`;
return {
key,
indexValue,
gradientId,
colorScheme,
};
});
});
}, [data, indexBy, keys, colorRegistry, id, instanceId, seriesConfigMap]);
const enrichedKeys: BarChartEnrichedKey[] = keys.map((key, index) => {
const seriesConfig = seriesConfigMap.get(key);
const colorScheme = getColorScheme(
colorRegistry,
seriesConfig?.color,
index,
);
return {
key,
colorScheme,
label: seriesConfig?.label || seriesLabels?.[key] || key,
};
});
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,
defs,
};
};
@@ -0,0 +1,35 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UseBarChartHandlersProps = {
data: BarChartDataItem[];
indexBy: string;
};
export const useBarChartHandlers = ({
data,
indexBy,
}: UseBarChartHandlersProps) => {
const [hoveredBar, setHoveredBar] = useState<{
key: string;
indexValue: string | number;
} | null>(null);
const handleBarClick = (datum: ComputedDatum<BarDatum>) => {
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
if (isDefined(dataItem?.to)) {
window.location.href = dataItem.to;
}
};
const hasClickableItems = data.some((item) => isDefined(item.to));
return {
hoveredBar,
setHoveredBar,
handleBarClick,
hasClickableItems,
};
};
@@ -0,0 +1,46 @@
import { useTheme } from '@emotion/react';
export const useBarChartTheme = () => {
const theme = useTheme();
return {
axis: {
domain: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
},
},
ticks: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
},
text: {
fill: theme.font.color.secondary,
fontSize: 11,
},
},
legend: {
text: {
fill: theme.font.color.light,
fontSize: 12,
fontWeight: theme.font.weight.medium,
},
},
},
grid: {
line: {
stroke: theme.border.color.light,
strokeWidth: 1,
strokeDasharray: '4 4',
},
},
labels: {
text: {
fontSize: 11,
fontWeight: theme.font.weight.medium,
},
},
};
};
@@ -0,0 +1,47 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
type UseBarChartTooltipProps = {
hoveredBar: { key: string; indexValue: string | number } | null;
enrichedKeys: BarChartEnrichedKey[];
data: BarChartDataItem[];
indexBy: string;
formatOptions: GraphValueFormatOptions;
};
export const useBarChartTooltip = ({
hoveredBar,
enrichedKeys,
data,
indexBy,
formatOptions,
}: UseBarChartTooltipProps) => {
const renderTooltip = (datum: ComputedDatum<BarDatum>) => {
const hoveredKey = hoveredBar?.key;
if (!isDefined(hoveredKey)) return null;
const enrichedKey = enrichedKeys.find((item) => item.key === hoveredKey);
if (!enrichedKey) return null;
const dataItem = data.find((d) => d[indexBy] === datum.indexValue);
const seriesValue = Number(datum.data[hoveredKey] || 0);
const tooltipItem = {
label: enrichedKey.label,
formattedValue: formatGraphValue(seriesValue, formatOptions),
dotColor: enrichedKey.colorScheme.solid,
};
return {
tooltipItem,
showClickHint: isDefined(dataItem?.to),
};
};
return { renderTooltip };
};
@@ -0,0 +1,8 @@
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type BarChartConfig = {
key: string;
indexValue: string | number;
gradientId: string;
colorScheme: GraphColorScheme;
};
@@ -0,0 +1,5 @@
import { type BarDatum } from '@nivo/bar';
export type BarChartDataItem = BarDatum & {
to?: string;
};
@@ -0,0 +1,7 @@
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type BarChartEnrichedKey = {
key: string;
colorScheme: GraphColorScheme;
label: string;
};
@@ -0,0 +1,7 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
export type BarChartSeries = {
key: string;
label?: string;
color?: GraphColor;
};
@@ -0,0 +1,96 @@
import { type ComputedBarDatum } from '@nivo/bar';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { calculateBarChartEndLineCoordinates } from '../calculateBarChartEndLineCoordinates';
describe('calculateBarChartEndLineCoordinates', () => {
const createMockBar = (
overrides?: Partial<ComputedBarDatum<BarChartDataItem>>,
): ComputedBarDatum<BarChartDataItem> =>
({
x: 100,
y: 50,
width: 40,
height: 80,
color: 'url(#gradient-test)',
data: {
id: 'sales',
value: 100,
index: 0,
indexValue: 'Q1',
data: { Q1: 100 } as BarChartDataItem,
formattedValue: '100',
hidden: false,
},
label: 'Sales',
...overrides,
}) as ComputedBarDatum<BarChartDataItem>;
describe('vertical layout', () => {
it('should calculate horizontal line coordinates at the top of vertical bars', () => {
const mockBar = createMockBar();
const result = calculateBarChartEndLineCoordinates(mockBar, 'vertical');
expect(result).toEqual({
x1: 100,
x2: 140,
y1: 50,
y2: 50,
});
});
it('should handle bars at origin position', () => {
const barAtOrigin = createMockBar({ x: 0, y: 0 });
const result = calculateBarChartEndLineCoordinates(
barAtOrigin,
'vertical',
);
expect(result).toEqual({
x1: 0,
x2: 40,
y1: 0,
y2: 0,
});
});
it('should handle bars with negative positions', () => {
const negativeBar = createMockBar({ x: -50, y: -20 });
const result = calculateBarChartEndLineCoordinates(
negativeBar,
'vertical',
);
expect(result).toEqual({
x1: -50,
x2: -10,
y1: -20,
y2: -20,
});
});
});
describe('horizontal layout', () => {
it('should calculate vertical line coordinates at the end of horizontal bars', () => {
const mockBar = createMockBar();
const result = calculateBarChartEndLineCoordinates(mockBar, 'horizontal');
expect(result).toEqual({
x1: 140,
x2: 140,
y1: 50,
y2: 130,
});
});
it('should handle bars with different dimensions', () => {
const wideBar = createMockBar({ width: 100, height: 20 });
const result = calculateBarChartEndLineCoordinates(wideBar, 'horizontal');
expect(result).toEqual({
x1: 200,
x2: 200,
y1: 50,
y2: 70,
});
});
it('should handle very thin bars', () => {
const thinBar = createMockBar({ width: 1, height: 200 });
const result = calculateBarChartEndLineCoordinates(thinBar, 'horizontal');
expect(result).toEqual({
x1: 101,
x2: 101,
y1: 50,
y2: 250,
});
});
});
});
@@ -0,0 +1,22 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type ComputedBarDatum } from '@nivo/bar';
export const calculateBarChartEndLineCoordinates = (
bar: ComputedBarDatum<BarChartDataItem>,
layout: 'vertical' | 'horizontal',
) => {
if (layout === 'vertical') {
return {
x1: bar.x,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y,
};
}
return {
x1: bar.x + bar.width,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y + bar.height,
};
};
@@ -0,0 +1,30 @@
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
export const getBarChartAxisBottomConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: 40,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
};
};
@@ -0,0 +1,30 @@
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
export const getBarChartAxisLeftConfig = (
layout: 'vertical' | 'horizontal',
xAxisLabel?: string,
yAxisLabel?: string,
formatOptions?: GraphValueFormatOptions,
) => {
return layout === 'vertical'
? {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: yAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
format: (value: number) => formatGraphValue(value, formatOptions || {}),
}
: {
tickSize: 0,
tickPadding: 5,
tickRotation: 0,
legend: xAxisLabel,
legendPosition: 'middle' as const,
legendOffset: -50,
};
};
@@ -0,0 +1,18 @@
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
import { type ThemeType } from 'twenty-ui/theme';
export const getBarChartColor = (
datum: ComputedDatum<BarDatum>,
barConfigs: BarChartConfig[],
theme: ThemeType,
) => {
const bar = barConfigs.find(
(b) => b.key === datum.id && b.indexValue === datum.indexValue,
);
if (!isDefined(bar)) {
return theme.border.color.light;
}
return `url(#${bar.gradientId})`;
};