Files
twenty/packages/twenty-front/src/modules/page-layout/widgets/graph/components/GraphWidgetBarChart.tsx
T
Raphaël Bosi e80c552ae7 Create the Dashboard record show page (#14423)
Closes https://github.com/twentyhq/core-team-issues/issues/1438

- Reorganized PageLayout module
- Created `DashboardRenderer` and `PageLayoutRenderer`
- Created stories for the `PageLayoutRenderer`
- Refactored the Widget components


https://github.com/user-attachments/assets/27e9ac8f-b237-4c21-8494-3fab6d65af3a
2025-09-11 18:26:26 +02:00

445 lines
11 KiB
TypeScript

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>
);
};