Connect the number chart to the backend (#15229)

https://github.com/user-attachments/assets/e8fecb5d-bc9b-425a-9c19-baca15f88b1c
This commit is contained in:
Raphaël Bosi
2025-10-22 10:20:08 +02:00
committed by GitHub
parent bf3c3fc5a5
commit c8fe805ea5
7 changed files with 177 additions and 64 deletions
@@ -3,7 +3,7 @@ import { getDefaultWidgetData } from '@/page-layout/utils/getDefaultWidgetData';
import { PageLayoutWidgetNoDataDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay';
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { GraphWidgetBarChartRenderer } from '@/page-layout/widgets/graph/graphWidgetBarChart/components/GraphWidgetBarChartRenderer';
import { GraphWidgetNumberChart } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart';
import { GraphWidgetNumberChartRenderer } from '@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChartRenderer';
import { areChartConfigurationFieldsValidForQuery } from '@/page-layout/widgets/graph/utils/areChartConfigurationFieldsValidForQuery';
import { lazy, Suspense } from 'react';
import { GraphType, type PageLayoutWidget } from '~/generated/graphql';
@@ -64,12 +64,7 @@ export const GraphWidget = ({
switch (graphType) {
case GraphType.NUMBER:
return (
<GraphWidgetNumberChart
value={data.value}
trendPercentage={data.trendPercentage}
/>
);
return <GraphWidgetNumberChartRenderer widget={widget} />;
case GraphType.GAUGE:
return (
@@ -1,7 +1,6 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
import { lazy, Suspense } from 'react';
import { isDefined } from 'twenty-shared/utils';
import {
type BarChartConfiguration,
type PageLayoutWidget,
@@ -30,7 +29,6 @@ export const GraphWidgetBarChartRenderer = ({
showDataLabels,
layout,
loading,
error,
} = useGraphBarChartWidgetData({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration as BarChartConfiguration,
@@ -40,10 +38,6 @@ export const GraphWidgetBarChartRenderer = ({
return <ChartSkeletonLoader />;
}
if (isDefined(error)) {
return <div>Error: {error.message}</div>;
}
const configuration = widget.configuration as BarChartConfiguration;
const groupMode =
configuration.groupMode === 'GROUPED' ? 'grouped' : 'stacked';
@@ -1,6 +1,7 @@
import { formatNumberChartTrend } from '@/page-layout/widgets/graph/graphWidgetNumberChart/utils/formatNumberChartTrend';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { isDefined } from 'twenty-shared/utils';
import {
H1Title,
H1TitleFontColor,
@@ -9,8 +10,8 @@ import {
} from 'twenty-ui/display';
type GraphWidgetNumberChartProps = {
value: string;
trendPercentage: number;
value: string | number;
trendPercentage?: number;
};
const StyledTrendPercentageValue = styled.span`
@@ -43,27 +44,32 @@ export const GraphWidgetNumberChart = ({
trendPercentage,
}: GraphWidgetNumberChartProps) => {
const theme = useTheme();
const formattedPercentage = formatNumberChartTrend(trendPercentage);
const formattedPercentage = isDefined(trendPercentage)
? formatNumberChartTrend(trendPercentage)
: undefined;
return (
<StyledContainer>
<StyledH1Title title={value} fontColor={H1TitleFontColor.Primary} />
<StyledTrendIconContainer>
<StyledTrendPercentageValue>
{formattedPercentage}%
</StyledTrendPercentageValue>
{trendPercentage >= 0 ? (
<IconTrendingUp
color={theme.color.turquoise40}
size={theme.icon.size.md}
/>
) : (
<IconTrendingDown
color={theme.adaptiveColors.red4}
size={theme.icon.size.md}
/>
)}
</StyledTrendIconContainer>
{isDefined(trendPercentage) && (
<StyledTrendIconContainer>
<StyledTrendPercentageValue>
{formattedPercentage}%
</StyledTrendPercentageValue>
{trendPercentage >= 0 ? (
<IconTrendingUp
color={theme.color.turquoise40}
size={theme.icon.size.md}
/>
) : (
<IconTrendingDown
color={theme.adaptiveColors.red4}
size={theme.icon.size.md}
/>
)}
</StyledTrendIconContainer>
)}
</StyledContainer>
);
};
@@ -0,0 +1,36 @@
import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/ChartSkeletonLoader';
import { useGraphWidgetAggregateQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery';
import { lazy, Suspense } from 'react';
import {
type NumberChartConfiguration,
type PageLayoutWidget,
} from '~/generated/graphql';
const GraphWidgetNumberChart = lazy(() =>
import(
'@/page-layout/widgets/graph/graphWidgetNumberChart/components/GraphWidgetNumberChart'
).then((module) => ({
default: module.GraphWidgetNumberChart,
})),
);
export const GraphWidgetNumberChartRenderer = ({
widget,
}: {
widget: PageLayoutWidget;
}) => {
const { value, loading } = useGraphWidgetAggregateQuery({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration as NumberChartConfiguration,
});
if (loading) {
return <ChartSkeletonLoader />;
}
return (
<Suspense fallback={<ChartSkeletonLoader />}>
<GraphWidgetNumberChart value={value ?? 0} />
</Suspense>
);
};
@@ -0,0 +1,56 @@
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { computeAggregateValueAndLabel } from '@/object-record/record-board/record-board-column/utils/computeAggregateValueAndLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { UserContext } from '@/users/contexts/UserContext';
import { useContext } from 'react';
import { useRecoilValue } from 'recoil';
import { type NumberChartConfiguration } from '~/generated/graphql';
import { dateLocaleState } from '~/localization/states/dateLocaleState';
export const useGraphWidgetAggregateQuery = ({
objectMetadataItemId,
configuration,
}: {
objectMetadataItemId: string;
configuration: NumberChartConfiguration;
}) => {
const { objectMetadataItem, gqlOperationFilter, aggregateField } =
useGraphWidgetQueryCommon({
objectMetadataItemId,
configuration,
});
// TODO: Move this enum to shared
const aggregateOperation =
configuration.aggregateOperation as unknown as ExtendedAggregateOperations;
const { data, loading, error } = useAggregateRecords({
objectNameSingular: objectMetadataItem.nameSingular,
recordGqlFieldsAggregate: {
[aggregateField.name]: [aggregateOperation],
},
filter: gqlOperationFilter,
});
const { dateFormat, timeFormat, timeZone } = useContext(UserContext);
const dateLocale = useRecoilValue(dateLocaleState);
const { value, label } = computeAggregateValueAndLabel({
data,
objectMetadataItem,
fieldMetadataId: configuration.aggregateFieldMetadataId,
aggregateOperation,
dateFormat,
timeFormat,
timeZone,
localeCatalog: dateLocale.localeCatalog,
});
return {
value,
label,
loading,
error,
};
};
@@ -1,14 +1,11 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { getAvailableAggregationsFromObjectFields } from '@/object-record/utils/getAvailableAggregationsFromObjectFields';
import { useGraphWidgetQueryCommon } from '@/page-layout/widgets/graph/hooks/useGraphWidgetQueryCommon';
import { generateGroupByQuery } from '@/page-layout/widgets/graph/utils/generateGroupByQuery';
import { generateGroupByQueryVariablesFromBarChartConfiguration } from '@/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration';
import { useQuery } from '@apollo/client';
import { useMemo } from 'react';
import {
computeRecordGqlOperationFilter,
isDefined,
} from 'twenty-shared/utils';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated-metadata/graphql';
export const useGraphWidgetGroupByQuery = ({
@@ -18,9 +15,15 @@ export const useGraphWidgetGroupByQuery = ({
objectMetadataItemId: string;
configuration: BarChartConfiguration;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { objectMetadataItem, aggregateField, gqlOperationFilter } =
useGraphWidgetQueryCommon({
objectMetadataItemId,
configuration,
});
if (!isDefined(aggregateField)) {
throw new Error('Aggregate field not found');
}
const availableAggregations = useMemo(
() =>
@@ -30,16 +33,6 @@ export const useGraphWidgetGroupByQuery = ({
[objectMetadataItem.readableFields],
);
const aggregateFieldId = configuration.aggregateFieldMetadataId;
const aggregateField = objectMetadataItem.readableFields.find(
(field) => field.id === aggregateFieldId,
);
if (!isDefined(aggregateField)) {
throw new Error('Aggregate field not found');
}
const aggregateOperation =
availableAggregations[aggregateField.name]?.[
configuration.aggregateOperation
@@ -49,27 +42,16 @@ export const useGraphWidgetGroupByQuery = ({
throw new Error('Aggregate operation not found');
}
const gqlOperationFilter = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
filterValueDependencies: {},
recordFilters: configuration.filter?.recordFilters ?? [],
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
});
const filterQueryVariables = {
filter: gqlOperationFilter,
};
const groupByQueryVariables =
generateGroupByQueryVariablesFromBarChartConfiguration({
objectMetadataItem,
barChartConfiguration: configuration,
aggregateOperation,
aggregateOperation: aggregateOperation,
});
const variables = {
...groupByQueryVariables,
...filterQueryVariables,
filter: gqlOperationFilter,
};
const query = generateGroupByQuery({
@@ -89,6 +71,6 @@ export const useGraphWidgetGroupByQuery = ({
loading,
error,
refetch,
aggregateOperation,
aggregateOperation: aggregateOperation,
};
};
@@ -0,0 +1,44 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import {
computeRecordGqlOperationFilter,
isDefined,
} from 'twenty-shared/utils';
import {
type BarChartConfiguration,
type NumberChartConfiguration,
} from '~/generated/graphql';
export const useGraphWidgetQueryCommon = ({
objectMetadataItemId,
configuration,
}: {
objectMetadataItemId: string;
configuration: BarChartConfiguration | NumberChartConfiguration;
}) => {
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const aggregateFieldId = configuration.aggregateFieldMetadataId;
const aggregateField = objectMetadataItem.readableFields.find(
(field) => field.id === aggregateFieldId,
);
if (!isDefined(aggregateField)) {
throw new Error('Aggregate field not found');
}
const gqlOperationFilter = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
filterValueDependencies: {},
recordFilters: configuration.filter?.recordFilters ?? [],
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
});
return {
objectMetadataItem,
gqlOperationFilter,
aggregateField,
};
};