[Dashboards] - refactor - pie chart (#14526)

This is the third PR from the split of
https://github.com/twentyhq/twenty/pull/14458 - refactoring only the
PieChart widget.

Plus small performance improvements on already refactored widgets
This commit is contained in:
nitin
2025-09-16 21:14:31 +05:30
committed by GitHub
parent 3b868c3d2a
commit ba3cbd1676
22 changed files with 927 additions and 235 deletions
@@ -1,222 +0,0 @@
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import {
ResponsivePie,
type ComputedDatum,
type DatumId,
type PieCustomLayerProps,
} from '@nivo/pie';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type GraphColor } from '../types/GraphColor';
import { createGradientDef } from '../utils/createGradientDef';
import { createGraphColorRegistry } from '../utils/createGraphColorRegistry';
import { getColorScheme } from '../utils/getColorScheme';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '../utils/graphFormatters';
import { GraphWidgetChartContainer } from './GraphWidgetChartContainer';
import { GraphWidgetLegend } from './GraphWidgetLegend';
import { GraphWidgetTooltip } from './GraphWidgetTooltip';
type GraphWidgetPieChartProps = {
data: Array<{
id: string;
value: number;
label?: string;
color?: GraphColor;
to?: string;
}>;
showLegend?: boolean;
id: string;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
export const GraphWidgetPieChart = ({
data,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetPieChartProps) => {
const theme = useTheme();
const [hoveredSliceId, setHoveredSliceId] = useState<DatumId | null>(null);
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const totalValue = data.reduce((sum, item) => sum + item.value, 0);
let cumulativeAngle = 0;
const enrichedData = data.map((item, index) => {
const colorScheme = getColorScheme(colorRegistry, item.color, index);
const isHovered = hoveredSliceId === item.id;
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
const percentage = totalValue > 0 ? (item.value / totalValue) * 100 : 0;
const sliceAngle = (percentage / 100) * 360;
const middleAngle = cumulativeAngle + sliceAngle / 2;
cumulativeAngle += sliceAngle;
return {
...item,
gradientId,
colorScheme,
isHovered,
percentage,
middleAngle,
};
});
const defs = enrichedData.map((item) =>
createGradientDef(
item.colorScheme,
item.gradientId,
item.isHovered,
item.middleAngle,
),
);
const fill = enrichedData.map((item) => ({
match: { id: item.id },
id: item.gradientId,
}));
const handleSliceClick = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const clickedItem = data.find((d) => d.id === datum.id);
if (isDefined(clickedItem?.to)) {
window.location.href = clickedItem.to;
}
};
const renderTooltip = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const item = enrichedData.find((d) => d.id === datum.id);
if (!item) return null;
const dataItem = data.find((d) => d.id === datum.id);
const formattedValue = formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
);
const formattedWithPercentage = `${formattedValue} (${item.percentage.toFixed(1)}%)`;
return (
<GraphWidgetTooltip
items={[
{
label: item.label || item.id,
formattedValue: formattedWithPercentage,
dotColor: item.colorScheme.solid,
},
]}
showClickHint={isDefined(dataItem?.to)}
/>
);
};
const renderSliceEndLines = (
layerProps: PieCustomLayerProps<{
id: string;
value: number;
label?: string;
}>,
) => {
const { dataWithArc, centerX, centerY, innerRadius, radius } = layerProps;
if (!dataWithArc || !Array.isArray(dataWithArc) || dataWithArc.length < 2) {
return null;
}
return (
<g>
{dataWithArc.map((datum) => {
const enrichedItem = enrichedData.find((d) => d.id === datum.id);
const lineColor = enrichedItem
? enrichedItem.colorScheme.solid
: theme.border.color.strong;
const angle = datum.arc.endAngle - Math.PI / 2;
const x1 = centerX + Math.cos(angle) * innerRadius;
const y1 = centerY + Math.sin(angle) * innerRadius;
const x2 = centerX + Math.cos(angle) * radius;
const y2 = centerY + Math.sin(angle) * radius;
return (
<line
key={`${datum.id}-separator`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lineColor}
strokeWidth={1}
/>
);
})}
</g>
);
};
const hasClickableItems = data.some((item) => isDefined(item.to));
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
$isClickable={hasClickableItems}
$cursorSelector='svg g path[fill^="url(#"]'
>
<ResponsivePie
data={data}
innerRadius={0.8}
colors={enrichedData.map((item) => `url(#${item.gradientId})`)}
borderWidth={0}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={({ datum }) => renderTooltip(datum)}
onClick={handleSliceClick}
onMouseEnter={(datum) => setHoveredSliceId(datum.id)}
onMouseLeave={() => setHoveredSliceId(null)}
defs={defs}
fill={fill}
layers={['arcs', renderSliceEndLines]}
/>
</GraphWidgetChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedData.map((item) => ({
id: item.id,
label: item.label || item.id,
formattedValue: formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
),
color: item.colorScheme.solid,
}))}
/>
</StyledContainer>
);
};
@@ -22,11 +22,11 @@ const GraphWidgetLineChart = lazy(() =>
);
const GraphWidgetPieChart = lazy(() =>
import('@/page-layout/widgets/graph/components/GraphWidgetPieChart').then(
(module) => ({
default: module.GraphWidgetPieChart,
}),
),
import(
'@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart'
).then((module) => ({
default: module.GraphWidgetPieChart,
})),
);
const GraphWidgetGaugeChart = lazy(() =>
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetBarChart } from '../../graphWidgetBarChart/components/GraphWidgetBarChart';
import { GraphWidgetBarChart } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChart';
const meta: Meta<typeof GraphWidgetBarChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetBarChart',
@@ -2,7 +2,7 @@ import { type Meta, type StoryObj } from '@storybook/react';
import { type ComponentProps } from 'react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetLineChart } from '../../graphWidgetLineChart/components/GraphWidgetLineChart';
import { GraphWidgetLineChart } from '@/page-layout/widgets/graph/graphWidgetLineChart/components/GraphWidgetLineChart';
const meta: Meta<typeof GraphWidgetLineChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetLineChart',
@@ -1,7 +1,7 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import { GraphWidgetPieChart } from '../GraphWidgetPieChart';
import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart';
const meta: Meta<typeof GraphWidgetPieChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
@@ -6,19 +6,19 @@ import { isDefined } from 'twenty-shared/utils';
type BarChartEndLinesProps = {
bars: readonly ComputedBarDatum<BarChartDataItem>[];
enrichedKeys: BarChartEnrichedKey[];
enrichedKeysMap: Map<string, BarChartEnrichedKey>;
layout: 'vertical' | 'horizontal';
};
export const BarChartEndLines = ({
bars,
enrichedKeys,
enrichedKeysMap,
layout,
}: BarChartEndLinesProps) => {
return (
<g>
{bars.map((bar: ComputedBarDatum<BarChartDataItem>, index: number) => {
const enrichedKey = enrichedKeys.find((k) => k.key === bar.data.id);
const enrichedKey = enrichedKeysMap.get(String(bar.data.id));
if (!isDefined(enrichedKey)) {
return null;
}
@@ -87,7 +87,7 @@ export const GraphWidgetBarChart = ({
const chartTheme = useBarChartTheme();
const { barConfigs, enrichedKeys, defs } = useBarChartData({
const { barConfigs, enrichedKeys, enrichedKeysMap, defs } = useBarChartData({
data,
indexBy,
keys,
@@ -138,7 +138,7 @@ export const GraphWidgetBarChart = ({
return (
<BarChartEndLines
bars={props.bars}
enrichedKeys={enrichedKeys}
enrichedKeysMap={enrichedKeysMap}
layout={layout}
/>
);
@@ -72,6 +72,11 @@ 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;
@@ -88,6 +93,7 @@ export const useBarChartData = ({
seriesConfigMap,
barConfigs,
enrichedKeys,
enrichedKeysMap,
defs,
};
};
@@ -0,0 +1,141 @@
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 { PieChartEndLines } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines';
import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData';
import { usePieChartHandlers } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartHandlers';
import { usePieChartTooltip } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartTooltip';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
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 {
ResponsivePie,
type PieCustomLayerProps,
type PieTooltipProps,
} from '@nivo/pie';
import { isDefined } from 'twenty-shared/utils';
type GraphWidgetPieChartProps = {
data: PieChartDataItem[];
showLegend?: boolean;
id: string;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
align-items: center;
display: flex;
flex-direction: column;
height: 100%;
justify-content: center;
width: 100%;
`;
export const GraphWidgetPieChart = ({
data,
showLegend = true,
id,
displayType,
decimals,
prefix,
suffix,
customFormatter,
}: GraphWidgetPieChartProps) => {
const theme = useTheme();
const colorRegistry = createGraphColorRegistry(theme);
const formatOptions: GraphValueFormatOptions = {
displayType,
decimals,
prefix,
suffix,
customFormatter,
};
const {
hoveredSliceId,
setHoveredSliceId,
handleSliceClick,
hasClickableItems,
} = usePieChartHandlers({ data });
const { enrichedData, enrichedDataMap, defs, fill } = usePieChartData({
data,
colorRegistry,
id,
hoveredSliceId,
});
const { createTooltipData } = usePieChartTooltip({
enrichedData,
data,
formatOptions,
displayType,
});
const renderSliceEndLines = (
layerProps: PieCustomLayerProps<PieChartDataItem>,
) => (
<PieChartEndLines
dataWithArc={layerProps.dataWithArc}
centerX={layerProps.centerX}
centerY={layerProps.centerY}
innerRadius={layerProps.innerRadius}
radius={layerProps.radius}
enrichedDataMap={enrichedDataMap}
/>
);
const renderTooltip = ({ datum }: PieTooltipProps<PieChartDataItem>) => {
const tooltipData = createTooltipData(datum);
if (!isDefined(tooltipData)) return null;
return (
<GraphWidgetTooltip
items={[tooltipData.tooltipItem]}
showClickHint={tooltipData.showClickHint}
/>
);
};
return (
<StyledContainer id={id}>
<GraphWidgetChartContainer
$isClickable={hasClickableItems}
$cursorSelector='svg g path[fill^="url(#"]'
>
<ResponsivePie
data={data}
innerRadius={0.8}
colors={enrichedData.map((item) => `url(#${item.gradientId})`)}
borderWidth={0}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={renderTooltip}
onClick={handleSliceClick}
onMouseEnter={(datum) => setHoveredSliceId(datum.id)}
onMouseLeave={() => setHoveredSliceId(null)}
defs={defs}
fill={fill}
layers={['arcs', renderSliceEndLines]}
/>
</GraphWidgetChartContainer>
<GraphWidgetLegend
show={showLegend}
items={enrichedData.map((item) => ({
id: item.id,
label: item.label || item.id,
formattedValue: formatGraphValue(
displayType === 'percentage' ? item.percentage / 100 : item.value,
formatOptions,
),
color: item.colorScheme.solid,
}))}
/>
</StyledContainer>
);
};
@@ -0,0 +1,68 @@
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { calculatePieChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartEndLineCoordinates';
import { useTheme } from '@emotion/react';
import { type ComputedDatum } from '@nivo/pie';
import { isDefined } from 'twenty-shared/utils';
type PieChartEndLinesProps = {
dataWithArc: readonly ComputedDatum<{
id: string;
value: number;
label?: string;
}>[];
centerX: number;
centerY: number;
innerRadius: number;
radius: number;
enrichedDataMap: Map<string, PieChartEnrichedData>;
};
export const PieChartEndLines = ({
dataWithArc,
centerX,
centerY,
innerRadius,
radius,
enrichedDataMap,
}: PieChartEndLinesProps) => {
const theme = useTheme();
if (
!isDefined(dataWithArc) ||
!Array.isArray(dataWithArc) ||
dataWithArc.length < 2
) {
return null;
}
return (
<g>
{dataWithArc.map((datum) => {
const enrichedItem = enrichedDataMap.get(datum.id);
const lineColor = enrichedItem
? enrichedItem.colorScheme.solid
: theme.border.color.strong;
const { x1, y1, x2, y2 } = calculatePieChartEndLineCoordinates(
datum.arc.endAngle,
centerX,
centerY,
innerRadius,
radius,
);
return (
<line
key={`${datum.id}-separator`}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={lineColor}
strokeWidth={1}
/>
);
})}
</g>
);
};
@@ -0,0 +1,208 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { type DatumId } from '@nivo/pie';
import { renderHook } from '@testing-library/react';
import { usePieChartData } from '../usePieChartData';
describe('usePieChartData', () => {
beforeEach(() => {
jest.clearAllMocks();
});
const mockColorRegistry: GraphColorRegistry = {
red: {
name: 'red',
gradient: {
normal: ['red1', 'red2'],
hover: ['red3', 'red4'],
},
solid: 'redSolid',
},
blue: {
name: 'blue',
gradient: {
normal: ['blue1', 'blue2'],
hover: ['blue3', 'blue4'],
},
solid: 'blueSolid',
},
};
const mockData: PieChartDataItem[] = [
{ id: 'item1', value: 30, label: 'Item 1' },
{ id: 'item2', value: 50, label: 'Item 2' },
{ id: 'item3', value: 20, label: 'Item 3' },
];
it('should enrich data with color schemes and percentages', () => {
const { result } = renderHook(() =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.enrichedData).toHaveLength(3);
expect(result.current.enrichedData[0]).toMatchObject({
id: 'item1',
value: 30,
label: 'Item 1',
percentage: 30,
colorScheme: mockColorRegistry.red,
isHovered: false,
gradientId: 'redGradient-test-chart-0',
});
expect(result.current.enrichedData[1].percentage).toBe(50);
expect(result.current.enrichedData[2].percentage).toBe(20);
});
it('should calculate middle angles for each slice', () => {
const { result } = renderHook(() =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.enrichedData[0].middleAngle).toBe(54);
expect(result.current.enrichedData[1].middleAngle).toBe(198);
expect(result.current.enrichedData[2].middleAngle).toBe(324);
});
it('should handle hover state', () => {
const { result, rerender } = renderHook(
({ hoveredSliceId }: { hoveredSliceId: DatumId | null }) =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId,
}),
{ initialProps: { hoveredSliceId: null as DatumId | null } },
);
expect(result.current.enrichedData[1].isHovered).toBe(false);
rerender({ hoveredSliceId: 'item2' as DatumId });
expect(result.current.enrichedData[1].isHovered).toBe(true);
expect(result.current.enrichedData[0].isHovered).toBe(false);
expect(result.current.enrichedData[2].isHovered).toBe(false);
});
it('should generate gradient definitions', () => {
const { result } = renderHook(() =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: 'item1',
}),
);
expect(result.current.defs).toHaveLength(3);
expect(result.current.defs[0]).toMatchObject({
id: 'redGradient-test-chart-0',
type: 'linearGradient',
colors: [
{ offset: 0, color: 'red3' },
{ offset: 100, color: 'red4' },
],
});
});
it('should generate fill configuration', () => {
const { result } = renderHook(() =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.fill).toEqual([
{ match: { id: 'item1' }, id: 'redGradient-test-chart-0' },
{ match: { id: 'item2' }, id: 'blueGradient-test-chart-1' },
{ match: { id: 'item3' }, id: 'redGradient-test-chart-2' },
]);
});
it('should handle empty data', () => {
const { result } = renderHook(() =>
usePieChartData({
data: [],
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.enrichedData).toEqual([]);
expect(result.current.defs).toEqual([]);
expect(result.current.fill).toEqual([]);
});
it('should handle single data item', () => {
const singleData: PieChartDataItem[] = [
{ id: 'single', value: 100, label: 'Single Item' },
];
const { result } = renderHook(() =>
usePieChartData({
data: singleData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.enrichedData[0].percentage).toBe(100);
expect(result.current.enrichedData[0].middleAngle).toBe(180);
});
it('should handle custom colors in data items', () => {
const dataWithColors: PieChartDataItem[] = [
{ id: 'item1', value: 50, label: 'Item 1', color: 'blue' },
{ id: 'item2', value: 50, label: 'Item 2' },
];
const { result } = renderHook(() =>
usePieChartData({
data: dataWithColors,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
expect(result.current.enrichedData[0].colorScheme.name).toBe('blue');
expect(result.current.enrichedData[1].colorScheme.name).toBe('blue');
});
it('should memoize calculations', () => {
const { result, rerender } = renderHook(
({ id }) =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id,
hoveredSliceId: null,
}),
{ initialProps: { id: 'test-chart' } },
);
const firstEnrichedData = result.current.enrichedData;
const firstDefs = result.current.defs;
const firstFill = result.current.fill;
rerender({ id: 'test-chart' });
expect(result.current.enrichedData).toBe(firstEnrichedData);
expect(result.current.defs).toBe(firstDefs);
expect(result.current.fill).toBe(firstFill);
});
});
@@ -0,0 +1,77 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { useMemo } from 'react';
import { calculatePieChartAngles } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartAngles';
import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage';
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 { type DatumId } from '@nivo/pie';
type UsePieChartDataProps = {
data: PieChartDataItem[];
colorRegistry: GraphColorRegistry;
id: string;
hoveredSliceId: DatumId | null;
};
export const usePieChartData = ({
data,
colorRegistry,
id,
hoveredSliceId,
}: UsePieChartDataProps) => {
const enrichedData = useMemo((): PieChartEnrichedData[] => {
const totalValue = data.reduce((sum, item) => sum + item.value, 0);
let cumulativeAngle = 0;
return data.map((item, index) => {
const colorScheme = getColorScheme(colorRegistry, item.color, index);
const isHovered = hoveredSliceId === item.id;
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
const percentage = calculatePieChartPercentage(item.value, totalValue);
const angles = calculatePieChartAngles(percentage, cumulativeAngle);
cumulativeAngle = angles.newCumulativeAngle;
return {
...item,
gradientId,
colorScheme,
isHovered,
percentage,
middleAngle: angles.middleAngle,
};
});
}, [data, colorRegistry, id, hoveredSliceId]);
const defs = useMemo(() => {
return enrichedData.map((item) =>
createGradientDef(
item.colorScheme,
item.gradientId,
item.isHovered,
item.middleAngle,
),
);
}, [enrichedData]);
const fill = useMemo(() => {
return enrichedData.map((item) => ({
match: { id: item.id },
id: item.gradientId,
}));
}, [enrichedData]);
const enrichedDataMap = useMemo(
() => new Map(enrichedData.map((item) => [item.id, item])),
[enrichedData],
);
return {
enrichedData,
enrichedDataMap,
defs,
fill,
};
};
@@ -0,0 +1,30 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type ComputedDatum, type DatumId } from '@nivo/pie';
import { useState } from 'react';
import { isDefined } from 'twenty-shared/utils';
type UsePieChartHandlersProps = {
data: PieChartDataItem[];
};
export const usePieChartHandlers = ({ data }: UsePieChartHandlersProps) => {
const [hoveredSliceId, setHoveredSliceId] = useState<DatumId | null>(null);
const handleSliceClick = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const clickedItem = data.find((d) => d.id === datum.id);
if (isDefined(clickedItem?.to)) {
window.location.href = clickedItem.to;
}
};
const hasClickableItems = data.some((item) => isDefined(item.to));
return {
hoveredSliceId,
setHoveredSliceId,
handleSliceClick,
hasClickableItems,
};
};
@@ -0,0 +1,49 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { type ComputedDatum } from '@nivo/pie';
import { isDefined } from 'twenty-shared/utils';
type UsePieChartTooltipProps = {
enrichedData: PieChartEnrichedData[];
data: PieChartDataItem[];
formatOptions: GraphValueFormatOptions;
displayType?: string;
};
export const usePieChartTooltip = ({
enrichedData,
data,
formatOptions,
displayType,
}: UsePieChartTooltipProps) => {
const createTooltipData = (
datum: ComputedDatum<{ id: string; value: number; label?: string }>,
) => {
const item = enrichedData.find((d) => d.id === datum.id);
if (!isDefined(item)) return null;
const dataItem = data.find((d) => d.id === datum.id);
const formattedValue =
displayType === 'percentage'
? formatGraphValue(item.percentage / 100, formatOptions)
: `${formatGraphValue(item.value, formatOptions)} (${item.percentage.toFixed(1)}%)`;
return {
tooltipItem: {
label: item.label || item.id,
formattedValue,
dotColor: item.colorScheme.solid,
},
showClickHint: isDefined(dataItem?.to),
};
};
return {
createTooltipData,
};
};
@@ -0,0 +1,9 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
export type PieChartDataItem = {
id: string;
value: number;
label?: string;
color?: GraphColor;
to?: string;
};
@@ -0,0 +1,10 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type PieChartEnrichedData = PieChartDataItem & {
gradientId: string;
colorScheme: GraphColorScheme;
isHovered: boolean;
percentage: number;
middleAngle: number;
};
@@ -0,0 +1,88 @@
import { calculatePieChartAngles } from '../calculatePieChartAngles';
describe('calculatePieChartAngles', () => {
it('should calculate angles for a quarter slice (25%)', () => {
const result = calculatePieChartAngles(25, 0);
expect(result).toEqual({
sliceAngle: 90,
middleAngle: 45,
newCumulativeAngle: 90,
});
});
it('should calculate angles for a half slice (50%)', () => {
const result = calculatePieChartAngles(50, 0);
expect(result).toEqual({
sliceAngle: 180,
middleAngle: 90,
newCumulativeAngle: 180,
});
});
it('should calculate angles for a full circle (100%)', () => {
const result = calculatePieChartAngles(100, 0);
expect(result).toEqual({
sliceAngle: 360,
middleAngle: 180,
newCumulativeAngle: 360,
});
});
it('should handle cumulative angles correctly', () => {
const result = calculatePieChartAngles(25, 90);
expect(result).toEqual({
sliceAngle: 90,
middleAngle: 135,
newCumulativeAngle: 180,
});
});
it('should handle very small percentages', () => {
const result = calculatePieChartAngles(1, 0);
expect(result).toEqual({
sliceAngle: 3.6,
middleAngle: 1.8,
newCumulativeAngle: 3.6,
});
});
it('should handle decimal percentages', () => {
const result = calculatePieChartAngles(33.33, 0);
expect(result.sliceAngle).toBeCloseTo(119.988, 2);
expect(result.middleAngle).toBeCloseTo(59.994, 2);
expect(result.newCumulativeAngle).toBeCloseTo(119.988, 2);
});
it('should handle zero percentage', () => {
const result = calculatePieChartAngles(0, 45);
expect(result).toEqual({
sliceAngle: 0,
middleAngle: 45,
newCumulativeAngle: 45,
});
});
it('should calculate sequential slices correctly', () => {
let cumulative = 0;
const slice1 = calculatePieChartAngles(30, cumulative);
expect(slice1.sliceAngle).toBe(108);
expect(slice1.middleAngle).toBe(54);
cumulative = slice1.newCumulativeAngle;
const slice2 = calculatePieChartAngles(50, cumulative);
expect(slice2.sliceAngle).toBe(180);
expect(slice2.middleAngle).toBe(198);
cumulative = slice2.newCumulativeAngle;
const slice3 = calculatePieChartAngles(20, cumulative);
expect(slice3.sliceAngle).toBe(72);
expect(slice3.middleAngle).toBe(324);
expect(slice3.newCumulativeAngle).toBe(360);
});
it('should handle negative percentages', () => {
const result = calculatePieChartAngles(-10, 0);
expect(result).toEqual({
sliceAngle: -36,
middleAngle: -18,
newCumulativeAngle: -36,
});
});
it('should handle percentages over 100', () => {
const result = calculatePieChartAngles(150, 0);
expect(result).toEqual({
sliceAngle: 540,
middleAngle: 270,
newCumulativeAngle: 540,
});
});
});
@@ -0,0 +1,133 @@
import { calculatePieChartEndLineCoordinates } from '../calculatePieChartEndLineCoordinates';
describe('calculatePieChartEndLineCoordinates', () => {
it('should calculate coordinates for angle 0 (top)', () => {
const result = calculatePieChartEndLineCoordinates(0, 100, 100, 50, 80);
expect(result).toEqual({
x1: 100,
y1: 50,
x2: 100,
y2: 20,
});
});
it('should calculate coordinates for angle π/2 (right)', () => {
const result = calculatePieChartEndLineCoordinates(
Math.PI / 2,
100,
100,
50,
80,
);
expect(result).toEqual({
x1: 150,
y1: 100,
x2: 180,
y2: 100,
});
});
it('should calculate coordinates for angle π (bottom)', () => {
const result = calculatePieChartEndLineCoordinates(
Math.PI,
100,
100,
50,
80,
);
expect(result).toEqual({
x1: 100,
y1: 150,
x2: 100,
y2: 180,
});
});
it('should calculate coordinates for angle 3π/2 (left)', () => {
const result = calculatePieChartEndLineCoordinates(
(3 * Math.PI) / 2,
100,
100,
50,
80,
);
expect(result.x1).toBeCloseTo(50, 5);
expect(result.y1).toBeCloseTo(100, 5);
expect(result.x2).toBeCloseTo(20, 5);
expect(result.y2).toBeCloseTo(100, 5);
});
it('should calculate coordinates for 45-degree angle', () => {
const angle = Math.PI / 4;
const result = calculatePieChartEndLineCoordinates(angle, 100, 100, 50, 80);
const expectedCos = Math.sqrt(2) / 2;
const expectedSin = -Math.sqrt(2) / 2;
expect(result.x1).toBeCloseTo(100 + expectedCos * 50, 5);
expect(result.y1).toBeCloseTo(100 + expectedSin * 50, 5);
expect(result.x2).toBeCloseTo(100 + expectedCos * 80, 5);
expect(result.y2).toBeCloseTo(100 + expectedSin * 80, 5);
});
it('should handle different center positions', () => {
const result = calculatePieChartEndLineCoordinates(
Math.PI / 2,
200,
150,
30,
60,
);
expect(result).toEqual({
x1: 230,
y1: 150,
x2: 260,
y2: 150,
});
});
it('should handle zero radius', () => {
const result = calculatePieChartEndLineCoordinates(
Math.PI / 4,
100,
100,
0,
0,
);
expect(result).toEqual({
x1: 100,
y1: 100,
x2: 100,
y2: 100,
});
});
it('should handle negative angles', () => {
const result = calculatePieChartEndLineCoordinates(
-Math.PI / 2,
100,
100,
50,
80,
);
expect(result.x1).toBeCloseTo(50, 5);
expect(result.y1).toBeCloseTo(100, 5);
expect(result.x2).toBeCloseTo(20, 5);
expect(result.y2).toBeCloseTo(100, 5);
});
it('should create a line from inner to outer radius', () => {
const angle = Math.PI / 6;
const centerX = 100;
const centerY = 100;
const innerRadius = 40;
const outerRadius = 70;
const result = calculatePieChartEndLineCoordinates(
angle,
centerX,
centerY,
innerRadius,
outerRadius,
);
const dist1 = Math.sqrt(
(result.x1 - centerX) ** 2 + (result.y1 - centerY) ** 2,
);
const dist2 = Math.sqrt(
(result.x2 - centerX) ** 2 + (result.y2 - centerY) ** 2,
);
expect(dist1).toBeCloseTo(innerRadius, 5);
expect(dist2).toBeCloseTo(outerRadius, 5);
const angle1 = Math.atan2(result.y1 - centerY, result.x1 - centerX);
const angle2 = Math.atan2(result.y2 - centerY, result.x2 - centerX);
expect(angle1).toBeCloseTo(angle2, 5);
});
});
@@ -0,0 +1,57 @@
import { calculatePieChartPercentage } from '../calculatePieChartPercentage';
describe('calculatePieChartPercentage', () => {
it('should calculate percentage for normal values', () => {
expect(calculatePieChartPercentage(25, 100)).toBe(25);
expect(calculatePieChartPercentage(50, 100)).toBe(50);
expect(calculatePieChartPercentage(75, 100)).toBe(75);
});
it('should calculate percentage with decimal values', () => {
expect(calculatePieChartPercentage(33, 100)).toBe(33);
expect(calculatePieChartPercentage(1, 3)).toBeCloseTo(33.333, 2);
expect(calculatePieChartPercentage(2, 3)).toBeCloseTo(66.667, 2);
});
it('should handle zero value', () => {
expect(calculatePieChartPercentage(0, 100)).toBe(0);
expect(calculatePieChartPercentage(0, 1)).toBe(0);
});
it('should handle zero total (divide by zero)', () => {
expect(calculatePieChartPercentage(10, 0)).toBe(0);
expect(calculatePieChartPercentage(0, 0)).toBe(0);
expect(calculatePieChartPercentage(-5, 0)).toBe(0);
});
it('should handle negative total', () => {
expect(calculatePieChartPercentage(10, -100)).toBe(0);
expect(calculatePieChartPercentage(-10, -100)).toBe(0);
});
it('should handle value greater than total', () => {
expect(calculatePieChartPercentage(150, 100)).toBe(150);
expect(calculatePieChartPercentage(200, 50)).toBe(400);
});
it('should handle very small values', () => {
expect(calculatePieChartPercentage(0.01, 100)).toBe(0.01);
expect(calculatePieChartPercentage(0.001, 1)).toBe(0.1);
});
it('should handle very large values', () => {
expect(calculatePieChartPercentage(1000000, 10000000)).toBe(10);
expect(calculatePieChartPercentage(1e10, 1e12)).toBe(1);
});
it('should maintain precision for financial calculations', () => {
const value1 = 33.33;
const value2 = 33.33;
const value3 = 33.34;
const total = value1 + value2 + value3;
expect(calculatePieChartPercentage(value1, total)).toBeCloseTo(33.33, 2);
expect(calculatePieChartPercentage(value2, total)).toBeCloseTo(33.33, 2);
expect(calculatePieChartPercentage(value3, total)).toBeCloseTo(33.34, 2);
});
it('should handle edge case with Infinity', () => {
expect(calculatePieChartPercentage(Infinity, 100)).toBe(Infinity);
expect(calculatePieChartPercentage(100, Infinity)).toBe(0);
expect(calculatePieChartPercentage(Infinity, Infinity)).toBeNaN();
});
it('should handle NaN inputs', () => {
expect(calculatePieChartPercentage(NaN, 100)).toBeNaN();
expect(calculatePieChartPercentage(100, NaN)).toBeNaN();
expect(calculatePieChartPercentage(NaN, NaN)).toBeNaN();
});
});
@@ -0,0 +1,14 @@
export const calculatePieChartAngles = (
percentage: number,
cumulativeAngle: number,
) => {
const sliceAngle = (percentage / 100) * 360;
const middleAngle = cumulativeAngle + sliceAngle / 2;
const newCumulativeAngle = cumulativeAngle + sliceAngle;
return {
sliceAngle,
middleAngle,
newCumulativeAngle,
};
};
@@ -0,0 +1,15 @@
export const calculatePieChartEndLineCoordinates = (
angle: number,
centerX: number,
centerY: number,
innerRadius: number,
outerRadius: number,
) => {
const adjustedAngle = angle - Math.PI / 2;
const x1 = centerX + Math.cos(adjustedAngle) * innerRadius;
const y1 = centerY + Math.sin(adjustedAngle) * innerRadius;
const x2 = centerX + Math.cos(adjustedAngle) * outerRadius;
const y2 = centerY + Math.sin(adjustedAngle) * outerRadius;
return { x1, y1, x2, y2 };
};
@@ -0,0 +1,9 @@
export const calculatePieChartPercentage = (
value: number,
totalValue: number,
): number => {
if (isNaN(value) || isNaN(totalValue)) {
return NaN;
}
return totalValue > 0 ? (value / totalValue) * 100 : 0;
};