[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:
+60
-2
@@ -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>
|
||||
),
|
||||
|
||||
+7
-2
@@ -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>
|
||||
|
||||
+2
@@ -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}
|
||||
|
||||
+32
-21
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+135
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user