[DASHBOARDS] Move all the graph computing logic to the backend (#17189)

- Create resolvers for each type of charts which needs data
transformation after the group by operation: Bar Chart, Line Chart and
Pie Chart
- Move all the utils to the backend and refactored some into services

This allows all the computation to be done in the backend, improving
performances in the frontend.
This commit is contained in:
Raphaël Bosi
2026-01-19 18:25:45 +01:00
committed by GitHub
parent 1f1ccb281e
commit d0bc8b9ffe
264 changed files with 12408 additions and 8872 deletions
+1
View File
@@ -20,6 +20,7 @@ module.exports = {
'./src/modules/subscription/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/graphql/**/*.{ts,tsx}',
'./src/modules/page-layout/widgets/**/graphql/**/*.{ts,tsx}',
'!./src/**/*.test.{ts,tsx}',
'!./src/**/*.stories.{ts,tsx}',
+2 -2
View File
@@ -62,8 +62,8 @@ const jestConfig = {
extensionsToTreatAsEsm: ['.ts', '.tsx'],
coverageThreshold: {
global: {
statements: 51,
lines: 50,
statements: 50,
lines: 49,
functions: 41,
},
},
@@ -380,6 +380,27 @@ export type BarChartConfiguration = {
timezone?: Maybe<Scalars['String']>;
};
export type BarChartDataInput = {
configuration: Scalars['JSON'];
objectMetadataId: Scalars['UUID'];
};
export type BarChartDataOutput = {
__typename?: 'BarChartDataOutput';
data: Array<Scalars['JSON']>;
formattedToRawLookup: Scalars['JSON'];
groupMode: BarChartGroupMode;
hasTooManyGroups: Scalars['Boolean'];
indexBy: Scalars['String'];
keys: Array<Scalars['String']>;
layout: BarChartLayout;
series: Array<BarChartSeries>;
showDataLabels: Scalars['Boolean'];
showLegend: Scalars['Boolean'];
xAxisLabel: Scalars['String'];
yAxisLabel: Scalars['String'];
};
/** Display mode for bar charts with secondary grouping */
export enum BarChartGroupMode {
GROUPED = 'GROUPED',
@@ -392,6 +413,12 @@ export enum BarChartLayout {
VERTICAL = 'VERTICAL'
}
export type BarChartSeries = {
__typename?: 'BarChartSeries';
key: Scalars['String'];
label: Scalars['String'];
};
export type Billing = {
__typename?: 'Billing';
billingUrl?: Maybe<Scalars['String']>;
@@ -1851,6 +1878,35 @@ export type LineChartConfiguration = {
timezone?: Maybe<Scalars['String']>;
};
export type LineChartDataInput = {
configuration: Scalars['JSON'];
objectMetadataId: Scalars['UUID'];
};
export type LineChartDataOutput = {
__typename?: 'LineChartDataOutput';
formattedToRawLookup: Scalars['JSON'];
hasTooManyGroups: Scalars['Boolean'];
series: Array<LineChartSeries>;
showDataLabels: Scalars['Boolean'];
showLegend: Scalars['Boolean'];
xAxisLabel: Scalars['String'];
yAxisLabel: Scalars['String'];
};
export type LineChartDataPoint = {
__typename?: 'LineChartDataPoint';
x: Scalars['String'];
y: Scalars['Float'];
};
export type LineChartSeries = {
__typename?: 'LineChartSeries';
data: Array<LineChartDataPoint>;
id: Scalars['String'];
label: Scalars['String'];
};
export type LinkMetadata = {
__typename?: 'LinkMetadata';
label: Scalars['String'];
@@ -3375,6 +3431,27 @@ export type PieChartConfiguration = {
timezone?: Maybe<Scalars['String']>;
};
export type PieChartDataInput = {
configuration: Scalars['JSON'];
objectMetadataId: Scalars['UUID'];
};
export type PieChartDataItem = {
__typename?: 'PieChartDataItem';
id: Scalars['String'];
value: Scalars['Float'];
};
export type PieChartDataOutput = {
__typename?: 'PieChartDataOutput';
data: Array<PieChartDataItem>;
formattedToRawLookup: Scalars['JSON'];
hasTooManyGroups: Scalars['Boolean'];
showCenterMetric: Scalars['Boolean'];
showDataLabels: Scalars['Boolean'];
showLegend: Scalars['Boolean'];
};
export type PlaceDetailsResult = {
__typename?: 'PlaceDetailsResult';
city?: Maybe<Scalars['String']>;
@@ -3433,6 +3510,7 @@ export type Query = {
agentTurns: Array<AgentTurn>;
apiKey?: Maybe<ApiKey>;
apiKeys: Array<ApiKey>;
barChartData: BarChartDataOutput;
billingPortalSession: BillingSessionOutput;
chatMessages: Array<AgentMessage>;
chatThread: AgentChatThread;
@@ -3510,9 +3588,11 @@ export type Query = {
getToolIndex: Array<ToolIndexEntry>;
index: Index;
indexMetadatas: IndexConnection;
lineChartData: LineChartDataOutput;
listPlans: Array<BillingPlanOutput>;
object: Object;
objects: ObjectConnection;
pieChartData: PieChartDataOutput;
search: SearchResultConnection;
skill?: Maybe<Skill>;
skills: Array<Skill>;
@@ -3533,6 +3613,11 @@ export type QueryApiKeyArgs = {
};
export type QueryBarChartDataArgs = {
input: BarChartDataInput;
};
export type QueryBillingPortalSessionArgs = {
returnUrlPath?: InputMaybe<Scalars['String']>;
};
@@ -3832,6 +3917,11 @@ export type QueryIndexMetadatasArgs = {
};
export type QueryLineChartDataArgs = {
input: LineChartDataInput;
};
export type QueryObjectArgs = {
id: Scalars['UUID'];
};
@@ -3843,6 +3933,11 @@ export type QueryObjectsArgs = {
};
export type QueryPieChartDataArgs = {
input: PieChartDataInput;
};
export type QuerySearchArgs = {
after?: InputMaybe<Scalars['String']>;
excludedObjectNameSingulars?: InputMaybe<Array<Scalars['String']>>;
File diff suppressed because one or more lines are too long
@@ -11,11 +11,11 @@ import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { t } from '@lingui/core/macro';
import { formatToShortNumber } from 'twenty-shared/utils';
import { H2Title } from 'twenty-ui/display';
import { ProgressBar } from 'twenty-ui/feedback';
import { Section } from 'twenty-ui/layout';
import { SubscriptionStatus } from '~/generated/graphql';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
const StyledLineSeparator = styled.div`
width: 100%;
@@ -1,6 +1,6 @@
import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page-layout/utils/isFieldOrNestedFieldDateKind';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { FieldMetadataType } from 'twenty-shared/types';
import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page-layout/utils/isFieldOrNestedFieldDateKind';
describe('isFieldOrNestedFieldDateKind', () => {
it('returns false when fieldId is null', () => {
@@ -47,12 +47,13 @@ describe('isFieldOrNestedFieldDateKind', () => {
{
id: 'relation-field-id',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
relation: { targetObjectMetadata: { id: 'company-id' } },
},
],
} as ObjectMetadataItem;
const companyObjectMetadataItem = {
id: 'company-id',
nameSingular: 'company',
fields: [
{
@@ -1,11 +1,11 @@
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds';
import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup';
import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/shouldHideChartSetting';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { msg } from '@lingui/core/macro';
import { FieldMetadataType } from 'twenty-shared/types';
import { IconChartBar } from 'twenty-ui/display';
import { shouldHideChartSetting } from '@/command-menu/pages/page-layout/utils/shouldHideChartSetting';
describe('shouldHideChartSetting', () => {
const mockItemWithoutDependencies: ChartSettingsItem = {
@@ -390,7 +390,9 @@ describe('shouldHideChartSetting', () => {
name: 'company',
label: 'Company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
relation: {
targetObjectMetadata: { id: 'company-id', nameSingular: 'company' },
},
};
const targetObjectMetadata: any = {
@@ -6,10 +6,9 @@ import { PERCENT_AGGREGATE_OPERATION_OPTIONS } from '@/object-record/record-tabl
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { FieldMetadataType, type Nullable } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { formatToShortNumber, isDefined } from 'twenty-shared/utils';
import { type AggregateOperations } from '~/generated-metadata/graphql';
import { formatNumber } from '~/utils/format/formatNumber';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatDateString } from '~/utils/string/formatDateString';
import { formatDateTimeString } from '~/utils/string/formatDateTimeString';
@@ -1,8 +1,7 @@
import { useNumberFormat } from '@/localization/hooks/useNumberFormat';
import { useNumberFieldDisplay } from '@/object-record/record-field/ui/meta-types/hooks/useNumberFieldDisplay';
import { NumberDisplay } from '@/ui/field/display/components/NumberDisplay';
import { isDefined } from 'twenty-shared/utils';
import { formatToShortNumber } from '~/utils/format/formatToShortNumber';
import { formatToShortNumber, isDefined } from 'twenty-shared/utils';
export const NumberFieldDisplay = () => {
const { fieldValue, fieldDefinition } = useNumberFieldDisplay();
@@ -1,2 +0,0 @@
// TODO: Remove this once backend returns total group count
export const EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS = 1;
@@ -1 +0,0 @@
export const GRAPH_DEFAULT_AGGREGATE_VALUE = 0;
@@ -1,3 +0,0 @@
import { GraphOrderBy } from '~/generated/graphql';
export const GRAPH_DEFAULT_ORDER_BY = GraphOrderBy.FIELD_ASC;
@@ -10,7 +10,7 @@ import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart
import { useBarChartTheme } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartTheme';
import { graphWidgetBarTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetBarTooltipComponentState';
import { graphWidgetHoveredSliceIndexComponentState } from '@/page-layout/widgets/graph/graphWidgetBarChart/states/graphWidgetHoveredSliceIndexComponentState';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
import { calculateStackedBarChartValueRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateStackedBarChartValueRange';
import { calculateValueRangeFromBarChartKeys } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateValueRangeFromBarChartKeys';
@@ -49,7 +49,7 @@ type GraphWidgetBarChartProps = {
data: BarDatum[];
indexBy: string;
keys: string[];
series?: BarChartSeries[];
series?: BarChartSeriesWithColor[];
showLegend?: boolean;
showGrid?: boolean;
showValues?: boolean;
@@ -3,7 +3,6 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useGraphBarChartWidgetData';
import { type BarChartSlice } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSlice';
import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode';
import { assertBarChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertBarChartWidget';
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
@@ -17,7 +16,8 @@ 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 { getAppPath } from 'twenty-shared/utils';
import { AxisNameDisplay } from '~/generated/graphql';
const GraphWidgetBarChart = lazy(() =>
import(
@@ -45,6 +45,7 @@ export const GraphWidgetBarChartRenderer = () => {
showDataLabels,
showLegend,
layout,
groupMode,
loading,
hasTooManyGroups,
formattedToRawLookup,
@@ -61,13 +62,19 @@ export const GraphWidgetBarChartRenderer = () => {
isPageLayoutInEditModeComponentState,
);
const hasGroupByOnSecondaryAxis = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
);
const groupMode = getEffectiveGroupMode(
configuration.groupMode,
hasGroupByOnSecondaryAxis,
);
const axisNameDisplay = configuration.axisNameDisplay;
const showXLabel =
axisNameDisplay === AxisNameDisplay.X ||
axisNameDisplay === AxisNameDisplay.BOTH;
const showYLabel =
axisNameDisplay === AxisNameDisplay.Y ||
axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabelToDisplay = showXLabel ? xAxisLabel : undefined;
const yAxisLabelToDisplay = showYLabel ? yAxisLabel : undefined;
const chartFilterKey = generateChartAggregateFilterKey(
configuration.rangeMin,
configuration.rangeMax,
@@ -125,8 +132,8 @@ export const GraphWidgetBarChartRenderer = () => {
series={series}
indexBy={indexBy}
keys={keys}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
xAxisLabel={xAxisLabelToDisplay}
yAxisLabel={yAxisLabelToDisplay}
showValues={showDataLabels}
showLegend={showLegend}
layout={layout}
@@ -1,8 +1,8 @@
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { type BarDatum } from '@nivo/bar';
import { renderHook } from '@testing-library/react';
import { useBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/hooks/useBarChartData';
const mockUseRecoilComponentValue = jest.fn();
jest.mock(
@@ -63,7 +63,7 @@ describe('useBarChartData', () => {
{ month: 'Mar', sales: 150, costs: 100 },
];
const mockSeries: BarChartSeries[] = [
const mockSeries: BarChartSeriesWithColor[] = [
{ key: 'sales', label: 'Sales', color: 'green' },
{ key: 'costs', label: 'Costs', color: 'purple' },
];
@@ -1,7 +1,7 @@
import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { type BarChartEnrichedKey } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartEnrichedKey';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
@@ -16,7 +16,7 @@ type UseBarChartDataProps = {
data: BarDatum[];
indexBy: string;
keys: string[];
series?: BarChartSeries[];
series?: BarChartSeriesWithColor[];
colorRegistry: GraphColorRegistry;
seriesLabels?: Record<string, string>;
groupMode?: 'grouped' | 'stacked';
@@ -37,7 +37,10 @@ export const useBarChartData = ({
);
const seriesConfigMap = useMemo(
() => new Map<string, BarChartSeries>(series?.map((s) => [s.key, s]) || []),
() =>
new Map<string, BarChartSeriesWithColor>(
series?.map((s) => [s.key, s]) || [],
),
[series],
);
@@ -1,18 +1,25 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit';
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { BAR_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/barChartData';
import { type BarChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { extractBarChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractBarChartDataConfiguration';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { useQuery } from '@apollo/client';
import { type BarDatum } from '@nivo/bar';
import { isString } from '@sniptt/guards';
import { useMemo } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type BarChartConfiguration,
type BarChartLayout,
type BarChartSeries,
} from '~/generated/graphql';
type UseGraphBarChartWidgetDataProps = {
@@ -24,12 +31,13 @@ type UseGraphBarChartWidgetDataResult = {
data: BarDatum[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
series: BarChartSeriesWithColor[];
xAxisLabel: string;
yAxisLabel: string;
showDataLabels: boolean;
showLegend: boolean;
layout?: BarChartLayout;
groupMode: 'grouped' | 'stacked' | undefined;
loading: boolean;
error?: Error;
hasTooManyGroups: boolean;
@@ -47,48 +55,102 @@ export const useGraphBarChartWidgetData = ({
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { objectMetadataItems } = useObjectMetadataItems();
const { userTimezone } = useUserTimezone();
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
const apolloCoreClient = useApolloCoreClient();
const limit = getBarChartQueryLimit(configuration);
const dataConfiguration = useMemo(
() => extractBarChartDataConfiguration(configuration),
[configuration],
);
const {
data: groupByData,
data: queryData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
limit,
} = useQuery(BAR_CHART_DATA, {
client: apolloCoreClient,
variables: {
input: {
objectMetadataId: objectMetadataItemId,
configuration: dataConfiguration,
},
},
});
const transformedData = useMemo(
() =>
transformGroupByDataToBarChartData({
groupByData,
objectMetadataItem,
objectMetadataItems: objectMetadataItems ?? [],
configuration,
aggregateOperation,
userTimezone,
firstDayOfTheWeek: userFirstDayOfTheWeek,
}),
[
groupByData,
objectMetadataItem,
objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
userFirstDayOfTheWeek,
],
const chartData = (queryData?.barChartData?.data as BarDatum[]) ?? [];
const formattedToRawLookup = queryData?.barChartData?.formattedToRawLookup
? new Map(Object.entries(queryData.barChartData.formattedToRawLookup))
: new Map();
const colorDeterminingFieldId = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? configuration.secondaryAxisGroupByFieldMetadataId
: configuration.primaryAxisGroupByFieldMetadataId;
const colorDeterminingField = objectMetadataItem?.fields?.find(
(field) => field.id === colorDeterminingFieldId,
);
const selectFieldOptions = useMemo((): FieldMetadataItemOption[] | null => {
if (!isDefined(colorDeterminingField)) {
return null;
}
const isSelectField =
colorDeterminingField.type === FieldMetadataType.SELECT ||
colorDeterminingField.type === FieldMetadataType.MULTI_SELECT;
if (!isSelectField || !isDefined(colorDeterminingField.options)) {
return null;
}
return colorDeterminingField.options;
}, [colorDeterminingField]);
const configurationColor = parseGraphColor(configuration.color);
const colorMode = determineGraphColorMode({
configurationColor,
selectFieldOptions,
});
const series = queryData?.barChartData?.series?.map(
(seriesItem: BarChartSeries): BarChartSeriesWithColor => {
const rawValue = formattedToRawLookup.get(seriesItem.key);
const itemColor = determineChartItemColor({
configurationColor,
selectOptions: selectFieldOptions,
rawValue: isString(rawValue) ? rawValue : undefined,
});
return {
key: seriesItem.key,
label: seriesItem.label,
color: itemColor,
};
},
);
return {
...transformedData,
data: chartData,
indexBy: queryData?.barChartData?.indexBy ?? 'id',
keys: queryData?.barChartData?.keys ?? [],
series,
xAxisLabel: queryData?.barChartData?.xAxisLabel ?? '',
yAxisLabel: queryData?.barChartData?.yAxisLabel ?? '',
showDataLabels: configuration.displayDataLabel ?? false,
showLegend: configuration.displayLegend ?? true,
layout: queryData?.barChartData?.layout,
groupMode: getEffectiveGroupMode(
configuration.groupMode,
configuration.secondaryAxisGroupByFieldMetadataId,
),
hasTooManyGroups: queryData?.barChartData?.hasTooManyGroups ?? false,
colorMode,
formattedToRawLookup,
objectMetadataItem,
loading,
error,
@@ -1,7 +1,6 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type BarChartSeries } from '~/generated/graphql';
export type BarChartSeries = {
key: string;
label?: string;
export type BarChartSeriesWithColor = BarChartSeries & {
color?: GraphColor;
};
@@ -1,6 +0,0 @@
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
export type FillDateGapsResult = {
data: GroupByRawResult[];
wasTruncated: boolean;
};
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`applyCumulativeTransformToBarChartData should handle basic accumulation 1`] = `
[
{
"id": "a",
"value": 10,
},
{
"id": "b",
"value": 30,
},
{
"id": "c",
"value": 60,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToBarChartData should handle filter above rangeMax 1`] = `
[
{
"id": "a",
"value": 10,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle filter below rangeMin 1`] = `
[
{
"id": "b",
"value": 20,
},
{
"id": "c",
"value": 30,
},
]
`;
exports[`applyCumulativeTransformToBarChartData should handle skip non-numeric values 1`] = `
[
{
"id": "a",
"value": 10,
},
{
"id": "b",
"value": 10,
},
{
"id": "c",
"value": 30,
},
]
`;
@@ -1,60 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle accumulate each key independently 1`] = `
[
{
"id": "Jan",
"revenue": 100,
"sales": 10,
},
{
"id": "Feb",
"revenue": 300,
"sales": 30,
},
{
"id": "Mar",
"revenue": 600,
"sales": 60,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter above rangeMax based on total sum 1`] = `
[
{
"id": "a",
"x": 10,
"y": 10,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle filter below rangeMin based on total sum 1`] = `
[
{
"id": "c",
"x": 30,
"y": 30,
},
]
`;
exports[`applyCumulativeTransformToTwoDimensionalBarChartData should handle skip non-numeric values 1`] = `
[
{
"id": "a",
"x": 10,
},
{
"id": "b",
"x": 10,
},
{
"id": "c",
"x": 30,
},
]
`;
@@ -1,63 +0,0 @@
import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData';
describe('applyCumulativeTransformToBarChartData', () => {
const testCases = [
{
name: 'basic accumulation',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 20 },
{ id: 'c', value: 30 },
],
aggregateKey: 'value',
},
{
name: 'filter below rangeMin',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 10 },
{ id: 'c', value: 10 },
],
aggregateKey: 'value',
rangeMin: 15,
},
{
name: 'filter above rangeMax',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 20 },
{ id: 'c', value: 30 },
],
aggregateKey: 'value',
rangeMax: 25,
},
{
name: 'empty data',
data: [],
aggregateKey: 'value',
},
{
name: 'skip non-numeric values',
data: [
{ id: 'a', value: 10 },
{ id: 'b', value: 'not a number' },
{ id: 'c', value: 20 },
],
aggregateKey: 'value',
},
];
it.each(testCases)(
'should handle $name',
({ data, aggregateKey, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToBarChartData({
data,
aggregateKey,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
},
);
});
@@ -1,63 +0,0 @@
import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData';
describe('applyCumulativeTransformToTwoDimensionalBarChartData', () => {
const testCases = [
{
name: 'accumulate each key independently',
data: [
{ id: 'Jan', sales: 10, revenue: 100 },
{ id: 'Feb', sales: 20, revenue: 200 },
{ id: 'Mar', sales: 30, revenue: 300 },
],
keys: ['sales', 'revenue'],
},
{
name: 'filter below rangeMin based on total sum',
data: [
{ id: 'a', x: 10, y: 10 },
{ id: 'b', x: 10, y: 10 },
{ id: 'c', x: 10, y: 10 },
],
keys: ['x', 'y'],
rangeMin: 50,
},
{
name: 'filter above rangeMax based on total sum',
data: [
{ id: 'a', x: 10, y: 10 },
{ id: 'b', x: 20, y: 20 },
{ id: 'c', x: 30, y: 30 },
],
keys: ['x', 'y'],
rangeMax: 50,
},
{
name: 'empty data',
data: [],
keys: ['x', 'y'],
},
{
name: 'skip non-numeric values',
data: [
{ id: 'a', x: 10 },
{ id: 'b', x: 'not a number' },
{ id: 'c', x: 20 },
],
keys: ['x'],
},
];
it.each(testCases)(
'should handle $name',
({ data, keys, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToTwoDimensionalBarChartData({
data,
keys,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
},
);
});
@@ -1,109 +0,0 @@
import { calculateBarChartEndLineCoordinates } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateBarChartEndLineCoordinates';
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
import { BarChartLayout } from '~/generated/graphql';
describe('calculateBarChartEndLineCoordinates', () => {
const createMockBar = (
overrides?: Partial<ComputedBarDatum<BarDatum>>,
): ComputedBarDatum<BarDatum> =>
({
x: 100,
y: 50,
width: 40,
height: 80,
color: 'url(#gradient-test)',
data: {
id: 'sales',
value: 100,
index: 0,
indexValue: 'Q1',
data: { Q1: 100 } as BarDatum,
formattedValue: '100',
hidden: false,
},
label: 'Sales',
...overrides,
}) as ComputedBarDatum<BarDatum>;
describe('vertical layout', () => {
it('should calculate horizontal line coordinates at the top of vertical bars', () => {
const mockBar = createMockBar();
const result = calculateBarChartEndLineCoordinates(
mockBar,
BarChartLayout.VERTICAL,
);
expect(result).toEqual({
x1: 100,
x2: 140,
y1: 50,
y2: 50,
});
});
it('should handle bars at origin position', () => {
const barAtOrigin = createMockBar({ x: 0, y: 0 });
const result = calculateBarChartEndLineCoordinates(
barAtOrigin,
BarChartLayout.VERTICAL,
);
expect(result).toEqual({
x1: 0,
x2: 40,
y1: 0,
y2: 0,
});
});
it('should handle bars with negative positions', () => {
const negativeBar = createMockBar({ x: -50, y: -20 });
const result = calculateBarChartEndLineCoordinates(
negativeBar,
BarChartLayout.VERTICAL,
);
expect(result).toEqual({
x1: -50,
x2: -10,
y1: -20,
y2: -20,
});
});
});
describe('horizontal layout', () => {
it('should calculate vertical line coordinates at the end of horizontal bars', () => {
const mockBar = createMockBar();
const result = calculateBarChartEndLineCoordinates(
mockBar,
BarChartLayout.HORIZONTAL,
);
expect(result).toEqual({
x1: 140,
x2: 140,
y1: 50,
y2: 130,
});
});
it('should handle bars with different dimensions', () => {
const wideBar = createMockBar({ width: 100, height: 20 });
const result = calculateBarChartEndLineCoordinates(
wideBar,
BarChartLayout.HORIZONTAL,
);
expect(result).toEqual({
x1: 200,
x2: 200,
y1: 50,
y2: 70,
});
});
it('should handle very thin bars', () => {
const thinBar = createMockBar({ width: 1, height: 200 });
const result = calculateBarChartEndLineCoordinates(
thinBar,
BarChartLayout.HORIZONTAL,
);
expect(result).toEqual({
x1: 101,
x2: 101,
y1: 50,
y2: 250,
});
});
});
});
@@ -0,0 +1,98 @@
import { calculateWidthPerTick } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/calculateWidthPerTick';
import { BarChartLayout } from '~/generated/graphql';
describe('calculateWidthPerTick', () => {
describe('vertical layout', () => {
it('should calculate width per tick based on categoryTickCount for vertical layout', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.VERTICAL,
availableWidth: 500,
categoryTickCount: 10,
valueTickCount: 5,
});
expect(result).toBe(50);
});
it('should return 0 when categoryTickCount is 0 for vertical layout', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.VERTICAL,
availableWidth: 500,
categoryTickCount: 0,
valueTickCount: 5,
});
expect(result).toBe(0);
});
it('should handle small available width', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.VERTICAL,
availableWidth: 100,
categoryTickCount: 20,
valueTickCount: 5,
});
expect(result).toBe(5);
});
});
describe('horizontal layout', () => {
it('should calculate width per tick based on valueTickCount for horizontal layout', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.HORIZONTAL,
availableWidth: 600,
categoryTickCount: 10,
valueTickCount: 6,
});
expect(result).toBe(100);
});
it('should return 0 when valueTickCount is 0 for horizontal layout', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.HORIZONTAL,
availableWidth: 500,
categoryTickCount: 10,
valueTickCount: 0,
});
expect(result).toBe(0);
});
it('should handle decimal results', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.HORIZONTAL,
availableWidth: 100,
categoryTickCount: 10,
valueTickCount: 3,
});
expect(result).toBeCloseTo(33.33, 1);
});
});
describe('edge cases', () => {
it('should handle availableWidth of 0', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.VERTICAL,
availableWidth: 0,
categoryTickCount: 10,
valueTickCount: 5,
});
expect(result).toBe(0);
});
it('should handle single tick', () => {
const result = calculateWidthPerTick({
layout: BarChartLayout.VERTICAL,
availableWidth: 500,
categoryTickCount: 1,
valueTickCount: 5,
});
expect(result).toBe(500);
});
});
});
@@ -0,0 +1,191 @@
import { computeBarChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/computeBarChartGroupedLabels';
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
type MockBarData = {
id?: string;
indexValue?: string;
value?: number;
};
describe('computeBarChartGroupedLabels', () => {
const createMockBar = (overrides: {
x?: number;
y?: number;
width?: number;
height?: number;
data?: MockBarData;
}): ComputedBarDatum<BarDatum> =>
({
x: overrides.x ?? 0,
y: overrides.y ?? 0,
width: overrides.width ?? 50,
height: overrides.height ?? 100,
data: {
id: overrides.data?.id ?? 'bar1',
indexValue: overrides.data?.indexValue ?? 'Category1',
value: overrides.data?.value ?? 100,
},
}) as unknown as ComputedBarDatum<BarDatum>;
describe('basic label computation', () => {
it('should return labels for each bar', () => {
const bars = [
createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 100 } }),
createMockBar({ data: { id: 'bar2', indexValue: 'Cat2', value: 200 } }),
];
const result = computeBarChartGroupedLabels(bars);
expect(result).toHaveLength(2);
});
it('should generate unique keys for each label', () => {
const bars = [
createMockBar({ data: { id: 'sales', indexValue: 'Jan', value: 100 } }),
createMockBar({ data: { id: 'sales', indexValue: 'Feb', value: 150 } }),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].key).toBe('value-sales-Jan');
expect(result[1].key).toBe('value-sales-Feb');
});
});
describe('label positioning', () => {
it('should calculate center X position correctly', () => {
const bars = [createMockBar({ x: 100, width: 50 })];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].verticalX).toBe(125);
});
it('should calculate center Y position for horizontal labels', () => {
const bars = [createMockBar({ y: 50, height: 100 })];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].horizontalY).toBe(100);
});
it('should set verticalY to top of bar for positive values', () => {
const bars = [
createMockBar({
y: 50,
height: 100,
data: { id: 'bar1', indexValue: 'Cat1', value: 100 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].verticalY).toBe(50);
expect(result[0].shouldRenderBelow).toBe(false);
});
it('should set verticalY to bottom of bar for negative values', () => {
const bars = [
createMockBar({
y: 50,
height: 100,
data: { id: 'bar1', indexValue: 'Cat1', value: -100 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].verticalY).toBe(150);
expect(result[0].shouldRenderBelow).toBe(true);
});
it('should set horizontalX to right edge for positive values', () => {
const bars = [
createMockBar({
x: 50,
width: 100,
data: { id: 'bar1', indexValue: 'Cat1', value: 100 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].horizontalX).toBe(150);
});
it('should set horizontalX to left edge for negative values', () => {
const bars = [
createMockBar({
x: 50,
width: 100,
data: { id: 'bar1', indexValue: 'Cat1', value: -100 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].horizontalX).toBe(50);
});
});
describe('value handling', () => {
it('should extract numeric value from bar data', () => {
const bars = [
createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 42 } }),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].value).toBe(42);
});
it('should handle zero values', () => {
const bars = [
createMockBar({ data: { id: 'bar1', indexValue: 'Cat1', value: 0 } }),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].value).toBe(0);
expect(result[0].shouldRenderBelow).toBe(false);
});
it('should handle decimal values', () => {
const bars = [
createMockBar({
data: { id: 'bar1', indexValue: 'Cat1', value: 123.456 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result[0].value).toBe(123.456);
});
});
describe('edge cases', () => {
it('should return empty array for empty input', () => {
const result = computeBarChartGroupedLabels([]);
expect(result).toEqual([]);
});
it('should handle bars with zero dimensions', () => {
const bars = [
createMockBar({
x: 0,
y: 0,
width: 0,
height: 0,
data: { id: 'bar1', indexValue: 'Cat1', value: 100 },
}),
];
const result = computeBarChartGroupedLabels(bars);
expect(result).toHaveLength(1);
expect(result[0].verticalX).toBe(0);
expect(result[0].horizontalY).toBe(0);
});
});
});
@@ -1,208 +0,0 @@
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { GraphOrderBy } from '~/generated/graphql';
import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData';
describe('fillDateGapsInBarChartData', () => {
describe('one-dimensional data', () => {
it('fills gaps in date data with zero values', () => {
const data = [
{
groupByDimensionValues: ['2024-01-01'],
count: 5,
},
{
groupByDimensionValues: ['2024-01-03'],
count: 3,
},
];
const result = fillDateGapsInBarChartData({
data,
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
});
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
groupByDimensionValues: ['2024-01-01'],
count: 5,
});
expect(result.data[1]).toEqual({
groupByDimensionValues: ['2024-01-02'],
count: 0,
});
expect(result.data[2]).toEqual({
groupByDimensionValues: ['2024-01-03'],
count: 3,
});
expect(result.wasTruncated).toBe(false);
});
it('returns empty data unchanged', () => {
const result = fillDateGapsInBarChartData({
data: [],
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
});
expect(result.data).toEqual([]);
expect(result.wasTruncated).toBe(false);
});
it('returns data in descending order when orderBy is FIELD_DESC', () => {
const data = [
{
groupByDimensionValues: ['2024-01-01'],
count: 5,
},
{
groupByDimensionValues: ['2024-01-03'],
count: 3,
},
];
const result = fillDateGapsInBarChartData({
data,
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
orderBy: GraphOrderBy.FIELD_DESC,
});
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
groupByDimensionValues: ['2024-01-03'],
count: 3,
});
expect(result.data[1]).toEqual({
groupByDimensionValues: ['2024-01-02'],
count: 0,
});
expect(result.data[2]).toEqual({
groupByDimensionValues: ['2024-01-01'],
count: 5,
});
expect(result.wasTruncated).toBe(false);
});
it('returns data in ascending order when orderBy is FIELD_ASC', () => {
const data = [
{
groupByDimensionValues: ['2024-01-01'],
count: 5,
},
{
groupByDimensionValues: ['2024-01-03'],
count: 3,
},
];
const result = fillDateGapsInBarChartData({
data,
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
orderBy: GraphOrderBy.FIELD_ASC,
});
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
groupByDimensionValues: ['2024-01-01'],
count: 5,
});
expect(result.data[1]).toEqual({
groupByDimensionValues: ['2024-01-02'],
count: 0,
});
expect(result.data[2]).toEqual({
groupByDimensionValues: ['2024-01-03'],
count: 3,
});
expect(result.wasTruncated).toBe(false);
});
});
describe('two-dimensional data', () => {
it('fills gaps for all second dimension values', () => {
const data = [
{
groupByDimensionValues: ['2024-01-01', 'A'],
count: 5,
},
{
groupByDimensionValues: ['2024-01-03', 'A'],
count: 3,
},
{
groupByDimensionValues: ['2024-01-01', 'B'],
count: 2,
},
];
const result = fillDateGapsInBarChartData({
data,
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
hasSecondDimension: true,
});
expect(result.data).toHaveLength(6);
expect(
result.data.filter((r) => r.groupByDimensionValues[1] === 'A'),
).toHaveLength(3);
expect(
result.data.filter((r) => r.groupByDimensionValues[1] === 'B'),
).toHaveLength(3);
expect(
result.data.find(
(r) =>
r.groupByDimensionValues[0] === '2024-01-02' &&
r.groupByDimensionValues[1] === 'A',
),
).toEqual({
groupByDimensionValues: ['2024-01-02', 'A'],
count: 0,
});
expect(result.wasTruncated).toBe(false);
});
it('fills gaps in descending order when orderBy is FIELD_DESC', () => {
const data = [
{
groupByDimensionValues: ['2024-01-01', 'A'],
count: 5,
},
{
groupByDimensionValues: ['2024-01-03', 'A'],
count: 3,
},
{
groupByDimensionValues: ['2024-01-01', 'B'],
count: 2,
},
];
const result = fillDateGapsInBarChartData({
data,
keys: ['count'],
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
hasSecondDimension: true,
orderBy: GraphOrderBy.FIELD_DESC,
});
expect(result.data).toHaveLength(6);
// First date group should be Jan 3 (descending)
expect(result.data[0].groupByDimensionValues[0]).toBe('2024-01-03');
expect(result.data[1].groupByDimensionValues[0]).toBe('2024-01-03');
// Middle date group should be Jan 2
expect(result.data[2].groupByDimensionValues[0]).toBe('2024-01-02');
expect(result.data[3].groupByDimensionValues[0]).toBe('2024-01-02');
// Last date group should be Jan 1
expect(result.data[4].groupByDimensionValues[0]).toBe('2024-01-01');
expect(result.data[5].groupByDimensionValues[0]).toBe('2024-01-01');
expect(result.wasTruncated).toBe(false);
});
});
});
@@ -1,90 +0,0 @@
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { Temporal } from 'temporal-polyfill';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange';
describe('generateDateGroupsInRange', () => {
it('generates daily date groups', () => {
const result = generateDateGroupsInRange({
startDate: Temporal.PlainDate.from('2024-01-01'),
endDate: Temporal.PlainDate.from('2024-01-07'),
granularity: ObjectRecordGroupByDateGranularity.DAY,
});
expect(result.dates).toHaveLength(7);
expect(result.dates[0].toString()).toEqual(
Temporal.PlainDate.from('2024-01-01').toString(),
);
expect(result.dates[6].toString()).toEqual(
Temporal.PlainDate.from('2024-01-07').toString(),
);
expect(result.wasTruncated).toBe(false);
});
it('generates monthly date groups', () => {
const result = generateDateGroupsInRange({
startDate: Temporal.PlainDate.from('2024-01-01'),
endDate: Temporal.PlainDate.from('2024-06-01'),
granularity: ObjectRecordGroupByDateGranularity.MONTH,
});
expect(result.dates).toHaveLength(6);
expect(result.dates[0].toString()).toEqual(
Temporal.PlainDate.from('2024-01-01').toString(),
);
expect(result.dates[5].toString()).toEqual(
Temporal.PlainDate.from('2024-06-01').toString(),
);
expect(result.wasTruncated).toBe(false);
});
it('generates quarterly date groups', () => {
const result = generateDateGroupsInRange({
startDate: Temporal.PlainDate.from('2024-01-01'),
endDate: Temporal.PlainDate.from('2024-12-31'),
granularity: ObjectRecordGroupByDateGranularity.QUARTER,
});
expect(result.dates).toHaveLength(4);
expect(result.dates[0].toString()).toEqual(
Temporal.PlainDate.from('2024-01-01').toString(),
);
expect(result.dates[3].toString()).toEqual(
Temporal.PlainDate.from('2024-10-01').toString(),
);
expect(result.wasTruncated).toBe(false);
});
it('generates yearly date groups', () => {
const result = generateDateGroupsInRange({
startDate: Temporal.PlainDate.from('2020-01-01'),
endDate: Temporal.PlainDate.from('2024-12-31'),
granularity: ObjectRecordGroupByDateGranularity.YEAR,
});
expect(result.dates).toHaveLength(5);
expect(result.dates[0].toString()).toEqual(
Temporal.PlainDate.from('2020-01-01').toString(),
);
expect(result.dates[4].toString()).toEqual(
Temporal.PlainDate.from('2024-01-01').toString(),
);
expect(result.wasTruncated).toBe(false);
});
it('truncates when exceeding maximum number of bars', () => {
const result = generateDateGroupsInRange({
startDate: Temporal.PlainDate.from('2024-01-01'),
endDate: Temporal.PlainDate.from('2025-12-31'),
granularity: ObjectRecordGroupByDateGranularity.DAY,
});
expect(result.dates.length).toBeLessThanOrEqual(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS,
);
expect(result.dates.length).toBe(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS,
);
expect(result.wasTruncated).toBe(true);
});
});
@@ -0,0 +1,163 @@
import { type BarChartConfig } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartConfig';
import { getBarChartColor } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartColor';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { type BarDatum, type ComputedDatum } from '@nivo/bar';
import { type ThemeType } from 'twenty-ui/theme';
describe('getBarChartColor', () => {
const mockTheme = {
border: {
color: {
light: '#fallback',
},
},
} as unknown as ThemeType;
const mockBlueColorScheme: GraphColorScheme = {
name: 'blue',
solid: '#solidBlue',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
const mockGreenColorScheme: GraphColorScheme = {
name: 'green',
solid: '#solidGreen',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
const mockBarConfigs: BarChartConfig[] = [
{
key: 'sales',
indexValue: 'January',
colorScheme: mockBlueColorScheme,
},
{
key: 'revenue',
indexValue: 'January',
colorScheme: mockGreenColorScheme,
},
{
key: 'sales',
indexValue: 'February',
colorScheme: mockBlueColorScheme,
},
];
it('should return the correct color when datum matches bar config', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
expect(result).toBe('#solidBlue');
});
it('should return different colors for different keys at same index', () => {
const salesDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const revenueDatum: ComputedDatum<BarDatum> = {
id: 'revenue',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const salesColor = getBarChartColor(salesDatum, mockBarConfigs, mockTheme);
const revenueColor = getBarChartColor(
revenueDatum,
mockBarConfigs,
mockTheme,
);
expect(salesColor).toBe('#solidBlue');
expect(revenueColor).toBe('#solidGreen');
});
it('should return theme fallback color when no matching config is found', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'unknown',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
expect(result).toBe('#fallback');
});
it('should return fallback color when indexValue does not match', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'March',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, mockBarConfigs, mockTheme);
expect(result).toBe('#fallback');
});
it('should return fallback color when barConfigs is empty', () => {
const datum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const result = getBarChartColor(datum, [], mockTheme);
expect(result).toBe('#fallback');
});
it('should match based on both key and indexValue', () => {
const januaryDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'January',
} as unknown as ComputedDatum<BarDatum>;
const februaryDatum: ComputedDatum<BarDatum> = {
id: 'sales',
indexValue: 'February',
} as unknown as ComputedDatum<BarDatum>;
const januaryColor = getBarChartColor(
januaryDatum,
mockBarConfigs,
mockTheme,
);
const februaryColor = getBarChartColor(
februaryDatum,
mockBarConfigs,
mockTheme,
);
expect(januaryColor).toBe('#solidBlue');
expect(februaryColor).toBe('#solidBlue');
});
});
@@ -0,0 +1,146 @@
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { getBarChartInnerPadding } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartInnerPadding';
import { BarChartLayout } from '~/generated/graphql';
describe('getBarChartInnerPadding', () => {
const defaultMargins = { top: 20, right: 20, bottom: 40, left: 60 };
describe('non-grouped mode', () => {
it('should return 0 when groupMode is undefined', () => {
const result = getBarChartInnerPadding({
chartWidth: 500,
chartHeight: 300,
dataLength: 5,
keysLength: 3,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: undefined,
});
expect(result).toBe(0);
});
it('should return 0 when groupMode is stacked', () => {
const result = getBarChartInnerPadding({
chartWidth: 500,
chartHeight: 300,
dataLength: 5,
keysLength: 3,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'stacked',
});
expect(result).toBe(0);
});
});
describe('grouped mode with empty data', () => {
it('should return default inner padding when dataLength is 0', () => {
const result = getBarChartInnerPadding({
chartWidth: 500,
chartHeight: 300,
dataLength: 0,
keysLength: 3,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING);
});
it('should return default inner padding when keysLength is 0', () => {
const result = getBarChartInnerPadding({
chartWidth: 500,
chartHeight: 300,
dataLength: 5,
keysLength: 0,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING);
});
});
describe('grouped mode with vertical layout', () => {
it('should calculate inner padding based on available horizontal space', () => {
const result = getBarChartInnerPadding({
chartWidth: 800,
chartHeight: 400,
dataLength: 5,
keysLength: 2,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBeGreaterThanOrEqual(0);
});
it('should return default padding when there is enough space', () => {
const result = getBarChartInnerPadding({
chartWidth: 1000,
chartHeight: 400,
dataLength: 3,
keysLength: 2,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBe(BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING);
});
});
describe('grouped mode with horizontal layout', () => {
it('should calculate inner padding based on available vertical space', () => {
const result = getBarChartInnerPadding({
chartWidth: 500,
chartHeight: 600,
dataLength: 5,
keysLength: 2,
layout: BarChartLayout.HORIZONTAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBeGreaterThanOrEqual(0);
});
});
describe('tight space constraints', () => {
it('should reduce padding when space per bar is limited', () => {
const result = getBarChartInnerPadding({
chartWidth: 200,
chartHeight: 300,
dataLength: 10,
keysLength: 5,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBeLessThanOrEqual(
BAR_CHART_CONSTANTS.DEFAULT_INNER_PADDING,
);
expect(result).toBeGreaterThanOrEqual(0);
});
it('should not return negative padding', () => {
const result = getBarChartInnerPadding({
chartWidth: 100,
chartHeight: 100,
dataLength: 50,
keysLength: 10,
layout: BarChartLayout.VERTICAL,
margins: defaultMargins,
groupMode: 'grouped',
});
expect(result).toBeGreaterThanOrEqual(0);
});
});
});
@@ -1,49 +0,0 @@
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups';
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import {
type BarChartConfiguration,
BarChartGroupMode,
} from '~/generated-metadata/graphql';
import { getBarChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getBarChartQueryLimit';
describe('getBarChartQueryLimit', () => {
it('should return one-dimensional limit for bar chart without secondary axis', () => {
const result = getBarChartQueryLimit({
__typename: 'BarChartConfiguration',
secondaryAxisGroupByFieldMetadataId: null,
groupMode: BarChartGroupMode.STACKED,
} as BarChartConfiguration);
expect(result).toBe(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
it('should return two-dimensional stacked limit for bar chart with secondary axis and stacked mode', () => {
const result = getBarChartQueryLimit({
__typename: 'BarChartConfiguration',
secondaryAxisGroupByFieldMetadataId: 'some-field-id',
groupMode: BarChartGroupMode.STACKED,
} as BarChartConfiguration);
expect(result).toBe(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS *
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
it('should return one-dimensional limit for bar chart with secondary axis and grouped mode', () => {
const result = getBarChartQueryLimit({
__typename: 'BarChartConfiguration',
secondaryAxisGroupByFieldMetadataId: 'some-field-id',
groupMode: BarChartGroupMode.GROUPED,
} as BarChartConfiguration);
expect(result).toBe(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
});
@@ -0,0 +1,42 @@
import { getEffectiveGroupMode } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getEffectiveGroupMode';
import { BarChartGroupMode } from '~/generated/graphql';
describe('getEffectiveGroupMode', () => {
describe('without secondary axis grouping', () => {
it('should return undefined when hasGroupByOnSecondaryAxis is false', () => {
expect(
getEffectiveGroupMode(BarChartGroupMode.GROUPED, false),
).toBeUndefined();
});
it('should return undefined regardless of groupMode when no secondary axis', () => {
expect(
getEffectiveGroupMode(BarChartGroupMode.STACKED, false),
).toBeUndefined();
expect(getEffectiveGroupMode(null, false)).toBeUndefined();
expect(getEffectiveGroupMode(undefined, false)).toBeUndefined();
});
});
describe('with secondary axis grouping', () => {
it('should return "grouped" when groupMode is GROUPED', () => {
expect(getEffectiveGroupMode(BarChartGroupMode.GROUPED, true)).toBe(
'grouped',
);
});
it('should return "stacked" when groupMode is STACKED', () => {
expect(getEffectiveGroupMode(BarChartGroupMode.STACKED, true)).toBe(
'stacked',
);
});
it('should return "stacked" when groupMode is null', () => {
expect(getEffectiveGroupMode(null, true)).toBe('stacked');
});
it('should return "stacked" when groupMode is undefined', () => {
expect(getEffectiveGroupMode(undefined, true)).toBe('stacked');
});
});
});
@@ -1,144 +0,0 @@
import { GraphOrderBy } from '~/generated/graphql';
import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum';
describe('sortBarChartDataBySecondaryDimensionSum', () => {
const mockData = [
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Berlin', Open: 8, Closed: 7 },
];
const keys = ['Open', 'Closed'];
describe('VALUE_DESC sorting', () => {
it('should sort by totals in descending order', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'Berlin', Open: 8, Closed: 7 },
]);
});
});
describe('VALUE_ASC sorting', () => {
it('should sort by totals in ascending order', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.VALUE_ASC,
});
expect(result).toEqual([
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'Berlin', Open: 8, Closed: 7 },
{ city: 'London', Open: 20, Closed: 2 },
]);
});
});
describe('FIELD_ASC/FIELD_DESC (non-value sorting)', () => {
it('should not sort when orderBy is FIELD_ASC', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.FIELD_ASC,
});
expect(result).toEqual(mockData);
});
it('should not sort when orderBy is FIELD_DESC', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.FIELD_DESC,
});
expect(result).toEqual(mockData);
});
});
describe('edge cases', () => {
it('should handle data with missing keys in some items', () => {
const dataWithMissing = [
{ city: 'Paris', Open: 5 } as any,
{ city: 'London', Open: 20, Closed: 2 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithMissing,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Paris', Open: 5 },
]);
});
it('should handle data with zero values', () => {
const dataWithZeros = [
{ city: 'Paris', Open: 0, Closed: 10 },
{ city: 'London', Open: 20, Closed: 0 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithZeros,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 0 },
{ city: 'Paris', Open: 0, Closed: 10 },
]);
});
it('should handle empty data array', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: [],
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([]);
});
it('should handle single item', () => {
const singleItem = [{ city: 'Paris', Open: 5, Closed: 10 }];
const result = sortBarChartDataBySecondaryDimensionSum({
data: singleItem,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual(singleItem);
});
});
describe('stability with equal totals', () => {
it('should maintain original order for items with equal totals', () => {
const dataWithEqualTotals = [
{ city: 'Paris', Open: 10, Closed: 5 },
{ city: 'Berlin', Open: 7, Closed: 8 },
{ city: 'Madrid', Open: 9, Closed: 6 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithEqualTotals,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual(dataWithEqualTotals);
});
});
});
@@ -1,114 +0,0 @@
import { transformGroupByDataToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData';
import {
FieldMetadataType,
FirstDayOfTheWeek,
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import {
AxisNameDisplay,
BarChartLayout,
WidgetConfigurationType,
} from '~/generated/graphql';
jest.mock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData',
() => ({
fillDateGapsInBarChartData: jest.fn(({ data }) => ({
data,
wasTruncated: true,
})),
}),
);
jest.mock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData',
() => ({
transformOneDimensionalGroupByToBarChartData: jest.fn(() => ({
data: [],
indexBy: 'x',
keys: ['value'],
series: [],
hasTooManyGroups: false,
formattedToRawLookup: new Map(),
})),
}),
);
jest.mock('@/page-layout/widgets/graph/utils/filterGroupByResults', () => ({
filterGroupByResults: jest.fn((args) => args.rawResults),
}));
const { fillDateGapsInBarChartData } = jest.requireMock(
'@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData',
) as { fillDateGapsInBarChartData: jest.Mock };
describe('transformGroupByDataToBarChartData', () => {
const userTimezone = 'Europe/Paris';
it('fills date gaps when grouping by a relation date subfield with granularity', () => {
const groupByField = {
id: 'group-by-field',
name: 'company',
type: FieldMetadataType.RELATION,
relation: { targetObjectMetadata: { nameSingular: 'company' } },
};
const aggregateField = {
id: 'aggregate-field',
name: 'count',
type: FieldMetadataType.NUMBER,
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [
groupByField,
aggregateField,
{ id: 'createdAt', name: 'createdAt', type: FieldMetadataType.DATE },
],
} as any;
const objectMetadataItems = [objectMetadataItem];
const configuration = {
__typename: 'BarChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: 'COUNT',
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
primaryAxisGroupByFieldMetadataId: groupByField.id,
primaryAxisGroupBySubFieldName: 'createdAt',
primaryAxisDateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
axisNameDisplay: AxisNameDisplay.BOTH,
} as any;
const groupByData = {
companiesGroupBy: [
{ groupByDimensionValues: ['2024-01-01T00:00:00.000Z'], COUNT: 1 },
],
};
fillDateGapsInBarChartData.mockClear();
const result = transformGroupByDataToBarChartData({
groupByData,
objectMetadataItem,
objectMetadataItems,
configuration,
aggregateOperation: 'COUNT',
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(fillDateGapsInBarChartData).toHaveBeenCalledTimes(1);
expect(fillDateGapsInBarChartData).toHaveBeenCalledWith(
expect.objectContaining({
dateGranularity: ObjectRecordGroupByDateGranularity.MONTH,
hasSecondDimension: false,
}),
);
expect(result.hasTooManyGroups).toBe(true);
});
});
@@ -1,121 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
AggregateOperations,
BarChartLayout,
GraphOrderBy,
WidgetConfigurationType,
type BarChartConfiguration,
} from '~/generated/graphql';
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData';
describe('transformTwoDimensionalGroupByToBarChartData', () => {
const userTimezone = 'Europe/Paris';
const mockGroupByFieldX = {
id: 'field-x',
name: 'createdAt',
type: FieldMetadataType.DATE,
label: 'Created At',
} as FieldMetadataItem;
const mockGroupByFieldY = {
id: 'field-y',
name: 'stage',
type: FieldMetadataType.SELECT,
label: 'Stage',
} as FieldMetadataItem;
const mockAggregateField = {
id: 'field-aggregate',
name: 'amount',
type: FieldMetadataType.NUMBER,
label: 'Amount',
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'object-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockGroupByFieldX, mockGroupByFieldY, mockAggregateField],
} as ObjectMetadataItem;
const mockConfiguration: BarChartConfiguration = {
__typename: 'BarChartConfiguration',
configurationType: WidgetConfigurationType.BAR_CHART,
layout: BarChartLayout.VERTICAL,
aggregateFieldMetadataId: 'field-aggregate',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'field-x',
secondaryAxisGroupByFieldMetadataId: 'field-y',
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
};
it('should order keys correctly despite unordered raw results', () => {
// This test demonstrates the key ordering issue described in the user query
// Raw results where "CUSTOMER" appears before "NEW" in the data stream despite orderBy,
// bc there is no "NEW" group before october 21st and the results are primarily ordered by date ASC
// but we want them ordered alphabetically-reversed
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'SCREENING'],
sumAmount: 75000000000,
},
{
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'PROPOSAL'],
sumAmount: 380000000000,
},
{
groupByDimensionValues: ['2025-10-17T00:00:00.000Z', 'CUSTOMER'],
sumAmount: 720000000000,
},
{
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'PROPOSAL'],
sumAmount: 580000000000,
},
{
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'NEW'],
sumAmount: 125000000000,
},
];
const result = transformTwoDimensionalGroupByToBarChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: mockConfiguration,
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']);
expect(result.series).toEqual([
{ key: 'SCREENING', label: 'SCREENING' },
{ key: 'PROPOSAL', label: 'PROPOSAL' },
{ key: 'NEW', label: 'NEW' },
{ key: 'CUSTOMER', label: 'CUSTOMER' },
]);
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
createdAt: 'Oct 16, 2025',
SCREENING: 75000000000,
PROPOSAL: 380000000000,
});
expect(result.data[1]).toEqual({
createdAt: 'Oct 17, 2025',
CUSTOMER: 720000000000,
});
expect(result.data[2]).toEqual({
createdAt: 'Oct 21, 2025',
PROPOSAL: 580000000000,
NEW: 125000000000,
});
});
});
@@ -1,42 +0,0 @@
import { type BarDatum } from '@nivo/bar';
import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToBarChartDataOptions = {
data: BarDatum[];
aggregateKey: string;
rangeMin?: number;
rangeMax?: number;
};
export const applyCumulativeTransformToBarChartData = ({
data,
aggregateKey,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToBarChartDataOptions): BarDatum[] => {
const { result } = data.reduce<{ result: BarDatum[]; runningTotal: number }>(
(accumulator, datum) => {
const value = datum[aggregateKey];
if (isNumber(value)) {
accumulator.runningTotal += value;
}
const cumulativeValue = accumulator.runningTotal;
const isOutOfRange =
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push({ ...datum, [aggregateKey]: cumulativeValue });
}
return accumulator;
},
{ result: [], runningTotal: 0 },
);
return result;
};
@@ -1,57 +0,0 @@
import { type BarDatum } from '@nivo/bar';
import { isNumber } from '@sniptt/guards';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions = {
data: BarDatum[];
keys: string[];
rangeMin?: number | null;
rangeMax?: number | null;
};
export const applyCumulativeTransformToTwoDimensionalBarChartData = ({
data,
keys,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions): BarDatum[] => {
const { result } = data.reduce<{
result: BarDatum[];
runningTotals: Record<string, number>;
}>(
(accumulator, datum) => {
const newDatum = { ...datum };
for (const key of keys) {
const value = datum[key];
if (isNumber(value)) {
accumulator.runningTotals[key] += value;
}
newDatum[key] = accumulator.runningTotals[key];
}
const totalValue = keys.reduce((sum, key) => {
const value = newDatum[key];
return sum + (typeof value === 'number' ? value : 0);
}, 0);
const isOutOfRange =
(isDefined(rangeMin) && totalValue < rangeMin) ||
(isDefined(rangeMax) && totalValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push(newDatum);
}
return accumulator;
},
{
result: [],
runningTotals: Object.fromEntries(keys.map((key) => [key, 0])),
},
);
return result;
};
@@ -1,38 +0,0 @@
import { type ProcessedTwoDimensionalDataPoint } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
import { type BarDatum } from '@nivo/bar';
type BuildTwoDimensionalBarChartDataParams = {
processedDataPoints: ProcessedTwoDimensionalDataPoint[];
indexByKey: string;
};
type BuildTwoDimensionalBarChartDataResult = {
unsortedData: BarDatum[];
yValues: Set<string>;
};
export const buildTwoDimensionalBarChartData = ({
processedDataPoints,
indexByKey,
}: BuildTwoDimensionalBarChartDataParams): BuildTwoDimensionalBarChartDataResult => {
const dataMap = new Map<string, BarDatum>();
const yValues = new Set<string>();
for (const { xValue, yValue, aggregateValue } of processedDataPoints) {
yValues.add(yValue);
if (!dataMap.has(xValue)) {
dataMap.set(xValue, {
[indexByKey]: xValue,
});
}
const dataItem = dataMap.get(xValue)!;
dataItem[yValue] = aggregateValue;
}
return {
unsortedData: Array.from(dataMap.values()),
yValues,
};
};
@@ -1,22 +0,0 @@
import { type BarDatum, type ComputedBarDatum } from '@nivo/bar';
import { BarChartLayout } from '~/generated/graphql';
export const calculateBarChartEndLineCoordinates = (
bar: ComputedBarDatum<BarDatum>,
layout: BarChartLayout,
) => {
if (layout === BarChartLayout.VERTICAL) {
return {
x1: bar.x,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y,
};
}
return {
x1: bar.x + bar.width,
x2: bar.x + bar.width,
y1: bar.y,
y2: bar.y + bar.height,
};
};
@@ -1,19 +0,0 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
export const calculateMaxTickLabelLength = ({
widthPerTick,
axisFontSize,
}: {
widthPerTick: number;
axisFontSize: number;
}): number => {
const averageCharacterWidth =
axisFontSize *
COMMON_CHART_CONSTANTS.HORIZONTAL_LABEL_CHARACTER_WIDTH_RATIO;
const calculatedLength = Math.floor(widthPerTick / averageCharacterWidth);
return Math.max(
COMMON_CHART_CONSTANTS.MIN_TICK_LABEL_LENGTH,
calculatedLength,
);
};
@@ -28,7 +28,7 @@ export const computeBarChartCategoryTickValues = ({
const values = data.map((item) => item[indexBy] as string | number);
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel });
const totalMargins =
layout === BarChartLayout.VERTICAL
@@ -1,21 +0,0 @@
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { Temporal } from 'temporal-polyfill';
export type DimensionValue = string | Temporal.PlainDate | number | null;
export const createEmptyDateGroup = (
dimensionValues: DimensionValue[],
keys: string[],
): GroupByRawResult => {
const newItem: GroupByRawResult = {
groupByDimensionValues: dimensionValues.map((value) =>
value instanceof Temporal.PlainDate ? value.toString() : value,
),
};
for (const key of keys) {
newItem[key] = 0;
}
return newItem;
};
@@ -1,51 +0,0 @@
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { fillDateGapsInOneDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInOneDimensionalBarChartData';
import { fillDateGapsInTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInTwoDimensionalBarChartData';
import { type SupportedDateGranularity } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type GraphOrderBy } from '~/generated/graphql';
type FillDateGapsParams = {
data: GroupByRawResult[];
keys: string[];
dateGranularity: ObjectRecordGroupByDateGranularity;
hasSecondDimension?: boolean;
orderBy?: GraphOrderBy | null;
};
export const fillDateGapsInBarChartData = ({
data,
keys,
dateGranularity,
hasSecondDimension = false,
orderBy,
}: FillDateGapsParams): { data: GroupByRawResult[]; wasTruncated: boolean } => {
if (data.length === 0) {
return { data, wasTruncated: false };
}
if (
BAR_CHART_CONSTANTS.DATE_GRANULARITIES_WITHOUT_GAP_FILLING.has(
dateGranularity,
)
) {
return { data, wasTruncated: false };
}
if (hasSecondDimension) {
return fillDateGapsInTwoDimensionalBarChartData({
data,
keys,
dateGranularity: dateGranularity as SupportedDateGranularity,
orderBy,
});
}
return fillDateGapsInOneDimensionalBarChartData({
data,
keys,
dateGranularity: dateGranularity as SupportedDateGranularity,
orderBy,
});
};
@@ -1,62 +0,0 @@
import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult';
import { createEmptyDateGroup } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup';
import {
getDateGroupsFromData,
type SupportedDateGranularity,
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { type GraphOrderBy } from '~/generated/graphql';
type OneDimensionalFillParams = {
data: GroupByRawResult[];
keys: string[];
dateGranularity: SupportedDateGranularity;
orderBy?: GraphOrderBy | null;
};
// TODO: should handle DATE and DATE_TIME here
export const fillDateGapsInOneDimensionalBarChartData = ({
data,
keys,
dateGranularity,
orderBy,
}: OneDimensionalFillParams): FillDateGapsResult => {
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
const parsedDates: Temporal.PlainDate[] = [];
for (const item of data) {
const dateValue = item.groupByDimensionValues?.[0];
if (!isDefined(dateValue)) {
continue;
}
const parsedDate = Temporal.PlainDate.from(String(dateValue));
parsedDates.push(parsedDate);
existingDateGroupsMap.set(parsedDate.toString(), item);
}
if (parsedDates.length === 0) {
return { data, wasTruncated: false };
}
const { dates: allDates, wasTruncated } = getDateGroupsFromData({
parsedDates,
dateGranularity,
orderBy,
});
const filledData = allDates.map((date) => {
const key = date.toString();
const existingDateGroup = existingDateGroupsMap.get(key);
return isDefined(existingDateGroup)
? existingDateGroup
: createEmptyDateGroup([date], keys);
});
return { data: filledData, wasTruncated };
};
@@ -1,73 +0,0 @@
import { type FillDateGapsResult } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/FillDateGapsResult';
import {
createEmptyDateGroup,
type DimensionValue,
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/createEmptyDateGroup';
import {
getDateGroupsFromData,
type SupportedDateGranularity,
} from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/getDateGroupsFromData';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { Temporal } from 'temporal-polyfill';
import { isDefined } from 'twenty-shared/utils';
import { type GraphOrderBy } from '~/generated/graphql';
type TwoDimensionalFillParams = {
data: GroupByRawResult[];
keys: string[];
dateGranularity: SupportedDateGranularity;
orderBy?: GraphOrderBy | null;
};
export const fillDateGapsInTwoDimensionalBarChartData = ({
data,
keys,
dateGranularity,
orderBy,
}: TwoDimensionalFillParams): FillDateGapsResult => {
const existingDateGroupsMap = new Map<string, GroupByRawResult>();
const parsedDates: Temporal.PlainDate[] = [];
const uniqueSecondDimensionValues = new Set<DimensionValue>();
for (const item of data) {
const dateValue = item.groupByDimensionValues?.[0];
if (!isDefined(dateValue)) {
continue;
}
const parsedDate = Temporal.PlainDate.from(String(dateValue));
parsedDates.push(parsedDate);
const secondDimensionValue = (item.groupByDimensionValues?.[1] ??
null) as DimensionValue;
uniqueSecondDimensionValues.add(secondDimensionValue);
const key = `${parsedDate.toString()}_${String(secondDimensionValue)}`;
existingDateGroupsMap.set(key, item);
}
if (parsedDates.length === 0) {
return { data, wasTruncated: false };
}
const { dates: allDates, wasTruncated } = getDateGroupsFromData({
parsedDates,
dateGranularity,
orderBy,
});
const filledData = allDates.flatMap((date) =>
Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => {
const key = `${date.toString()}_${String(secondDimensionValue)}`;
const existingDateGroup = existingDateGroupsMap.get(key);
return isDefined(existingDateGroup)
? existingDateGroup
: createEmptyDateGroup([date, secondDimensionValue], keys);
}),
);
return { data: filledData, wasTruncated };
};
@@ -40,7 +40,7 @@ export const getBarChartAxisConfigs = ({
? valueTickValues
: numberOfValueTicks;
const baseMargins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const baseMargins = getBarChartMargins({ xAxisLabel, yAxisLabel });
const hasRotation = bottomAxisTickRotation !== 0;
const margins =
@@ -96,7 +96,7 @@ export const getBarChartAxisConfigs = ({
legendPosition: 'middle' as const,
tickRotation: BAR_CHART_CONSTANTS.NO_ROTATION_ANGLE,
tickValues: resolvedValueTickValues,
legend: yAxisLabel,
legend: xAxisLabel,
legendOffset: BAR_CHART_CONSTANTS.BOTTOM_AXIS_LEGEND_OFFSET,
format: (value: number) =>
truncateTickLabel(
@@ -110,7 +110,7 @@ export const getBarChartAxisConfigs = ({
legendPosition: 'middle' as const,
tickRotation: COMMON_CHART_CONSTANTS.NO_ROTATION_ANGLE,
tickValues: categoryTickValues,
legend: xAxisLabel,
legend: yAxisLabel,
legendOffset:
-margins.left + BAR_CHART_CONSTANTS.LEFT_AXIS_LEGEND_OFFSET_PADDING,
format: (value: string | number) =>
@@ -1,6 +1,5 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
import { isDefined } from 'twenty-shared/utils';
import { BarChartLayout } from '~/generated/graphql';
const BAR_CHART_MARGINS = {
top: COMMON_CHART_CONSTANTS.MARGIN_TOP,
@@ -33,26 +32,20 @@ const BAR_CHART_MARGINS_WITH_Y_LABEL = {
export const getBarChartMargins = ({
xAxisLabel,
yAxisLabel,
layout,
}: {
xAxisLabel?: string;
yAxisLabel?: string;
layout: BarChartLayout;
}) => {
if (isDefined(xAxisLabel) && isDefined(yAxisLabel)) {
return BAR_CHART_MARGINS_WITH_BOTH_LABELS;
}
if (isDefined(xAxisLabel)) {
return layout === BarChartLayout.HORIZONTAL
? BAR_CHART_MARGINS_WITH_Y_LABEL
: BAR_CHART_MARGINS_WITH_X_LABEL;
return BAR_CHART_MARGINS_WITH_X_LABEL;
}
if (isDefined(yAxisLabel)) {
return layout === BarChartLayout.HORIZONTAL
? BAR_CHART_MARGINS_WITH_X_LABEL
: BAR_CHART_MARGINS_WITH_Y_LABEL;
return BAR_CHART_MARGINS_WITH_Y_LABEL;
}
return BAR_CHART_MARGINS;
@@ -1,27 +0,0 @@
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups';
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { isChartConfigurationTwoDimensional } from '@/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional';
import {
BarChartGroupMode,
type BarChartConfiguration,
} from '~/generated/graphql';
export const getBarChartQueryLimit = (
configuration: BarChartConfiguration,
): number => {
if (
isChartConfigurationTwoDimensional(configuration) &&
configuration.groupMode === BarChartGroupMode.STACKED
) {
return (
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS *
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
);
}
return (
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
);
};
@@ -51,7 +51,7 @@ export const getBarChartTickConfig = ({
layout,
});
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel, layout });
const margins = getBarChartMargins({ xAxisLabel, yAxisLabel });
const availableWidth = width - (margins.left + margins.right);
const availableHeight = height - (margins.top + margins.bottom);
@@ -1,49 +0,0 @@
import { generateDateGroupsInRange } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/generateDateGroupsInRange';
import { type Temporal } from 'temporal-polyfill';
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { isDefined, sortPlainDate } from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
export type SupportedDateGranularity =
| ObjectRecordGroupByDateGranularity.DAY
| ObjectRecordGroupByDateGranularity.MONTH
| ObjectRecordGroupByDateGranularity.QUARTER
| ObjectRecordGroupByDateGranularity.YEAR
| ObjectRecordGroupByDateGranularity.WEEK;
type GetDateGroupsFromDataParams = {
parsedDates: Temporal.PlainDate[];
dateGranularity: SupportedDateGranularity;
orderBy?: GraphOrderBy | null;
};
export const getDateGroupsFromData = ({
parsedDates,
dateGranularity,
orderBy,
}: GetDateGroupsFromDataParams): {
dates: Temporal.PlainDate[];
wasTruncated: boolean;
} => {
const sortedPlainDates = parsedDates.toSorted(sortPlainDate('asc'));
const minDate = sortedPlainDates.at(0);
const maxDate = sortedPlainDates.at(-1);
if (!isDefined(minDate) || !isDefined(maxDate)) {
return { dates: [], wasTruncated: false };
}
const result = generateDateGroupsInRange({
startDate: minDate,
endDate: maxDate,
granularity: dateGranularity,
});
const dates =
orderBy === GraphOrderBy.FIELD_DESC
? result.dates.toReversed()
: result.dates;
return { dates, wasTruncated: result.wasTruncated };
};
@@ -1,80 +0,0 @@
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type BarDatum } from '@nivo/bar';
import { BarChartGroupMode } from '~/generated/graphql';
type LimitTwoDimensionalBarChartDataParams = {
sortedData: BarDatum[];
sortedKeys: string[];
sortedSeries: BarChartSeries[];
groupMode?: BarChartGroupMode | null;
};
type LimitTwoDimensionalBarChartDataResult = {
limitedData: BarDatum[];
limitedKeys: string[];
limitedSeries: BarChartSeries[];
hasTooManyGroups: boolean;
};
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
export const limitTwoDimensionalBarChartData = ({
sortedData,
sortedKeys,
sortedSeries,
groupMode,
}: LimitTwoDimensionalBarChartDataParams): LimitTwoDimensionalBarChartDataResult => {
const effectiveGroupMode = groupMode ?? BarChartGroupMode.STACKED;
const hasTooManyBars =
sortedData.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS;
const hasTooManyGroups =
sortedKeys.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR;
const limitedData = sortedData.slice(
0,
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS,
);
const limitedKeys = sortedKeys.slice(
0,
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR,
);
const limitedSeries = sortedSeries.slice(
0,
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_GROUPS_PER_BAR,
);
if (effectiveGroupMode === BarChartGroupMode.STACKED) {
return {
limitedData,
limitedKeys,
limitedSeries,
hasTooManyGroups: hasTooManyBars || hasTooManyGroups,
};
}
const totalSegments = limitedData.length * limitedKeys.length;
const hasTooManySegments =
totalSegments > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS;
if (!hasTooManySegments) {
return {
limitedData,
limitedKeys,
limitedSeries,
hasTooManyGroups: hasTooManyBars || hasTooManyGroups,
};
}
const maxXValues = Math.floor(
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS / limitedKeys.length,
);
const furtherLimitedData = limitedData.slice(0, Math.max(1, maxXValues));
return {
limitedData: furtherLimitedData,
limitedKeys,
limitedSeries,
hasTooManyGroups: true,
};
};
@@ -1,46 +0,0 @@
import { type BarDatum } from '@nivo/bar';
import { isDefined } from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
type SortBarChartDataBySecondaryDimensionSumParams = {
data: BarDatum[];
keys: string[];
orderBy: GraphOrderBy;
};
export const sortBarChartDataBySecondaryDimensionSum = ({
data,
keys,
orderBy,
}: SortBarChartDataBySecondaryDimensionSumParams): BarDatum[] => {
if (
orderBy !== GraphOrderBy.VALUE_ASC &&
orderBy !== GraphOrderBy.VALUE_DESC
) {
return data;
}
const dataWithSecondaryDimensionSums = data.map((datum) => {
const secondaryDimensionSum = keys.reduce((sumAccumulator, segmentKey) => {
const segmentValue = datum[segmentKey];
if (isDefined(segmentValue) && typeof segmentValue === 'number') {
return sumAccumulator + segmentValue;
}
return sumAccumulator;
}, 0);
return { datum, secondaryDimensionSum };
});
dataWithSecondaryDimensionSums.sort((a, b) => {
if (orderBy === GraphOrderBy.VALUE_ASC) {
return a.secondaryDimensionSum - b.secondaryDimensionSum;
} else {
return b.secondaryDimensionSum - a.secondaryDimensionSum;
}
});
return dataWithSecondaryDimensionSums.map(({ datum }) => datum);
};
@@ -1,116 +0,0 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData';
import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded';
import { type BarDatum } from '@nivo/bar';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataType } from '~/generated-metadata/graphql';
import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql';
type SortTwoDimensionalBarChartDataConfiguration = {
data: BarDatum[];
keys: string[];
indexByKey: string;
configuration: BarChartConfiguration;
primaryAxisFormattedToRawLookup: Map<string, RawDimensionValue>;
primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFormattedToRawLookup?: Map<string, RawDimensionValue>;
secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFieldType?: FieldMetadataType;
secondaryAxisSubFieldName?: CompositeFieldSubFieldName;
};
type SortTwoDimensionalBarChartDataResult = {
sortedData: BarDatum[];
sortedKeys: string[];
sortedSeries: BarChartSeries[];
colorMode: GraphColorMode;
};
export const sortTwoDimensionalBarChartData = ({
data,
keys,
indexByKey,
configuration: {
primaryAxisOrderBy,
primaryAxisManualSortOrder,
secondaryAxisOrderBy,
secondaryAxisManualSortOrder,
color,
},
primaryAxisFormattedToRawLookup,
primaryAxisSelectFieldOptions,
secondaryAxisFormattedToRawLookup,
secondaryAxisSelectFieldOptions,
secondaryAxisFieldType,
secondaryAxisSubFieldName,
}: SortTwoDimensionalBarChartDataConfiguration): SortTwoDimensionalBarChartDataResult => {
const sortedKeys = sortSecondaryAxisData({
items: keys,
orderBy: secondaryAxisOrderBy,
manualSortOrder: secondaryAxisManualSortOrder,
formattedToRawLookup: secondaryAxisFormattedToRawLookup,
selectFieldOptions: secondaryAxisSelectFieldOptions,
getFormattedValue: (item) => item,
fieldType: secondaryAxisFieldType,
subFieldName: secondaryAxisSubFieldName,
});
const sortedSeries: BarChartSeries[] = sortedKeys.map((key) => {
const rawValue = secondaryAxisFormattedToRawLookup?.get(key);
return {
key,
label: key,
color: determineChartItemColor({
configurationColor: parseGraphColor(color),
selectOptions: secondaryAxisSelectFieldOptions,
rawValue: isDefined(rawValue) ? String(rawValue) : key,
}),
};
});
let sortedData: BarDatum[] = data;
if (isDefined(primaryAxisOrderBy)) {
if (
primaryAxisOrderBy === GraphOrderBy.VALUE_ASC ||
primaryAxisOrderBy === GraphOrderBy.VALUE_DESC
) {
sortedData = sortBarChartDataBySecondaryDimensionSum({
data,
keys: sortedKeys,
orderBy: primaryAxisOrderBy,
});
} else {
sortedData =
sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({
data,
orderBy: primaryAxisOrderBy,
manualSortOrder: primaryAxisManualSortOrder,
formattedToRawLookup: primaryAxisFormattedToRawLookup,
getFormattedValue: (datum) => datum[indexByKey] as string,
selectFieldOptions: primaryAxisSelectFieldOptions,
});
}
}
const colorMode = determineGraphColorMode({
configurationColor: color,
selectFieldOptions: secondaryAxisSelectFieldOptions,
});
return {
sortedData,
sortedKeys,
sortedSeries,
colorMode,
};
};
@@ -1,296 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData';
import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData';
import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData';
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
import { type BarDatum } from '@nivo/bar';
import {
isDefined,
isFieldMetadataDateKind,
type FirstDayOfTheWeek,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
AxisNameDisplay,
BarChartLayout,
type BarChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToBarChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
configuration: BarChartConfiguration;
aggregateOperation: string;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformGroupByDataToBarChartDataResult = {
data: BarDatum[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
showLegend: boolean;
layout?: BarChartLayout;
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
const EMPTY_BAR_CHART_RESULT: Omit<
TransformGroupByDataToBarChartDataResult,
'xAxisLabel' | 'yAxisLabel'
> = {
data: [],
indexBy: '',
keys: [],
series: [],
showDataLabels: false,
showLegend: true,
layout: BarChartLayout.VERTICAL,
hasTooManyGroups: false,
formattedToRawLookup: new Map(),
colorMode: 'automaticPalette',
};
export const transformGroupByDataToBarChartData = ({
groupByData,
objectMetadataItem,
objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
firstDayOfTheWeek,
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData?.[queryResultGqlFieldName];
const layout =
configuration.layout === BarChartLayout.HORIZONTAL
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL;
const isHorizontal = layout === BarChartLayout.HORIZONTAL;
const showCategoryLabel = isHorizontal
? configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH
: configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showValueLabel = isHorizontal
? configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH
: configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel =
showCategoryLabel && isDefined(groupByFieldX)
? groupByFieldX.label
: undefined;
const yAxisLabel =
showValueLabel && isDefined(aggregateField)
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
if (!isDefined(groupByData)) {
return {
...EMPTY_BAR_CHART_RESULT,
xAxisLabel,
yAxisLabel,
layout,
};
}
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return {
...EMPTY_BAR_CHART_RESULT,
xAxisLabel,
yAxisLabel,
layout,
};
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const secondaryAxisSubFieldName =
configuration.secondaryAxisGroupBySubFieldName ?? undefined;
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName,
});
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return {
...EMPTY_BAR_CHART_RESULT,
indexBy: indexByKey,
xAxisLabel,
yAxisLabel,
layout,
};
}
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.isCumulative
? undefined
: (configuration.rangeMin ?? undefined),
rangeMax: configuration.isCumulative
? undefined
: (configuration.rangeMax ?? undefined),
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
const showDataLabels = configuration.displayDataLabel ?? false;
const showLegend = configuration.displayLegend ?? true;
const isDateField = isFieldMetadataDateKind(groupByFieldX.type);
const isNestedDateField = isRelationNestedFieldDateKind({
relationField: groupByFieldX,
relationNestedFieldName: primaryAxisSubFieldName,
objectMetadataItems,
});
const primaryAxisDateGranularity =
isDateField || isNestedDateField
? (configuration.primaryAxisDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined;
const isSecondaryDateField = isDefined(groupByFieldY)
? isFieldMetadataDateKind(groupByFieldY.type)
: false;
const isSecondaryNestedDateField =
isDefined(groupByFieldY) &&
isRelationNestedFieldDateKind({
relationField: groupByFieldY,
relationNestedFieldName: secondaryAxisSubFieldName,
objectMetadataItems,
});
const secondaryAxisDateGranularity =
isSecondaryDateField || isSecondaryNestedDateField
? (configuration.secondaryAxisGroupByDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined;
const sanitizedConfiguration: BarChartConfiguration = {
...configuration,
primaryAxisDateGranularity: primaryAxisDateGranularity ?? undefined,
secondaryAxisGroupByDateGranularity:
secondaryAxisDateGranularity ?? undefined,
};
const shouldApplyDateGapFill = isDefined(primaryAxisDateGranularity);
const omitNullValues = configuration.omitNullValues ?? false;
const dateGapFillResult =
shouldApplyDateGapFill && !omitNullValues
? fillDateGapsInBarChartData({
data: filteredResults,
keys: [aggregateField.name],
dateGranularity:
primaryAxisDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY,
hasSecondDimension: isDefined(groupByFieldY),
orderBy: configuration.primaryAxisOrderBy,
})
: { data: filteredResults, wasTruncated: false };
const filteredResultsWithDateGaps = dateGapFillResult.data;
const dateRangeWasTruncated = dateGapFillResult.wasTruncated;
const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT;
const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues;
const resultsWithAllGapsFilled = shouldApplySelectGapFill
? fillSelectGapsInChartData({
data: filteredResultsWithDateGaps,
selectOptions: groupByFieldX.options,
aggregateKeys: [aggregateField.name],
hasSecondDimension: isDefined(groupByFieldY),
})
: filteredResultsWithDateGaps;
const baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToBarChartData({
rawResults: resultsWithAllGapsFilled,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration: sanitizedConfiguration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
})
: transformOneDimensionalGroupByToBarChartData({
rawResults: resultsWithAllGapsFilled,
groupByFieldX,
aggregateField,
configuration: sanitizedConfiguration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
showLegend,
layout,
hasTooManyGroups: baseResult.hasTooManyGroups || dateRangeWasTruncated,
formattedToRawLookup: baseResult.formattedToRawLookup,
};
};
@@ -1,147 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults';
import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded';
import { type BarDatum } from '@nivo/bar';
import {
isDefined,
isFieldMetadataSelectKind,
type FirstDayOfTheWeek,
} from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformOneDimensionalGroupByToBarChartDataResult = {
data: BarDatum[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
export const transformOneDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
const aggregateValueKey =
indexByKey === aggregateField.name
? `${aggregateField.name}-aggregate`
: aggregateField.name;
const { processedDataPoints, formattedToRawLookup } =
processOneDimensionalGroupByResults({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
const unsortedData: BarDatum[] = processedDataPoints.map(
({ xValue, rawXValue, aggregateValue }) => {
const color = determineChartItemColor({
configurationColor: parseGraphColor(configuration.color),
selectOptions: isFieldMetadataSelectKind(groupByFieldX.type)
? groupByFieldX.options
: undefined,
rawValue: isDefined(rawXValue) ? String(rawXValue) : null,
});
return {
[indexByKey]: xValue,
[aggregateValueKey]: aggregateValue,
...(isDefined(color) && { color }),
};
},
);
const sortedData = sortChartDataIfNeeded({
data: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
manualSortOrder: configuration.primaryAxisManualSortOrder,
formattedToRawLookup,
getFieldValue: (datum) => datum[indexByKey] as string,
getNumericValue: (datum) => datum[aggregateValueKey] as number,
selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type)
? groupByFieldX.options
: undefined,
});
const limitedSortedData = sortedData.slice(
0,
BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS,
);
const series: BarChartSeries[] = [
{
key: aggregateValueKey,
label: aggregateField.label,
},
];
const finalData = configuration.isCumulative
? applyCumulativeTransformToBarChartData({
data: limitedSortedData,
aggregateKey: aggregateValueKey,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
: limitedSortedData;
const colorMode = determineGraphColorMode({
configurationColor: configuration.color,
selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type)
? groupByFieldX.options
: undefined,
});
return {
data: finalData,
indexBy: indexByKey,
keys: [aggregateValueKey],
series,
hasTooManyGroups:
rawResults.length > BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS,
formattedToRawLookup,
colorMode,
};
};
@@ -1,120 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData';
import { buildTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData';
import { limitTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData';
import { sortTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
import { type BarDatum } from '@nivo/bar';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformTwoDimensionalGroupByToBarChartDataResult = {
data: BarDatum[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
export const transformTwoDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
const { processedDataPoints, formattedToRawLookup, yFormattedToRawLookup } =
processTwoDimensionalGroupByResults({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
const { unsortedData, yValues } = buildTwoDimensionalBarChartData({
processedDataPoints,
indexByKey,
});
const { sortedData, sortedKeys, sortedSeries, colorMode } =
sortTwoDimensionalBarChartData({
data: unsortedData,
keys: Array.from(yValues),
indexByKey,
configuration,
primaryAxisFormattedToRawLookup: formattedToRawLookup,
primaryAxisSelectFieldOptions: groupByFieldX.options,
secondaryAxisFormattedToRawLookup: yFormattedToRawLookup,
secondaryAxisSelectFieldOptions: groupByFieldY.options,
secondaryAxisFieldType: groupByFieldY.type,
secondaryAxisSubFieldName:
(configuration.secondaryAxisGroupBySubFieldName ?? undefined) as
| CompositeFieldSubFieldName
| undefined,
});
const { limitedData, limitedKeys, limitedSeries, hasTooManyGroups } =
limitTwoDimensionalBarChartData({
sortedData,
sortedKeys,
sortedSeries,
groupMode: configuration.groupMode,
});
const finalData = configuration.isCumulative
? applyCumulativeTransformToTwoDimensionalBarChartData({
data: limitedData,
keys: limitedKeys,
rangeMin: configuration.rangeMin,
rangeMax: configuration.rangeMax,
})
: limitedData;
return {
data: finalData,
indexBy: indexByKey,
keys: limitedKeys,
series: limitedSeries,
hasTooManyGroups,
formattedToRawLookup,
colorMode,
};
};
@@ -15,7 +15,7 @@ import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineCha
import { useLineChartTheme } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartTheme';
import { graphWidgetLineCrosshairXComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineCrosshairXComponentState';
import { graphWidgetLineTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetLineChart/states/graphWidgetLineTooltipComponentState';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries';
import { getLineChartAxisBottomConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisBottomConfig';
import { getLineChartAxisLeftConfig } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartAxisLeftConfig';
@@ -49,7 +49,7 @@ type LinesLayerProps = LineCustomSvgLayerProps<LineSeries>;
type NoDataLayerWrapperProps = LineCustomSvgLayerProps<LineSeries>;
type GraphWidgetLineChartProps = {
data: LineChartSeries[];
data: LineChartSeriesWithColor[];
showLegend?: boolean;
showGrid?: boolean;
enablePointLabel?: boolean;
@@ -3,7 +3,6 @@ import { ChartSkeletonLoader } from '@/page-layout/widgets/graph/components/Char
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { useGraphLineChartWidgetData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { assertLineChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertLineChartWidget';
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
import { generateChartAggregateFilterKey } from '@/page-layout/widgets/graph/utils/generateChartAggregateFilterKey';
@@ -19,6 +18,7 @@ import { useNavigate } from 'react-router-dom';
import { useRecoilValue } from 'recoil';
import { AppPath } from 'twenty-shared/types';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { AxisNameDisplay, type LineChartDataPoint } from '~/generated/graphql';
const GraphWidgetLineChart = lazy(() =>
import(
@@ -67,6 +67,19 @@ export const GraphWidgetLineChartRenderer = () => {
? 'stacked'
: undefined;
const axisNameDisplay = configuration.axisNameDisplay;
const showXAxis =
axisNameDisplay === AxisNameDisplay.X ||
axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
axisNameDisplay === AxisNameDisplay.Y ||
axisNameDisplay === AxisNameDisplay.BOTH;
const displayXAxisLabel = showXAxis ? xAxisLabel : undefined;
const displayYAxisLabel = showYAxis ? yAxisLabel : undefined;
const chartFilterKey = generateChartAggregateFilterKey(
configuration.rangeMin,
configuration.rangeMax,
@@ -124,8 +137,8 @@ export const GraphWidgetLineChartRenderer = () => {
key={chartFilterKey}
id={widget.id}
data={series}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
xAxisLabel={displayXAxisLabel}
yAxisLabel={displayYAxisLabel}
enablePointLabel={showDataLabels}
showLegend={showLegend}
rangeMin={configuration.rangeMin ?? undefined}
@@ -1,8 +1,9 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { useLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/hooks/useLineChartData';
import { type LineChartSeries } from '~/generated/graphql';
const mockUseRecoilComponentValue = jest.fn();
jest.mock(
@@ -57,7 +58,7 @@ describe('useLineChartData', () => {
},
};
const mockData: LineChartSeries[] = [
const mockData: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
@@ -175,6 +176,7 @@ describe('useLineChartData', () => {
{
id: 'series1',
data: [{ x: 'Jan', y: 100 }],
label: 'series1',
},
];
@@ -1,15 +1,23 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit';
import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { LINE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/lineChartData';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useMemo } from 'react';
import { type LineChartConfiguration } from '~/generated/graphql';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { extractLineChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractLineChartDataConfiguration';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { useQuery } from '@apollo/client';
import { isString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type LineChartConfiguration,
type LineChartDataPoint,
type LineChartSeries,
} from '~/generated/graphql';
type UseGraphLineChartWidgetDataProps = {
objectMetadataItemId: string;
@@ -18,18 +26,16 @@ type UseGraphLineChartWidgetDataProps = {
type UseGraphLineChartWidgetDataResult = {
series: LineChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
showLegend: boolean;
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
xAxisLabel: string;
yAxisLabel: string;
loading: boolean;
error?: Error;
objectMetadataItem: ReturnType<
typeof useObjectMetadataItemById
>['objectMetadataItem'];
objectMetadataItem: ObjectMetadataItem;
};
export const useGraphLineChartWidgetData = ({
@@ -39,49 +45,85 @@ export const useGraphLineChartWidgetData = ({
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
const { objectMetadataItems } = useObjectMetadataItems();
const limit = getLineChartQueryLimit(configuration);
const apolloCoreClient = useApolloCoreClient();
const dataConfiguration = extractLineChartDataConfiguration(configuration);
const {
data: groupByData,
data: queryData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
limit,
} = useQuery(LINE_CHART_DATA, {
client: apolloCoreClient,
variables: {
input: {
objectMetadataId: objectMetadataItemId,
configuration: dataConfiguration,
},
},
});
const { userTimezone } = useUserTimezone();
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
const formattedToRawLookup = queryData?.lineChartData?.formattedToRawLookup
? new Map(Object.entries(queryData.lineChartData.formattedToRawLookup))
: new Map();
const transformedData = useMemo(
() =>
transformGroupByDataToLineChartData({
groupByData,
objectMetadataItem,
objectMetadataItems: objectMetadataItems ?? [],
configuration,
aggregateOperation,
userTimezone,
firstDayOfTheWeek: userFirstDayOfTheWeek,
}),
[
groupByData,
objectMetadataItem,
objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
userFirstDayOfTheWeek,
],
const secondaryAxisField = objectMetadataItem?.fields?.find(
(field) => field.id === configuration.secondaryAxisGroupByFieldMetadataId,
);
const selectFieldOptions =
isDefined(secondaryAxisField) &&
(secondaryAxisField.type === FieldMetadataType.SELECT ||
secondaryAxisField.type === FieldMetadataType.MULTI_SELECT)
? secondaryAxisField.options
: null;
const configurationColor = parseGraphColor(configuration.color);
const colorMode = determineGraphColorMode({
configurationColor,
selectFieldOptions,
});
const series = queryData?.lineChartData?.series?.map(
(seriesItem: {
id: string;
label: string;
data: Array<LineChartDataPoint>;
}): LineChartSeriesWithColor => {
const rawValue = formattedToRawLookup.get(seriesItem.id);
const itemColor = determineChartItemColor({
configurationColor,
selectOptions: selectFieldOptions,
rawValue: isString(rawValue) ? rawValue : undefined,
});
return {
id: seriesItem.id,
label: seriesItem.label,
color: itemColor,
data: seriesItem.data.map(
(point: LineChartDataPoint): LineChartDataPoint => ({
x: point.x,
y: point.y,
}),
),
};
},
);
return {
...transformedData,
series,
showDataLabels: configuration.displayDataLabel ?? false,
showLegend: configuration.displayLegend ?? true,
hasTooManyGroups: queryData?.lineChartData?.hasTooManyGroups ?? false,
colorMode,
formattedToRawLookup,
objectMetadataItem,
xAxisLabel: queryData?.lineChartData?.xAxisLabel ?? '',
yAxisLabel: queryData?.lineChartData?.yAxisLabel ?? '',
loading,
error,
};
@@ -1,6 +1,6 @@
import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { type LineChartEnrichedSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartEnrichedSeries';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
@@ -10,7 +10,7 @@ import { type LineSeries } from '@nivo/line';
import { useMemo } from 'react';
type UseLineChartDataProps = {
data: LineChartSeries[];
data: LineChartSeriesWithColor[];
colorRegistry: GraphColorRegistry;
id: string;
colorMode: GraphColorMode;
@@ -41,9 +41,8 @@ export const useLineChartData = ({
.replace(/\s+/g, '_')
.replace(/[^a-zA-Z0-9_-]/g, '');
const areaFillId = `areaFill-${id}-${sanitizedSeriesId}-${index}`;
const label = series.label ?? series.id;
return { ...series, colorScheme, areaFillId, label };
return { ...series, colorScheme, areaFillId };
});
}, [data, colorRegistry, id, colorMode]);
@@ -1,7 +1,7 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type LineChartEnrichedSeries = LineChartSeries & {
export type LineChartEnrichedSeries = LineChartSeriesWithColor & {
colorScheme: GraphColorScheme;
areaFillId: string;
label: string;
@@ -1,9 +1,9 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
export type LineChartSeries = {
export type LineChartSeriesWithColor = {
id: string;
label?: string;
label: string;
color?: GraphColor;
data: LineChartDataPoint[];
};
@@ -1,59 +0,0 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`applyCumulativeTransformToLineChartData should handle basic accumulation 1`] = `
[
{
"x": "Jan",
"y": 10,
},
{
"x": "Feb",
"y": 30,
},
{
"x": "Mar",
"y": 60,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle empty data 1`] = `[]`;
exports[`applyCumulativeTransformToLineChartData should handle filter above rangeMax 1`] = `
[
{
"x": "a",
"y": 10,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle filter below rangeMin 1`] = `
[
{
"x": "b",
"y": 20,
},
{
"x": "c",
"y": 30,
},
]
`;
exports[`applyCumulativeTransformToLineChartData should handle skip null y values 1`] = `
[
{
"x": "a",
"y": 10,
},
{
"x": "b",
"y": 10,
},
{
"x": "c",
"y": 30,
},
]
`;
@@ -1,54 +0,0 @@
import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData';
describe('applyCumulativeTransformToLineChartData', () => {
const testCases = [
{
name: 'basic accumulation',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 },
{ x: 'Mar', y: 30 },
],
},
{
name: 'filter below rangeMin',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: 10 },
{ x: 'c', y: 10 },
],
rangeMin: 15,
},
{
name: 'filter above rangeMax',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: 20 },
{ x: 'c', y: 30 },
],
rangeMax: 25,
},
{
name: 'empty data',
data: [],
},
{
name: 'skip null y values',
data: [
{ x: 'a', y: 10 },
{ x: 'b', y: null },
{ x: 'c', y: 20 },
],
},
];
it.each(testCases)('should handle $name', ({ data, rangeMin, rangeMax }) => {
const result = applyCumulativeTransformToLineChartData({
data,
rangeMin,
rangeMax,
});
expect(result).toMatchSnapshot();
});
});
@@ -0,0 +1,146 @@
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { calculateValueRangeFromLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/calculateValueRangeFromLineChartSeries';
import { type LineChartSeries } from '~/generated/graphql';
describe('calculateValueRangeFromLineChartSeries', () => {
describe('with valid data', () => {
it('should calculate minimum and maximum from single series', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 50 },
{ x: 'Mar', y: 30 },
],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(50);
});
it('should calculate minimum and maximum from multiple series', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: 10 },
{ x: 'Feb', y: 20 },
],
},
{
id: 'series2',
data: [
{ x: 'Jan', y: 5 },
{ x: 'Feb', y: 100 },
],
},
] as unknown as LineChartSeries[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(100);
});
it('should handle negative values', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: -50 },
{ x: 'Feb', y: 25 },
{ x: 'Mar', y: -10 },
],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(-50);
expect(result.maximum).toBe(25);
});
it('should handle all same values', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: 42 },
{ x: 'Feb', y: 42 },
{ x: 'Mar', y: 42 },
],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(42);
});
});
describe('with null/undefined values', () => {
it('should treat null y values as 0', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: null },
{ x: 'Feb', y: 50 },
],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(50);
});
it('should treat undefined y values as 0', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [
{ x: 'Jan', y: undefined },
{ x: 'Feb', y: 30 },
],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(30);
});
});
describe('empty data', () => {
it('should handle empty series array', () => {
const data: LineChartSeriesWithColor[] = [];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(0);
});
it('should handle series with empty data array', () => {
const data: LineChartSeriesWithColor[] = [
{
id: 'series1',
data: [],
},
] as unknown as LineChartSeriesWithColor[];
const result = calculateValueRangeFromLineChartSeries(data);
expect(result.minimum).toBe(0);
expect(result.maximum).toBe(0);
});
});
});
@@ -0,0 +1,158 @@
import { computeLineChartGroupedLabels } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartGroupedLabels';
import { type LineSeries, type Point } from '@nivo/line';
type MockPointOverrides = Omit<Partial<Point<LineSeries>>, 'data'> & {
data?: { x?: string | number; y?: string | number };
};
describe('computeLineChartGroupedLabels', () => {
const createMockPoint = (
overrides: MockPointOverrides,
): Point<LineSeries> => {
const { data: dataOverrides, ...restOverrides } = overrides;
return {
id: 'point1',
seriesId: 'series1',
x: 100,
y: 50,
data: {
x: 'Jan',
y: 100,
xFormatted: 'Jan',
yFormatted: '100',
...dataOverrides,
},
...restOverrides,
} as unknown as Point<LineSeries>;
};
describe('basic label computation', () => {
it('should return labels for each point', () => {
const points = [
createMockPoint({ seriesId: 'series1', data: { x: 'Jan', y: 100 } }),
createMockPoint({ seriesId: 'series1', data: { x: 'Feb', y: 150 } }),
];
const result = computeLineChartGroupedLabels(points);
expect(result).toHaveLength(2);
});
it('should generate unique keys for each label', () => {
const points = [
createMockPoint({ seriesId: 'revenue', data: { x: 'Q1', y: 100 } }),
createMockPoint({ seriesId: 'revenue', data: { x: 'Q2', y: 150 } }),
];
const result = computeLineChartGroupedLabels(points);
expect(result[0].key).toBe('value-revenue-Q1');
expect(result[1].key).toBe('value-revenue-Q2');
});
});
describe('label positioning', () => {
it('should use point x coordinate for label x', () => {
const points = [createMockPoint({ x: 250 })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].x).toBe(250);
});
it('should use point y coordinate for label y', () => {
const points = [createMockPoint({ y: 75 })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].y).toBe(75);
});
});
describe('shouldRenderBelow logic', () => {
it('should set shouldRenderBelow to false for positive values', () => {
const points = [createMockPoint({ data: { x: 'Jan', y: 100 } })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].shouldRenderBelow).toBe(false);
});
it('should set shouldRenderBelow to true for negative values', () => {
const points = [createMockPoint({ data: { x: 'Jan', y: -50 } })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].shouldRenderBelow).toBe(true);
});
it('should set shouldRenderBelow to false for zero', () => {
const points = [createMockPoint({ data: { x: 'Jan', y: 0 } })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].shouldRenderBelow).toBe(false);
});
});
describe('value extraction', () => {
it('should extract numeric value from point data', () => {
const points = [createMockPoint({ data: { x: 'Jan', y: 42.5 } })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].value).toBe(42.5);
});
it('should handle string y values by converting to number', () => {
const points = [
createMockPoint({ data: { x: 'Jan', y: '123' as unknown as number } }),
];
const result = computeLineChartGroupedLabels(points);
expect(result[0].value).toBe(123);
});
it('should handle large numbers', () => {
const points = [createMockPoint({ data: { x: 'Jan', y: 1000000 } })];
const result = computeLineChartGroupedLabels(points);
expect(result[0].value).toBe(1000000);
});
});
describe('multiple series', () => {
it('should handle points from different series', () => {
const points = [
createMockPoint({ seriesId: 'sales', data: { x: 'Jan', y: 100 } }),
createMockPoint({ seriesId: 'revenue', data: { x: 'Jan', y: 200 } }),
];
const result = computeLineChartGroupedLabels(points);
expect(result[0].key).toBe('value-sales-Jan');
expect(result[1].key).toBe('value-revenue-Jan');
});
});
describe('edge cases', () => {
it('should return empty array for empty input', () => {
const result = computeLineChartGroupedLabels([]);
expect(result).toEqual([]);
});
it('should handle single point', () => {
const points = [
createMockPoint({ seriesId: 'series1', data: { x: 'Only', y: 50 } }),
];
const result = computeLineChartGroupedLabels(points);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(50);
});
});
});
@@ -0,0 +1,95 @@
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { createAreaFillDef } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/createAreaFillDef';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
describe('createAreaFillDef', () => {
const mockColorScheme: GraphColorScheme = {
name: 'blue',
solid: '#solid',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
it('should create a linear gradient definition with correct structure', () => {
const result = createAreaFillDef(mockColorScheme, 'area-gradient-1');
expect(result.id).toBe('area-gradient-1');
expect(result.type).toBe('linearGradient');
expect(result.x1).toBe('0%');
expect(result.y1).toBe('0%');
expect(result.x2).toBe('0%');
expect(result.y2).toBe('100%');
});
it('should create gradient colors with correct opacity values', () => {
const result = createAreaFillDef(mockColorScheme, 'test-gradient');
expect(result.colors).toHaveLength(2);
expect(result.colors[0]).toEqual({
offset: 0,
color: mockColorScheme.solid,
opacity: LINE_CHART_CONSTANTS.AREA_FILL_START_OPACITY,
});
expect(result.colors[1]).toEqual({
offset: 100,
color: mockColorScheme.solid,
opacity: LINE_CHART_CONSTANTS.AREA_FILL_END_OPACITY,
});
});
it('should use the solid color from color scheme', () => {
const customColorScheme: GraphColorScheme = {
name: 'custom',
solid: '#customSolid',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
const result = createAreaFillDef(customColorScheme, 'custom-gradient');
expect(result.colors[0].color).toBe('#customSolid');
expect(result.colors[1].color).toBe('#customSolid');
});
it('should generate unique IDs for different gradients', () => {
const result1 = createAreaFillDef(mockColorScheme, 'gradient-a');
const result2 = createAreaFillDef(mockColorScheme, 'gradient-b');
expect(result1.id).toBe('gradient-a');
expect(result2.id).toBe('gradient-b');
expect(result1.id).not.toBe(result2.id);
});
it('should create vertical gradient (top to bottom)', () => {
const result = createAreaFillDef(mockColorScheme, 'vertical-gradient');
expect(result.x1).toBe('0%');
expect(result.y1).toBe('0%');
expect(result.x2).toBe('0%');
expect(result.y2).toBe('100%');
});
});
@@ -1,47 +0,0 @@
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartConfiguration } from '~/generated-metadata/graphql';
import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit';
describe('getLineChartQueryLimit', () => {
it('should return one-dimensional limit for line chart without secondary axis', () => {
const result = getLineChartQueryLimit({
__typename: 'LineChartConfiguration',
secondaryAxisGroupByFieldMetadataId: null,
isStacked: false,
} as LineChartConfiguration);
expect(result).toBe(
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
it('should return two-dimensional stacked limit for line chart with secondary axis and stacked mode', () => {
const result = getLineChartQueryLimit({
__typename: 'LineChartConfiguration',
secondaryAxisGroupByFieldMetadataId: 'some-field-id',
isStacked: true,
} as LineChartConfiguration);
expect(result).toBe(
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS *
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
it('should return two-dimensional non-stacked limit for line chart with secondary axis and non-stacked mode', () => {
const result = getLineChartQueryLimit({
__typename: 'LineChartConfiguration',
secondaryAxisGroupByFieldMetadataId: 'some-field-id',
isStacked: false,
} as LineChartConfiguration);
expect(result).toBe(
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS *
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
);
});
});
@@ -1,250 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import {
AggregateOperations,
FieldMetadataType,
WidgetConfigurationType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
describe('transformOneDimensionalGroupByToLineChartData', () => {
const userTimezone = 'Europe/Paris';
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockDateGroupByField: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockDateGroupByField],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
configurationType: WidgetConfigurationType.LINE_CHART,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Categorical X-axis', () => {
it('should transform simple categorical groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Qualification'],
sumAmount: 150000,
},
{
groupByDimensionValues: ['Proposal'],
sumAmount: 280000,
},
{
groupByDimensionValues: ['Closed Won'],
sumAmount: 450000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toHaveLength(1);
expect(result.series[0]).toMatchObject({
id: 'amount',
label: 'Amount',
color: 'blue',
});
expect(result.series[0].data).toEqual([
{ x: 'Qualification', y: 150000 },
{ x: 'Proposal', y: 280000 },
{ x: 'Closed Won', y: 450000 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should treat null aggregate values as zero', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['Stage B'],
sumAmount: null,
},
{
groupByDimensionValues: ['Stage C'],
sumAmount: 200,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 100 },
{ x: 'Stage B', y: 0 },
{ x: 'Stage C', y: 200 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Time-series X-axis', () => {
it('should transform date-based groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-02-01'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-03-01'],
sumAmount: 60000,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockDateGroupByField,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
primaryAxisGroupByFieldMetadataId: 'created-at-field',
primaryAxisDateGranularity: 'MONTH' as any,
}),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y', 50000);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should use default color when not specified', () => {
const result = transformOneDimensionalGroupByToLineChartData({
rawResults: [
{
groupByDimensionValues: ['Test'],
sumAmount: 100,
},
],
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({ color: undefined }),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series[0].color).toBeDefined();
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['Stage B'],
_count: 10,
},
];
const result = transformOneDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series[0].data).toEqual([
{ x: 'Stage A', y: 5 },
{ x: 'Stage B', y: 10 },
]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -1,327 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import {
AggregateOperations,
FieldMetadataType,
WidgetConfigurationType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
describe('transformTwoDimensionalGroupByToLineChartData', () => {
const userTimezone = 'Europe/Paris';
const mockAggregateField: FieldMetadataItem = {
id: 'amount-field',
name: 'amount',
label: 'Amount',
type: FieldMetadataType.NUMBER,
} as FieldMetadataItem;
const mockGroupByFieldX: FieldMetadataItem = {
id: 'created-at-field',
name: 'createdAt',
label: 'Created At',
type: FieldMetadataType.DATE_TIME,
} as FieldMetadataItem;
const mockGroupByFieldY: FieldMetadataItem = {
id: 'stage-field',
name: 'stage',
label: 'Stage',
type: FieldMetadataType.TEXT,
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'opportunity-object',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockAggregateField, mockGroupByFieldX, mockGroupByFieldY],
} as ObjectMetadataItem;
const buildConfiguration = (
overrides: Partial<LineChartConfiguration> = {},
): LineChartConfiguration =>
({
__typename: 'LineChartConfiguration',
configurationType: WidgetConfigurationType.LINE_CHART,
aggregateFieldMetadataId: 'amount-field',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'created-at-field',
secondaryAxisGroupByFieldMetadataId: 'stage-field',
color: 'blue',
...overrides,
}) as LineChartConfiguration;
describe('Multi-series transformation', () => {
it('should create multiple series from 2D groupBy results', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Qualification'],
sumAmount: 50000,
},
{
groupByDimensionValues: ['2024-01-01', 'Proposal'],
sumAmount: 75000,
},
{
groupByDimensionValues: ['2024-02-01', 'Qualification'],
sumAmount: 60000,
},
{
groupByDimensionValues: ['2024-02-01', 'Proposal'],
sumAmount: 90000,
},
{
groupByDimensionValues: ['2024-03-01', 'Qualification'],
sumAmount: 55000,
},
{
groupByDimensionValues: ['2024-03-01', 'Proposal'],
sumAmount: 80000,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toHaveLength(2);
expect(result.series[0]).toMatchObject({
id: expect.any(String),
label: expect.any(String),
color: 'blue',
});
expect(result.series[0].data).toHaveLength(3);
expect(result.series[1].data).toHaveLength(3);
expect(result.series[0].data[0]).toHaveProperty('x');
expect(result.series[0].data[0]).toHaveProperty('y');
result.series.forEach((series) => {
series.data.forEach((point) => {
expect(point).toHaveProperty('x');
expect(point).toHaveProperty('y');
});
});
expect(result.hasTooManyGroups).toBe(false);
});
it('should preserve backend ordering of data points', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02-01', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-03-01', 'Stage A'],
sumAmount: 300,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
const series = result.series[0];
expect(series.data[0].y).toBe(100);
expect(series.data[1].y).toBe(200);
expect(series.data[2].y).toBe(300);
expect(result.hasTooManyGroups).toBe(false);
});
it('should normalize sparse data (all series share same X values, with 0 for missing)', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02-01', 'Stage A'],
sumAmount: 200,
},
{
groupByDimensionValues: ['2024-01-01', 'Stage B'],
sumAmount: 150,
},
{
groupByDimensionValues: ['2024-03-01', 'Stage B'],
sumAmount: 250,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
const stageA = result.series.find((s) => s.id === 'Stage A');
expect(stageA?.data).toHaveLength(3);
expect(stageA?.data[0].y).toBe(100);
expect(stageA?.data[1].y).toBe(200);
expect(stageA?.data[2].y).toBe(0);
const stageB = result.series.find((s) => s.id === 'Stage B');
expect(stageB?.data).toHaveLength(3);
expect(stageB?.data[0].y).toBe(150);
expect(stageB?.data[1].y).toBe(0);
expect(stageB?.data[2].y).toBe(250);
expect(result.hasTooManyGroups).toBe(false);
});
it('should convert null aggregate values to zero', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Stage A'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02-01', 'Stage A'],
sumAmount: null,
},
{
groupByDimensionValues: ['2024-03-01', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series[0].data).toHaveLength(3);
expect(result.series[0].data.map((d) => d.y)).toEqual([100, 0, 200]);
expect(result.hasTooManyGroups).toBe(false);
});
});
describe('Edge cases', () => {
it('should handle empty results', () => {
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults: [],
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toEqual([]);
expect(result.hasTooManyGroups).toBe(false);
});
it('should skip results with missing dimension values', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01'],
sumAmount: 100,
},
{
groupByDimensionValues: ['2024-02-01', 'Stage A'],
sumAmount: 200,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration(),
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series).toHaveLength(1);
expect(result.series[0].data).toHaveLength(1);
expect(result.hasTooManyGroups).toBe(false);
});
it('should handle COUNT operation', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Stage A'],
_count: 5,
},
{
groupByDimensionValues: ['2024-02-01', 'Stage A'],
_count: 10,
},
];
const result = transformTwoDimensionalGroupByToLineChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: buildConfiguration({
aggregateOperation: AggregateOperations.COUNT,
}),
aggregateOperation: '_count',
objectMetadataItem: mockObjectMetadataItem,
primaryAxisSubFieldName: null,
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.series[0].data.map((d) => d.y)).toEqual([5, 10]);
expect(result.hasTooManyGroups).toBe(false);
});
});
});
@@ -1,40 +0,0 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToLineChartDataOptions = {
data: LineChartDataPoint[];
rangeMin?: number;
rangeMax?: number;
};
export const applyCumulativeTransformToLineChartData = ({
data,
rangeMin,
rangeMax,
}: ApplyCumulativeTransformToLineChartDataOptions): LineChartDataPoint[] => {
const { result } = data.reduce<{
result: LineChartDataPoint[];
runningTotal: number;
}>(
(accumulator, point) => {
if (point.y !== null) {
accumulator.runningTotal += point.y;
}
const cumulativeValue = accumulator.runningTotal;
const isOutOfRange =
(isDefined(rangeMin) && cumulativeValue < rangeMin) ||
(isDefined(rangeMax) && cumulativeValue > rangeMax);
if (!isOutOfRange) {
accumulator.result.push({ ...point, y: cumulativeValue });
}
return accumulator;
},
{ result: [], runningTotal: 0 },
);
return result;
};
@@ -1,57 +0,0 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type ProcessedTwoDimensionalDataPoint } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
type BuildTwoDimensionalLineChartSeriesParams = {
processedDataPoints: ProcessedTwoDimensionalDataPoint[];
color?: GraphColor | null;
};
type BuildTwoDimensionalLineChartSeriesResult = {
unsortedSeries: LineChartSeries[];
};
export const buildTwoDimensionalLineChartSeries = ({
processedDataPoints,
color,
}: BuildTwoDimensionalLineChartSeriesParams): BuildTwoDimensionalLineChartSeriesResult => {
const seriesMap = new Map<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
for (const { xValue, yValue, aggregateValue } of processedDataPoints) {
const isNewX = !xValueSet.has(xValue);
if (isNewX) {
xValueSet.add(xValue);
allXValues.push(xValue);
}
if (!seriesMap.has(yValue)) {
seriesMap.set(yValue, new Map());
}
seriesMap.get(yValue)!.set(xValue, aggregateValue);
}
const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map(
([seriesKey, xToYMap]) => {
const data: LineChartDataPoint[] = allXValues.map((xValue) => ({
x: xValue,
y: xToYMap.get(xValue) ?? 0,
}));
return {
id: seriesKey,
label: seriesKey,
color: color ?? undefined,
data,
};
},
);
return {
unsortedSeries,
};
};
@@ -1,15 +1,15 @@
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { type ChartValueRange } from '@/page-layout/widgets/graph/types/ChartValueRange';
import { calculateValueRangeFromValues } from '@/page-layout/widgets/graph/utils/calculateValueRangeFromValues';
export const calculateValueRangeFromLineChartSeries = (
data: LineChartSeries[],
data: LineChartSeriesWithColor[],
): ChartValueRange => {
const values: number[] = [];
for (const series of data) {
for (const point of series.data) {
const value = Number(point.y ?? 0);
const value = Number(point.y);
values.push(value);
}
}
@@ -19,7 +19,7 @@ export const computeLineAreaPath = ({
type PositionData = (typeof currentSeries.data)[number];
const areaGenerator = area<PositionData>()
.defined((d) => d.position.x !== null && d.position.y !== null)
.defined((d) => isDefined(d.position.x) && isDefined(d.position.y))
.x((d) => d.position.x ?? 0)
.y1((d) => d.position.y ?? 0)
.y0((_, index) => {
@@ -1,5 +1,7 @@
import { isNumber, isString } from '@sniptt/guards';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { computeChartCategoryTickValues } from '@/page-layout/widgets/graph/utils/computeChartCategoryTickValues';
export const computeLineChartCategoryTickValues = ({
@@ -9,7 +11,7 @@ export const computeLineChartCategoryTickValues = ({
marginRight,
}: {
width: number;
data: LineChartSeries[];
data: LineChartSeriesWithColor[];
marginLeft: number;
marginRight: number;
}): (string | number)[] => {
@@ -19,9 +21,7 @@ export const computeLineChartCategoryTickValues = ({
const values = data[0].data.map((point) => {
const value = point.x;
return typeof value === 'number' || typeof value === 'string'
? value
: String(value);
return isNumber(value) || isString(value) ? value : String(value);
});
const availableWidth = width - (marginLeft + marginRight);
@@ -1,6 +1,6 @@
import { COMMON_CHART_CONSTANTS } from '@/page-layout/widgets/graph/constants/CommonChartConstants';
import { truncateTickLabel } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/truncateTickLabel';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { type LineChartSeriesWithColor } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeriesWithColor';
import { computeLineChartCategoryTickValues } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/computeLineChartCategoryTickValues';
import { getTickRotationConfig } from '@/page-layout/widgets/graph/utils/getTickRotationConfig';
import { isNonEmptyArray } from '@sniptt/guards';
@@ -23,7 +23,7 @@ export type LineChartAxisBottomResult = {
export const getLineChartAxisBottomConfig = (
xAxisLabel?: string,
width?: number,
data?: LineChartSeries[],
data?: LineChartSeriesWithColor[],
marginLeft?: number,
): LineChartAxisBottomResult => {
const effectiveMarginLeft =
@@ -1,31 +0,0 @@
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { isChartConfigurationTwoDimensional } from '@/page-layout/widgets/graph/utils/isChartConfigurationTwoDimensional';
import { type LineChartConfiguration } from '~/generated/graphql';
export const getLineChartQueryLimit = (
configuration: LineChartConfiguration,
): number => {
const isTwoDimensional = isChartConfigurationTwoDimensional(configuration);
if (!isTwoDimensional) {
return (
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
);
}
if (configuration.isStacked === true) {
return (
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS *
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
);
}
return (
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS *
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS
);
};
@@ -1,59 +0,0 @@
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
type LimitTwoDimensionalLineChartDataParams = {
sortedSeries: LineChartSeries[];
isStacked: boolean;
};
type LimitTwoDimensionalLineChartDataResult = {
limitedSeries: LineChartSeries[];
hasTooManyGroups: boolean;
};
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
export const limitTwoDimensionalLineChartData = ({
sortedSeries,
isStacked,
}: LimitTwoDimensionalLineChartDataParams): LimitTwoDimensionalLineChartDataResult => {
if (sortedSeries.length === 0) {
return {
limitedSeries: sortedSeries,
hasTooManyGroups: false,
};
}
const maxSeries = isStacked
? LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_STACKED_SERIES
: LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_NON_STACKED_SERIES;
const hasTooManySeries = sortedSeries.length > maxSeries;
const dataPointCount = sortedSeries[0].data.length;
const hasTooManyDataPoints =
dataPointCount > LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS;
const hasTooManyGroups = hasTooManySeries || hasTooManyDataPoints;
if (!hasTooManyGroups) {
return {
limitedSeries: sortedSeries,
hasTooManyGroups: false,
};
}
const seriesLimited = sortedSeries.slice(0, maxSeries);
const limitedSeries = seriesLimited.map((series) => ({
...series,
data: series.data.slice(
0,
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS,
),
}));
return {
limitedSeries,
hasTooManyGroups: true,
};
};
@@ -1,56 +0,0 @@
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { GraphOrderBy } from '~/generated/graphql';
type SortLineChartDataBySecondaryDimensionSumParams = {
series: LineChartSeries[];
orderBy: GraphOrderBy;
};
export const sortLineChartDataBySecondaryDimensionSum = ({
series,
orderBy,
}: SortLineChartDataBySecondaryDimensionSumParams): LineChartSeries[] => {
if (series.length === 0) {
return series;
}
const allXValues = series[0].data.map((point) => point.x);
const seriesLookups = series.map(
(seriesItem) => new Map(seriesItem.data.map((point) => [point.x, point.y])),
);
const xValueSums = new Map<
LineChartDataPoint['x'],
LineChartDataPoint['y']
>();
for (const xValue of allXValues) {
const sum = seriesLookups.reduce((accumulator, lookup) => {
return accumulator + (lookup.get(xValue) ?? 0);
}, 0);
xValueSums.set(xValue, sum);
}
const sortedXValues = allXValues.toSorted((a, b) => {
const sumA = xValueSums.get(a) ?? 0;
const sumB = xValueSums.get(b) ?? 0;
return orderBy === GraphOrderBy.VALUE_ASC ? sumA - sumB : sumB - sumA;
});
const xValueToIndex = new Map<LineChartDataPoint['x'], number>(
sortedXValues.map((xValue, index) => [xValue, index]),
);
return series.map((seriesItem) => ({
...seriesItem,
data: seriesItem.data.toSorted((a, b) => {
const indexA = xValueToIndex.get(a.x) ?? 0;
const indexB = xValueToIndex.get(b.x) ?? 0;
return indexA - indexB;
}),
}));
};
@@ -1,114 +0,0 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { sortLineChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData';
import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FieldMetadataType } from '~/generated-metadata/graphql';
import { type LineChartConfiguration, GraphOrderBy } from '~/generated/graphql';
type SortTwoDimensionalLineChartDataConfiguration = {
series: LineChartSeries[];
configuration: LineChartConfiguration;
primaryAxisFormattedToRawLookup: Map<string, RawDimensionValue>;
primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFormattedToRawLookup?: Map<string, RawDimensionValue>;
secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFieldType?: FieldMetadataType;
secondaryAxisSubFieldName?: CompositeFieldSubFieldName;
};
type SortTwoDimensionalLineChartDataResult = {
sortedSeries: LineChartSeries[];
colorMode: GraphColorMode;
};
export const sortTwoDimensionalLineChartData = ({
series,
configuration: {
primaryAxisOrderBy,
primaryAxisManualSortOrder,
secondaryAxisOrderBy,
secondaryAxisManualSortOrder,
color,
},
primaryAxisFormattedToRawLookup,
primaryAxisSelectFieldOptions,
secondaryAxisFormattedToRawLookup,
secondaryAxisSelectFieldOptions,
secondaryAxisFieldType,
secondaryAxisSubFieldName,
}: SortTwoDimensionalLineChartDataConfiguration): SortTwoDimensionalLineChartDataResult => {
let sortedSeries = series;
if (isDefined(primaryAxisOrderBy)) {
if (
primaryAxisOrderBy === GraphOrderBy.VALUE_ASC ||
primaryAxisOrderBy === GraphOrderBy.VALUE_DESC
) {
sortedSeries = sortLineChartDataBySecondaryDimensionSum({
series,
orderBy: primaryAxisOrderBy,
});
} else {
sortedSeries = series.map((seriesItem) => {
const sortedDataPoints =
sortTwoDimensionalChartPrimaryAxisDataByFieldOrManuallyIfNeeded({
data: seriesItem.data,
orderBy: primaryAxisOrderBy,
manualSortOrder: primaryAxisManualSortOrder,
formattedToRawLookup: primaryAxisFormattedToRawLookup,
getFormattedValue: (dataPoint: LineChartDataPoint) =>
String(dataPoint.x),
selectFieldOptions: primaryAxisSelectFieldOptions,
});
return {
...seriesItem,
data: sortedDataPoints,
};
});
}
}
sortedSeries = sortSecondaryAxisData({
items: sortedSeries,
orderBy: secondaryAxisOrderBy,
manualSortOrder: secondaryAxisManualSortOrder,
formattedToRawLookup: secondaryAxisFormattedToRawLookup,
selectFieldOptions: secondaryAxisSelectFieldOptions,
getFormattedValue: (item) => item.id,
fieldType: secondaryAxisFieldType,
subFieldName: secondaryAxisSubFieldName,
});
sortedSeries = sortedSeries.map((seriesItem) => {
const rawValue = secondaryAxisFormattedToRawLookup?.get(seriesItem.id);
return {
...seriesItem,
color: determineChartItemColor({
configurationColor: parseGraphColor(color),
selectOptions: secondaryAxisSelectFieldOptions,
rawValue: isDefined(rawValue) ? String(rawValue) : seriesItem.id,
}),
};
});
const colorMode = determineGraphColorMode({
configurationColor: color,
selectFieldOptions: secondaryAxisSelectFieldOptions,
});
return {
sortedSeries,
colorMode,
};
};
@@ -1,239 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData';
import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData';
import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
import {
type FirstDayOfTheWeek,
isDefined,
isFieldMetadataDateKind,
} from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
AxisNameDisplay,
type LineChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToLineChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
configuration: LineChartConfiguration;
aggregateOperation: string;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformGroupByDataToLineChartDataResult = {
series: LineChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
showLegend: boolean;
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
const EMPTY_LINE_CHART_RESULT: Omit<
TransformGroupByDataToLineChartDataResult,
'xAxisLabel' | 'yAxisLabel'
> = {
series: [],
showDataLabels: false,
showLegend: true,
hasTooManyGroups: false,
formattedToRawLookup: new Map(),
colorMode: 'automaticPalette',
};
export const transformGroupByDataToLineChartData = ({
groupByData,
objectMetadataItem,
objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
firstDayOfTheWeek,
}: TransformGroupByDataToLineChartDataParams): TransformGroupByDataToLineChartDataResult => {
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
const queryResultGqlFieldName =
getGroupByQueryResultGqlFieldName(objectMetadataItem);
const rawResults = groupByData?.[queryResultGqlFieldName];
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel =
showXAxis && isDefined(groupByFieldX) ? groupByFieldX.label : undefined;
const yAxisLabel =
showYAxis && isDefined(aggregateField)
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
if (!isDefined(groupByData)) {
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const secondaryAxisSubFieldName =
configuration.secondaryAxisGroupBySubFieldName ?? undefined;
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return {
...EMPTY_LINE_CHART_RESULT,
xAxisLabel,
yAxisLabel,
};
}
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.isCumulative
? undefined
: (configuration.rangeMin ?? undefined),
rangeMax: configuration.isCumulative
? undefined
: (configuration.rangeMax ?? undefined),
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
const showDataLabels = configuration.displayDataLabel ?? false;
const showLegend = configuration.displayLegend ?? true;
const omitNullValues = configuration.omitNullValues ?? false;
const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT;
const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues;
const resultsWithSelectGaps = shouldApplySelectGapFill
? fillSelectGapsInChartData({
data: filteredResults,
selectOptions: groupByFieldX.options,
aggregateKeys: [aggregateField.name],
hasSecondDimension: isDefined(groupByFieldY),
})
: filteredResults;
const isDateField = isFieldMetadataDateKind(groupByFieldX.type);
const isNestedDateField = isRelationNestedFieldDateKind({
relationField: groupByFieldX,
relationNestedFieldName: primaryAxisSubFieldName,
objectMetadataItems,
});
const primaryAxisDateGranularity =
isDateField || isNestedDateField
? configuration.primaryAxisDateGranularity
: undefined;
const isSecondaryDateField = isDefined(groupByFieldY)
? isFieldMetadataDateKind(groupByFieldY.type)
: false;
const isSecondaryNestedDateField =
isDefined(groupByFieldY) &&
isRelationNestedFieldDateKind({
relationField: groupByFieldY,
relationNestedFieldName: secondaryAxisSubFieldName,
objectMetadataItems,
});
const secondaryAxisDateGranularity =
isSecondaryDateField || isSecondaryNestedDateField
? configuration.secondaryAxisGroupByDateGranularity
: undefined;
const sanitizedConfiguration: LineChartConfiguration = {
...configuration,
primaryAxisDateGranularity: primaryAxisDateGranularity ?? undefined,
secondaryAxisGroupByDateGranularity:
secondaryAxisDateGranularity ?? undefined,
};
const baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToLineChartData({
rawResults: resultsWithSelectGaps,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration: sanitizedConfiguration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
})
: transformOneDimensionalGroupByToLineChartData({
rawResults: resultsWithSelectGaps,
groupByFieldX,
aggregateField,
configuration: sanitizedConfiguration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
showLegend,
formattedToRawLookup: baseResult.formattedToRawLookup,
};
};
@@ -1,119 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartDataPoint } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartDataPoint';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults';
import { sortChartDataIfNeeded } from '@/page-layout/widgets/graph/utils/sortChartDataIfNeeded';
import {
isFieldMetadataSelectKind,
type FirstDayOfTheWeek,
} from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformOneDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
export const transformOneDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => {
const { processedDataPoints, formattedToRawLookup } =
processOneDimensionalGroupByResults({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
const unsortedData: LineChartDataPoint[] = processedDataPoints.map(
({ xValue, aggregateValue }) => ({
x: xValue,
y: aggregateValue,
}),
);
const sortedData = sortChartDataIfNeeded({
data: unsortedData,
orderBy: configuration.primaryAxisOrderBy,
manualSortOrder: configuration.primaryAxisManualSortOrder,
formattedToRawLookup,
getFieldValue: (point) => String(point.x),
getNumericValue: (point) => point.y ?? 0,
selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type)
? groupByFieldX.options
: undefined,
});
const limitedSortedData = sortedData.slice(
0,
LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS,
);
const transformedData = configuration.isCumulative
? applyCumulativeTransformToLineChartData({
data: limitedSortedData,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
: limitedSortedData;
const series: LineChartSeries[] = [
{
id: aggregateField.name,
label: aggregateField.label,
color: parseGraphColor(configuration.color) ?? GRAPH_DEFAULT_COLOR,
data: transformedData,
},
];
const colorMode = determineGraphColorMode({
configurationColor: configuration.color,
selectFieldOptions: isFieldMetadataSelectKind(groupByFieldX.type)
? groupByFieldX.options
: undefined,
});
return {
series,
hasTooManyGroups:
rawResults.length > LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS,
formattedToRawLookup,
colorMode,
};
};
@@ -1,106 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetLineChart/constants/LineChartConstants';
import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData';
import { buildTwoDimensionalLineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries';
import { limitTwoDimensionalLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData';
import { sortTwoDimensionalLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToLineChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: LineChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
type TransformTwoDimensionalGroupByToLineChartDataResult = {
series: LineChartSeries[];
hasTooManyGroups: boolean;
formattedToRawLookup: Map<string, RawDimensionValue>;
colorMode: GraphColorMode;
};
export const transformTwoDimensionalGroupByToLineChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => {
const { processedDataPoints, formattedToRawLookup, yFormattedToRawLookup } =
processTwoDimensionalGroupByResults({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
});
const { unsortedSeries } = buildTwoDimensionalLineChartSeries({
processedDataPoints,
color: parseGraphColor(configuration.color),
});
const { sortedSeries, colorMode } = sortTwoDimensionalLineChartData({
series: unsortedSeries,
configuration,
primaryAxisFormattedToRawLookup: formattedToRawLookup,
primaryAxisSelectFieldOptions: groupByFieldX.options,
secondaryAxisFormattedToRawLookup: yFormattedToRawLookup,
secondaryAxisSelectFieldOptions: groupByFieldY.options,
secondaryAxisFieldType: groupByFieldY.type,
secondaryAxisSubFieldName:
(configuration.secondaryAxisGroupBySubFieldName ?? undefined) as
| CompositeFieldSubFieldName
| undefined,
});
const { limitedSeries, hasTooManyGroups } = limitTwoDimensionalLineChartData({
sortedSeries,
isStacked:
configuration.isStacked ?? LINE_CHART_CONSTANTS.IS_STACKED_DEFAULT,
});
const finalSeries = configuration.isCumulative
? limitedSeries.map((seriesItem) => ({
...seriesItem,
data: applyCumulativeTransformToLineChartData({
data: seriesItem.data,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
}),
}))
: limitedSeries;
return {
series: finalSeries,
hasTooManyGroups,
formattedToRawLookup,
colorMode,
};
};
@@ -1,5 +1,5 @@
import { LEGEND_HIGHLIGHT_DIMMED_OPACITY } from '@/page-layout/widgets/graph/constants/LegendHighlightDimmedOpacity.constant';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { graphWidgetHighlightedLegendIdComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHighlightedLegendIdComponentState';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useArcsTransition } from '@nivo/arcs';
@@ -8,12 +8,12 @@ import { animated } from '@react-spring/web';
import { isDefined } from 'twenty-shared/utils';
type CustomArcsLayerProps = Pick<
PieCustomLayerProps<PieChartDataItem>,
PieCustomLayerProps<PieChartDataItemWithColor>,
'dataWithArc' | 'arcGenerator' | 'centerX' | 'centerY'
> & {
onMouseMove?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
onMouseLeave?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
onClick?: MouseEventHandler<PieChartDataItem, SVGPathElement>;
onMouseMove?: MouseEventHandler<PieChartDataItemWithColor, SVGPathElement>;
onMouseLeave?: MouseEventHandler<PieChartDataItemWithColor, SVGPathElement>;
onClick?: MouseEventHandler<PieChartDataItemWithColor, SVGPathElement>;
};
export const CustomArcsLayer = ({
@@ -1,7 +1,7 @@
import { GraphWidgetFloatingTooltip } from '@/page-layout/widgets/graph/components/GraphWidgetFloatingTooltip';
import { PIE_CHART_TOOLTIP_OFFSET_PX } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartTooltipOffsetPx';
import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { getPieChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData';
import { createVirtualElementFromContainerOffset } from '@/page-layout/widgets/graph/utils/createVirtualElementFromContainerOffset';
@@ -15,7 +15,7 @@ type GraphPieChartTooltipProps = {
enrichedData: PieChartEnrichedData[];
formatOptions: GraphValueFormatOptions;
displayType?: string;
onSliceClick?: (datum: PieChartDataItem) => void;
onSliceClick?: (datum: PieChartDataItemWithColor) => void;
};
export const GraphPieChartTooltip = ({
@@ -7,7 +7,7 @@ import { PIE_CHART_HOVER_BRIGHTNESS } from '@/page-layout/widgets/graph/graphWid
import { PIE_CHART_MARGINS } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMargins';
import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData';
import { graphWidgetPieTooltipComponentState } from '@/page-layout/widgets/graph/graphWidgetPieChart/states/graphWidgetPieTooltipComponentState';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { getPieChartFormattedValue } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { createGraphColorRegistry } from '@/page-layout/widgets/graph/utils/createGraphColorRegistry';
@@ -27,21 +27,24 @@ import {
type MouseEvent as ReactMouseEvent,
} from 'react';
import { isDefined } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
import {
type PieChartConfiguration,
type PieChartDataItem,
} from '~/generated/graphql';
type GraphWidgetPieChartProps = {
data: PieChartDataItem[];
data: PieChartDataItemWithColor[];
showLegend?: boolean;
id: string;
objectMetadataItemId: string;
configuration: PieChartConfiguration;
colorMode: GraphColorMode;
onSliceClick?: (datum: PieChartDataItem) => void;
onSliceClick?: (datum: PieChartDataItemWithColor) => void;
showDataLabels?: boolean;
showCenterMetric?: boolean;
} & GraphValueFormatOptions;
const emptyStateData: PieChartDataItem[] = [{ id: 'empty', value: 1 }];
const emptyStateData: PieChartDataItemWithColor[] = [{ id: 'empty', value: 1 }];
const StyledContainer = styled.div`
align-items: center;
@@ -107,7 +110,7 @@ export const GraphWidgetPieChart = ({
const handleSliceMove = useCallback(
(
datum: ComputedDatum<PieChartDataItem>,
datum: ComputedDatum<PieChartDataItemWithColor>,
event: ReactMouseEvent<SVGPathElement>,
) => {
if (!isDefined(containerRef.current)) return;
@@ -2,7 +2,7 @@ import { isPageLayoutInEditModeComponentState } from '@/page-layout/states/isPag
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 { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { assertPieChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertPieChartWidget';
import { buildChartDrilldownQueryParams } from '@/page-layout/widgets/graph/utils/buildChartDrilldownQueryParams';
import { isFilteredViewRedirectionSupported } from '@/page-layout/widgets/graph/utils/isFilteredViewRedirectionSupported';
@@ -66,7 +66,7 @@ export const GraphWidgetPieChartRenderer = () => {
const canRedirectToFilteredView =
isFilteredViewRedirectionSupported(groupByField);
const handleSliceClick = (datum: PieChartDataItem) => {
const handleSliceClick = (datum: PieChartDataItemWithColor) => {
const rawValue = formattedToRawLookup.get(datum.id) ?? null;
const drilldownQueryParams = buildChartDrilldownQueryParams({
@@ -1,7 +1,8 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColorRegistry } from '@/page-layout/widgets/graph/types/GraphColorRegistry';
import { renderHook } from '@testing-library/react';
import { usePieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/hooks/usePieChartData';
import { type PieChartDataItem } from '~/generated/graphql';
const mockUseRecoilComponentValue = jest.fn();
jest.mock(
@@ -56,7 +57,7 @@ describe('usePieChartData', () => {
},
};
const mockData: PieChartDataItem[] = [
const mockData: PieChartDataItemWithColor[] = [
{ id: 'item1', value: 30 },
{ id: 'item2', value: 50 },
{ id: 'item3', value: 20 },
@@ -1,15 +1,19 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from '@/page-layout/widgets/graph/constants/ExtraItemToDetectTooManyGroups';
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 { PIE_CHART_DATA } from '@/page-layout/widgets/graph/graphql/queries/pieChartData';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { determineChartItemColor } from '@/page-layout/widgets/graph/utils/determineChartItemColor';
import { determineGraphColorMode } from '@/page-layout/widgets/graph/utils/determineGraphColorMode';
import { extractPieChartDataConfiguration } from '@/page-layout/widgets/graph/utils/extractPieChartDataConfiguration';
import { parseGraphColor } from '@/page-layout/widgets/graph/utils/parseGraphColor';
import { useQuery } from '@apollo/client';
import { isString } from '@sniptt/guards';
import { useMemo } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
type UseGraphPieChartWidgetDataProps = {
@@ -18,7 +22,7 @@ type UseGraphPieChartWidgetDataProps = {
};
type UseGraphPieChartWidgetDataResult = {
data: PieChartDataItem[];
data: PieChartDataItemWithColor[];
showLegend: boolean;
loading: boolean;
error?: Error;
@@ -38,46 +42,76 @@ export const useGraphPieChartWidgetData = ({
objectId: objectMetadataItemId,
});
const apolloCoreClient = useApolloCoreClient();
const dataConfiguration = useMemo(
() => extractPieChartDataConfiguration(configuration),
[configuration],
);
const {
data: groupByData,
data: queryData,
loading,
error,
aggregateOperation,
} = useGraphWidgetGroupByQuery({
objectMetadataItemId,
configuration,
limit:
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS,
} = useQuery(PIE_CHART_DATA, {
client: apolloCoreClient,
variables: {
input: {
objectMetadataId: objectMetadataItemId,
configuration: dataConfiguration,
},
},
});
const { userTimezone } = useUserTimezone();
const { userFirstDayOfTheWeek } = useUserFirstDayOfTheWeek();
const formattedToRawLookup = queryData?.pieChartData?.formattedToRawLookup
? new Map(Object.entries(queryData.pieChartData.formattedToRawLookup))
: new Map();
const transformedData = useMemo(
() =>
transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
userTimezone,
firstDayOfTheWeek: userFirstDayOfTheWeek,
}),
[
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
userTimezone,
userFirstDayOfTheWeek,
],
const groupByField = objectMetadataItem?.fields?.find(
(field) => field.id === configuration.groupByFieldMetadataId,
);
const selectFieldOptions =
isDefined(groupByField) &&
(groupByField.type === FieldMetadataType.SELECT ||
groupByField.type === FieldMetadataType.MULTI_SELECT)
? groupByField.options
: null;
const configurationColor = parseGraphColor(configuration.color);
const colorMode = determineGraphColorMode({
configurationColor,
selectFieldOptions,
});
const chartData = queryData?.pieChartData?.data?.map(
(item: PieChartDataItemWithColor): PieChartDataItemWithColor => {
const rawValue = formattedToRawLookup.get(item.id);
const itemColor = determineChartItemColor({
configurationColor,
selectOptions: selectFieldOptions,
rawValue: isString(rawValue) ? rawValue : undefined,
});
return {
id: item.id,
value: item.value,
color: itemColor,
};
},
);
return {
...transformedData,
objectMetadataItem,
data: chartData,
showLegend: configuration.displayLegend ?? true,
showDataLabels: configuration.displayDataLabel ?? false,
showCenterMetric: configuration.showCenterMetric ?? true,
hasTooManyGroups: queryData?.pieChartData?.hasTooManyGroups ?? false,
colorMode,
formattedToRawLookup,
objectMetadataItem,
loading,
error,
};
@@ -1,5 +1,5 @@
import { type GraphWidgetLegendItem } from '@/page-layout/widgets/graph/components/GraphWidgetLegend';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage';
import { graphWidgetHiddenLegendIdsComponentState } from '@/page-layout/widgets/graph/states/graphWidgetHiddenLegendIdsComponentState';
@@ -10,7 +10,7 @@ import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/ho
import { useMemo } from 'react';
type UsePieChartDataProps = {
data: PieChartDataItem[];
data: PieChartDataItemWithColor[];
colorRegistry: GraphColorRegistry;
colorMode: GraphColorMode;
};
@@ -1,10 +1,10 @@
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { WidgetComponentInstanceContext } from '@/page-layout/widgets/states/contexts/WidgetComponentInstanceContext';
import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState';
import { type ComputedDatum } from '@nivo/pie';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
export const graphWidgetPieTooltipComponentState = createComponentState<{
datum: ComputedDatum<PieChartDataItem>;
datum: ComputedDatum<PieChartDataItemWithColor>;
offsetLeft: number;
offsetTop: number;
} | null>({
@@ -1,7 +1,6 @@
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type PieChartDataItem } from '~/generated/graphql';
export type PieChartDataItem = {
id: string;
value: number;
export type PieChartDataItemWithColor = PieChartDataItem & {
color?: GraphColor;
};
@@ -1,7 +1,7 @@
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
export type PieChartEnrichedData = PieChartDataItem & {
export type PieChartEnrichedData = PieChartDataItemWithColor & {
colorScheme: GraphColorScheme;
percentage: number;
};
@@ -0,0 +1,61 @@
import { calculatePieChartPercentage } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/calculatePieChartPercentage';
describe('calculatePieChartPercentage', () => {
describe('valid calculations', () => {
it('should calculate correct percentage for positive values', () => {
expect(calculatePieChartPercentage(25, 100)).toBe(25);
});
it('should calculate 100% when value equals total', () => {
expect(calculatePieChartPercentage(100, 100)).toBe(100);
});
it('should calculate percentage for decimal values', () => {
expect(calculatePieChartPercentage(1, 3)).toBeCloseTo(33.33, 1);
});
it('should handle small fractions', () => {
expect(calculatePieChartPercentage(1, 1000)).toBe(0.1);
});
it('should handle large values', () => {
expect(calculatePieChartPercentage(500000, 1000000)).toBe(50);
});
});
describe('edge cases', () => {
it('should return 0 when totalValue is 0', () => {
expect(calculatePieChartPercentage(50, 0)).toBe(0);
});
it('should return 0 when value is 0', () => {
expect(calculatePieChartPercentage(0, 100)).toBe(0);
});
it('should return 0 when both values are 0', () => {
expect(calculatePieChartPercentage(0, 0)).toBe(0);
});
it('should handle negative values', () => {
expect(calculatePieChartPercentage(-25, 100)).toBe(-25);
});
it('should return 0 when totalValue is negative', () => {
expect(calculatePieChartPercentage(25, -100)).toBe(0);
});
});
describe('NaN handling', () => {
it('should return NaN when value is NaN', () => {
expect(calculatePieChartPercentage(NaN, 100)).toBeNaN();
});
it('should return NaN when totalValue is NaN', () => {
expect(calculatePieChartPercentage(50, NaN)).toBeNaN();
});
it('should return NaN when both values are NaN', () => {
expect(calculatePieChartPercentage(NaN, NaN)).toBeNaN();
});
});
});
@@ -0,0 +1,206 @@
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { getPieChartFormattedValue } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartFormattedValue';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { type ComputedDatum } from '@nivo/pie';
describe('getPieChartFormattedValue', () => {
const mockColorScheme: GraphColorScheme = {
name: 'blue',
solid: '#solidBlue',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
const mockEnrichedData: PieChartEnrichedData[] = [
{
id: 'slice1',
value: 30,
percentage: 30,
colorScheme: mockColorScheme,
},
{
id: 'slice2',
value: 50,
percentage: 50,
colorScheme: mockColorScheme,
},
{
id: 'slice3',
value: 20,
percentage: 20,
colorScheme: mockColorScheme,
},
];
const createMockDatum = (
id: string,
): ComputedDatum<PieChartDataItemWithColor> =>
({
id,
value: 0,
}) as unknown as ComputedDatum<PieChartDataItemWithColor>;
const defaultFormatOptions = {
displayType: 'number' as const,
};
describe('matching datum to enriched data', () => {
it('should return formatted value when datum matches enriched data', () => {
const datum = createMockDatum('slice1');
const result = getPieChartFormattedValue({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result).not.toBeNull();
});
it('should return null when datum does not match any enriched data', () => {
const datum = createMockDatum('nonexistent');
const result = getPieChartFormattedValue({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result).toBeNull();
});
});
describe('percentage display type', () => {
it('should format as percentage when displayType is percentage', () => {
const datum = createMockDatum('slice1');
const result = getPieChartFormattedValue({
datum,
enrichedData: mockEnrichedData,
formatOptions: { displayType: 'percentage' },
displayType: 'percentage',
});
expect(result).toContain('%');
});
it('should use the item percentage for percentage display', () => {
const datum = createMockDatum('slice2');
const result = getPieChartFormattedValue({
datum,
enrichedData: mockEnrichedData,
formatOptions: { displayType: 'percentage' },
displayType: 'percentage',
});
expect(result).toBeDefined();
expect(result).toContain('%');
});
});
describe('number display type', () => {
it('should include both value and percentage when not percentage display', () => {
const datum = createMockDatum('slice1');
const result = getPieChartFormattedValue({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result).toContain('30');
expect(result).toContain('%');
});
it('should format percentage to one decimal place', () => {
const enrichedDataWithDecimal: PieChartEnrichedData[] = [
{
id: 'slice1',
value: 33,
percentage: 33.333,
colorScheme: mockColorScheme,
},
];
const datum = createMockDatum('slice1');
const result = getPieChartFormattedValue({
datum,
enrichedData: enrichedDataWithDecimal,
formatOptions: defaultFormatOptions,
});
expect(result).toContain('33.3%');
});
});
describe('edge cases', () => {
it('should handle zero value', () => {
const enrichedDataWithZero: PieChartEnrichedData[] = [
{
id: 'zero',
value: 0,
percentage: 0,
colorScheme: mockColorScheme,
},
];
const datum = createMockDatum('zero');
const result = getPieChartFormattedValue({
datum,
enrichedData: enrichedDataWithZero,
formatOptions: defaultFormatOptions,
});
expect(result).toContain('0');
});
it('should handle 100% value', () => {
const enrichedDataWith100: PieChartEnrichedData[] = [
{
id: 'full',
value: 100,
percentage: 100,
colorScheme: mockColorScheme,
},
];
const datum = createMockDatum('full');
const result = getPieChartFormattedValue({
datum,
enrichedData: enrichedDataWith100,
formatOptions: defaultFormatOptions,
});
expect(result).toContain('100');
});
it('should return null when enrichedData is empty', () => {
const datum = createMockDatum('slice1');
const result = getPieChartFormattedValue({
datum,
enrichedData: [],
formatOptions: defaultFormatOptions,
});
expect(result).toBeNull();
});
});
});
@@ -0,0 +1,208 @@
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { getPieChartTooltipData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/getPieChartTooltipData';
import { type GraphColorScheme } from '@/page-layout/widgets/graph/types/GraphColorScheme';
import { type ComputedDatum } from '@nivo/pie';
describe('getPieChartTooltipData', () => {
const mockColorScheme: GraphColorScheme = {
name: 'blue',
solid: '#solid',
variations: [
'#v0',
'#v1',
'#v2',
'#v3',
'#v4',
'#v5',
'#v6',
'#v7',
'#v8',
'#v9',
'#v10',
'#v11',
],
};
const mockEnrichedData: PieChartEnrichedData[] = [
{
id: 'Product A',
value: 500,
percentage: 50,
colorScheme: mockColorScheme,
},
{
id: 'Product B',
value: 300,
percentage: 30,
colorScheme: { ...mockColorScheme, solid: '#solidB' },
},
{
id: 'Product C',
value: 200,
percentage: 20,
colorScheme: { ...mockColorScheme, solid: '#solidC' },
},
];
const createMockDatum = (
id: string,
): ComputedDatum<PieChartDataItemWithColor> =>
({
id,
value: 0,
}) as unknown as ComputedDatum<PieChartDataItemWithColor>;
const defaultFormatOptions = {
displayType: 'number' as const,
};
describe('tooltip item generation', () => {
it('should return tooltip data when datum matches enriched data', () => {
const datum = createMockDatum('Product A');
const result = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result).not.toBeNull();
expect(result?.tooltipItem).toBeDefined();
});
it('should include correct tooltip item properties', () => {
const datum = createMockDatum('Product A');
const result = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result?.tooltipItem.key).toBe('Product A');
expect(result?.tooltipItem.label).toBe('Product A');
expect(result?.tooltipItem.value).toBe(500);
expect(result?.tooltipItem.dotColor).toBe('#solid');
});
it('should use the correct color for different items', () => {
const datumB = createMockDatum('Product B');
const result = getPieChartTooltipData({
datum: datumB,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result?.tooltipItem.dotColor).toBe('#solidB');
});
});
describe('formatted value', () => {
it('should include formatted value in tooltip item', () => {
const datum = createMockDatum('Product A');
const result = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result?.tooltipItem.formattedValue).toBeDefined();
expect(typeof result?.tooltipItem.formattedValue).toBe('string');
});
it('should format value based on displayType', () => {
const datum = createMockDatum('Product A');
const resultNumber = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: { displayType: 'number' },
});
const resultPercentage = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: { displayType: 'percentage' },
displayType: 'percentage',
});
expect(resultNumber?.tooltipItem.formattedValue).not.toBe(
resultPercentage?.tooltipItem.formattedValue,
);
});
});
describe('null returns', () => {
it('should return null when datum does not match any enriched data', () => {
const datum = createMockDatum('Unknown Product');
const result = getPieChartTooltipData({
datum,
enrichedData: mockEnrichedData,
formatOptions: defaultFormatOptions,
});
expect(result).toBeNull();
});
it('should return null when enrichedData is empty', () => {
const datum = createMockDatum('Product A');
const result = getPieChartTooltipData({
datum,
enrichedData: [],
formatOptions: defaultFormatOptions,
});
expect(result).toBeNull();
});
});
describe('edge cases', () => {
it('should handle item with zero value', () => {
const enrichedDataWithZero: PieChartEnrichedData[] = [
{
id: 'Zero Item',
value: 0,
percentage: 0,
colorScheme: mockColorScheme,
},
];
const datum = createMockDatum('Zero Item');
const result = getPieChartTooltipData({
datum,
enrichedData: enrichedDataWithZero,
formatOptions: defaultFormatOptions,
});
expect(result?.tooltipItem.value).toBe(0);
});
it('should handle special characters in id', () => {
const enrichedDataWithSpecialChars: PieChartEnrichedData[] = [
{
id: 'Item & Special <chars>',
value: 100,
percentage: 100,
colorScheme: mockColorScheme,
},
];
const datum = createMockDatum('Item & Special <chars>');
const result = getPieChartTooltipData({
datum,
enrichedData: enrichedDataWithSpecialChars,
formatOptions: defaultFormatOptions,
});
expect(result?.tooltipItem.key).toBe('Item & Special <chars>');
expect(result?.tooltipItem.label).toBe('Item & Special <chars>');
});
});
});
@@ -1,160 +0,0 @@
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
import { transformGroupByDataToPieChartData } from '@/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import {
AggregateOperations,
FieldMetadataType,
} from '~/generated-metadata/graphql';
import {
WidgetConfigurationType,
type PieChartConfiguration,
} from '~/generated/graphql';
jest.mock(
'@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues',
() => ({
formatPrimaryDimensionValues: jest.fn(),
}),
);
const { formatPrimaryDimensionValues } = jest.requireMock(
'@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues',
) as { formatPrimaryDimensionValues: jest.Mock };
describe('transformGroupByDataToPieChartData', () => {
const userTimezone = 'Europe/Paris';
it('keeps null group buckets aligned with their aggregate values', () => {
const groupByField = {
id: 'group-by-field',
name: 'status',
type: FieldMetadataType.TEXT,
label: 'Status',
};
const aggregateField = {
id: 'aggregate-field',
name: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
const configuration = {
__typename: 'PieChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: AggregateOperations.COUNT,
configurationType: WidgetConfigurationType.PIE_CHART,
groupByFieldMetadataId: groupByField.id,
displayLegend: true,
} as PieChartConfiguration;
const groupByData = {
companiesGroupBy: [
{ groupByDimensionValues: [null], COUNT: 2 },
{ groupByDimensionValues: ['Active'], COUNT: 5 },
],
};
formatPrimaryDimensionValues.mockReturnValue([
{
formattedPrimaryDimensionValue: 'Not Set',
rawPrimaryDimensionValue: null,
},
{
formattedPrimaryDimensionValue: 'Active',
rawPrimaryDimensionValue: 'Active',
},
]);
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation: 'COUNT',
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.data).toEqual([
{ id: 'Not Set', value: 2, color: undefined },
{ id: 'Active', value: 5, color: undefined },
]);
expect(result.formattedToRawLookup.get('Not Set')).toBeNull();
expect(result.formattedToRawLookup.get('Active')).toBe('Active');
expect(result.hasTooManyGroups).toBe(false);
});
it('honors color configuration, hides legend, and flags too many groups', () => {
const groupByField = {
id: 'group-by-field',
name: 'status',
type: FieldMetadataType.TEXT,
label: 'Status',
};
const aggregateField = {
id: 'aggregate-field',
name: 'id',
type: FieldMetadataType.UUID,
label: 'Id',
};
const objectMetadataItem = {
id: 'obj-1',
nameSingular: 'company',
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
const configuration = {
__typename: 'PieChartConfiguration',
aggregateFieldMetadataId: aggregateField.id,
aggregateOperation: AggregateOperations.COUNT,
configurationType: WidgetConfigurationType.PIE_CHART,
groupByFieldMetadataId: groupByField.id,
displayLegend: false,
color: 'red',
} as PieChartConfiguration;
const totalResults = PIE_CHART_MAXIMUM_NUMBER_OF_SLICES + 1;
const groupByData = {
companiesGroupBy: Array.from({ length: totalResults }).map(
(_unused, index) => ({
groupByDimensionValues: [`Group ${index}`],
COUNT: index + 1,
}),
),
};
formatPrimaryDimensionValues.mockReturnValue(
Array.from({ length: totalResults }).map((_unused, index) => ({
formattedPrimaryDimensionValue: `Group ${index}`,
rawPrimaryDimensionValue: `Group ${index}`,
})),
);
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation: 'COUNT',
userTimezone,
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
expect(result.showLegend).toBe(false);
expect(result.hasTooManyGroups).toBe(true);
expect(result.data).toHaveLength(PIE_CHART_MAXIMUM_NUMBER_OF_SLICES);
expect(result.data[0]).toEqual({
id: 'Group 0',
value: 1,
color: 'red',
});
});
});
@@ -5,11 +5,11 @@ import {
import { type ComputedDatum } from '@nivo/pie';
import { isDefined } from 'twenty-shared/utils';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
type GetPieChartFormattedValueParams = {
datum: ComputedDatum<PieChartDataItem>;
datum: ComputedDatum<PieChartDataItemWithColor>;
enrichedData: PieChartEnrichedData[];
formatOptions: GraphValueFormatOptions;
displayType?: string;
@@ -1,5 +1,5 @@
import { type GraphWidgetTooltipItem } from '@/page-layout/widgets/graph/components/GraphWidgetTooltip';
import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartDataItemWithColor } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartDataItem';
import { type PieChartEnrichedData } from '@/page-layout/widgets/graph/graphWidgetPieChart/types/PieChartEnrichedData';
import { type GraphValueFormatOptions } from '@/page-layout/widgets/graph/utils/graphFormatters';
import { type ComputedDatum } from '@nivo/pie';
@@ -8,7 +8,7 @@ import { isDefined } from 'twenty-shared/utils';
import { getPieChartFormattedValue } from './getPieChartFormattedValue';
type GetPieChartTooltipDataParams = {
datum: ComputedDatum<PieChartDataItem>;
datum: ComputedDatum<PieChartDataItemWithColor>;
enrichedData: PieChartEnrichedData[];
formatOptions: GraphValueFormatOptions;
displayType?: string;

Some files were not shown because too many files have changed in this diff Show More