[DASHBOARDS] Pie Chart (#16048)

## Description

- Connect Pie Chart to the backend
- Update from the old design to the new design
- Switch seamlessly from/to other types of chart from the Pie Chart by
converting the configuration

Note: There are a couple things to implement before the pie chart is
ready to go live:
- Add a new setting on Bar, Line and Pie chart to hide and display
legends (toggle)
- Add data labels next to each slice

## Video QA


https://github.com/user-attachments/assets/b1561e32-0434-43ad-8855-d617b209ce27
This commit is contained in:
Raphaël Bosi
2025-11-25 14:30:16 +01:00
committed by GitHub
parent 88fbe6c51d
commit 6d141c2439
43 changed files with 1147 additions and 476 deletions
@@ -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 { PieChartEndLines } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/PieChartEndLines';
import { PIE_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartHoverBrightness';
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';
@@ -10,17 +10,14 @@ import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/crea
import { 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 { ResponsivePie, type PieTooltipProps } from '@nivo/pie';
import { isDefined } from 'twenty-shared/utils';
type GraphWidgetPieChartProps = {
data: PieChartDataItem[];
showLegend?: boolean;
id: string;
onSliceClick?: (datum: PieChartDataItem) => void;
} & GraphValueFormatOptions;
const StyledContainer = styled.div`
@@ -32,6 +29,19 @@ const StyledContainer = styled.div`
width: 100%;
`;
const StyledPieChartWrapper = styled.div`
width: 100%;
height: 100%;
svg g path {
transition: filter 0.15s ease-in-out;
&:hover {
filter: brightness(${PIE_CHART_HOVER_BRIGHTNESS});
}
}
`;
export const GraphWidgetPieChart = ({
data,
showLegend = true,
@@ -41,6 +51,7 @@ export const GraphWidgetPieChart = ({
prefix,
suffix,
customFormatter,
onSliceClick,
}: GraphWidgetPieChartProps) => {
const theme = useTheme();
const colorRegistry = createGraphColorRegistry(theme);
@@ -58,12 +69,11 @@ export const GraphWidgetPieChart = ({
setHoveredSliceId,
handleSliceClick,
hasClickableItems,
} = usePieChartHandlers({ data });
} = usePieChartHandlers({ data, onSliceClick });
const { enrichedData, enrichedDataMap, defs, fill } = usePieChartData({
const { enrichedData } = usePieChartData({
data,
colorRegistry,
id,
hoveredSliceId,
});
@@ -73,19 +83,6 @@ export const GraphWidgetPieChart = ({
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;
@@ -97,23 +94,23 @@ export const GraphWidgetPieChart = ({
<StyledContainer id={id}>
<GraphWidgetChartContainer
$isClickable={hasClickableItems}
$cursorSelector='svg g path[fill^="url(#"]'
$cursorSelector="svg g path"
>
<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]}
/>
<StyledPieChartWrapper>
<ResponsivePie
data={data}
innerRadius={0.8}
colors={enrichedData.map((item) => item.colorScheme.solid)}
borderWidth={0}
enableArcLinkLabels={false}
enableArcLabels={false}
tooltip={renderTooltip}
onClick={handleSliceClick}
onMouseEnter={(datum) => setHoveredSliceId(datum.id)}
onMouseLeave={() => setHoveredSliceId(null)}
layers={['arcs']}
/>
</StyledPieChartWrapper>
</GraphWidgetChartContainer>
<GraphWidgetLegend
show={showLegend}
@@ -0,0 +1,72 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { useGraphPieChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { coreIndexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/selectors/coreIndexViewIdFromObjectMetadataItemFamilySelector';
import { lazy, Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import {
type PageLayoutWidget,
type PieChartConfiguration,
} from '~/generated/graphql';
const GraphWidgetPieChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart'
).then((module) => ({
default: module.GraphWidgetPieChart,
})),
);
export const GraphWidgetPieChartRenderer = ({
widget,
}: {
widget: PageLayoutWidget;
}) => {
const { data, loading, hasTooManyGroups, objectMetadataItem } =
useGraphPieChartWidgetData({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration as PieChartConfiguration,
});
const navigate = useNavigate();
const indexViewId = useRecoilValue(
coreIndexViewIdFromObjectMetadataItemFamilySelector({
objectMetadataItemId: objectMetadataItem.id,
}),
);
const handleSliceClick = (_datum: PieChartDataItem) => {
return navigate(
getAppPath(
AppPath.RecordIndexPage,
{
objectNamePlural: objectMetadataItem.namePlural,
},
isDefined(indexViewId) ? { viewId: indexViewId } : undefined,
),
);
};
if (loading) {
return <ChartSkeletonLoader />;
}
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetChartHasTooManyGroupsEffect
hasTooManyGroups={hasTooManyGroups}
/>
<GraphWidgetPieChart
data={data}
id={widget.id}
displayType="shortNumber"
onSliceClick={handleSliceClick}
/>
</Suspense>
);
};
@@ -0,0 +1 @@
export const PIE_CHART_HOVER_BRIGHTNESS = 0.85;
@@ -0,0 +1 @@
export const PIE_CHART_MAXIMUM_NUMBER_OF_SLICES = 50;
@@ -67,7 +67,6 @@ describe('usePieChartData', () => {
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
@@ -80,7 +79,6 @@ describe('usePieChartData', () => {
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);
@@ -91,7 +89,6 @@ describe('usePieChartData', () => {
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
@@ -107,7 +104,6 @@ describe('usePieChartData', () => {
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId,
}),
{ initialProps: { hoveredSliceId: null as DatumId | null } },
@@ -121,57 +117,16 @@ describe('usePieChartData', () => {
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', () => {
@@ -183,7 +138,6 @@ describe('usePieChartData', () => {
usePieChartData({
data: singleData,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
@@ -202,7 +156,6 @@ describe('usePieChartData', () => {
usePieChartData({
data: dataWithColors,
colorRegistry: mockColorRegistry,
id: 'test-chart',
hoveredSliceId: null,
}),
);
@@ -213,24 +166,19 @@ describe('usePieChartData', () => {
it('should memoize calculations', () => {
const { result, rerender } = renderHook(
({ id }) =>
({ hoveredSliceId }) =>
usePieChartData({
data: mockData,
colorRegistry: mockColorRegistry,
id,
hoveredSliceId: null,
hoveredSliceId,
}),
{ initialProps: { id: 'test-chart' } },
{ initialProps: { hoveredSliceId: null as DatumId | null } },
);
const firstEnrichedData = result.current.enrichedData;
const firstDefs = result.current.defs;
const firstFill = result.current.fill;
rerender({ id: 'test-chart' });
rerender({ hoveredSliceId: null as DatumId | null });
expect(result.current.enrichedData).toBe(firstEnrichedData);
expect(result.current.defs).toBe(firstDefs);
expect(result.current.fill).toBe(firstFill);
});
});
@@ -0,0 +1,63 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { useMemo } from 'react';
import { type PieChartConfiguration } from '~/generated/graphql';
type UseGraphPieChartWidgetDataProps = {
objectMetadataItemId: string;
configuration: PieChartConfiguration;
};
type UseGraphPieChartWidgetDataResult = {
data: PieChartDataItem[];
loading: boolean;
error?: Error;
hasTooManyGroups: boolean;
objectMetadataItem: ObjectMetadataItem;
};
// TODO: Remove this once backend returns total group count
const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1;
export const useGraphPieChartWidgetData = ({
objectMetadataItemId,
configuration,
}: UseGraphPieChartWidgetDataProps): UseGraphPieChartWidgetDataResult => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const {
data: groupByData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
limit:
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
});
const transformedData = useMemo(
() =>
transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}),
[groupByData, objectMetadataItem, configuration, aggregateOperation],
);
return {
...transformedData,
objectMetadataItem,
loading,
error,
};
};
@@ -3,7 +3,6 @@ import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidg
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';
import { useMemo } from 'react';
@@ -11,14 +10,12 @@ import { useMemo } from 'react';
type UsePieChartDataProps = {
data: PieChartDataItem[];
colorRegistry: GraphColorRegistry;
id: string;
hoveredSliceId: DatumId | null;
};
export const usePieChartData = ({
data,
colorRegistry,
id,
hoveredSliceId,
}: UsePieChartDataProps) => {
const enrichedData = useMemo((): PieChartEnrichedData[] => {
@@ -34,7 +31,6 @@ export const usePieChartData = ({
});
const isHovered = hoveredSliceId === item.id;
const gradientId = `${colorScheme.name}Gradient-${id}-${index}`;
const percentage = calculatePieChartPercentage(item.value, totalValue);
const angles = calculatePieChartAngles(percentage, cumulativeAngle);
@@ -42,32 +38,13 @@ export const usePieChartData = ({
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]);
}, [data, colorRegistry, hoveredSliceId]);
const enrichedDataMap = useMemo(
() => new Map(enrichedData.map((item) => [item.id, item])),
@@ -77,7 +54,5 @@ export const usePieChartData = ({
return {
enrichedData,
enrichedDataMap,
defs,
fill,
};
};
@@ -5,21 +5,30 @@ import { isDefined } from 'twenty-shared/utils';
type UsePieChartHandlersProps = {
data: PieChartDataItem[];
onSliceClick?: (datum: PieChartDataItem) => void;
};
export const usePieChartHandlers = ({ data }: UsePieChartHandlersProps) => {
export const usePieChartHandlers = ({
data,
onSliceClick,
}: 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;
if (isDefined(clickedItem)) {
if (isDefined(onSliceClick)) {
onSliceClick(clickedItem);
} else if (isDefined(clickedItem.to)) {
window.location.href = clickedItem.to;
}
}
};
const hasClickableItems = data.some((item) => isDefined(item.to));
const hasClickableItems =
isDefined(onSliceClick) || data.some((item) => isDefined(item.to));
return {
hoveredSliceId,
@@ -2,7 +2,6 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type PieChartEnrichedData = PieChartDataItem & {
gradientId: string;
colorScheme: GraphColorScheme;
isHovered: boolean;
percentage: number;
@@ -0,0 +1,101 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { isDefined } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
type TransformGroupByDataToPieChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
configuration: PieChartConfiguration;
aggregateOperation: string;
};
type TransformGroupByDataToPieChartDataResult = {
data: PieChartDataItem[];
hasTooManyGroups: boolean;
};
const EMPTY_PIE_CHART_RESULT: TransformGroupByDataToPieChartDataResult = {
data: [],
hasTooManyGroups: false,
};
export const transformGroupByDataToPieChartData = ({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}: TransformGroupByDataToPieChartDataParams): TransformGroupByDataToPieChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_PIE_CHART_RESULT;
}
const groupByField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.groupByFieldMetadataId,
);
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
if (!isDefined(groupByField) || !isDefined(aggregateField)) {
return EMPTY_PIE_CHART_RESULT;
}
const queryName = getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData[queryName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return EMPTY_PIE_CHART_RESULT;
}
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const limitedResults = rawResults.slice(
0,
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
);
const data: PieChartDataItem[] = limitedResults.map((result) => {
const dimensionValues = result.groupByDimensionValues;
const label = isDefined(dimensionValues?.[0])
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByField,
dateGranularity: configuration.dateGranularity ?? undefined,
subFieldName: configuration.groupBySubFieldName ?? undefined,
})
: '';
const value = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
return {
id: label,
value: value,
label: label,
color: (configuration.color as GraphColor | undefined) ?? undefined,
};
});
return {
data,
hasTooManyGroups: rawResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
};
};