[DASHBOARDS] Use aggregate for pie chart center metric (#16153)

## Description

The pie chart center metric wasn't implemented the right way.
It always calculated the sum of the values, but this only make sense for
additive aggregate operations (count, sum ...).
What we should do instead is calculate the right aggregate value.
This PR fixes this.

## Video QA


https://github.com/user-attachments/assets/2190da5a-e608-4732-86a2-478c9cf1477a
This commit is contained in:
Raphaël Bosi
2025-11-27 18:26:14 +01:00
committed by GitHub
parent 32a876bbd4
commit 2f25922f4c
5 changed files with 236 additions and 25 deletions
@@ -1,13 +1,44 @@
import { type Meta, type StoryObj } from '@storybook/react';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { GraphWidgetPieChart } from '@/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart';
import { CatalogDecorator, ComponentDecorator } from 'twenty-ui/testing';
import {
AggregateOperations,
GraphType,
type PieChartConfiguration,
} from '~/generated/graphql';
import { I18nFrontDecorator } from '~/testing/decorators/I18nFrontDecorator';
import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator';
import { RootDecorator } from '~/testing/decorators/RootDecorator';
import { getMockFieldMetadataItemOrThrow } from '~/testing/utils/getMockFieldMetadataItemOrThrow';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
const companyObjectMetadataItem = getMockObjectMetadataItemOrThrow(
CoreObjectNameSingular.Company,
);
const idField = getMockFieldMetadataItemOrThrow({
objectMetadataItem: companyObjectMetadataItem,
fieldName: 'id',
});
const mockObjectMetadataItemId = companyObjectMetadataItem.id;
const mockConfiguration: PieChartConfiguration = {
aggregateFieldMetadataId: idField.id,
aggregateOperation: AggregateOperations.COUNT,
graphType: GraphType.PIE,
groupByFieldMetadataId: idField.id,
};
const meta: Meta<typeof GraphWidgetPieChart> = {
title: 'Modules/PageLayout/Widgets/GraphWidgetPieChart',
component: GraphWidgetPieChart,
decorators: [ComponentDecorator, I18nFrontDecorator],
decorators: [
ComponentDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
RootDecorator,
],
parameters: {
layout: 'centered',
},
@@ -72,6 +103,8 @@ export const Default: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -93,6 +126,8 @@ export const WithCenterMetric: Story = {
data={args.data}
showCenterMetric={args.showCenterMetric}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -114,6 +149,8 @@ export const WithDataLabels: Story = {
data={args.data}
showDataLabels={args.showDataLabels}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -141,6 +178,8 @@ export const Revenue: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -167,6 +206,8 @@ export const TaskStatus: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -192,6 +233,8 @@ export const TwoSlices: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -222,6 +265,8 @@ export const ManySlices: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -248,6 +293,8 @@ export const WithoutLegend: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -275,6 +322,8 @@ export const MarketShare: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -302,13 +351,20 @@ export const Storage: Story = {
decimals={args.decimals}
showLegend={args.showLegend}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
};
export const Catalog: Story = {
decorators: [CatalogDecorator, I18nFrontDecorator],
decorators: [
CatalogDecorator,
I18nFrontDecorator,
ObjectMetadataItemsDecorator,
RootDecorator,
],
parameters: {
catalog: {
dimensions: [
@@ -355,6 +411,8 @@ export const Catalog: Story = {
displayType="percentage"
showLegend={true}
id={args.id}
objectMetadataItemId={mockObjectMetadataItemId}
configuration={mockConfiguration}
/>
</Container>
),
@@ -18,11 +18,14 @@ import {
} from '@nivo/pie';
import { useMemo } from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
type GraphWidgetPieChartProps = {
data: PieChartDataItem[];
showLegend?: boolean;
id: string;
objectMetadataItemId: string;
configuration: PieChartConfiguration;
onSliceClick?: (datum: PieChartDataItem) => void;
showDataLabels?: boolean;
showCenterMetric?: boolean;
@@ -59,6 +62,8 @@ export const GraphWidgetPieChart = ({
data,
showLegend = true,
id,
objectMetadataItemId,
configuration,
displayType,
decimals,
prefix,
@@ -158,8 +163,8 @@ export const GraphWidgetPieChart = ({
}}
/>
<PieChartCenterMetric
data={data}
formatOptions={formatOptions}
objectMetadataItemId={objectMetadataItemId}
configuration={configuration}
show={showCenterMetric && !hasNoData}
/>
</StyledPieChartWrapper>
@@ -91,6 +91,8 @@ export const GraphWidgetPieChartRenderer = ({
<GraphWidgetPieChart
data={data}
id={widget.id}
objectMetadataItemId={widget.objectMetadataId}
configuration={configuration}
showLegend={showLegend}
displayType="shortNumber"
onSliceClick={isPageLayoutInEditMode ? undefined : handleSliceClick}
@@ -1,30 +1,25 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import {
formatGraphValue,
type GraphValueFormatOptions,
} from '@/page-layout/widgets/graph/utils/graphFormatters';
import { usePieChartCenterMetricData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { Trans } from '@lingui/react/macro';
import { AnimatePresence, motion } from 'framer-motion';
import { type PieChartConfiguration } from '~/generated/graphql';
type PieChartCenterMetricProps = {
data: PieChartDataItem[];
formatOptions: GraphValueFormatOptions;
objectMetadataItemId: string;
configuration: PieChartConfiguration;
show: boolean;
};
const StyledCenterMetricContainer = styled.div<{ show: boolean }>`
const StyledCenterMetricContainer = styled(motion.div)`
align-items: center;
display: flex;
flex-direction: column;
justify-content: center;
left: 50%;
opacity: ${({ show }) => (show ? 1 : 0)};
pointer-events: none;
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
transition: opacity ${({ theme }) => theme.animation.duration.fast}s
ease-in-out;
`;
const StyledValue = styled.span`
@@ -39,18 +34,34 @@ const StyledLabel = styled.span`
`;
export const PieChartCenterMetric = ({
data,
formatOptions,
objectMetadataItemId,
configuration,
show,
}: PieChartCenterMetricProps) => {
const total = data.reduce((sum, datum) => sum + datum.value, 0);
const theme = useTheme();
const { centerMetricValue, centerMetricLabel } = usePieChartCenterMetricData({
objectMetadataItemId,
configuration,
skip: !show,
});
return (
<StyledCenterMetricContainer show={show}>
<StyledValue>{formatGraphValue(total, formatOptions)}</StyledValue>
<StyledLabel>
<Trans>Total</Trans>
</StyledLabel>
</StyledCenterMetricContainer>
<AnimatePresence>
{show && (
<StyledCenterMetricContainer
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{
duration: theme.animation.duration.fast,
ease: 'easeInOut',
}}
>
<StyledValue>{centerMetricValue}</StyledValue>
<StyledLabel>{centerMetricLabel}</StyledLabel>
</StyledCenterMetricContainer>
)}
</AnimatePresence>
);
};
@@ -0,0 +1,135 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { convertAggregateOperationToExtendedAggregateOperation } from '@/object-record/utils/convertAggregateOperationToExtendedAggregateOperation';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { UserContext } from '@/users/contexts/UserContext';
import { t } from '@lingui/core/macro';
import { useContext, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from 'twenty-shared/constants';
import { findById, isDefined } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
type UsePieChartCenterMetricDataProps = {
objectMetadataItemId: string;
configuration: PieChartConfiguration;
skip: boolean;
};
type UsePieChartCenterMetricDataResult = {
centerMetricValue: string | number | undefined;
centerMetricLabel: string;
};
export const usePieChartCenterMetricData = ({
objectMetadataItemId,
configuration,
skip,
}: UsePieChartCenterMetricDataProps): UsePieChartCenterMetricDataResult => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { gqlOperationFilter, aggregateField } = useGraphWidgetQueryCommon({
objectMetadataItemId,
configuration,
});
const centerMetricAggregateOperation =
configuration.aggregateOperation as AggregateOperations;
const extendedAggregateOperation =
convertAggregateOperationToExtendedAggregateOperation(
centerMetricAggregateOperation,
aggregateField.type,
);
const recordGqlFieldsAggregate = useMemo(
() => ({
[aggregateField.name]: [extendedAggregateOperation],
}),
[aggregateField.name, extendedAggregateOperation],
);
const { data: centerMetricData } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate,
filter: gqlOperationFilter,
skip,
});
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const aggregateFieldMetadataItem = objectMetadataItem.readableFields.find(
findById(configuration.aggregateFieldMetadataId),
);
const centerMetricLabel = useMemo(() => {
const isCountOrSum =
centerMetricAggregateOperation === AggregateOperations.SUM ||
centerMetricAggregateOperation === AggregateOperations.COUNT ||
centerMetricAggregateOperation === AggregateOperations.COUNT_EMPTY ||
centerMetricAggregateOperation === AggregateOperations.COUNT_NOT_EMPTY ||
centerMetricAggregateOperation ===
AggregateOperations.COUNT_UNIQUE_VALUES ||
centerMetricAggregateOperation === AggregateOperations.COUNT_TRUE ||
centerMetricAggregateOperation === AggregateOperations.COUNT_FALSE;
if (isCountOrSum) {
return t`Total`;
}
const isPercentage =
centerMetricAggregateOperation === AggregateOperations.PERCENTAGE_EMPTY ||
centerMetricAggregateOperation ===
AggregateOperations.PERCENTAGE_NOT_EMPTY;
if (isPercentage) {
return t`Percentage`;
}
return getAggregateOperationLabel(centerMetricAggregateOperation);
}, [centerMetricAggregateOperation]);
const centerMetricValue = useMemo(() => {
if (!isDefined(aggregateFieldMetadataItem)) {
return centerMetricData?.[FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION]?.[
AggregateOperations.COUNT
];
}
const aggregateRawValue =
centerMetricData?.[aggregateFieldMetadataItem.name]?.[
centerMetricAggregateOperation
];
return transformAggregateRawValueIntoAggregateDisplayValue({
aggregateFieldMetadataItem,
aggregateOperation: extendedAggregateOperation,
aggregateRawValue,
dateFormat,
localeCatalog: dateLocale.localeCatalog,
timeFormat,
timeZone,
});
}, [
aggregateFieldMetadataItem,
centerMetricData,
centerMetricAggregateOperation,
extendedAggregateOperation,
dateFormat,
dateLocale.localeCatalog,
timeFormat,
timeZone,
]);
return {
centerMetricValue,
centerMetricLabel,
};
};