From 2f25922f4cd5bd61e1427c57c4f8ea224e1d552c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Thu, 27 Nov 2025 18:26:14 +0100 Subject: [PATCH] [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 --- .../GraphWidgetPieChart.stories.tsx | 62 +++++++- .../components/GraphWidgetPieChart.tsx | 9 +- .../GraphWidgetPieChartRenderer.tsx | 2 + .../components/PieChartCenterMetricLayer.tsx | 53 ++++--- .../hooks/usePieChartCenterMetricData.ts | 135 ++++++++++++++++++ 5 files changed, 236 insertions(+), 25 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx index 7e3a90ffc8..586beb48bd 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/components/__stories__/GraphWidgetPieChart.stories.tsx @@ -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 = { 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} /> ), @@ -93,6 +126,8 @@ export const WithCenterMetric: Story = { data={args.data} showCenterMetric={args.showCenterMetric} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -114,6 +149,8 @@ export const WithDataLabels: Story = { data={args.data} showDataLabels={args.showDataLabels} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -141,6 +178,8 @@ export const Revenue: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -167,6 +206,8 @@ export const TaskStatus: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -192,6 +233,8 @@ export const TwoSlices: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -222,6 +265,8 @@ export const ManySlices: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -248,6 +293,8 @@ export const WithoutLegend: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -275,6 +322,8 @@ export const MarketShare: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), @@ -302,13 +351,20 @@ export const Storage: Story = { decimals={args.decimals} showLegend={args.showLegend} id={args.id} + objectMetadataItemId={mockObjectMetadataItemId} + configuration={mockConfiguration} /> ), }; 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} /> ), diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx index 3feaecf55b..993ce75500 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChart.tsx @@ -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 = ({ }} /> diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx index d2f8b6cf44..ef6925e637 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/components/GraphWidgetPieChartRenderer.tsx @@ -91,6 +91,8 @@ export const GraphWidgetPieChartRenderer = ({ ` +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 ( - - {formatGraphValue(total, formatOptions)} - - Total - - + + {show && ( + + {centerMetricValue} + {centerMetricLabel} + + )} + ); }; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData.ts new file mode 100644 index 0000000000..d8b215e9ba --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartCenterMetricData.ts @@ -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, + }; +};