From c531cfe0a42f0fd8dbe12b9c8df268bb80cad4a1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?=
<71827178+bosiraphael@users.noreply.github.com>
Date: Tue, 6 Jan 2026 11:52:54 +0100
Subject: [PATCH] [DASHBOARDS] Manual and position-based sorting for chart
widgets (#16794)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Description
SELECT fields have a defined option order that users expect to see
reflected in charts.
This PR allows sorting by that position and also enables custom manual
ordering.
## Video QA
### Reordering on primary axis
https://github.com/user-attachments/assets/994f515e-19cb-4a5e-b745-e8c77e92ae0b
### Reordering on secondary axis
https://github.com/user-attachments/assets/444c16f2-1920-4dc4-8b42-312d520ab43b
Note: The colors in the graph will match the colors of the select
options, but this will be done in another PR
---
> [!NOTE]
> Introduces new sort modes and UI for chart groupings, with full FE/BE
support and updated GraphQL schema.
>
> - Extend `GraphOrderBy` with `FIELD_POSITION_ASC/DESC` and `MANUAL`;
add corresponding fields in configs: `primaryAxisManualSortOrder`,
`secondaryAxisManualSortOrder`, and `manualSortOrder` (pie)
> - New UI: dropdown options filtered by field type, icons, and a
draggable submenu (`ChartManualSortSubMenuContent`) to reorder select
options; integrates with widget edit flow
> - Sorting logic added/refactored: `sortChartData`,
`sortByManualOrder`, `sortBySelectOptionPosition`,
`sortLineChartSeries`, plus updates to bar/line/pie transformers to
honor new modes and manual orders
> - Default behaviors: select fields default to `FIELD_POSITION_ASC`;
query variable builders skip `orderBy` when using manual/position sorts
> - Update GraphQL generated types/fragments/queries and backend
DTOs/schemas to persist new fields; add tests for sorting utilities and
snapshots; add sorting icons in `twenty-ui`
>
> Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
78c9b56c0f1f2d45f7f8b270bb59ca599a005abe. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).
---------
Co-authored-by: Félix Malfait
Co-authored-by: Lucas Bordeau
---
.../src/generated-metadata/graphql.ts | 8 +
.../twenty-front/src/generated/graphql.ts | 17 +-
.../ChartManualSortSubMenuContent.tsx | 123 +++++++++
...ChartSortByGroupByFieldDropdownContent.tsx | 119 +++++++--
.../ChartSortBySelectionDropdownContent.tsx | 189 +++++++------
.../constants/AggregateSortByOptions.ts | 22 +-
.../page-layout/constants/XSortByOptions.ts | 29 +-
.../hooks/useGraphGroupBySortOptionLabels.ts | 19 +-
.../hooks/useGraphXSortOptionLabels.ts | 21 +-
...buildChartGroupByFieldConfigUpdate.test.ts | 20 +-
.../filterSortOptionsByFieldType.test.ts | 65 +++++
...getChartDefaultOrderByForFieldType.test.ts | 36 +++
.../getDefaultManualSortOrder.test.ts | 29 ++
.../getManualSortOrderFromConfig.test.ts | 112 ++++++++
.../__tests__/getSortIconForFieldType.test.ts | 208 +++++++++++++++
.../getSortLabelSuffixForFieldType.test.ts | 32 +++
.../buildChartGroupByFieldConfigUpdate.ts | 53 +---
.../utils/filterSortOptionsByFieldType.ts | 50 ++++
.../getChartDefaultOrderByForFieldType.ts | 12 +
.../utils/getDefaultManualSortOrder.ts | 16 ++
.../utils/getManualSortOrderFromConfig.ts | 29 ++
.../utils/getSortIconForFieldType.ts | 57 ++++
.../utils/getSortLabelSuffixForFieldType.ts | 54 ++++
.../fragments/pageLayoutWidgetFragment.ts | 5 +
...lativeTransformToBarChartData.test.ts.snap | 0
...mToTwoDimensionalBarChartData.test.ts.snap | 0
...yCumulativeTransformToBarChartData.test.ts | 2 +-
...nsformToTwoDimensionalBarChartData.test.ts | 2 +-
.../applyCumulativeTransformToBarChartData.ts | 0
...veTransformToTwoDimensionalBarChartData.ts | 4 +-
.../utils/buildTwoDimensionalBarChartData.ts | 38 +++
.../utils/limitTwoDimensionalBarChartData.ts | 80 ++++++
...sortBarChartDataBySecondaryDimensionSum.ts | 2 +
.../utils/sortTwoDimensionalBarChartData.ts | 89 +++++++
...formOneDimensionalGroupByToBarChartData.ts | 96 +++----
...formTwoDimensionalGroupByToBarChartData.ts | 175 ++++--------
.../hooks/useGraphLineChartWidgetData.ts | 2 +-
...ativeTransformToLineChartData.test.ts.snap | 0
...CumulativeTransformToLineChartData.test.ts | 2 +-
...eDimensionalGroupByToLineChartData.test.ts | 5 +-
...oDimensionalGroupByToLineChartData.test.ts | 8 +-
...applyCumulativeTransformToLineChartData.ts | 0
.../buildTwoDimensionalLineChartSeries.ts | 57 ++++
.../utils/limitTwoDimensionalLineChartData.ts | 59 ++++
...ortLineChartDataBySecondaryDimensionSum.ts | 56 ++++
.../utils/sortTwoDimensionalLineChartData.ts | 81 ++++++
.../transformGroupByDataToLineChartData.ts | 4 +-
...ormOneDimensionalGroupByToLineChartData.ts | 102 +++----
...ormTwoDimensionalGroupByToLineChartData.ts | 97 +++++++
.../hooks/useGraphPieChartWidgetData.ts | 4 -
...transformGroupByDataToPieChartData.test.ts | 4 -
.../transformGroupByDataToPieChartData.ts | 94 +++----
.../utils/__tests__/sortByManualOrder.test.ts | 146 ++++++++++
.../sortBySelectOptionPosition.test.ts | 203 ++++++++++++++
.../utils/__tests__/sortChartData.test.ts | 251 ++++++++++++++++++
.../sortOptionsForManualOrder.test.ts | 94 +++++++
.../__tests__/sortSecondaryAxisData.test.ts | 43 +++
...rtPrimaryAxisDataByFieldOrManually.test.ts | 52 ++++
...ariablesFromBarOrLineChartConfiguration.ts | 57 ++--
...QueryVariablesFromPieChartConfiguration.ts | 24 +-
.../widgets/graph/utils/getGroupByOrderBy.ts | 7 +-
.../widgets/graph/utils/getSortedKeys.ts | 18 --
.../graph/utils/mapOrderByToDirection.ts | 7 +-
.../processOneDimensionalGroupByResults.ts | 112 ++++++++
...=> processTwoDimensionalGroupByResults.ts} | 139 ++++------
.../widgets/graph/utils/sortByManualOrder.ts | 43 +++
.../graph/utils/sortBySelectOptionPosition.ts | 43 +++
.../widgets/graph/utils/sortChartData.ts | 87 ++++++
.../graph/utils/sortLineChartSeries.ts | 19 --
.../graph/utils/sortOptionsForManualOrder.ts | 22 ++
.../graph/utils/sortSecondaryAxisData.ts | 77 ++++++
...alChartPrimaryAxisDataByFieldOrManually.ts | 82 ++++++
.../dtos/bar-chart-configuration.dto.ts | 13 +
.../dtos/line-chart-configuration.dto.ts | 13 +
.../dtos/pie-chart-configuration.dto.ts | 7 +
.../enums/graph-order-by.enum.ts | 3 +
.../dashboard/tools/schemas/widget.schema.ts | 205 ++++++++++----
...idget-configuration-gql-fields.constant.ts | 5 +
...-with-tabs-update.integration-spec.ts.snap | 2 +
.../isFieldMetadataNumericKind.test.ts | 21 ++
.../isFieldMetadataSelectKind.test.ts | 18 ++
.../__tests__/isFieldMetadataTextKind.test.ts | 19 ++
.../src/utils/fieldMetadata/index.ts | 4 +
.../isFieldMetadataNumericKind.ts | 15 ++
.../isFieldMetadataSelectKind.ts | 10 +
.../fieldMetadata/isFieldMetadataTextKind.ts | 13 +
packages/twenty-shared/src/utils/index.ts | 3 +
.../display/icon/components/TablerIcons.ts | 5 +
packages/twenty-ui/src/display/index.ts | 5 +
89 files changed, 3592 insertions(+), 683 deletions(-)
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/filterSortOptionsByFieldType.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getChartDefaultOrderByForFieldType.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getDefaultManualSortOrder.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getManualSortOrderFromConfig.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortIconForFieldType.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortLabelSuffixForFieldType.test.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDefaultManualSortOrder.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortIconForFieldType.ts
create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType.ts
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap (100%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap (100%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts (94%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts (92%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/applyCumulativeTransformToBarChartData.ts (100%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetBarChart}/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts (96%)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap (100%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts (92%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts (97%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts (97%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/applyCumulativeTransformToLineChartData.ts (100%)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/transformGroupByDataToLineChartData.ts (97%)
rename packages/twenty-front/src/modules/page-layout/widgets/graph/{ => graphWidgetLineChart}/utils/transformOneDimensionalGroupByToLineChartData.ts (51%)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartData.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts
delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getSortedKeys.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts
rename packages/twenty-front/src/modules/page-layout/widgets/graph/utils/{transformTwoDimensionalGroupByToLineChartData.ts => processTwoDimensionalGroupByResults.ts} (50%)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartData.ts
delete mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataNumericKind.test.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSelectKind.test.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataTextKind.test.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataNumericKind.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSelectKind.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataTextKind.ts
diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts
index b3ceb1b5ba..32d87ca0b0 100644
--- a/packages/twenty-front/src/generated-metadata/graphql.ts
+++ b/packages/twenty-front/src/generated-metadata/graphql.ts
@@ -362,12 +362,14 @@ export type BarChartConfiguration = {
primaryAxisDateGranularity?: Maybe;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe;
+ primaryAxisManualSortOrder?: Maybe>;
primaryAxisOrderBy?: Maybe;
rangeMax?: Maybe;
rangeMin?: Maybe;
secondaryAxisGroupByDateGranularity?: Maybe;
secondaryAxisGroupByFieldMetadataId?: Maybe;
secondaryAxisGroupBySubFieldName?: Maybe;
+ secondaryAxisManualSortOrder?: Maybe>;
secondaryAxisOrderBy?: Maybe;
timezone?: Maybe;
};
@@ -1559,6 +1561,9 @@ export type GetWebhookInput = {
export enum GraphOrderBy {
FIELD_ASC = 'FIELD_ASC',
FIELD_DESC = 'FIELD_DESC',
+ FIELD_POSITION_ASC = 'FIELD_POSITION_ASC',
+ FIELD_POSITION_DESC = 'FIELD_POSITION_DESC',
+ MANUAL = 'MANUAL',
VALUE_ASC = 'VALUE_ASC',
VALUE_DESC = 'VALUE_DESC'
}
@@ -1767,12 +1772,14 @@ export type LineChartConfiguration = {
primaryAxisDateGranularity?: Maybe;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe;
+ primaryAxisManualSortOrder?: Maybe>;
primaryAxisOrderBy?: Maybe;
rangeMax?: Maybe;
rangeMin?: Maybe;
secondaryAxisGroupByDateGranularity?: Maybe;
secondaryAxisGroupByFieldMetadataId?: Maybe;
secondaryAxisGroupBySubFieldName?: Maybe;
+ secondaryAxisManualSortOrder?: Maybe>;
secondaryAxisOrderBy?: Maybe;
timezone?: Maybe;
};
@@ -3288,6 +3295,7 @@ export type PieChartConfiguration = {
groupByFieldMetadataId: Scalars['UUID'];
groupBySubFieldName?: Maybe;
hideEmptyCategory?: Maybe;
+ manualSortOrder?: Maybe>;
orderBy?: Maybe;
showCenterMetric?: Maybe;
timezone?: Maybe;
diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts
index 2493da8c29..c7311e252e 100644
--- a/packages/twenty-front/src/generated/graphql.ts
+++ b/packages/twenty-front/src/generated/graphql.ts
@@ -362,12 +362,14 @@ export type BarChartConfiguration = {
primaryAxisDateGranularity?: Maybe;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe;
+ primaryAxisManualSortOrder?: Maybe>;
primaryAxisOrderBy?: Maybe;
rangeMax?: Maybe;
rangeMin?: Maybe;
secondaryAxisGroupByDateGranularity?: Maybe;
secondaryAxisGroupByFieldMetadataId?: Maybe;
secondaryAxisGroupBySubFieldName?: Maybe;
+ secondaryAxisManualSortOrder?: Maybe>;
secondaryAxisOrderBy?: Maybe;
timezone?: Maybe;
};
@@ -1527,6 +1529,9 @@ export type GetWebhookInput = {
export enum GraphOrderBy {
FIELD_ASC = 'FIELD_ASC',
FIELD_DESC = 'FIELD_DESC',
+ FIELD_POSITION_ASC = 'FIELD_POSITION_ASC',
+ FIELD_POSITION_DESC = 'FIELD_POSITION_DESC',
+ MANUAL = 'MANUAL',
VALUE_ASC = 'VALUE_ASC',
VALUE_DESC = 'VALUE_DESC'
}
@@ -1735,12 +1740,14 @@ export type LineChartConfiguration = {
primaryAxisDateGranularity?: Maybe;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe;
+ primaryAxisManualSortOrder?: Maybe>;
primaryAxisOrderBy?: Maybe;
rangeMax?: Maybe;
rangeMin?: Maybe;
secondaryAxisGroupByDateGranularity?: Maybe;
secondaryAxisGroupByFieldMetadataId?: Maybe;
secondaryAxisGroupBySubFieldName?: Maybe;
+ secondaryAxisManualSortOrder?: Maybe>;
secondaryAxisOrderBy?: Maybe;
timezone?: Maybe;
};
@@ -3171,6 +3178,7 @@ export type PieChartConfiguration = {
groupByFieldMetadataId: Scalars['UUID'];
groupBySubFieldName?: Maybe;
hideEmptyCategory?: Maybe;
+ manualSortOrder?: Maybe>;
orderBy?: Maybe;
showCenterMetric?: Maybe;
timezone?: Maybe;
@@ -5082,7 +5090,7 @@ export type SearchQueryVariables = Exact<{
export type SearchQuery = { __typename?: 'Query', search: { __typename?: 'SearchResultConnection', edges: Array<{ __typename?: 'SearchResultEdge', cursor: string, node: { __typename?: 'SearchRecord', recordId: any, objectNameSingular: string, label: string, imageUrl?: string | null, tsRankCD: number, tsRank: number } }>, pageInfo: { __typename?: 'SearchResultPageInfo', hasNextPage: boolean, endCursor?: string | null } } };
-export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } };
+export type PageLayoutWidgetFragmentFragment = { __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } };
export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
id: Scalars['String'];
@@ -5090,7 +5098,7 @@ export type UpdatePageLayoutWithTabsAndWidgetsMutationVariables = Exact<{
}>;
-export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } }> | null }> | null } };
+export type UpdatePageLayoutWithTabsAndWidgetsMutation = { __typename?: 'Mutation', updatePageLayoutWithTabsAndWidgets: { __typename?: 'PageLayout', id: any, name: string, type: PageLayoutType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, tabs?: Array<{ __typename?: 'PageLayoutTab', id: any, title: string, position: number, pageLayoutId: any, createdAt: string, updatedAt: string, widgets?: Array<{ __typename?: 'PageLayoutWidget', id: any, title: string, type: WidgetType, objectMetadataId?: any | null, createdAt: string, updatedAt: string, deletedAt?: string | null, pageLayoutTabId: any, gridPosition: { __typename?: 'GridPosition', column: number, columnSpan: number, row: number, rowSpan: number }, configuration: { __typename?: 'AggregateChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, label?: string | null, displayDataLabel?: boolean | null, format?: string | null, description?: string | null, filter?: any | null, prefix?: string | null, suffix?: string | null, timezone?: string | null, firstDayOfTheWeek?: number | null, ratioAggregateConfig?: { __typename?: 'RatioAggregateConfig', fieldMetadataId: any, optionValue: string } | null } | { __typename?: 'BarChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, groupMode?: BarChartGroupMode | null, layout: BarChartLayout, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'GaugeChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, displayDataLabel?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'IframeConfiguration', configurationType: WidgetConfigurationType, url?: string | null } | { __typename?: 'LineChartConfiguration', configurationType: WidgetConfigurationType, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, primaryAxisGroupByFieldMetadataId: any, primaryAxisGroupBySubFieldName?: string | null, primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null, primaryAxisOrderBy?: GraphOrderBy | null, primaryAxisManualSortOrder?: Array | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array | null, omitNullValues?: boolean | null, axisNameDisplay?: AxisNameDisplay | null, displayDataLabel?: boolean | null, displayLegend?: boolean | null, rangeMin?: number | null, rangeMax?: number | null, color?: string | null, description?: string | null, filter?: any | null, isStacked?: boolean | null, isCumulative?: boolean | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'PieChartConfiguration', configurationType: WidgetConfigurationType, groupByFieldMetadataId: any, aggregateFieldMetadataId: any, aggregateOperation: AggregateOperations, groupBySubFieldName?: string | null, dateGranularity?: ObjectRecordGroupByDateGranularity | null, orderBy?: GraphOrderBy | null, manualSortOrder?: Array | null, displayDataLabel?: boolean | null, showCenterMetric?: boolean | null, displayLegend?: boolean | null, color?: string | null, description?: string | null, filter?: any | null, timezone?: string | null, firstDayOfTheWeek?: number | null } | { __typename?: 'StandaloneRichTextConfiguration', configurationType: WidgetConfigurationType, body: { __typename?: 'RichTextV2Body', blocknote?: string | null, markdown?: string | null } } }> | null }> | null } };
export type OnDbEventSubscriptionVariables = Exact<{
input: OnDbEventInput;
@@ -5416,10 +5424,12 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -5443,10 +5453,12 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -5469,6 +5481,7 @@ export const PageLayoutWidgetFragmentFragmentDoc = gql`
groupBySubFieldName
dateGranularity
orderBy
+ manualSortOrder
displayDataLabel
showCenterMetric
displayLegend
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx
new file mode 100644
index 0000000000..913347c442
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent.tsx
@@ -0,0 +1,123 @@
+import { type DropResult } from '@hello-pangea/dnd';
+
+import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
+import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
+import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
+
+import { getManualSortOrderFromConfig } from '@/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig';
+import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { sortOptionsForManualOrder } from '@/page-layout/widgets/graph/utils/sortOptionsForManualOrder';
+import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem';
+import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList';
+import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
+import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { t } from '@lingui/core/macro';
+import { isDefined } from 'twenty-shared/utils';
+import { Tag } from 'twenty-ui/components';
+import { IconChevronLeft } from 'twenty-ui/display';
+import { MenuItemDraggable } from 'twenty-ui/navigation';
+import { type WidgetConfiguration } from '~/generated/graphql';
+import { moveArrayItem } from '~/utils/array/moveArrayItem';
+
+type ChartManualSortSubMenuContentProps = {
+ fieldMetadataItem: FieldMetadataItem;
+ axis: 'primary' | 'secondary';
+ onBack: () => void;
+};
+
+export const ChartManualSortSubMenuContent = ({
+ fieldMetadataItem,
+ axis,
+ onBack,
+}: ChartManualSortSubMenuContentProps) => {
+ const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
+ const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
+ const { updateCurrentWidgetConfig } =
+ useUpdateCurrentWidgetConfig(pageLayoutId);
+
+ // TODO: Remove this cast when FieldsConfiguration and FieldConfiguration are in the backend
+ const configuration = widgetInEditMode?.configuration as WidgetConfiguration;
+ const options = fieldMetadataItem.options ?? [];
+
+ const currentManualSortOrder = getManualSortOrderFromConfig(
+ configuration,
+ axis,
+ );
+
+ const sortedOptions = sortOptionsForManualOrder(
+ options,
+ currentManualSortOrder,
+ );
+
+ const handleDragEnd = (result: DropResult) => {
+ if (!isDefined(result.destination)) {
+ return;
+ }
+
+ const reorderedOptions = moveArrayItem(sortedOptions, {
+ fromIndex: result.source.index,
+ toIndex: result.destination.index,
+ });
+
+ const newManualSortOrder = reorderedOptions.map((option) => option.value);
+ const configKey = isWidgetConfigurationOfType(
+ configuration,
+ 'PieChartConfiguration',
+ )
+ ? 'manualSortOrder'
+ : axis === 'primary'
+ ? 'primaryAxisManualSortOrder'
+ : 'secondaryAxisManualSortOrder';
+
+ updateCurrentWidgetConfig({
+ configToUpdate: { [configKey]: newManualSortOrder },
+ });
+ };
+
+ return (
+ <>
+
+ }
+ >
+ {t`Reorder options`}
+
+
+
+ {sortedOptions.map((option, index) => (
+
+ }
+ />
+ }
+ />
+ ))}
+ >
+ }
+ />
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortByGroupByFieldDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortByGroupByFieldDropdownContent.tsx
index 69b55c8852..cf79593b2f 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortByGroupByFieldDropdownContent.tsx
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortByGroupByFieldDropdownContent.tsx
@@ -1,9 +1,16 @@
+import { useState } from 'react';
+
+import { ChartManualSortSubMenuContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent';
import { AGGREGATE_SORT_BY_OPTIONS } from '@/command-menu/pages/page-layout/constants/AggregateSortByOptions';
import { useGraphGroupBySortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
+import { filterSortOptionsByFieldType } from '@/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType';
+import { getDefaultManualSortOrder } from '@/command-menu/pages/page-layout/utils/getDefaultManualSortOrder';
+import { getSortIconForFieldType } from '@/command-menu/pages/page-layout/utils/getSortIconForFieldType';
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -13,13 +20,20 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { isDefined } from 'twenty-shared/utils';
-
import { MenuItemSelect } from 'twenty-ui/navigation';
-import { type GraphOrderBy } from '~/generated/graphql';
+import {
+ GraphOrderBy,
+ type GraphOrderBy as GraphOrderByType,
+} from '~/generated/graphql';
export const ChartSortByGroupByFieldDropdownContent = () => {
+ const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
+ const { objectMetadataItems } = useObjectMetadataItems();
+ const { updateCurrentWidgetConfig } =
+ useUpdateCurrentWidgetConfig(pageLayoutId);
+ const { closeDropdown } = useCloseDropdown();
const configuration = widgetInEditMode?.configuration;
@@ -34,6 +48,7 @@ export const ChartSortByGroupByFieldDropdownContent = () => {
if (!isDefined(widgetInEditMode?.objectMetadataId)) {
throw new Error('No data source in chart');
}
+
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
@@ -43,33 +58,76 @@ export const ChartSortByGroupByFieldDropdownContent = () => {
dropdownId,
);
- const { updateCurrentWidgetConfig } =
- useUpdateCurrentWidgetConfig(pageLayoutId);
+ const objectMetadataItem = objectMetadataItems.find(
+ (item) => item.id === widgetInEditMode.objectMetadataId,
+ );
- const { closeDropdown } = useCloseDropdown();
-
- const handleSelectSortOption = (orderBy: GraphOrderBy) => {
- updateCurrentWidgetConfig({
- configToUpdate: { secondaryAxisOrderBy: orderBy },
- });
- closeDropdown();
- };
+ const secondaryAxisField = objectMetadataItem?.fields.find(
+ (field) => field.id === configuration.secondaryAxisGroupByFieldMetadataId,
+ );
const { getGroupBySortOptionLabel } = useGraphGroupBySortOptionLabels({
objectMetadataId: widgetInEditMode.objectMetadataId,
});
+ if (!isDefined(secondaryAxisField)) {
+ return null;
+ }
+
+ const handleSelectSortOption = (orderBy: GraphOrderByType) => {
+ const configToUpdate: Record = {
+ secondaryAxisOrderBy: orderBy,
+ };
+
+ if (orderBy === GraphOrderBy.MANUAL) {
+ const existingManualSortOrder =
+ configuration.secondaryAxisManualSortOrder;
+
+ if (!isDefined(existingManualSortOrder)) {
+ configToUpdate.secondaryAxisManualSortOrder = getDefaultManualSortOrder(
+ secondaryAxisField?.options,
+ );
+ }
+
+ updateCurrentWidgetConfig({ configToUpdate });
+ setIsSubMenuOpen(true);
+ return;
+ }
+
+ if (configuration.secondaryAxisOrderBy === GraphOrderBy.MANUAL) {
+ configToUpdate.secondaryAxisManualSortOrder = null;
+ }
+
+ updateCurrentWidgetConfig({ configToUpdate });
+ closeDropdown();
+ };
+
+ const availableOptions = filterSortOptionsByFieldType({
+ options: AGGREGATE_SORT_BY_OPTIONS,
+ fieldType: secondaryAxisField?.type,
+ });
+
+ if (isSubMenuOpen && isDefined(secondaryAxisField)) {
+ return (
+ setIsSubMenuOpen(false)}
+ />
+ );
+ }
+
return (
- <>
-
- option.value,
- )}
- >
- {AGGREGATE_SORT_BY_OPTIONS.map((sortOption) => (
+
+ option.value)}
+ >
+ {availableOptions.map((sortOption) => {
+ const isManualOption = sortOption.value === GraphOrderBy.MANUAL;
+
+ return (
{
configuration.secondaryAxisOrderBy === sortOption.value
}
focused={selectedItemId === sortOption.value}
- LeftIcon={sortOption.icon}
+ LeftIcon={
+ sortOption.icon ??
+ getSortIconForFieldType({
+ fieldType: secondaryAxisField?.type,
+ orderBy: sortOption.value,
+ })
+ }
+ hasSubMenu={isManualOption}
onClick={() => {
handleSelectSortOption(sortOption.value);
}}
/>
- ))}
-
-
- >
+ );
+ })}
+
+
);
};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortBySelectionDropdownContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortBySelectionDropdownContent.tsx
index 143eee5739..862f468268 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortBySelectionDropdownContent.tsx
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/dropdown-content/ChartSortBySelectionDropdownContent.tsx
@@ -1,11 +1,16 @@
+import { useState } from 'react';
+
+import { ChartManualSortSubMenuContent } from '@/command-menu/pages/page-layout/components/dropdown-content/ChartManualSortSubMenuContent';
import { X_SORT_BY_OPTIONS } from '@/command-menu/pages/page-layout/constants/XSortByOptions';
import { useGraphXSortOptionLabels } from '@/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels';
import { usePageLayoutIdFromContextStoreTargetedRecord } from '@/command-menu/pages/page-layout/hooks/usePageLayoutFromContextStoreTargetedRecord';
import { useUpdateCurrentWidgetConfig } from '@/command-menu/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/command-menu/pages/page-layout/hooks/useWidgetInEditMode';
+import { filterSortOptionsByFieldType } from '@/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType';
+import { getDefaultManualSortOrder } from '@/command-menu/pages/page-layout/utils/getDefaultManualSortOrder';
+import { getSortIconForFieldType } from '@/command-menu/pages/page-layout/utils/getSortIconForFieldType';
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
-import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown';
@@ -15,7 +20,7 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
-import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
+import { isDefined } from 'twenty-shared/utils';
import { MenuItemSelect } from 'twenty-ui/navigation';
import {
type BarChartConfiguration,
@@ -24,8 +29,23 @@ import {
} from '~/generated/graphql';
export const ChartSortBySelectionDropdownContent = () => {
+ const [isSubMenuOpen, setIsSubMenuOpen] = useState(false);
const { pageLayoutId } = usePageLayoutIdFromContextStoreTargetedRecord();
const { widgetInEditMode } = useWidgetInEditMode(pageLayoutId);
+ const { objectMetadataItems } = useObjectMetadataItems();
+ const { updateCurrentWidgetConfig } =
+ useUpdateCurrentWidgetConfig(pageLayoutId);
+ const { closeDropdown } = useCloseDropdown();
+
+ const dropdownId = useAvailableComponentInstanceIdOrThrow(
+ DropdownComponentInstanceContext,
+ );
+
+ const selectedItemId = useRecoilComponentValue(
+ selectedItemIdComponentState,
+ dropdownId,
+ );
+
const configuration = widgetInEditMode?.configuration;
const isPieChart = isWidgetConfigurationOfType(
@@ -49,25 +69,10 @@ export const ChartSortBySelectionDropdownContent = () => {
throw new Error('No data source in chart');
}
- const dropdownId = useAvailableComponentInstanceIdOrThrow(
- DropdownComponentInstanceContext,
- );
-
- const selectedItemId = useRecoilComponentValue(
- selectedItemIdComponentState,
- dropdownId,
- );
-
- const { updateCurrentWidgetConfig } =
- useUpdateCurrentWidgetConfig(pageLayoutId);
- const { closeDropdown } = useCloseDropdown();
-
const { getXSortOptionLabel } = useGraphXSortOptionLabels({
objectMetadataId: widgetInEditMode.objectMetadataId,
});
- const { objectMetadataItems } = useObjectMetadataItems();
-
const objectMetadataItem = objectMetadataItems.find(
(item) => item.id === widgetInEditMode.objectMetadataId,
);
@@ -96,44 +101,69 @@ export const ChartSortBySelectionDropdownContent = () => {
(field) => field.id === groupByFieldMetadataId,
);
- const isPrimaryAxisDateField =
- isFieldMetadataDateKind(primaryAxisField?.type) ||
- (isDefined(primaryAxisField) &&
- isRelationNestedFieldDateKind({
- relationField: primaryAxisField,
- relationNestedFieldName: groupBySubFieldName ?? undefined,
- objectMetadataItems,
- }));
+ if (!isDefined(primaryAxisField)) {
+ return null;
+ }
+
+ const existingManualSortOrder = isPieChart
+ ? configuration.manualSortOrder
+ : (configuration as BarChartConfiguration | LineChartConfiguration)
+ .primaryAxisManualSortOrder;
const handleSelect = (orderBy: GraphOrderBy) => {
- if (isPieChart) {
- updateCurrentWidgetConfig({
- configToUpdate: { orderBy },
- });
- } else {
- updateCurrentWidgetConfig({
- configToUpdate: { primaryAxisOrderBy: orderBy },
- });
+ const configToUpdate: Record = {};
+
+ if (orderBy === GraphOrderBy.MANUAL) {
+ const orderByKey = isPieChart ? 'orderBy' : 'primaryAxisOrderBy';
+ const manualSortOrderKey = isPieChart
+ ? 'manualSortOrder'
+ : 'primaryAxisManualSortOrder';
+
+ configToUpdate[orderByKey] = orderBy;
+
+ if (!isDefined(existingManualSortOrder)) {
+ configToUpdate[manualSortOrderKey] = getDefaultManualSortOrder(
+ primaryAxisField?.options,
+ );
+ }
+
+ updateCurrentWidgetConfig({ configToUpdate });
+ setIsSubMenuOpen(true);
+ return;
}
+
+ if (currentOrderBy === GraphOrderBy.MANUAL) {
+ const manualSortOrderKey = isPieChart
+ ? 'manualSortOrder'
+ : 'primaryAxisManualSortOrder';
+ configToUpdate[manualSortOrderKey] = null;
+ }
+
+ if (isPieChart) {
+ configToUpdate.orderBy = orderBy;
+ } else {
+ configToUpdate.primaryAxisOrderBy = orderBy;
+ }
+
+ updateCurrentWidgetConfig({ configToUpdate });
closeDropdown();
};
- const availableOptions = X_SORT_BY_OPTIONS.filter((option) => {
- const isValueSort =
- option.value === GraphOrderBy.VALUE_ASC ||
- option.value === GraphOrderBy.VALUE_DESC;
-
- if (isLineChart) {
- return !isValueSort;
- }
-
- if ((isBarChart || isPieChart) && isPrimaryAxisDateField) {
- return !isValueSort;
- }
-
- return true;
+ const availableOptions = filterSortOptionsByFieldType({
+ options: X_SORT_BY_OPTIONS,
+ fieldType: primaryAxisField?.type,
});
+ if (isSubMenuOpen && isDefined(primaryAxisField)) {
+ return (
+ setIsSubMenuOpen(false)}
+ />
+ );
+ }
+
return (
{
focusId={dropdownId}
selectableItemIdArray={availableOptions.map((option) => option.value)}
>
- {availableOptions.map((sortOption) => (
- {
- handleSelect(sortOption.value);
- }}
- >
- {
+ {availableOptions.map((sortOption) => {
+ const isManualOption = sortOption.value === GraphOrderBy.MANUAL;
+
+ return (
+ {
handleSelect(sortOption.value);
}}
- />
-
- ))}
+ >
+ {
+ handleSelect(sortOption.value);
+ }}
+ />
+
+ );
+ })}
);
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/AggregateSortByOptions.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/AggregateSortByOptions.ts
index 4ebd6824d7..f4fab91b47 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/AggregateSortByOptions.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/AggregateSortByOptions.ts
@@ -1,13 +1,29 @@
-import { IconArrowDown, IconArrowUp } from 'twenty-ui/display';
+import {
+ IconHandMove,
+ IconSortAscending,
+ IconSortDescending,
+} from 'twenty-ui/display';
import { GraphOrderBy } from '~/generated/graphql';
export const AGGREGATE_SORT_BY_OPTIONS = [
+ {
+ value: GraphOrderBy.FIELD_POSITION_ASC,
+ icon: IconSortAscending,
+ },
+ {
+ value: GraphOrderBy.FIELD_POSITION_DESC,
+ icon: IconSortDescending,
+ },
{
value: GraphOrderBy.FIELD_ASC,
- icon: IconArrowUp,
+ icon: null,
},
{
value: GraphOrderBy.FIELD_DESC,
- icon: IconArrowDown,
+ icon: null,
+ },
+ {
+ value: GraphOrderBy.MANUAL,
+ icon: IconHandMove,
},
];
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/XSortByOptions.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/XSortByOptions.ts
index 37523423ff..1c4abba768 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/XSortByOptions.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/XSortByOptions.ts
@@ -1,19 +1,34 @@
import {
- IconArrowDown,
- IconArrowUp,
+ type IconComponent,
+ IconHandMove,
+ IconSortAscending,
+ IconSortDescending,
IconTrendingDown,
IconTrendingUp,
} from 'twenty-ui/display';
import { GraphOrderBy } from '~/generated/graphql';
-export const X_SORT_BY_OPTIONS = [
+type XSortByOption = {
+ value: GraphOrderBy;
+ icon: IconComponent | null;
+};
+
+export const X_SORT_BY_OPTIONS: XSortByOption[] = [
+ {
+ value: GraphOrderBy.FIELD_POSITION_ASC,
+ icon: IconSortAscending,
+ },
+ {
+ value: GraphOrderBy.FIELD_POSITION_DESC,
+ icon: IconSortDescending,
+ },
{
value: GraphOrderBy.FIELD_ASC,
- icon: IconArrowUp,
+ icon: null,
},
{
value: GraphOrderBy.FIELD_DESC,
- icon: IconArrowDown,
+ icon: null,
},
{
value: GraphOrderBy.VALUE_ASC,
@@ -23,4 +38,8 @@ export const X_SORT_BY_OPTIONS = [
value: GraphOrderBy.VALUE_DESC,
icon: IconTrendingDown,
},
+ {
+ value: GraphOrderBy.MANUAL,
+ icon: IconHandMove,
+ },
];
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels.ts
index 75bfe6916c..9ee90cf9fd 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphGroupBySortOptionLabels.ts
@@ -1,8 +1,10 @@
import { getFieldLabelWithSubField } from '@/command-menu/pages/page-layout/utils/getFieldLabelWithSubField';
+import { getSortLabelSuffixForFieldType } from '@/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { t } from '@lingui/core/macro';
import { useRecoilValue } from 'recoil';
import { type CompositeFieldSubFieldName } from 'twenty-shared/types';
+import { assertUnreachable } from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
export const useGraphGroupBySortOptionLabels = ({
@@ -35,13 +37,24 @@ export const useGraphGroupBySortOptionLabels = ({
objectMetadataItems,
});
+ const groupBySortLabelSuffix = getSortLabelSuffixForFieldType({
+ fieldType: field?.type,
+ orderBy: graphOrderBy,
+ });
+
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
- return `${fieldLabel} ${t`Ascending`}`;
case GraphOrderBy.FIELD_DESC:
- return `${fieldLabel} ${t`Descending`}`;
- default:
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ case GraphOrderBy.FIELD_POSITION_DESC:
+ return `${fieldLabel} ${groupBySortLabelSuffix}`;
+ case GraphOrderBy.VALUE_ASC:
+ case GraphOrderBy.VALUE_DESC:
return '';
+ case GraphOrderBy.MANUAL:
+ return t`Manual`;
+ default:
+ assertUnreachable(graphOrderBy);
}
};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels.ts
index deae60edeb..142d98373b 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useGraphXSortOptionLabels.ts
@@ -1,4 +1,5 @@
import { getFieldLabelWithSubField } from '@/command-menu/pages/page-layout/utils/getFieldLabelWithSubField';
+import { getSortLabelSuffixForFieldType } from '@/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType';
import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
@@ -53,15 +54,27 @@ export const useGraphXSortOptionLabels = ({
? getAggregateOperationLabel(aggregateOperation)
: t`Value`;
+ const groupBySortLabelSuffix = getSortLabelSuffixForFieldType({
+ fieldType: groupByField?.type,
+ orderBy: graphOrderBy,
+ });
+
+ const aggregateSortLabelSuffix = getSortLabelSuffixForFieldType({
+ fieldType: aggregateField?.type,
+ orderBy: graphOrderBy,
+ });
+
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
- return `${fieldLabel} ${t`Ascending`}`;
case GraphOrderBy.FIELD_DESC:
- return `${fieldLabel} ${t`Descending`}`;
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ case GraphOrderBy.FIELD_POSITION_DESC:
+ return `${fieldLabel} ${groupBySortLabelSuffix}`;
case GraphOrderBy.VALUE_ASC:
- return `${valueLabel} ${t`Ascending`}`;
case GraphOrderBy.VALUE_DESC:
- return `${valueLabel} ${t`Descending`}`;
+ return `${valueLabel} ${aggregateSortLabelSuffix}`;
+ case GraphOrderBy.MANUAL:
+ return t`Manual`;
default:
assertUnreachable(graphOrderBy);
}
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/buildChartGroupByFieldConfigUpdate.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/buildChartGroupByFieldConfigUpdate.test.ts
index 16dd04191b..bf71573c2e 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/buildChartGroupByFieldConfigUpdate.test.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/buildChartGroupByFieldConfigUpdate.test.ts
@@ -1,12 +1,12 @@
import { type TypedBarChartConfiguration } from '@/command-menu/pages/page-layout/types/TypedBarChartConfiguration';
import { type TypedPieChartConfiguration } from '@/command-menu/pages/page-layout/types/TypedPieChartConfiguration';
+import { buildChartGroupByFieldConfigUpdate } from '@/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate';
import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import {
BarChartGroupMode,
GraphOrderBy,
WidgetConfigurationType,
} from '~/generated/graphql';
-import { buildChartGroupByFieldConfigUpdate } from '@/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate';
describe('buildChartGroupByFieldConfigUpdate', () => {
it('sets default orderBy and dateGranularity for primary axis', () => {
@@ -79,4 +79,22 @@ describe('buildChartGroupByFieldConfigUpdate', () => {
dateGranularity: ObjectRecordGroupByDateGranularity.DAY,
});
});
+
+ it('resets orderBy to default when field changes', () => {
+ const result = buildChartGroupByFieldConfigUpdate({
+ configuration: {
+ __typename: 'BarChartConfiguration',
+ configurationType: WidgetConfigurationType.BAR_CHART,
+ primaryAxisOrderBy: GraphOrderBy.VALUE_DESC,
+ } as TypedBarChartConfiguration,
+ fieldMetadataIdKey: 'primaryAxisGroupByFieldMetadataId',
+ subFieldNameKey: 'primaryAxisGroupBySubFieldName',
+ fieldId: 'new-field-id',
+ subFieldName: null,
+ });
+
+ expect(result).toMatchObject({
+ primaryAxisOrderBy: GraphOrderBy.FIELD_ASC,
+ });
+ });
});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/filterSortOptionsByFieldType.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/filterSortOptionsByFieldType.test.ts
new file mode 100644
index 0000000000..f88a86bb0f
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/filterSortOptionsByFieldType.test.ts
@@ -0,0 +1,65 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+
+import { filterSortOptionsByFieldType } from '@/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType';
+import { GraphOrderBy } from '~/generated/graphql';
+
+describe('filterSortOptionsByFieldType', () => {
+ const allOptions = [
+ { value: GraphOrderBy.FIELD_POSITION_ASC },
+ { value: GraphOrderBy.FIELD_POSITION_DESC },
+ { value: GraphOrderBy.FIELD_ASC },
+ { value: GraphOrderBy.FIELD_DESC },
+ { value: GraphOrderBy.VALUE_ASC },
+ { value: GraphOrderBy.VALUE_DESC },
+ { value: GraphOrderBy.MANUAL },
+ ];
+
+ describe('select field', () => {
+ it('should include all options for select field', () => {
+ const result = filterSortOptionsByFieldType({
+ options: allOptions,
+ fieldType: FieldMetadataType.SELECT,
+ });
+
+ expect(result).toHaveLength(7);
+ });
+ });
+
+ describe('date field', () => {
+ it('should exclude value sorts for date select field', () => {
+ const result = filterSortOptionsByFieldType({
+ options: allOptions,
+ fieldType: FieldMetadataType.DATE,
+ });
+
+ expect(result).not.toContainEqual({ value: GraphOrderBy.VALUE_ASC });
+ expect(result).not.toContainEqual({ value: GraphOrderBy.VALUE_DESC });
+ expect(result).not.toContainEqual({ value: GraphOrderBy.MANUAL });
+ expect(result).not.toContainEqual({
+ value: GraphOrderBy.FIELD_POSITION_ASC,
+ });
+ expect(result).not.toContainEqual({
+ value: GraphOrderBy.FIELD_POSITION_DESC,
+ });
+ expect(result).toHaveLength(2);
+ });
+ });
+
+ describe('non-select, non-date field', () => {
+ it('should exclude manual and position sorts', () => {
+ const result = filterSortOptionsByFieldType({
+ options: allOptions,
+ fieldType: FieldMetadataType.TEXT,
+ });
+
+ expect(result).not.toContainEqual({ value: GraphOrderBy.MANUAL });
+ expect(result).not.toContainEqual({
+ value: GraphOrderBy.FIELD_POSITION_ASC,
+ });
+ expect(result).not.toContainEqual({
+ value: GraphOrderBy.FIELD_POSITION_DESC,
+ });
+ expect(result).toHaveLength(4);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getChartDefaultOrderByForFieldType.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getChartDefaultOrderByForFieldType.test.ts
new file mode 100644
index 0000000000..0bdb9ab2cd
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getChartDefaultOrderByForFieldType.test.ts
@@ -0,0 +1,36 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+
+import { getChartDefaultOrderByForFieldType } from '@/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType';
+import { GraphOrderBy } from '~/generated/graphql';
+
+describe('getChartDefaultOrderByForFieldType', () => {
+ it('should return FIELD_POSITION_ASC for SELECT field type', () => {
+ expect(getChartDefaultOrderByForFieldType(FieldMetadataType.SELECT)).toBe(
+ GraphOrderBy.FIELD_POSITION_ASC,
+ );
+ });
+
+ it('should return FIELD_ASC for TEXT field type', () => {
+ expect(getChartDefaultOrderByForFieldType(FieldMetadataType.TEXT)).toBe(
+ GraphOrderBy.FIELD_ASC,
+ );
+ });
+
+ it('should return FIELD_ASC for NUMBER field type', () => {
+ expect(getChartDefaultOrderByForFieldType(FieldMetadataType.NUMBER)).toBe(
+ GraphOrderBy.FIELD_ASC,
+ );
+ });
+
+ it('should return FIELD_ASC for DATE field type', () => {
+ expect(getChartDefaultOrderByForFieldType(FieldMetadataType.DATE)).toBe(
+ GraphOrderBy.FIELD_ASC,
+ );
+ });
+
+ it('should return FIELD_ASC for MULTI_SELECT field type', () => {
+ expect(
+ getChartDefaultOrderByForFieldType(FieldMetadataType.MULTI_SELECT),
+ ).toBe(GraphOrderBy.FIELD_ASC);
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getDefaultManualSortOrder.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getDefaultManualSortOrder.test.ts
new file mode 100644
index 0000000000..bf973b9d52
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getDefaultManualSortOrder.test.ts
@@ -0,0 +1,29 @@
+import { getDefaultManualSortOrder } from '@/command-menu/pages/page-layout/utils/getDefaultManualSortOrder';
+
+describe('getDefaultManualSortOrder', () => {
+ it('should return empty array for null options', () => {
+ expect(getDefaultManualSortOrder(null)).toEqual([]);
+ });
+
+ it('should return empty array for undefined options', () => {
+ expect(getDefaultManualSortOrder(undefined)).toEqual([]);
+ });
+
+ it('should return empty array for empty options', () => {
+ expect(getDefaultManualSortOrder([])).toEqual([]);
+ });
+
+ it('should return values sorted by position', () => {
+ const options = [
+ { value: 'third', position: 2 },
+ { value: 'first', position: 0 },
+ { value: 'second', position: 1 },
+ ];
+
+ expect(getDefaultManualSortOrder(options)).toEqual([
+ 'first',
+ 'second',
+ 'third',
+ ]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getManualSortOrderFromConfig.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getManualSortOrderFromConfig.test.ts
new file mode 100644
index 0000000000..cfcecc435d
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getManualSortOrderFromConfig.test.ts
@@ -0,0 +1,112 @@
+import { getManualSortOrderFromConfig } from '@/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig';
+import { expect } from '@storybook/test';
+import {
+ WidgetConfigurationType,
+ type BarChartConfiguration,
+ type LineChartConfiguration,
+ type PieChartConfiguration,
+} from '~/generated/graphql';
+
+describe('getManualSortOrderFromConfig', () => {
+ describe('pie chart configuration', () => {
+ it('should return manualSortOrder for pie axis', () => {
+ const config = {
+ __typename: 'PieChartConfiguration' as const,
+ manualSortOrder: ['a', 'b', 'c'],
+ } as PieChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should return undefined for null manualSortOrder', () => {
+ const config = {
+ __typename: 'PieChartConfiguration' as const,
+ manualSortOrder: null,
+ } as PieChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config)).toBeUndefined();
+ });
+
+ it('should return undefined when manualSortOrder is not in config', () => {
+ const config = {
+ __typename: 'PieChartConfiguration',
+ configurationType: WidgetConfigurationType.PIE_CHART,
+ } as PieChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config)).toBeUndefined();
+ });
+
+ it('should return undefined for wrong typename', () => {
+ const config = {
+ __typename: 'BarChartConfiguration',
+ configurationType: WidgetConfigurationType.BAR_CHART,
+ manualSortOrder: ['a', 'b', 'c'],
+ } as unknown as BarChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config)).toBeUndefined();
+ });
+ });
+
+ describe('bar chart configuration', () => {
+ it('should return primaryAxisManualSortOrder for primary axis', () => {
+ const config = {
+ __typename: 'BarChartConfiguration' as const,
+ primaryAxisManualSortOrder: ['x', 'y', 'z'],
+ } as BarChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config, 'primary')).toEqual([
+ 'x',
+ 'y',
+ 'z',
+ ]);
+ });
+
+ it('should return secondaryAxisManualSortOrder for secondary axis', () => {
+ const config = {
+ __typename: 'BarChartConfiguration' as const,
+ secondaryAxisManualSortOrder: ['1', '2', '3'],
+ } as BarChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config, 'secondary')).toEqual([
+ '1',
+ '2',
+ '3',
+ ]);
+ });
+
+ it('should return undefined for null primaryAxisManualSortOrder', () => {
+ const config = {
+ __typename: 'BarChartConfiguration' as const,
+ primaryAxisManualSortOrder: null,
+ } as BarChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config, 'primary')).toBeUndefined();
+ });
+ });
+
+ describe('line chart configuration', () => {
+ it('should return primaryAxisManualSortOrder for primary axis', () => {
+ const config = {
+ __typename: 'LineChartConfiguration' as const,
+ primaryAxisManualSortOrder: ['a', 'b'],
+ } as LineChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config, 'primary')).toEqual([
+ 'a',
+ 'b',
+ ]);
+ });
+
+ it('should return secondaryAxisManualSortOrder for secondary axis', () => {
+ const config = {
+ __typename: 'LineChartConfiguration' as const,
+ secondaryAxisManualSortOrder: ['c', 'd'],
+ } as LineChartConfiguration;
+
+ expect(getManualSortOrderFromConfig(config, 'secondary')).toEqual([
+ 'c',
+ 'd',
+ ]);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortIconForFieldType.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortIconForFieldType.test.ts
new file mode 100644
index 0000000000..6ea922ca98
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortIconForFieldType.test.ts
@@ -0,0 +1,208 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+import {
+ IconSortAscending,
+ IconSortAscendingLetters,
+ IconSortAscendingNumbers,
+ IconSortDescending,
+ IconSortDescendingLetters,
+ IconSortDescendingNumbers,
+} from 'twenty-ui/display';
+
+import { getSortIconForFieldType } from '@/command-menu/pages/page-layout/utils/getSortIconForFieldType';
+import { GraphOrderBy } from '~/generated/graphql';
+
+describe('getSortIconForFieldType', () => {
+ describe('position sort', () => {
+ it('should return IconSortAscending for FIELD_POSITION_ASC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.SELECT,
+ orderBy: GraphOrderBy.FIELD_POSITION_ASC,
+ }),
+ ).toBe(IconSortAscending);
+ });
+
+ it('should return IconSortDescending for FIELD_POSITION_DESC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.SELECT,
+ orderBy: GraphOrderBy.FIELD_POSITION_DESC,
+ }),
+ ).toBe(IconSortDescending);
+ });
+ });
+
+ describe('text field types', () => {
+ it('should return IconSortAscendingLetters for TEXT field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.TEXT,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingLetters);
+ });
+
+ it('should return IconSortDescendingLetters for TEXT field descending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.TEXT,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ }),
+ ).toBe(IconSortDescendingLetters);
+ });
+
+ it('should return IconSortAscendingLetters for RICH_TEXT field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.RICH_TEXT,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingLetters);
+ });
+
+ it('should return IconSortAscendingLetters for RICH_TEXT_V2 field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.RICH_TEXT_V2,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingLetters);
+ });
+ });
+
+ describe('number field types', () => {
+ it('should return IconSortAscendingNumbers for NUMBER field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.NUMBER,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingNumbers);
+ });
+
+ it('should return IconSortDescendingNumbers for NUMBER field descending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.NUMBER,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ }),
+ ).toBe(IconSortDescendingNumbers);
+ });
+
+ it('should return IconSortAscendingNumbers for CURRENCY field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.CURRENCY,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingNumbers);
+ });
+
+ it('should return IconSortAscendingNumbers for RATING field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.RATING,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingNumbers);
+ });
+ });
+
+ describe('select field type', () => {
+ it('should return IconSortAscendingLetters for SELECT field with FIELD_ASC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.SELECT,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscendingLetters);
+ });
+
+ it('should return IconSortDescendingLetters for SELECT field with FIELD_DESC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.SELECT,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ }),
+ ).toBe(IconSortDescendingLetters);
+ });
+ });
+
+ describe('undefined field type', () => {
+ it('should return IconSortAscending for undefined field type ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: undefined,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscending);
+ });
+
+ it('should return IconSortDescending for undefined field type descending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: undefined,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ }),
+ ).toBe(IconSortDescending);
+ });
+ });
+
+ describe('value-based sort', () => {
+ it('should return IconSortAscending for VALUE_ASC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.NUMBER,
+ orderBy: GraphOrderBy.VALUE_ASC,
+ }),
+ ).toBe(IconSortAscendingNumbers);
+ });
+
+ it('should return IconSortDescending for VALUE_DESC', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.NUMBER,
+ orderBy: GraphOrderBy.VALUE_DESC,
+ }),
+ ).toBe(IconSortDescendingNumbers);
+ });
+
+ it('should return IconSortAscending for VALUE_ASC with undefined field type', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: undefined,
+ orderBy: GraphOrderBy.VALUE_ASC,
+ }),
+ ).toBe(IconSortAscending);
+ });
+
+ it('should return IconSortDescending for VALUE_DESC with undefined field type', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: undefined,
+ orderBy: GraphOrderBy.VALUE_DESC,
+ }),
+ ).toBe(IconSortDescending);
+ });
+ });
+
+ describe('default cases', () => {
+ it('should return IconSortAscending for DATE field ascending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.DATE,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe(IconSortAscending);
+ });
+
+ it('should return IconSortDescending for DATE field descending', () => {
+ expect(
+ getSortIconForFieldType({
+ fieldType: FieldMetadataType.DATE,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ }),
+ ).toBe(IconSortDescending);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortLabelSuffixForFieldType.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortLabelSuffixForFieldType.test.ts
new file mode 100644
index 0000000000..cc154ee0c7
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getSortLabelSuffixForFieldType.test.ts
@@ -0,0 +1,32 @@
+import { getSortLabelSuffixForFieldType } from '@/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType';
+import { FieldMetadataType } from 'twenty-shared/types';
+import { GraphOrderBy } from '~/generated/graphql';
+
+describe('getSortLabelSuffixForFieldType', () => {
+ it('returns alphabetical for TEXT field with FIELD_ASC', () => {
+ expect(
+ getSortLabelSuffixForFieldType({
+ fieldType: FieldMetadataType.TEXT,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe('alphabetical');
+ });
+
+ it('returns ascending for NUMBER field with FIELD_ASC', () => {
+ expect(
+ getSortLabelSuffixForFieldType({
+ fieldType: FieldMetadataType.NUMBER,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ }),
+ ).toBe('ascending');
+ });
+
+ it('returns position ascending for SELECT field with FIELD_POSITION_ASC', () => {
+ expect(
+ getSortLabelSuffixForFieldType({
+ fieldType: FieldMetadataType.SELECT,
+ orderBy: GraphOrderBy.FIELD_POSITION_ASC,
+ }),
+ ).toBe('position ascending');
+ });
+});
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate.ts
index 26cf7ee64e..ee7aad1752 100644
--- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate.ts
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate.ts
@@ -1,4 +1,5 @@
import { type ChartConfiguration } from '@/command-menu/pages/page-layout/types/ChartConfiguration';
+import { getChartDefaultOrderByForFieldType } from '@/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType';
import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page-layout/utils/isFieldOrNestedFieldDateKind';
import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
@@ -51,10 +52,15 @@ export const buildChartGroupByFieldConfigUpdate = <
'PieChartConfiguration',
);
- if (isPrimaryAxis) {
- const existingOrderBy =
- isBarChart || isLineChart ? configuration.primaryAxisOrderBy : null;
+ const fieldMetadataItem = objectMetadataItem?.fields?.find(
+ (field) => field.id === fieldId,
+ );
+ const defaultOrderBy = isDefined(fieldMetadataItem?.type)
+ ? getChartDefaultOrderByForFieldType(fieldMetadataItem?.type)
+ : GraphOrderBy.FIELD_ASC;
+
+ if (isPrimaryAxis) {
const existingDateGranularity =
isBarChart || isLineChart
? configuration.primaryAxisDateGranularity
@@ -73,19 +79,9 @@ export const buildChartGroupByFieldConfigUpdate = <
!isNewFieldDateType &&
(isBarChart || isLineChart);
- const isCurrentOrderByValueBased =
- existingOrderBy === GraphOrderBy.VALUE_ASC ||
- existingOrderBy === GraphOrderBy.VALUE_DESC;
-
- const shouldResetOrderBy = isNewFieldDateType && isCurrentOrderByValueBased;
-
- const newOrderBy = shouldResetOrderBy
- ? GraphOrderBy.FIELD_ASC
- : (existingOrderBy ?? GraphOrderBy.FIELD_ASC);
-
return {
...baseConfig,
- primaryAxisOrderBy: isDefined(fieldId) ? newOrderBy : null,
+ primaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null,
primaryAxisDateGranularity: isDefined(fieldId)
? (existingDateGranularity ?? ObjectRecordGroupByDateGranularity.DAY)
: null,
@@ -94,32 +90,13 @@ export const buildChartGroupByFieldConfigUpdate = <
}
if (isPieChartGroupBy) {
- const existingOrderBy = isPieChart ? configuration.orderBy : null;
-
const existingDateGranularity = isPieChart
? configuration.dateGranularity
: null;
- const isNewFieldDateType = isFieldOrRelationNestedFieldDateKind({
- fieldId,
- subFieldName,
- objectMetadataItem,
- objectMetadataItems,
- });
-
- const isCurrentOrderByValueBased =
- existingOrderBy === GraphOrderBy.VALUE_ASC ||
- existingOrderBy === GraphOrderBy.VALUE_DESC;
-
- const shouldResetOrderBy = isNewFieldDateType && isCurrentOrderByValueBased;
-
- const newOrderBy = shouldResetOrderBy
- ? GraphOrderBy.FIELD_ASC
- : (existingOrderBy ?? GraphOrderBy.FIELD_ASC);
-
return {
...baseConfig,
- orderBy: isDefined(fieldId) ? newOrderBy : null,
+ orderBy: isDefined(fieldId) ? defaultOrderBy : null,
dateGranularity: isDefined(fieldId)
? (existingDateGranularity ?? ObjectRecordGroupByDateGranularity.DAY)
: null,
@@ -133,9 +110,7 @@ export const buildChartGroupByFieldConfigUpdate = <
if (isBarChart) {
return {
...baseConfig,
- secondaryAxisOrderBy: isDefined(fieldId)
- ? (configuration.secondaryAxisOrderBy ?? GraphOrderBy.FIELD_ASC)
- : null,
+ secondaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null,
secondaryAxisGroupByDateGranularity: isDefined(fieldId)
? (configuration.secondaryAxisGroupByDateGranularity ??
ObjectRecordGroupByDateGranularity.DAY)
@@ -149,9 +124,7 @@ export const buildChartGroupByFieldConfigUpdate = <
if (isLineChart) {
return {
...baseConfig,
- secondaryAxisOrderBy: isDefined(fieldId)
- ? (configuration.secondaryAxisOrderBy ?? GraphOrderBy.FIELD_ASC)
- : null,
+ secondaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null,
secondaryAxisGroupByDateGranularity: isDefined(fieldId)
? (configuration.secondaryAxisGroupByDateGranularity ??
ObjectRecordGroupByDateGranularity.DAY)
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType.ts
new file mode 100644
index 0000000000..456b4a2128
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/filterSortOptionsByFieldType.ts
@@ -0,0 +1,50 @@
+import { type FieldMetadataType } from 'twenty-shared/types';
+import {
+ isFieldMetadataDateKind,
+ isFieldMetadataSelectKind,
+} from 'twenty-shared/utils';
+import { type IconComponent } from 'twenty-ui/display';
+import { GraphOrderBy } from '~/generated/graphql';
+
+export type SortOption = {
+ value: GraphOrderBy;
+ icon?: IconComponent | null;
+};
+
+type FilterSortOptionsParams = {
+ options: SortOption[];
+ fieldType: FieldMetadataType;
+};
+
+export const filterSortOptionsByFieldType = ({
+ options,
+ fieldType,
+}: FilterSortOptionsParams): SortOption[] => {
+ return options.filter((option) => {
+ const isValueSort =
+ option.value === GraphOrderBy.VALUE_ASC ||
+ option.value === GraphOrderBy.VALUE_DESC;
+
+ const isManualSort = option.value === GraphOrderBy.MANUAL;
+
+ const isPositionSort =
+ option.value === GraphOrderBy.FIELD_POSITION_ASC ||
+ option.value === GraphOrderBy.FIELD_POSITION_DESC;
+
+ const isSelectField = isFieldMetadataSelectKind(fieldType);
+
+ if (isManualSort && !isSelectField) {
+ return false;
+ }
+
+ if (isPositionSort && !isSelectField) {
+ return false;
+ }
+
+ if (isFieldMetadataDateKind(fieldType)) {
+ return !isValueSort;
+ }
+
+ return true;
+ });
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType.ts
new file mode 100644
index 0000000000..2ff797d6ff
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getChartDefaultOrderByForFieldType.ts
@@ -0,0 +1,12 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+import { GraphOrderBy } from '~/generated/graphql';
+
+export const getChartDefaultOrderByForFieldType = (
+ fieldType: FieldMetadataType,
+): GraphOrderBy => {
+ const isSelectField = fieldType === FieldMetadataType.SELECT;
+
+ return isSelectField
+ ? GraphOrderBy.FIELD_POSITION_ASC
+ : GraphOrderBy.FIELD_ASC;
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDefaultManualSortOrder.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDefaultManualSortOrder.ts
new file mode 100644
index 0000000000..d5d89835b9
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getDefaultManualSortOrder.ts
@@ -0,0 +1,16 @@
+type FieldOption = {
+ value: string;
+ position?: number | null;
+};
+
+export const getDefaultManualSortOrder = (
+ options: FieldOption[] | null | undefined,
+): string[] => {
+ if (!options) return [];
+
+ const sortedByPosition = options.toSorted(
+ (a, b) => (a.position ?? 0) - (b.position ?? 0),
+ );
+
+ return sortedByPosition.map((option) => option.value);
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig.ts
new file mode 100644
index 0000000000..076bc718a5
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getManualSortOrderFromConfig.ts
@@ -0,0 +1,29 @@
+import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType';
+import { isDefined } from 'twenty-shared/utils';
+import { type WidgetConfiguration } from '~/generated/graphql';
+
+export const getManualSortOrderFromConfig = (
+ configuration: WidgetConfiguration,
+ axis?: 'primary' | 'secondary',
+): string[] | undefined => {
+ if (isWidgetConfigurationOfType(configuration, 'PieChartConfiguration')) {
+ return configuration.manualSortOrder ?? undefined;
+ }
+
+ if (!isDefined(axis)) {
+ return undefined;
+ }
+
+ if (
+ isWidgetConfigurationOfType(configuration, 'BarChartConfiguration') ||
+ isWidgetConfigurationOfType(configuration, 'LineChartConfiguration')
+ ) {
+ if (axis === 'primary') {
+ return configuration.primaryAxisManualSortOrder ?? undefined;
+ }
+
+ return configuration.secondaryAxisManualSortOrder ?? undefined;
+ }
+
+ return undefined;
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortIconForFieldType.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortIconForFieldType.ts
new file mode 100644
index 0000000000..d4eaa635b7
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortIconForFieldType.ts
@@ -0,0 +1,57 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+import {
+ isFieldMetadataNumericKind,
+ isFieldMetadataTextKind,
+} from 'twenty-shared/utils';
+import {
+ type IconComponent,
+ IconSortAscending,
+ IconSortAscendingLetters,
+ IconSortAscendingNumbers,
+ IconSortDescending,
+ IconSortDescendingLetters,
+ IconSortDescendingNumbers,
+} from 'twenty-ui/display';
+import { GraphOrderBy } from '~/generated/graphql';
+
+export const getSortIconForFieldType = ({
+ fieldType,
+ orderBy,
+}: {
+ fieldType: FieldMetadataType | undefined;
+ orderBy: GraphOrderBy;
+}): IconComponent => {
+ const isAscending =
+ orderBy === GraphOrderBy.FIELD_ASC ||
+ orderBy === GraphOrderBy.FIELD_POSITION_ASC ||
+ orderBy === GraphOrderBy.VALUE_ASC;
+
+ const isPositionSort =
+ orderBy === GraphOrderBy.FIELD_POSITION_ASC ||
+ orderBy === GraphOrderBy.FIELD_POSITION_DESC;
+
+ if (isPositionSort) {
+ return isAscending ? IconSortAscending : IconSortDescending;
+ }
+
+ if (!fieldType) {
+ return isAscending ? IconSortAscending : IconSortDescending;
+ }
+
+ if (isFieldMetadataTextKind(fieldType)) {
+ return isAscending ? IconSortAscendingLetters : IconSortDescendingLetters;
+ }
+
+ if (isFieldMetadataNumericKind(fieldType)) {
+ return isAscending ? IconSortAscendingNumbers : IconSortDescendingNumbers;
+ }
+
+ if (
+ fieldType === FieldMetadataType.SELECT &&
+ (orderBy === GraphOrderBy.FIELD_ASC || orderBy === GraphOrderBy.FIELD_DESC)
+ ) {
+ return isAscending ? IconSortAscendingLetters : IconSortDescendingLetters;
+ }
+
+ return isAscending ? IconSortAscending : IconSortDescending;
+};
diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType.ts
new file mode 100644
index 0000000000..5be5b01d54
--- /dev/null
+++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getSortLabelSuffixForFieldType.ts
@@ -0,0 +1,54 @@
+import { t } from '@lingui/core/macro';
+import { FieldMetadataType } from 'twenty-shared/types';
+import {
+ isDefined,
+ isFieldMetadataNumericKind,
+ isFieldMetadataTextKind,
+} from 'twenty-shared/utils';
+
+import { GraphOrderBy } from '~/generated/graphql';
+
+export const getSortLabelSuffixForFieldType = ({
+ fieldType,
+ orderBy,
+}: {
+ fieldType: FieldMetadataType | undefined;
+ orderBy: GraphOrderBy;
+}): string => {
+ const isAscending =
+ orderBy === GraphOrderBy.FIELD_ASC ||
+ orderBy === GraphOrderBy.FIELD_POSITION_ASC ||
+ orderBy === GraphOrderBy.VALUE_ASC;
+
+ if (!isDefined(fieldType)) {
+ return isAscending ? t`ascending` : t`descending`;
+ }
+
+ if (isFieldMetadataTextKind(fieldType)) {
+ return isAscending ? t`alphabetical` : t`reverse alphabetical`;
+ }
+
+ if (isFieldMetadataNumericKind(fieldType)) {
+ return isAscending ? t`ascending` : t`descending`;
+ }
+
+ if (fieldType === FieldMetadataType.SELECT) {
+ if (
+ orderBy === GraphOrderBy.FIELD_ASC ||
+ orderBy === GraphOrderBy.FIELD_DESC
+ ) {
+ return isAscending ? t`alphabetical` : t`reverse alphabetical`;
+ }
+
+ if (
+ orderBy === GraphOrderBy.FIELD_POSITION_ASC ||
+ orderBy === GraphOrderBy.FIELD_POSITION_DESC
+ ) {
+ return isAscending ? t`position ascending` : t`position descending`;
+ }
+
+ return isAscending ? t`ascending` : t`descending`;
+ }
+
+ return isAscending ? t`ascending` : t`descending`;
+};
diff --git a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts
index 5b349dd08f..1b792e57f7 100644
--- a/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts
+++ b/packages/twenty-front/src/modules/page-layout/graphql/fragments/pageLayoutWidgetFragment.ts
@@ -24,10 +24,12 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -51,10 +53,12 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -77,6 +81,7 @@ export const PAGE_LAYOUT_WIDGET_FRAGMENT = gql`
groupBySubFieldName
dateGranularity
orderBy
+ manualSortOrder
displayDataLabel
showCenterMetric
displayLegend
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToBarChartData.test.ts.snap
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/__snapshots__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts.snap
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts
similarity index 94%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts
index 8598ce13a9..04d9fd782b 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToBarChartData.test.ts
@@ -1,4 +1,4 @@
-import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToBarChartData';
+import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData';
describe('applyCumulativeTransformToBarChartData', () => {
const testCases = [
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts
similarity index 92%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts
index a5f4347243..c7f48d8912 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/__tests__/applyCumulativeTransformToTwoDimensionalBarChartData.test.ts
@@ -1,4 +1,4 @@
-import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToTwoDimensionalBarChartData';
+import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData';
describe('applyCumulativeTransformToTwoDimensionalBarChartData', () => {
const testCases = [
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData.ts
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToBarChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToBarChartData.ts
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts
similarity index 96%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts
index 5502581400..722716e2f5 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/applyCumulativeTransformToTwoDimensionalBarChartData.ts
@@ -5,8 +5,8 @@ import { isDefined } from 'twenty-shared/utils';
type ApplyCumulativeTransformToTwoDimensionalBarChartDataOptions = {
data: BarDatum[];
keys: string[];
- rangeMin?: number;
- rangeMax?: number;
+ rangeMin?: number | null;
+ rangeMax?: number | null;
};
export const applyCumulativeTransformToTwoDimensionalBarChartData = ({
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts
new file mode 100644
index 0000000000..f7b83241bb
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/buildTwoDimensionalBarChartData.ts
@@ -0,0 +1,38 @@
+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;
+};
+
+export const buildTwoDimensionalBarChartData = ({
+ processedDataPoints,
+ indexByKey,
+}: BuildTwoDimensionalBarChartDataParams): BuildTwoDimensionalBarChartDataResult => {
+ const dataMap = new Map();
+ const yValues = new Set();
+
+ 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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts
new file mode 100644
index 0000000000..edee0a3e3d
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/limitTwoDimensionalBarChartData.ts
@@ -0,0 +1,80 @@
+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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts
index 979f41fb10..e345fe7df4 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum.ts
@@ -23,9 +23,11 @@ export const sortBarChartDataBySecondaryDimensionSum = ({
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);
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts
new file mode 100644
index 0000000000..2c6a047556
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/sortTwoDimensionalBarChartData.ts
@@ -0,0 +1,89 @@
+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 GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData';
+import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually';
+import { type BarDatum } from '@nivo/bar';
+import { isDefined } from 'twenty-shared/utils';
+import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql';
+
+type SortTwoDimensionalBarChartDataConfiguration = {
+ data: BarDatum[];
+ keys: string[];
+ indexByKey: string;
+ configuration: BarChartConfiguration;
+ primaryAxisFormattedToRawLookup: Map;
+ primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
+ secondaryAxisFormattedToRawLookup?: Map;
+ secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
+};
+
+type SortTwoDimensionalBarChartDataResult = {
+ sortedData: BarDatum[];
+ sortedKeys: string[];
+ sortedSeries: BarChartSeries[];
+};
+
+export const sortTwoDimensionalBarChartData = ({
+ data,
+ keys,
+ indexByKey,
+ configuration: {
+ primaryAxisOrderBy,
+ primaryAxisManualSortOrder,
+ secondaryAxisOrderBy,
+ secondaryAxisManualSortOrder,
+ color,
+ },
+ primaryAxisFormattedToRawLookup,
+ primaryAxisSelectFieldOptions,
+ secondaryAxisFormattedToRawLookup,
+ secondaryAxisSelectFieldOptions,
+}: SortTwoDimensionalBarChartDataConfiguration): SortTwoDimensionalBarChartDataResult => {
+ const sortedKeys = sortSecondaryAxisData({
+ items: keys,
+ orderBy: secondaryAxisOrderBy,
+ manualSortOrder: secondaryAxisManualSortOrder,
+ formattedToRawLookup: secondaryAxisFormattedToRawLookup,
+ selectFieldOptions: secondaryAxisSelectFieldOptions,
+ getFormattedValue: (item) => item,
+ });
+
+ const sortedSeries: BarChartSeries[] = sortedKeys.map((key) => ({
+ key,
+ label: key,
+ color: color as GraphColor,
+ }));
+
+ 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 = sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually({
+ data,
+ orderBy: primaryAxisOrderBy,
+ manualSortOrder: primaryAxisManualSortOrder,
+ formattedToRawLookup: primaryAxisFormattedToRawLookup,
+ getFormattedValue: (datum) => datum[indexByKey] as string,
+ selectFieldOptions: primaryAxisSelectFieldOptions,
+ });
+ }
+ }
+
+ return {
+ sortedData,
+ sortedKeys,
+ sortedSeries,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts
index b0ed418312..9dc8692490 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData.ts
@@ -1,20 +1,20 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
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 GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { applyCumulativeTransformToBarChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToBarChartData';
-import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
-import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
-import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
-import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
+import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults';
+import { sortChartData } from '@/page-layout/widgets/graph/utils/sortChartData';
import { type BarDatum } from '@nivo/bar';
-import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
+import {
+ type FirstDayOfTheWeek,
+ isFieldMetadataSelectKind,
+} from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToBarChartDataParams = {
@@ -59,55 +59,43 @@ export const transformOneDimensionalGroupByToBarChartData = ({
? `${aggregateField.name}-aggregate`
: aggregateField.name;
- // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
- const limitedResults = rawResults.slice(
+ const { processedDataPoints, formattedToRawLookup } =
+ processOneDimensionalGroupByResults({
+ rawResults,
+ groupByFieldX,
+ aggregateField,
+ configuration,
+ aggregateOperation,
+ objectMetadataItem,
+ primaryAxisSubFieldName,
+ userTimezone,
+ firstDayOfTheWeek,
+ });
+
+ const unsortedData: BarDatum[] = processedDataPoints.map(
+ ({ xValue, aggregateValue }) => ({
+ [indexByKey]: xValue,
+ [aggregateValueKey]: aggregateValue,
+ }),
+ );
+
+ const sortedData = sortChartData({
+ 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 formattedValues = formatPrimaryDimensionValues({
- groupByRawResults: limitedResults,
- primaryAxisGroupByField: groupByFieldX,
- primaryAxisDateGranularity:
- configuration.primaryAxisDateGranularity ?? undefined,
- primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
-
- const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
-
- const data: BarDatum[] = limitedResults.map((result) => {
- const dimensionValues = result.groupByDimensionValues;
-
- const xValue = isDefined(dimensionValues?.[0])
- ? formatDimensionValue({
- value: dimensionValues[0],
- fieldMetadata: groupByFieldX,
- dateGranularity:
- configuration.primaryAxisDateGranularity ?? undefined,
- subFieldName:
- configuration.primaryAxisGroupBySubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- })
- : '';
-
- const aggregateValue = computeAggregateValueFromGroupByResult({
- rawResult: result,
- aggregateField,
- aggregateOperation:
- configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
- aggregateOperationFromRawResult: aggregateOperation,
- objectMetadataItem,
- });
-
- return {
- [indexByKey]: xValue,
- [aggregateValueKey]: aggregateValue,
- };
- });
-
const series: BarChartSeries[] = [
{
key: aggregateValueKey,
@@ -118,12 +106,12 @@ export const transformOneDimensionalGroupByToBarChartData = ({
const finalData = configuration.isCumulative
? applyCumulativeTransformToBarChartData({
- data,
+ data: limitedSortedData,
aggregateKey: aggregateValueKey,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
- : data;
+ : limitedSortedData;
return {
data: finalData,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts
index fd1de67bb5..8a5950afa1 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData.ts
@@ -1,25 +1,17 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
-import { BAR_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartConstants';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
-import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/sortBarChartDataBySecondaryDimensionSum';
-import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
+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 GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { applyCumulativeTransformToTwoDimensionalBarChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToTwoDimensionalBarChartData';
-import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
-import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
-import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
-import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
-import { getSortedKeys } from '@/page-layout/widgets/graph/utils/getSortedKeys';
+import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
import { type BarDatum } from '@nivo/bar';
-import { isDefined, type FirstDayOfTheWeek } from 'twenty-shared/utils';
-import {
- BarChartGroupMode,
- type BarChartConfiguration,
-} from '~/generated/graphql';
+import { type FirstDayOfTheWeek } from 'twenty-shared/utils';
+import { type BarChartConfiguration } from '~/generated/graphql';
type TransformTwoDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
@@ -60,136 +52,59 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
subFieldName: primaryAxisSubFieldName ?? undefined,
});
- const dataMap = new Map();
- const xValues = new Set();
- const yValues = new Set();
- const formattedValues = formatPrimaryDimensionValues({
- groupByRawResults: rawResults,
- primaryAxisGroupByField: groupByFieldX,
- primaryAxisDateGranularity:
- configuration.primaryAxisDateGranularity ?? undefined,
- primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
- const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
-
- let hasTooManyGroups = false;
-
- rawResults.forEach((result) => {
- const dimensionValues = result.groupByDimensionValues;
- if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
-
- const xValue = formatDimensionValue({
- value: dimensionValues[0],
- fieldMetadata: groupByFieldX,
- dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
- subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
-
- const yValue = formatDimensionValue({
- value: dimensionValues[1],
- fieldMetadata: groupByFieldY,
- dateGranularity:
- configuration.secondaryAxisGroupByDateGranularity ?? undefined,
- subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
-
- if (isDefined(dimensionValues[0])) {
- formattedToRawLookup.set(xValue, dimensionValues[0] as RawDimensionValue);
- }
-
- // TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
- const isNewX = !xValues.has(xValue);
- const isNewY = !yValues.has(yValue);
-
- if (configuration.groupMode === BarChartGroupMode.STACKED) {
- if (
- isNewX &&
- xValues.size >= BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS
- ) {
- hasTooManyGroups = true;
- return;
- }
- }
-
- if (configuration.groupMode === BarChartGroupMode.GROUPED) {
- const totalUniqueDimensions = xValues.size * yValues.size;
- const additionalDimensions =
- (isNewX ? 1 : 0) * yValues.size + (isNewY ? 1 : 0) * xValues.size;
-
- if (
- totalUniqueDimensions + additionalDimensions >
- BAR_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_BARS
- ) {
- hasTooManyGroups = true;
- return;
- }
- }
-
- const aggregateValue = computeAggregateValueFromGroupByResult({
- rawResult: result,
+ const { processedDataPoints, formattedToRawLookup, yFormattedToRawLookup } =
+ processTwoDimensionalGroupByResults({
+ rawResults,
+ groupByFieldX,
+ groupByFieldY,
aggregateField,
- aggregateOperation:
- configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
- aggregateOperationFromRawResult: aggregateOperation,
+ configuration,
+ aggregateOperation,
objectMetadataItem,
+ primaryAxisSubFieldName,
+ userTimezone,
+ firstDayOfTheWeek,
});
- if (!isDefined(aggregateValue)) return;
-
- xValues.add(xValue);
- yValues.add(yValue);
-
- if (!dataMap.has(xValue)) {
- dataMap.set(xValue, {
- [indexByKey]: xValue,
- });
- }
-
- const dataItem = dataMap.get(xValue)!;
- dataItem[yValue] = aggregateValue;
+ const { unsortedData, yValues } = buildTwoDimensionalBarChartData({
+ processedDataPoints,
+ indexByKey,
});
- // Sorting needed because yValues may be unordered despite BE orderBy, if there are empty groups
- const keys = getSortedKeys({
- orderByY: configuration.secondaryAxisOrderBy,
- yValues: Array.from(yValues),
- });
+ const { sortedData, sortedKeys, sortedSeries } =
+ sortTwoDimensionalBarChartData({
+ data: unsortedData,
+ keys: Array.from(yValues),
+ indexByKey,
+ configuration,
+ primaryAxisFormattedToRawLookup: formattedToRawLookup,
+ primaryAxisSelectFieldOptions: groupByFieldX.options,
+ secondaryAxisFormattedToRawLookup: yFormattedToRawLookup,
+ secondaryAxisSelectFieldOptions: groupByFieldY.options,
+ });
- const series: BarChartSeries[] = keys.map((key) => ({
- key,
- label: key,
- color: configuration.color as GraphColor,
- }));
-
- const unsortedData = Array.from(dataMap.values());
- const sortedData = isDefined(configuration.primaryAxisOrderBy)
- ? sortBarChartDataBySecondaryDimensionSum({
- data: unsortedData,
- keys,
- orderBy: configuration.primaryAxisOrderBy,
- })
- : unsortedData;
+ const { limitedData, limitedKeys, limitedSeries, hasTooManyGroups } =
+ limitTwoDimensionalBarChartData({
+ sortedData,
+ sortedKeys,
+ sortedSeries,
+ groupMode: configuration.groupMode,
+ });
const finalData = configuration.isCumulative
? applyCumulativeTransformToTwoDimensionalBarChartData({
- data: sortedData,
- keys,
- rangeMin: configuration.rangeMin ?? undefined,
- rangeMax: configuration.rangeMax ?? undefined,
+ data: limitedData,
+ keys: limitedKeys,
+ rangeMin: configuration.rangeMin,
+ rangeMax: configuration.rangeMax,
})
- : sortedData;
+ : limitedData;
return {
data: finalData,
indexBy: indexByKey,
- keys,
- series,
+ keys: limitedKeys,
+ series: limitedSeries,
hasTooManyGroups,
formattedToRawLookup,
};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts
index 72f9cf17af..aa7387db62 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/hooks/useGraphLineChartWidgetData.ts
@@ -4,7 +4,7 @@ import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLin
import { getLineChartQueryLimit } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/getLineChartQueryLimit';
import { useGraphWidgetGroupByQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetGroupByQuery';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData';
+import { transformGroupByDataToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData';
import { useUserFirstDayOfTheWeek } from '@/ui/input/components/internal/date/hooks/useUserFirstDayOfTheWeek';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useMemo } from 'react';
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/__snapshots__/applyCumulativeTransformToLineChartData.test.ts.snap
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts
similarity index 92%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts
index f4548240cd..19ea8c5537 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/applyCumulativeTransformToLineChartData.test.ts
@@ -1,4 +1,4 @@
-import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData';
+import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData';
describe('applyCumulativeTransformToLineChartData', () => {
const testCases = [
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts
similarity index 97%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts
index 89d9d555ac..e1375dcaca 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformOneDimensionalGroupByToLineChartData.test.ts
@@ -1,5 +1,6 @@
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 {
@@ -8,7 +9,6 @@ import {
WidgetConfigurationType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
-import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData';
describe('transformOneDimensionalGroupByToLineChartData', () => {
const userTimezone = 'Europe/Paris';
@@ -97,7 +97,7 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
expect(result.hasTooManyGroups).toBe(false);
});
- it('should filter out null aggregate values', () => {
+ it('should treat null aggregate values as zero', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['Stage A'],
@@ -127,6 +127,7 @@ describe('transformOneDimensionalGroupByToLineChartData', () => {
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);
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts
similarity index 97%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts
index be575208cf..b442b63dbc 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/__tests__/transformTwoDimensionalGroupByToLineChartData.test.ts
@@ -1,5 +1,6 @@
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 {
@@ -8,7 +9,6 @@ import {
WidgetConfigurationType,
type LineChartConfiguration,
} from '~/generated-metadata/graphql';
-import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData';
describe('transformTwoDimensionalGroupByToLineChartData', () => {
const userTimezone = 'Europe/Paris';
@@ -206,7 +206,7 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
expect(result.hasTooManyGroups).toBe(false);
});
- it('should filter out null aggregate values', () => {
+ it('should convert null aggregate values to zero', () => {
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2024-01-01', 'Stage A'],
@@ -235,8 +235,8 @@ describe('transformTwoDimensionalGroupByToLineChartData', () => {
firstDayOfTheWeek: FirstDayOfTheWeek.MONDAY,
});
- expect(result.series[0].data).toHaveLength(2);
- expect(result.series[0].data.map((d) => d.y)).toEqual([100, 200]);
+ 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);
});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData.ts
similarity index 100%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/applyCumulativeTransformToLineChartData.ts
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts
new file mode 100644
index 0000000000..5331140598
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/buildTwoDimensionalLineChartSeries.ts
@@ -0,0 +1,57 @@
+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>();
+ const allXValues: string[] = [];
+ const xValueSet = new Set();
+
+ 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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts
new file mode 100644
index 0000000000..db9e150313
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/limitTwoDimensionalLineChartData.ts
@@ -0,0 +1,59 @@
+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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts
new file mode 100644
index 0000000000..8a589c3e17
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortLineChartDataBySecondaryDimensionSum.ts
@@ -0,0 +1,56 @@
+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(
+ 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;
+ }),
+ }));
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts
new file mode 100644
index 0000000000..366f5a5411
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/sortTwoDimensionalLineChartData.ts
@@ -0,0 +1,81 @@
+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 RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData';
+import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually';
+import { isDefined } from 'twenty-shared/utils';
+import { type LineChartConfiguration, GraphOrderBy } from '~/generated/graphql';
+
+type SortTwoDimensionalLineChartDataConfiguration = {
+ series: LineChartSeries[];
+ configuration: LineChartConfiguration;
+ primaryAxisFormattedToRawLookup: Map;
+ primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
+ secondaryAxisFormattedToRawLookup?: Map;
+ secondaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
+};
+
+type SortTwoDimensionalLineChartDataResult = {
+ sortedSeries: LineChartSeries[];
+};
+
+export const sortTwoDimensionalLineChartData = ({
+ series,
+ configuration: {
+ primaryAxisOrderBy,
+ primaryAxisManualSortOrder,
+ secondaryAxisOrderBy,
+ secondaryAxisManualSortOrder,
+ },
+ primaryAxisFormattedToRawLookup,
+ primaryAxisSelectFieldOptions,
+ secondaryAxisFormattedToRawLookup,
+ secondaryAxisSelectFieldOptions,
+}: 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 =
+ sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually({
+ 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,
+ });
+
+ return {
+ sortedSeries,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts
similarity index 97%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts
index 36601675a0..d16ba62fe8 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformGroupByDataToLineChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts
@@ -8,8 +8,8 @@ import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupBy
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 { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData';
-import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData';
+import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData';
+import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData';
import {
type FirstDayOfTheWeek,
isDefined,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts
similarity index 51%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts
index fb2390e3e6..b20cd0c168 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToLineChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData.ts
@@ -1,19 +1,19 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
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 GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData';
-import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
-import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
-import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
-import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
-import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
+import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults';
+import { sortChartData } from '@/page-layout/widgets/graph/utils/sortChartData';
+import {
+ isFieldMetadataSelectKind,
+ type FirstDayOfTheWeek,
+} from 'twenty-shared/utils';
import { type LineChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToLineChartDataParams = {
@@ -45,68 +45,50 @@ export const transformOneDimensionalGroupByToLineChartData = ({
userTimezone,
firstDayOfTheWeek,
}: TransformOneDimensionalGroupByToLineChartDataParams): TransformOneDimensionalGroupByToLineChartDataResult => {
- // TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
- const limitedResults = rawResults.slice(
+ 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 = sortChartData({
+ 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 formattedValues = formatPrimaryDimensionValues({
- groupByRawResults: limitedResults,
- primaryAxisGroupByField: groupByFieldX,
- primaryAxisDateGranularity:
- configuration.primaryAxisDateGranularity ?? undefined,
- primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
-
- const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
-
- const data: LineChartDataPoint[] = limitedResults
- .map((result) => {
- const dimensionValues = result.groupByDimensionValues;
-
- const rawAggregateValue = result[aggregateOperation];
- if (!isDefined(rawAggregateValue)) {
- return null;
- }
-
- const xValue = isDefined(dimensionValues?.[0])
- ? formatDimensionValue({
- value: dimensionValues[0],
- fieldMetadata: groupByFieldX,
- dateGranularity:
- configuration.primaryAxisDateGranularity ?? undefined,
- subFieldName: primaryAxisSubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- })
- : '';
-
- const aggregateValue = computeAggregateValueFromGroupByResult({
- rawResult: result,
- aggregateField,
- aggregateOperation:
- configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
- aggregateOperationFromRawResult: aggregateOperation,
- objectMetadataItem,
- });
-
- return {
- x: xValue,
- y: aggregateValue,
- };
- })
- .filter((point) => isDefined(point));
-
const transformedData = configuration.isCumulative
? applyCumulativeTransformToLineChartData({
- data,
+ data: limitedSortedData,
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
})
- : data;
+ : limitedSortedData;
const series: LineChartSeries[] = [
{
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts
new file mode 100644
index 0000000000..a6621d3595
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData.ts
@@ -0,0 +1,97 @@
+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 GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
+import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { processTwoDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults';
+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;
+};
+
+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: configuration.color as GraphColor,
+ });
+
+ const { sortedSeries } = sortTwoDimensionalLineChartData({
+ series: unsortedSeries,
+ configuration,
+ primaryAxisFormattedToRawLookup: formattedToRawLookup,
+ primaryAxisSelectFieldOptions: groupByFieldX.options,
+ secondaryAxisFormattedToRawLookup: yFormattedToRawLookup,
+ secondaryAxisSelectFieldOptions: groupByFieldY.options,
+ });
+
+ 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,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts
index c60781b011..f84743e24a 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/hooks/useGraphPieChartWidgetData.ts
@@ -1,5 +1,4 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
-import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
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';
@@ -36,7 +35,6 @@ export const useGraphPieChartWidgetData = ({
const { objectMetadataItem } = useObjectMetadataItemById({
objectId: objectMetadataItemId,
});
- const { objectMetadataItems } = useObjectMetadataItems();
const {
data: groupByData,
@@ -58,7 +56,6 @@ export const useGraphPieChartWidgetData = ({
transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
- objectMetadataItems: objectMetadataItems ?? [],
configuration,
aggregateOperation,
userTimezone,
@@ -67,7 +64,6 @@ export const useGraphPieChartWidgetData = ({
[
groupByData,
objectMetadataItem,
- objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts
index 76af24c3e0..6375712fe9 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/__tests__/transformGroupByDataToPieChartData.test.ts
@@ -45,7 +45,6 @@ describe('transformGroupByDataToPieChartData', () => {
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
- const objectMetadataItems = [objectMetadataItem];
const configuration = {
__typename: 'PieChartConfiguration',
@@ -77,7 +76,6 @@ describe('transformGroupByDataToPieChartData', () => {
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
- objectMetadataItems,
configuration,
aggregateOperation: 'COUNT',
userTimezone,
@@ -114,7 +112,6 @@ describe('transformGroupByDataToPieChartData', () => {
namePlural: 'companies',
fields: [groupByField, aggregateField],
} as any;
- const objectMetadataItems = [objectMetadataItem];
const configuration = {
__typename: 'PieChartConfiguration',
@@ -146,7 +143,6 @@ describe('transformGroupByDataToPieChartData', () => {
const result = transformGroupByDataToPieChartData({
groupByData,
objectMetadataItem,
- objectMetadataItems,
configuration,
aggregateOperation: 'COUNT',
userTimezone,
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts
index 5dd9143939..9fb0260f38 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetPieChart/utils/transformGroupByDataToPieChartData.ts
@@ -1,6 +1,5 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
-import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupByQueryResultGqlFieldName';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from '@/page-layout/widgets/graph/graphWidgetPieChart/constants/PieChartMaximumNumberOfSlices.constant';
@@ -8,21 +7,15 @@ import { type PieChartDataItem } from '@/page-layout/widgets/graph/graphWidgetPi
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
-import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
-import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
-import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
-import {
- type FirstDayOfTheWeek,
- type ObjectRecordGroupByDateGranularity,
-} from 'twenty-shared/types';
-import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
+import { processOneDimensionalGroupByResults } from '@/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults';
+import { sortChartData } from '@/page-layout/widgets/graph/utils/sortChartData';
+import { type FirstDayOfTheWeek } from 'twenty-shared/types';
+import { isDefined, isFieldMetadataSelectKind } from 'twenty-shared/utils';
import { type PieChartConfiguration } from '~/generated/graphql';
type TransformGroupByDataToPieChartDataParams = {
groupByData: Record | null | undefined;
objectMetadataItem: ObjectMetadataItem;
- objectMetadataItems: ObjectMetadataItem[];
configuration: PieChartConfiguration;
aggregateOperation: string;
userTimezone: string;
@@ -46,7 +39,6 @@ const EMPTY_PIE_CHART_RESULT: TransformGroupByDataToPieChartDataResult = {
export const transformGroupByDataToPieChartData = ({
groupByData,
objectMetadataItem,
- objectMetadataItems,
configuration,
aggregateOperation,
userTimezone,
@@ -77,61 +69,59 @@ export const transformGroupByDataToPieChartData = ({
return EMPTY_PIE_CHART_RESULT;
}
- const isDateField = isFieldMetadataDateKind(groupByField.type);
- const isNestedDateField = isRelationNestedFieldDateKind({
- relationField: groupByField,
- relationNestedFieldName: configuration.groupBySubFieldName ?? undefined,
- objectMetadataItems,
- });
-
- const dateGranularity: ObjectRecordGroupByDateGranularity | undefined =
- isDateField || isNestedDateField
- ? (configuration.dateGranularity ?? undefined)
- : undefined;
-
const filteredResults = configuration.hideEmptyCategory
? rawResults.filter((result) =>
isDefined(result.groupByDimensionValues?.[0]),
)
: rawResults;
+ const { processedDataPoints, formattedToRawLookup } =
+ processOneDimensionalGroupByResults({
+ rawResults: filteredResults,
+ groupByFieldX: groupByField,
+ aggregateField,
+ configuration,
+ aggregateOperation,
+ objectMetadataItem,
+ primaryAxisSubFieldName: configuration.groupBySubFieldName,
+ userTimezone,
+ firstDayOfTheWeek,
+ });
+
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
- const limitedResults = filteredResults.slice(
+ const limitedProcessedDataPoints = processedDataPoints.slice(
0,
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
);
- const formattedValues = formatPrimaryDimensionValues({
- groupByRawResults: limitedResults,
- primaryAxisGroupByField: groupByField,
- primaryAxisDateGranularity: dateGranularity,
- primaryAxisGroupBySubFieldName:
- configuration.groupBySubFieldName ?? undefined,
- userTimezone,
- firstDayOfTheWeek,
- });
+ type PieChartDataItemWithRawValue = PieChartDataItem & {
+ rawValue: string | null | undefined;
+ };
- const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
-
- const data: PieChartDataItem[] = limitedResults.map((result, index) => {
- const id = formattedValues[index]?.formattedPrimaryDimensionValue ?? '';
-
- const value = computeAggregateValueFromGroupByResult({
- rawResult: result,
- aggregateField,
- aggregateOperation:
- configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
- aggregateOperationFromRawResult: aggregateOperation,
- objectMetadataItem,
- });
-
- return {
- id,
- value,
+ const unsortedDataWithRawValues: PieChartDataItemWithRawValue[] =
+ limitedProcessedDataPoints.map(({ xValue, rawXValue, aggregateValue }) => ({
+ id: xValue,
+ value: aggregateValue,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
- };
+ rawValue: isDefined(rawXValue) ? String(rawXValue) : null,
+ }));
+
+ const sortedDataWithRawValues = sortChartData({
+ data: unsortedDataWithRawValues,
+ orderBy: configuration.orderBy ?? undefined,
+ manualSortOrder: configuration.manualSortOrder ?? undefined,
+ formattedToRawLookup,
+ getFieldValue: (item) => item.id,
+ getNumericValue: (item) => item.value,
+ selectFieldOptions: isFieldMetadataSelectKind(groupByField.type)
+ ? groupByField.options
+ : undefined,
});
+ const data: PieChartDataItem[] = sortedDataWithRawValues.map(
+ ({ rawValue: _rawValue, ...item }) => item,
+ );
+
const showLegend = configuration.displayLegend ?? true;
return {
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts
new file mode 100644
index 0000000000..595dbff026
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortByManualOrder.test.ts
@@ -0,0 +1,146 @@
+import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder';
+
+describe('sortByManualOrder', () => {
+ describe('basic sorting', () => {
+ it('should sort items according to manual order', () => {
+ const items = [
+ { id: 'c', value: 3 },
+ { id: 'a', value: 1 },
+ { id: 'b', value: 2 },
+ ];
+ const manualSortOrder = ['a', 'b', 'c'];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder,
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should handle reverse order', () => {
+ const items = [
+ { id: 'a', value: 1 },
+ { id: 'b', value: 2 },
+ { id: 'c', value: 3 },
+ ];
+ const manualSortOrder = ['c', 'b', 'a'];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder,
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['c', 'b', 'a']);
+ });
+ });
+
+ describe('empty manual sort order', () => {
+ it('should return items unchanged when manual order is empty', () => {
+ const items = [
+ { id: 'c', value: 3 },
+ { id: 'a', value: 1 },
+ { id: 'b', value: 2 },
+ ];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder: [],
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result).toEqual(items);
+ });
+ });
+
+ describe('items not in manual order', () => {
+ it('should place items not in manual order at the end', () => {
+ const items = [
+ { id: 'unknown', value: 0 },
+ { id: 'b', value: 2 },
+ { id: 'a', value: 1 },
+ ];
+ const manualSortOrder = ['a', 'b'];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder,
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['a', 'b', 'unknown']);
+ });
+
+ it('should maintain relative order of items not in manual order', () => {
+ const items = [
+ { id: 'unknown1', value: 0 },
+ { id: 'a', value: 1 },
+ { id: 'unknown2', value: 0 },
+ ];
+ const manualSortOrder = ['a'];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder,
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result[0].id).toBe('a');
+ expect(result[1].id).toBe('unknown1');
+ expect(result[2].id).toBe('unknown2');
+ });
+ });
+
+ describe('null and undefined values', () => {
+ it('should handle null raw values', () => {
+ const items = [
+ { id: null as string | null, value: 0 },
+ { id: 'a', value: 1 },
+ ];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder: ['a'],
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result[0].id).toBe('a');
+ });
+
+ it('should handle undefined raw values', () => {
+ const items = [
+ { id: undefined as string | undefined, value: 0 },
+ { id: 'a', value: 1 },
+ ];
+
+ const result = sortByManualOrder({
+ items,
+ manualSortOrder: ['a'],
+ getRawValue: (item) => item.id,
+ });
+
+ expect(result[0].id).toBe('a');
+ });
+ });
+
+ describe('immutability', () => {
+ it('should not mutate the original array', () => {
+ const items = [
+ { id: 'c', value: 3 },
+ { id: 'a', value: 1 },
+ { id: 'b', value: 2 },
+ ];
+ const originalItems = [...items];
+
+ sortByManualOrder({
+ items,
+ manualSortOrder: ['a', 'b', 'c'],
+ getRawValue: (item) => item.id,
+ });
+
+ expect(items).toEqual(originalItems);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts
new file mode 100644
index 0000000000..54514cf03d
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortBySelectOptionPosition.test.ts
@@ -0,0 +1,203 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition';
+
+describe('sortBySelectOptionPosition', () => {
+ const mockOptions: FieldMetadataItemOption[] = [
+ { id: '1', value: 'NEW', label: 'New', position: 0, color: 'red' },
+ {
+ id: '2',
+ value: 'IN_PROGRESS',
+ label: 'In Progress',
+ position: 1,
+ color: 'yellow',
+ },
+ { id: '3', value: 'DONE', label: 'Done', position: 2, color: 'green' },
+ {
+ id: '4',
+ value: 'ARCHIVED',
+ label: 'Archived',
+ position: 3,
+ color: 'gray',
+ },
+ ];
+
+ const buildFormattedToRawLookup = (
+ values: { formatted: string; raw: string }[],
+ ): Map => {
+ const lookup = new Map();
+ values.forEach(({ formatted, raw }) => {
+ lookup.set(formatted, raw as RawDimensionValue);
+ });
+ return lookup;
+ };
+
+ describe('ASC direction', () => {
+ it('should sort items by select option position in ascending order', () => {
+ const items = [
+ { id: 'Done', value: 10 },
+ { id: 'New', value: 5 },
+ { id: 'In Progress', value: 8 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'Done', raw: 'DONE' },
+ { formatted: 'New', raw: 'NEW' },
+ { formatted: 'In Progress', raw: 'IN_PROGRESS' },
+ ]);
+
+ const result = sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'ASC',
+ });
+
+ expect(result.map((item) => item.id)).toEqual([
+ 'New',
+ 'In Progress',
+ 'Done',
+ ]);
+ });
+
+ it('should place items with unknown options at the end when ascending', () => {
+ const items = [
+ { id: 'Unknown', value: 1 },
+ { id: 'Done', value: 10 },
+ { id: 'New', value: 5 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'Unknown', raw: 'UNKNOWN_VALUE' },
+ { formatted: 'Done', raw: 'DONE' },
+ { formatted: 'New', raw: 'NEW' },
+ ]);
+
+ const result = sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'ASC',
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['New', 'Done', 'Unknown']);
+ });
+ });
+
+ describe('DESC direction', () => {
+ it('should sort items by select option position in descending order', () => {
+ const items = [
+ { id: 'New', value: 5 },
+ { id: 'Done', value: 10 },
+ { id: 'In Progress', value: 8 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'New', raw: 'NEW' },
+ { formatted: 'Done', raw: 'DONE' },
+ { formatted: 'In Progress', raw: 'IN_PROGRESS' },
+ ]);
+
+ const result = sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'DESC',
+ });
+
+ expect(result.map((item) => item.id)).toEqual([
+ 'Done',
+ 'In Progress',
+ 'New',
+ ]);
+ });
+
+ it('should place items with unknown options at the beginning when descending', () => {
+ const items = [
+ { id: 'New', value: 5 },
+ { id: 'Unknown', value: 1 },
+ { id: 'Done', value: 10 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'New', raw: 'NEW' },
+ { formatted: 'Unknown', raw: 'UNKNOWN_VALUE' },
+ { formatted: 'Done', raw: 'DONE' },
+ ]);
+
+ const result = sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'DESC',
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['Unknown', 'Done', 'New']);
+ });
+ });
+
+ describe('edge cases', () => {
+ it('should handle empty items array', () => {
+ const formattedToRawLookup = new Map();
+
+ const result = sortBySelectOptionPosition({
+ items: [],
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item: { id: string }) => item.id,
+ direction: 'ASC',
+ });
+
+ expect(result).toEqual([]);
+ });
+
+ it('should handle items not in lookup', () => {
+ const items = [
+ { id: 'NotInLookup', value: 5 },
+ { id: 'New', value: 10 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'New', raw: 'NEW' },
+ ]);
+
+ const result = sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'ASC',
+ });
+
+ expect(result.map((item) => item.id)).toEqual(['New', 'NotInLookup']);
+ });
+
+ it('should not mutate the original array', () => {
+ const items = [
+ { id: 'Done', value: 10 },
+ { id: 'New', value: 5 },
+ ];
+
+ const formattedToRawLookup = buildFormattedToRawLookup([
+ { formatted: 'Done', raw: 'DONE' },
+ { formatted: 'New', raw: 'NEW' },
+ ]);
+
+ const originalItems = [...items];
+
+ sortBySelectOptionPosition({
+ items,
+ options: mockOptions,
+ formattedToRawLookup,
+ getFormattedValue: (item) => item.id,
+ direction: 'ASC',
+ });
+
+ expect(items).toEqual(originalItems);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartData.test.ts
new file mode 100644
index 0000000000..c356a146f7
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortChartData.test.ts
@@ -0,0 +1,251 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortChartData } from '@/page-layout/widgets/graph/utils/sortChartData';
+import { GraphOrderBy } from '~/generated/graphql';
+
+describe('sortChartData', () => {
+ type TestDataPoint = { label: string; value: number };
+
+ const testData: TestDataPoint[] = [
+ { label: 'Beta', value: 30 },
+ { label: 'Alpha', value: 10 },
+ { label: 'Gamma', value: 20 },
+ ];
+
+ const formattedToRawLookup = new Map([
+ ['Alpha', 'ALPHA'],
+ ['Beta', 'BETA'],
+ ['Gamma', 'GAMMA'],
+ ]);
+
+ const getFieldValue = (item: TestDataPoint) => item.label;
+ const getNumericValue = (item: TestDataPoint) => item.value;
+
+ describe('null or undefined orderBy', () => {
+ it('should return data unchanged when orderBy is null', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: null,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result).toEqual(testData);
+ });
+
+ it('should return data unchanged when orderBy is undefined', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: undefined,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result).toEqual(testData);
+ });
+ });
+
+ describe('FIELD_ASC sorting', () => {
+ it('should sort by field value ascending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result.map((item) => item.label)).toEqual([
+ 'Alpha',
+ 'Beta',
+ 'Gamma',
+ ]);
+ });
+ });
+
+ describe('FIELD_DESC sorting', () => {
+ it('should sort by field value descending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result.map((item) => item.label)).toEqual([
+ 'Gamma',
+ 'Beta',
+ 'Alpha',
+ ]);
+ });
+ });
+
+ describe('VALUE_ASC sorting', () => {
+ it('should sort by numeric value ascending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.VALUE_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result.map((item) => item.value)).toEqual([10, 20, 30]);
+ });
+ });
+
+ describe('VALUE_DESC sorting', () => {
+ it('should sort by numeric value descending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.VALUE_DESC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result.map((item) => item.value)).toEqual([30, 20, 10]);
+ });
+ });
+
+ describe('MANUAL sorting', () => {
+ it('should sort by manual order', () => {
+ const manualSortOrder = ['GAMMA', 'ALPHA', 'BETA'];
+
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.MANUAL,
+ manualSortOrder,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result.map((item) => item.label)).toEqual([
+ 'Gamma',
+ 'Alpha',
+ 'Beta',
+ ]);
+ });
+
+ it('should return data unchanged when manual order is undefined', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.MANUAL,
+ manualSortOrder: undefined,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result).toEqual(testData);
+ });
+
+ it('should return data unchanged when manual order is null', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.MANUAL,
+ manualSortOrder: null,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(result).toEqual(testData);
+ });
+ });
+
+ describe('FIELD_POSITION_ASC sorting', () => {
+ const selectFieldOptions: FieldMetadataItemOption[] = [
+ { id: '1', value: 'ALPHA', label: 'Alpha', position: 2, color: 'blue' },
+ { id: '2', value: 'BETA', label: 'Beta', position: 0, color: 'red' },
+ { id: '3', value: 'GAMMA', label: 'Gamma', position: 1, color: 'green' },
+ ];
+
+ it('should sort by select option position ascending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_POSITION_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ selectFieldOptions,
+ });
+
+ expect(result.map((item) => item.label)).toEqual([
+ 'Beta',
+ 'Gamma',
+ 'Alpha',
+ ]);
+ });
+
+ it('should throw error when select options are not provided', () => {
+ expect(() =>
+ sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_POSITION_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ }),
+ ).toThrow('Select field options are required');
+ });
+
+ it('should throw error when select options are empty', () => {
+ expect(() =>
+ sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_POSITION_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ selectFieldOptions: [],
+ }),
+ ).toThrow('Select field options are required');
+ });
+ });
+
+ describe('FIELD_POSITION_DESC sorting', () => {
+ const selectFieldOptions: FieldMetadataItemOption[] = [
+ { id: '1', value: 'ALPHA', label: 'Alpha', position: 2, color: 'blue' },
+ { id: '2', value: 'BETA', label: 'Beta', position: 0, color: 'red' },
+ { id: '3', value: 'GAMMA', label: 'Gamma', position: 1, color: 'green' },
+ ];
+
+ it('should sort by select option position descending', () => {
+ const result = sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_POSITION_DESC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ selectFieldOptions,
+ });
+
+ expect(result.map((item) => item.label)).toEqual([
+ 'Alpha',
+ 'Gamma',
+ 'Beta',
+ ]);
+ });
+ });
+
+ describe('immutability', () => {
+ it('should not mutate the original data array', () => {
+ const originalData = [...testData];
+
+ sortChartData({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ });
+
+ expect(testData).toEqual(originalData);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts
new file mode 100644
index 0000000000..f1be6f302b
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortOptionsForManualOrder.test.ts
@@ -0,0 +1,94 @@
+import { sortOptionsForManualOrder } from '@/page-layout/widgets/graph/utils/sortOptionsForManualOrder';
+
+describe('sortOptionsForManualOrder', () => {
+ describe('without manual sort order', () => {
+ it('should sort by position when no manual order provided', () => {
+ const options = [
+ { value: 'c', position: 2 },
+ { value: 'a', position: 0 },
+ { value: 'b', position: 1 },
+ ];
+
+ const result = sortOptionsForManualOrder(options, undefined);
+
+ expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should sort by position when manual order is null', () => {
+ const options = [
+ { value: 'c', position: 2 },
+ { value: 'a', position: 0 },
+ { value: 'b', position: 1 },
+ ];
+
+ const result = sortOptionsForManualOrder(options, null);
+
+ expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should sort by position when manual order is empty', () => {
+ const options = [
+ { value: 'c', position: 2 },
+ { value: 'a', position: 0 },
+ { value: 'b', position: 1 },
+ ];
+
+ const result = sortOptionsForManualOrder(options, []);
+
+ expect(result.map((option) => option.value)).toEqual(['a', 'b', 'c']);
+ });
+
+ it('should handle null positions as 0', () => {
+ const options = [
+ { value: 'b', position: 1 },
+ { value: 'a', position: null },
+ ];
+
+ const result = sortOptionsForManualOrder(options, undefined);
+
+ expect(result.map((option) => option.value)).toEqual(['a', 'b']);
+ });
+ });
+
+ describe('with manual sort order', () => {
+ it('should sort according to manual order', () => {
+ const options = [
+ { value: 'a', position: 0 },
+ { value: 'b', position: 1 },
+ { value: 'c', position: 2 },
+ ];
+ const manualSortOrder = ['c', 'a', 'b'];
+
+ const result = sortOptionsForManualOrder(options, manualSortOrder);
+
+ expect(result.map((option) => option.value)).toEqual(['c', 'a', 'b']);
+ });
+
+ it('should place options not in manual order at the end', () => {
+ const options = [
+ { value: 'a', position: 0 },
+ { value: 'b', position: 1 },
+ { value: 'c', position: 2 },
+ ];
+ const manualSortOrder = ['b'];
+
+ const result = sortOptionsForManualOrder(options, manualSortOrder);
+
+ expect(result.map((option) => option.value)).toEqual(['b', 'a', 'c']);
+ });
+ });
+
+ describe('immutability', () => {
+ it('should not mutate the original array', () => {
+ const options = [
+ { value: 'c', position: 2 },
+ { value: 'a', position: 0 },
+ ];
+ const originalOptions = [...options];
+
+ sortOptionsForManualOrder(options, ['a', 'c']);
+
+ expect(options).toEqual(originalOptions);
+ });
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts
new file mode 100644
index 0000000000..65dacdaa0c
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortSecondaryAxisData.test.ts
@@ -0,0 +1,43 @@
+import { sortSecondaryAxisData } from '@/page-layout/widgets/graph/utils/sortSecondaryAxisData';
+import { GraphOrderBy } from '~/generated/graphql';
+
+type TestItem = { label: string };
+
+const testItems: TestItem[] = [
+ { label: 'Beta' },
+ { label: 'Alpha' },
+ { label: 'Gamma' },
+];
+const getFormattedValue = (item: TestItem) => item.label;
+
+describe('sortSecondaryAxisData', () => {
+ it('should sort by FIELD_ASC', () => {
+ const result = sortSecondaryAxisData({
+ items: testItems,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ getFormattedValue,
+ });
+
+ expect(result.map((i) => i.label)).toEqual(['Alpha', 'Beta', 'Gamma']);
+ });
+
+ it('should sort by FIELD_DESC', () => {
+ const result = sortSecondaryAxisData({
+ items: testItems,
+ orderBy: GraphOrderBy.FIELD_DESC,
+ getFormattedValue,
+ });
+
+ expect(result.map((i) => i.label)).toEqual(['Gamma', 'Beta', 'Alpha']);
+ });
+
+ it('should return items unchanged when orderBy is undefined', () => {
+ const result = sortSecondaryAxisData({
+ items: testItems,
+ orderBy: undefined,
+ getFormattedValue,
+ });
+
+ expect(result).toEqual(testItems);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts
new file mode 100644
index 0000000000..8a84fc83e5
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.test.ts
@@ -0,0 +1,52 @@
+import { sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually } from '@/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually';
+import { GraphOrderBy } from '~/generated/graphql';
+
+type TestItem = { label: string };
+
+const testData: TestItem[] = [
+ { label: 'Beta' },
+ { label: 'Alpha' },
+ { label: 'Gamma' },
+];
+const formattedToRawLookup = new Map([
+ ['Alpha', 'ALPHA'],
+ ['Beta', 'BETA'],
+ ['Gamma', 'GAMMA'],
+]);
+const getFormattedValue = (item: TestItem) => item.label;
+
+describe('sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually', () => {
+ it('should sort by FIELD_ASC', () => {
+ const result = sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually({
+ data: testData,
+ orderBy: GraphOrderBy.FIELD_ASC,
+ formattedToRawLookup,
+ getFormattedValue,
+ });
+
+ expect(result.map((i) => i.label)).toEqual(['Alpha', 'Beta', 'Gamma']);
+ });
+
+ it('should sort by MANUAL order', () => {
+ const result = sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually({
+ data: testData,
+ orderBy: GraphOrderBy.MANUAL,
+ manualSortOrder: ['GAMMA', 'ALPHA', 'BETA'],
+ formattedToRawLookup,
+ getFormattedValue,
+ });
+
+ expect(result.map((i) => i.label)).toEqual(['Gamma', 'Alpha', 'Beta']);
+ });
+
+ it('should return data unchanged when orderBy is undefined', () => {
+ const result = sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually({
+ data: testData,
+ orderBy: undefined,
+ formattedToRawLookup,
+ getFormattedValue,
+ });
+
+ expect(result).toEqual(testData);
+ });
+});
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts
index f193f7d002..f590d78aa7 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts
@@ -122,19 +122,21 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
| ObjectRecordOrderByForRelationField
> = [];
- orderBy.push(
- getGroupByOrderBy({
- graphOrderBy:
- chartConfiguration.primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
- groupByField: groupByFieldX,
- groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
- aggregateOperation,
- dateGranularity: shouldApplyDateGranularityX
- ? (chartConfiguration.primaryAxisDateGranularity ??
- GRAPH_DEFAULT_DATE_GRANULARITY)
- : undefined,
- }),
- );
+ const primaryAxisOrderBy = getGroupByOrderBy({
+ graphOrderBy:
+ chartConfiguration.primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
+ groupByField: groupByFieldX,
+ groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
+ aggregateOperation,
+ dateGranularity: shouldApplyDateGranularityX
+ ? (chartConfiguration.primaryAxisDateGranularity ??
+ GRAPH_DEFAULT_DATE_GRANULARITY)
+ : undefined,
+ });
+
+ if (isDefined(primaryAxisOrderBy)) {
+ orderBy.push(primaryAxisOrderBy);
+ }
if (isDefined(groupByFieldY)) {
const isFieldYDateForOrderBy = isFieldMetadataDateKind(groupByFieldY.type);
@@ -148,20 +150,21 @@ export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
const shouldApplyDateGranularityYForOrderBy =
isFieldYDateForOrderBy || isFieldYNestedDateForOrderBy;
- orderBy.push(
- getGroupByOrderBy({
- graphOrderBy:
- chartConfiguration.secondaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
- groupByField: groupByFieldY,
- groupBySubFieldName:
- chartConfiguration.secondaryAxisGroupBySubFieldName,
- aggregateOperation,
- dateGranularity: shouldApplyDateGranularityYForOrderBy
- ? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
- GRAPH_DEFAULT_DATE_GRANULARITY)
- : undefined,
- }),
- );
+ const secondaryAxisOrderBy = getGroupByOrderBy({
+ graphOrderBy:
+ chartConfiguration.secondaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
+ groupByField: groupByFieldY,
+ groupBySubFieldName: chartConfiguration.secondaryAxisGroupBySubFieldName,
+ aggregateOperation,
+ dateGranularity: shouldApplyDateGranularityYForOrderBy
+ ? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
+ GRAPH_DEFAULT_DATE_GRANULARITY)
+ : undefined,
+ });
+
+ if (isDefined(secondaryAxisOrderBy)) {
+ orderBy.push(secondaryAxisOrderBy);
+ }
}
return {
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts
index abe7cdec07..e9e873c693 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromPieChartConfiguration.ts
@@ -80,17 +80,19 @@ export const generateGroupByQueryVariablesFromPieChartConfiguration = ({
> = [];
if (isDefined(chartConfiguration.orderBy)) {
- orderBy.push(
- getGroupByOrderBy({
- graphOrderBy: chartConfiguration.orderBy,
- groupByField,
- groupBySubFieldName,
- aggregateOperation,
- dateGranularity: shouldApplyDateGranularity
- ? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
- : undefined,
- }),
- );
+ const orderByItem = getGroupByOrderBy({
+ graphOrderBy: chartConfiguration.orderBy,
+ groupByField,
+ groupBySubFieldName,
+ aggregateOperation,
+ dateGranularity: shouldApplyDateGranularity
+ ? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
+ : undefined,
+ });
+
+ if (isDefined(orderByItem)) {
+ orderBy.push(orderByItem);
+ }
}
return {
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts
index 15ddfea37c..70a99cce4a 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getGroupByOrderBy.ts
@@ -29,7 +29,8 @@ export const getGroupByOrderBy = ({
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForCompositeField
- | ObjectRecordOrderByForRelationField => {
+ | ObjectRecordOrderByForRelationField
+ | undefined => {
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
case GraphOrderBy.FIELD_DESC:
@@ -53,6 +54,10 @@ export const getGroupByOrderBy = ({
},
};
}
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ case GraphOrderBy.FIELD_POSITION_DESC:
+ case GraphOrderBy.MANUAL:
+ return undefined;
default:
assertUnreachable(graphOrderBy);
}
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getSortedKeys.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getSortedKeys.ts
deleted file mode 100644
index 4cc1eb564f..0000000000
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/getSortedKeys.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { GraphOrderBy } from '~/generated/graphql';
-
-export const getSortedKeys = ({
- orderByY,
- yValues,
-}: {
- orderByY?: GraphOrderBy | null;
- yValues: string[];
-}) => {
- switch (orderByY) {
- case GraphOrderBy.FIELD_ASC:
- return Array.from(yValues).sort((a, b) => a.localeCompare(b));
- case GraphOrderBy.FIELD_DESC:
- return Array.from(yValues).sort((a, b) => b.localeCompare(a));
- default:
- return Array.from(yValues);
- }
-};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts
index a4a9553044..e0b4c200da 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/mapOrderByToDirection.ts
@@ -3,7 +3,11 @@ import { assertUnreachable } from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
export const mapOrderByToDirection = (
- orderByEnum: GraphOrderBy,
+ orderByEnum:
+ | GraphOrderBy.FIELD_ASC
+ | GraphOrderBy.FIELD_DESC
+ | GraphOrderBy.VALUE_ASC
+ | GraphOrderBy.VALUE_DESC,
): OrderByDirection => {
switch (orderByEnum) {
case GraphOrderBy.FIELD_ASC:
@@ -14,6 +18,7 @@ export const mapOrderByToDirection = (
return OrderByDirection.AscNullsLast;
case GraphOrderBy.VALUE_DESC:
return OrderByDirection.DescNullsLast;
+
default:
assertUnreachable(orderByEnum);
}
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts
new file mode 100644
index 0000000000..b9ab19f006
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.ts
@@ -0,0 +1,112 @@
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
+import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
+import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
+import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
+import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
+import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
+import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
+import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
+
+type OneDimensionalChartConfiguration = {
+ primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null;
+ primaryAxisGroupBySubFieldName?: string | null;
+ aggregateOperation: string;
+};
+
+type ProcessOneDimensionalGroupByResultsParams = {
+ rawResults: GroupByRawResult[];
+ groupByFieldX: FieldMetadataItem;
+ aggregateField: FieldMetadataItem;
+ configuration: OneDimensionalChartConfiguration;
+ aggregateOperation: string;
+ objectMetadataItem: ObjectMetadataItem;
+ primaryAxisSubFieldName?: string | null;
+ userTimezone: string;
+ firstDayOfTheWeek: FirstDayOfTheWeek;
+};
+
+export type ProcessedOneDimensionalDataPoint = {
+ xValue: string;
+ rawXValue: RawDimensionValue;
+ aggregateValue: number;
+};
+
+export type ProcessOneDimensionalGroupByResultsOutput = {
+ processedDataPoints: ProcessedOneDimensionalDataPoint[];
+ formattedToRawLookup: Map;
+};
+
+export const processOneDimensionalGroupByResults = ({
+ rawResults,
+ groupByFieldX,
+ aggregateField,
+ configuration,
+ aggregateOperation,
+ objectMetadataItem,
+ primaryAxisSubFieldName,
+ userTimezone,
+ firstDayOfTheWeek,
+}: ProcessOneDimensionalGroupByResultsParams): ProcessOneDimensionalGroupByResultsOutput => {
+ const formattedValues = formatPrimaryDimensionValues({
+ groupByRawResults: rawResults,
+ primaryAxisGroupByField: groupByFieldX,
+ primaryAxisDateGranularity:
+ configuration.primaryAxisDateGranularity ?? undefined,
+ primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
+ userTimezone,
+ firstDayOfTheWeek,
+ });
+
+ const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
+ const processedDataPoints: ProcessedOneDimensionalDataPoint[] = [];
+
+ for (const result of rawResults) {
+ const dimensionValues = result.groupByDimensionValues;
+
+ if (!isDefined(dimensionValues) || dimensionValues.length < 1) {
+ continue;
+ }
+
+ const rawXValue = dimensionValues[0] as RawDimensionValue;
+
+ const xValue = formatDimensionValue({
+ value: rawXValue,
+ fieldMetadata: groupByFieldX,
+ dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
+ subFieldName: primaryAxisSubFieldName ?? undefined,
+ userTimezone,
+ firstDayOfTheWeek,
+ });
+
+ if (isDefined(rawXValue)) {
+ formattedToRawLookup.set(xValue, rawXValue);
+ }
+
+ const aggregateValue = computeAggregateValueFromGroupByResult({
+ rawResult: result,
+ aggregateField,
+ aggregateOperation:
+ configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
+ aggregateOperationFromRawResult: aggregateOperation,
+ objectMetadataItem,
+ });
+
+ if (!isDefined(aggregateValue)) {
+ continue;
+ }
+
+ processedDataPoints.push({
+ xValue,
+ rawXValue,
+ aggregateValue,
+ });
+ }
+
+ return {
+ processedDataPoints,
+ formattedToRawLookup,
+ };
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts
similarity index 50%
rename from packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts
rename to packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts
index 955feb18ce..89c0259df1 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToLineChartData.ts
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processTwoDimensionalGroupByResults.ts
@@ -1,27 +1,29 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
-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 { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
-import { applyCumulativeTransformToLineChartData } from '@/page-layout/widgets/graph/utils/applyCumulativeTransformToLineChartData';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
-import { sortLineChartSeries } from '@/page-layout/widgets/graph/utils/sortLineChartSeries';
+import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
-import { type LineChartConfiguration } from '~/generated/graphql';
-type TransformTwoDimensionalGroupByToLineChartDataParams = {
+type TwoDimensionalChartConfiguration = {
+ primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null;
+ primaryAxisGroupBySubFieldName?: string | null;
+ secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null;
+ secondaryAxisGroupBySubFieldName?: string | null;
+ aggregateOperation: string;
+};
+
+type ProcessTwoDimensionalGroupByResultsParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
- configuration: LineChartConfiguration;
+ configuration: TwoDimensionalChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
@@ -29,13 +31,21 @@ type TransformTwoDimensionalGroupByToLineChartDataParams = {
firstDayOfTheWeek: FirstDayOfTheWeek;
};
-type TransformTwoDimensionalGroupByToLineChartDataResult = {
- series: LineChartSeries[];
- hasTooManyGroups: boolean;
- formattedToRawLookup: Map;
+export type ProcessedTwoDimensionalDataPoint = {
+ xValue: string;
+ yValue: string;
+ rawXValue: RawDimensionValue;
+ rawYValue: RawDimensionValue;
+ aggregateValue: number;
};
-export const transformTwoDimensionalGroupByToLineChartData = ({
+export type ProcessTwoDimensionalGroupByResultsOutput = {
+ processedDataPoints: ProcessedTwoDimensionalDataPoint[];
+ formattedToRawLookup: Map;
+ yFormattedToRawLookup: Map;
+};
+
+export const processTwoDimensionalGroupByResults = ({
rawResults,
groupByFieldX,
groupByFieldY,
@@ -46,11 +56,7 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
-}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => {
- const seriesMap = new Map>();
- const allXValues: string[] = [];
- const xValueSet = new Set();
-
+}: ProcessTwoDimensionalGroupByResultsParams): ProcessTwoDimensionalGroupByResultsOutput => {
const formattedValues = formatPrimaryDimensionValues({
groupByRawResults: rawResults,
primaryAxisGroupByField: groupByFieldX,
@@ -62,17 +68,21 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
});
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
- let hasTooManyGroups = false;
+ const yFormattedToRawLookup = new Map();
+ const processedDataPoints: ProcessedTwoDimensionalDataPoint[] = [];
- rawResults.forEach((result) => {
+ for (const result of rawResults) {
const dimensionValues = result.groupByDimensionValues;
- if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
- const rawAggregateValue = result[aggregateOperation];
- if (!isDefined(rawAggregateValue)) return;
+ if (!isDefined(dimensionValues) || dimensionValues.length < 2) {
+ continue;
+ }
+
+ const rawXValue = dimensionValues[0] as RawDimensionValue;
+ const rawYValue = dimensionValues[1] as RawDimensionValue;
const xValue = formatDimensionValue({
- value: dimensionValues[0],
+ value: rawXValue,
fieldMetadata: groupByFieldX,
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
@@ -80,26 +90,8 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
firstDayOfTheWeek,
});
- // TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
- const isNewX = !xValueSet.has(xValue);
-
- if (
- isNewX &&
- xValueSet.size >= LINE_CHART_CONSTANTS.MAXIMUM_NUMBER_OF_DATA_POINTS
- ) {
- hasTooManyGroups = true;
- return;
- }
-
- if (isNewX) {
- xValueSet.add(xValue);
- allXValues.push(xValue);
- }
-
- const seriesRawValue = dimensionValues[1];
-
- const seriesKey = formatDimensionValue({
- value: seriesRawValue,
+ const yValue = formatDimensionValue({
+ value: rawYValue,
fieldMetadata: groupByFieldY,
dateGranularity:
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
@@ -108,6 +100,14 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
firstDayOfTheWeek,
});
+ if (isDefined(rawXValue)) {
+ formattedToRawLookup.set(xValue, rawXValue);
+ }
+
+ if (isDefined(rawYValue)) {
+ yFormattedToRawLookup.set(yValue, rawYValue);
+ }
+
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
@@ -117,47 +117,22 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
objectMetadataItem,
});
- if (!isDefined(aggregateValue)) return;
-
- if (!seriesMap.has(seriesKey)) {
- seriesMap.set(seriesKey, new Map());
+ if (!isDefined(aggregateValue)) {
+ continue;
}
- seriesMap.get(seriesKey)!.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,
- }));
-
- const transformedData = configuration.isCumulative
- ? applyCumulativeTransformToLineChartData({
- data,
- rangeMin: configuration.rangeMin ?? undefined,
- rangeMax: configuration.rangeMax ?? undefined,
- })
- : data;
-
- return {
- id: seriesKey,
- label: seriesKey,
- color: configuration.color as GraphColor,
- data: transformedData,
- };
- },
- );
-
- const series = sortLineChartSeries({
- series: unsortedSeries,
- orderByY: configuration.secondaryAxisOrderBy,
- });
+ processedDataPoints.push({
+ xValue,
+ yValue,
+ rawXValue,
+ rawYValue,
+ aggregateValue,
+ });
+ }
return {
- series,
- hasTooManyGroups,
+ processedDataPoints,
formattedToRawLookup,
+ yFormattedToRawLookup,
};
};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts
new file mode 100644
index 0000000000..d1ecbe5a3c
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortByManualOrder.ts
@@ -0,0 +1,43 @@
+import { isDefined } from 'twenty-shared/utils';
+
+type SortByManualOrderParams = {
+ items: T[];
+ manualSortOrder: string[];
+ getRawValue: (item: T) => string | null | undefined;
+};
+
+export const sortByManualOrder = ({
+ items,
+ manualSortOrder,
+ getRawValue,
+}: SortByManualOrderParams): T[] => {
+ if (manualSortOrder.length === 0) {
+ return items;
+ }
+
+ const orderMap = new Map(
+ manualSortOrder.map((value, index) => [value, index]),
+ );
+
+ return items.toSorted((a, b) => {
+ const rawValueA = getRawValue(a) ?? '';
+ const rawValueB = getRawValue(b) ?? '';
+
+ const indexA = orderMap.get(rawValueA);
+ const indexB = orderMap.get(rawValueB);
+
+ if (!isDefined(indexA) && !isDefined(indexB)) {
+ return 0;
+ }
+
+ if (!isDefined(indexA)) {
+ return 1;
+ }
+
+ if (!isDefined(indexB)) {
+ return -1;
+ }
+
+ return indexA - indexB;
+ });
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts
new file mode 100644
index 0000000000..ee5926a7a2
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortBySelectOptionPosition.ts
@@ -0,0 +1,43 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { isDefined } from 'twenty-shared/utils';
+
+type SortBySelectOptionPositionParams = {
+ items: T[];
+ options: FieldMetadataItemOption[];
+ formattedToRawLookup: Map;
+ getFormattedValue: (item: T) => string;
+ direction: 'ASC' | 'DESC';
+};
+
+export const sortBySelectOptionPosition = ({
+ items,
+ options,
+ formattedToRawLookup,
+ getFormattedValue,
+ direction,
+}: SortBySelectOptionPositionParams): T[] => {
+ const optionValueToPosition = new Map();
+
+ for (const option of options) {
+ optionValueToPosition.set(option.value, option.position);
+ }
+
+ return items.toSorted((a, b) => {
+ const formattedA = getFormattedValue(a);
+ const formattedB = getFormattedValue(b);
+
+ const rawA = formattedToRawLookup.get(formattedA);
+ const rawB = formattedToRawLookup.get(formattedB);
+
+ const positionA = isDefined(rawA)
+ ? (optionValueToPosition.get(String(rawA)) ?? Number.MAX_SAFE_INTEGER)
+ : Number.MAX_SAFE_INTEGER;
+
+ const positionB = isDefined(rawB)
+ ? (optionValueToPosition.get(String(rawB)) ?? Number.MAX_SAFE_INTEGER)
+ : Number.MAX_SAFE_INTEGER;
+
+ return direction === 'ASC' ? positionA - positionB : positionB - positionA;
+ });
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartData.ts
new file mode 100644
index 0000000000..7b1a91e2b0
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortChartData.ts
@@ -0,0 +1,87 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder';
+import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition';
+import { isDefined } from 'twenty-shared/utils';
+import { GraphOrderBy } from '~/generated/graphql';
+
+type SortChartDataParams = {
+ data: T[];
+ orderBy?: GraphOrderBy | null;
+ manualSortOrder?: string[] | null;
+ formattedToRawLookup: Map;
+ getFieldValue: (item: T) => string;
+ getNumericValue: (item: T) => number;
+ selectFieldOptions?: FieldMetadataItemOption[] | null;
+};
+
+export const sortChartData = ({
+ data,
+ orderBy,
+ manualSortOrder,
+ formattedToRawLookup,
+ getFieldValue,
+ getNumericValue,
+ selectFieldOptions,
+}: SortChartDataParams): T[] => {
+ if (!isDefined(orderBy)) {
+ return data;
+ }
+
+ switch (orderBy) {
+ case GraphOrderBy.MANUAL: {
+ if (!isDefined(manualSortOrder)) {
+ return data;
+ }
+
+ return sortByManualOrder({
+ items: data,
+ manualSortOrder,
+ getRawValue: (item) => {
+ const formatted = getFieldValue(item);
+ const raw = formattedToRawLookup.get(formatted);
+
+ return isDefined(raw) ? String(raw) : formatted;
+ },
+ });
+ }
+ case GraphOrderBy.VALUE_ASC:
+ return data.toSorted((a, b) => getNumericValue(a) - getNumericValue(b));
+ case GraphOrderBy.VALUE_DESC:
+ return data.toSorted((a, b) => getNumericValue(b) - getNumericValue(a));
+ case GraphOrderBy.FIELD_ASC:
+ return data.toSorted((a, b) =>
+ getFieldValue(a).localeCompare(getFieldValue(b)),
+ );
+ case GraphOrderBy.FIELD_DESC:
+ return data.toSorted((a, b) =>
+ getFieldValue(b).localeCompare(getFieldValue(a)),
+ );
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) {
+ throw new Error('Select field options are required');
+ }
+
+ return sortBySelectOptionPosition({
+ items: data,
+ options: selectFieldOptions,
+ formattedToRawLookup,
+ getFormattedValue: getFieldValue,
+ direction: 'ASC',
+ });
+ case GraphOrderBy.FIELD_POSITION_DESC:
+ if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) {
+ throw new Error('Select field options are required');
+ }
+
+ return sortBySelectOptionPosition({
+ items: data,
+ options: selectFieldOptions,
+ formattedToRawLookup,
+ getFormattedValue: getFieldValue,
+ direction: 'DESC',
+ });
+ default:
+ return data;
+ }
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts
deleted file mode 100644
index 6ae94f9366..0000000000
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortLineChartSeries.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries';
-import { GraphOrderBy } from '~/generated/graphql';
-
-export const sortLineChartSeries = ({
- series,
- orderByY,
-}: {
- series: LineChartSeries[];
- orderByY?: GraphOrderBy | null;
-}): LineChartSeries[] => {
- switch (orderByY) {
- case GraphOrderBy.FIELD_ASC:
- return [...series].sort((a, b) => b.id.localeCompare(a.id));
- case GraphOrderBy.FIELD_DESC:
- return [...series].sort((a, b) => a.id.localeCompare(b.id));
- default:
- return series;
- }
-};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts
new file mode 100644
index 0000000000..c09cd1bedd
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortOptionsForManualOrder.ts
@@ -0,0 +1,22 @@
+import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder';
+import { isDefined } from 'twenty-shared/utils';
+
+type SelectFieldOption = {
+ value: string;
+ position?: number | null;
+};
+
+export const sortOptionsForManualOrder = (
+ options: T[],
+ manualSortOrder?: string[] | null,
+): T[] => {
+ if (!isDefined(manualSortOrder) || manualSortOrder.length === 0) {
+ return options.toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0));
+ }
+
+ return sortByManualOrder({
+ items: options.toSorted((a, b) => (a.position ?? 0) - (b.position ?? 0)),
+ manualSortOrder,
+ getRawValue: (option) => option.value,
+ });
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts
new file mode 100644
index 0000000000..a0aa8b5f43
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortSecondaryAxisData.ts
@@ -0,0 +1,77 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder';
+import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition';
+import { isDefined } from 'twenty-shared/utils';
+import { GraphOrderBy } from '~/generated/graphql';
+
+type SortSecondaryAxisDataParams = {
+ items: T[];
+ orderBy?: GraphOrderBy | null;
+ manualSortOrder?: string[] | null;
+ formattedToRawLookup?: Map;
+ selectFieldOptions?: FieldMetadataItemOption[] | null;
+ getFormattedValue: (item: T) => string;
+};
+
+export const sortSecondaryAxisData = ({
+ items,
+ orderBy,
+ manualSortOrder,
+ formattedToRawLookup,
+ selectFieldOptions,
+ getFormattedValue,
+}: SortSecondaryAxisDataParams): T[] => {
+ switch (orderBy) {
+ case GraphOrderBy.FIELD_ASC:
+ return items.toSorted((a, b) =>
+ getFormattedValue(a).localeCompare(getFormattedValue(b)),
+ );
+
+ case GraphOrderBy.FIELD_DESC:
+ return items.toSorted((a, b) =>
+ getFormattedValue(b).localeCompare(getFormattedValue(a)),
+ );
+
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ case GraphOrderBy.FIELD_POSITION_DESC: {
+ if (
+ !isDefined(selectFieldOptions) ||
+ selectFieldOptions.length === 0 ||
+ !isDefined(formattedToRawLookup)
+ ) {
+ throw new Error(
+ 'Select field options and formatted to raw lookup are required',
+ );
+ }
+
+ return sortBySelectOptionPosition({
+ items,
+ options: selectFieldOptions,
+ formattedToRawLookup,
+ getFormattedValue,
+ direction: orderBy === GraphOrderBy.FIELD_POSITION_ASC ? 'ASC' : 'DESC',
+ });
+ }
+
+ case GraphOrderBy.MANUAL: {
+ if (!isDefined(manualSortOrder)) {
+ return items;
+ }
+
+ return sortByManualOrder({
+ items,
+ manualSortOrder,
+ getRawValue: (item) => {
+ const formattedValue = getFormattedValue(item);
+ const rawValue = formattedToRawLookup?.get(formattedValue);
+
+ return isDefined(rawValue) ? String(rawValue) : formattedValue;
+ },
+ });
+ }
+
+ default:
+ return items;
+ }
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.ts
new file mode 100644
index 0000000000..9355c3a4ae
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually.ts
@@ -0,0 +1,82 @@
+import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
+import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
+import { sortByManualOrder } from '@/page-layout/widgets/graph/utils/sortByManualOrder';
+import { sortBySelectOptionPosition } from '@/page-layout/widgets/graph/utils/sortBySelectOptionPosition';
+import { assertUnreachable, isDefined } from 'twenty-shared/utils';
+import { GraphOrderBy } from '~/generated/graphql';
+
+type SortTwoDimensionalChartPrimaryAxisDataParams = {
+ data: T[];
+ orderBy?:
+ | GraphOrderBy.FIELD_ASC
+ | GraphOrderBy.FIELD_DESC
+ | GraphOrderBy.FIELD_POSITION_ASC
+ | GraphOrderBy.FIELD_POSITION_DESC
+ | GraphOrderBy.MANUAL;
+ manualSortOrder?: string[] | null;
+ formattedToRawLookup: Map;
+ getFormattedValue: (item: T) => string;
+ selectFieldOptions?: FieldMetadataItemOption[] | null;
+};
+
+export const sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually = ({
+ data,
+ orderBy,
+ manualSortOrder,
+ formattedToRawLookup,
+ getFormattedValue,
+ selectFieldOptions,
+}: SortTwoDimensionalChartPrimaryAxisDataParams): T[] => {
+ if (!isDefined(orderBy)) {
+ return data;
+ }
+
+ switch (orderBy) {
+ case GraphOrderBy.MANUAL: {
+ if (!isDefined(manualSortOrder)) {
+ return data;
+ }
+
+ return sortByManualOrder({
+ items: data,
+ manualSortOrder,
+ getRawValue: (item) => {
+ const formattedValue = getFormattedValue(item);
+ const rawValue = formattedToRawLookup.get(formattedValue);
+
+ return isDefined(rawValue) ? String(rawValue) : formattedValue;
+ },
+ });
+ }
+
+ case GraphOrderBy.FIELD_ASC:
+ return data.toSorted((a, b) =>
+ getFormattedValue(a).localeCompare(getFormattedValue(b)),
+ );
+
+ case GraphOrderBy.FIELD_DESC:
+ return data.toSorted((a, b) =>
+ getFormattedValue(b).localeCompare(getFormattedValue(a)),
+ );
+
+ case GraphOrderBy.FIELD_POSITION_ASC:
+ case GraphOrderBy.FIELD_POSITION_DESC: {
+ if (!isDefined(selectFieldOptions) || selectFieldOptions.length === 0) {
+ throw new Error(
+ 'Select field options are required for field position sorting',
+ );
+ }
+
+ return sortBySelectOptionPosition({
+ items: data,
+ options: selectFieldOptions,
+ formattedToRawLookup,
+ getFormattedValue,
+ direction: orderBy === GraphOrderBy.FIELD_POSITION_ASC ? 'ASC' : 'DESC',
+ });
+ }
+
+ default:
+ assertUnreachable(orderBy);
+ }
+};
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts
index a3a6571b4e..bb73d0d55c 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto.ts
@@ -1,6 +1,7 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
+ IsArray,
IsBoolean,
IsEnum,
IsIn,
@@ -71,6 +72,12 @@ export class BarChartConfigurationDTO
@IsOptional()
primaryAxisOrderBy?: GraphOrderBy;
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ primaryAxisManualSortOrder?: string[];
+
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
@@ -94,6 +101,12 @@ export class BarChartConfigurationDTO
@IsOptional()
secondaryAxisOrderBy?: GraphOrderBy;
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ secondaryAxisManualSortOrder?: string[];
+
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts
index fcdac43ea8..5a7bb8bd51 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/line-chart-configuration.dto.ts
@@ -1,6 +1,7 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
+ IsArray,
IsBoolean,
IsEnum,
IsIn,
@@ -72,6 +73,12 @@ export class LineChartConfigurationDTO
@IsOptional()
primaryAxisOrderBy?: GraphOrderBy;
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ primaryAxisManualSortOrder?: string[];
+
@Field(() => UUIDScalarType, { nullable: true })
@IsUUID()
@IsOptional()
@@ -95,6 +102,12 @@ export class LineChartConfigurationDTO
@IsOptional()
secondaryAxisOrderBy?: GraphOrderBy;
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ secondaryAxisManualSortOrder?: string[];
+
@Field(() => Boolean, { nullable: true })
@IsBoolean()
@IsOptional()
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts
index 8f1ec1d483..b5b3017fb7 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto.ts
@@ -1,6 +1,7 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import {
+ IsArray,
IsBoolean,
IsEnum,
IsIn,
@@ -70,6 +71,12 @@ export class PieChartConfigurationDTO
@IsOptional()
orderBy?: GraphOrderBy;
+ @Field(() => [String], { nullable: true })
+ @IsArray()
+ @IsString({ each: true })
+ @IsOptional()
+ manualSortOrder?: string[];
+
@Field(() => Boolean, { nullable: true, defaultValue: false })
@IsBoolean()
@IsOptional()
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum.ts
index 0a04f44033..f961fc5b42 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum.ts
@@ -3,8 +3,11 @@ import { registerEnumType } from '@nestjs/graphql';
export enum GraphOrderBy {
FIELD_ASC = 'FIELD_ASC',
FIELD_DESC = 'FIELD_DESC',
+ FIELD_POSITION_ASC = 'FIELD_POSITION_ASC',
+ FIELD_POSITION_DESC = 'FIELD_POSITION_DESC',
VALUE_ASC = 'VALUE_ASC',
VALUE_DESC = 'VALUE_DESC',
+ MANUAL = 'MANUAL',
}
registerEnumType(GraphOrderBy, {
diff --git a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts
index ccd80905d5..3d3699a548 100644
--- a/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts
+++ b/packages/twenty-server/src/modules/dashboard/tools/schemas/widget.schema.ts
@@ -46,57 +46,168 @@ const aggregateChartConfigSchema = z.object({
});
// Graph configuration schema for BAR charts
-const barChartConfigSchema = z.object({
- graphType: z.literal(GraphType.BAR_CHART),
- aggregateFieldMetadataId: z
- .string()
- .uuid()
- .describe('Field UUID to aggregate'),
- aggregateOperation: z.nativeEnum(AggregateOperations),
- primaryAxisGroupByFieldMetadataId: z
- .string()
- .uuid()
- .describe('Field UUID to group by on primary axis'),
- secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
- primaryAxisOrderBy: z
- .enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
- .optional(),
- displayDataLabel: z.boolean().optional().default(false),
- displayLegend: z.boolean().optional().default(true),
- layout: z
- .enum(['VERTICAL', 'HORIZONTAL'])
- .optional()
- .default('VERTICAL')
- .describe('Layout orientation for bar charts'),
- filter: z.record(z.string(), z.unknown()).optional(),
-});
+const barChartConfigSchema = z
+ .object({
+ graphType: z.literal(GraphType.BAR_CHART),
+ aggregateFieldMetadataId: z
+ .string()
+ .uuid()
+ .describe('Field UUID to aggregate'),
+ aggregateOperation: z.nativeEnum(AggregateOperations),
+ primaryAxisGroupByFieldMetadataId: z
+ .string()
+ .uuid()
+ .describe('Field UUID to group by on primary axis'),
+ secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
+ primaryAxisOrderBy: z
+ .enum([
+ 'FIELD_ASC',
+ 'FIELD_DESC',
+ 'FIELD_POSITION_ASC',
+ 'FIELD_POSITION_DESC',
+ 'VALUE_ASC',
+ 'VALUE_DESC',
+ 'MANUAL',
+ ])
+ .optional(),
+ primaryAxisManualSortOrder: z.array(z.string()).optional(),
+ secondaryAxisOrderBy: z
+ .enum([
+ 'FIELD_ASC',
+ 'FIELD_DESC',
+ 'FIELD_POSITION_ASC',
+ 'FIELD_POSITION_DESC',
+ 'VALUE_ASC',
+ 'VALUE_DESC',
+ 'MANUAL',
+ ])
+ .optional(),
+ secondaryAxisManualSortOrder: z.array(z.string()).optional(),
+ displayDataLabel: z.boolean().optional().default(false),
+ displayLegend: z.boolean().optional().default(true),
+ layout: z
+ .enum(['VERTICAL', 'HORIZONTAL'])
+ .optional()
+ .default('VERTICAL')
+ .describe('Layout orientation for bar charts'),
+ filter: z.record(z.string(), z.unknown()).optional(),
+ })
+ .refine(
+ (data) =>
+ data.primaryAxisOrderBy !== 'MANUAL' ||
+ (Array.isArray(data.primaryAxisManualSortOrder) &&
+ data.primaryAxisManualSortOrder.length > 0),
+ {
+ message:
+ 'primaryAxisManualSortOrder must be a non-empty array when primaryAxisOrderBy is MANUAL',
+ path: ['primaryAxisManualSortOrder'],
+ },
+ )
+ .refine(
+ (data) =>
+ data.secondaryAxisOrderBy !== 'MANUAL' ||
+ (Array.isArray(data.secondaryAxisManualSortOrder) &&
+ data.secondaryAxisManualSortOrder.length > 0),
+ {
+ message:
+ 'secondaryAxisManualSortOrder must be a non-empty array when secondaryAxisOrderBy is MANUAL',
+ path: ['secondaryAxisManualSortOrder'],
+ },
+ );
// Graph configuration schema for LINE charts
-const lineChartConfigSchema = z.object({
- graphType: z.literal(GraphType.LINE_CHART),
- aggregateFieldMetadataId: z.string().uuid(),
- aggregateOperation: z.nativeEnum(AggregateOperations),
- primaryAxisGroupByFieldMetadataId: z.string().uuid(),
- secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
- primaryAxisOrderBy: z
- .enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
- .optional(),
- displayDataLabel: z.boolean().optional().default(false),
- filter: z.record(z.string(), z.unknown()).optional(),
-});
+const lineChartConfigSchema = z
+ .object({
+ graphType: z.literal(GraphType.LINE_CHART),
+ aggregateFieldMetadataId: z.string().uuid(),
+ aggregateOperation: z.nativeEnum(AggregateOperations),
+ primaryAxisGroupByFieldMetadataId: z.string().uuid(),
+ secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
+ primaryAxisOrderBy: z
+ .enum([
+ 'FIELD_ASC',
+ 'FIELD_DESC',
+ 'FIELD_POSITION_ASC',
+ 'FIELD_POSITION_DESC',
+ 'VALUE_ASC',
+ 'VALUE_DESC',
+ 'MANUAL',
+ ])
+ .optional(),
+ primaryAxisManualSortOrder: z.array(z.string()).optional(),
+ secondaryAxisOrderBy: z
+ .enum([
+ 'FIELD_ASC',
+ 'FIELD_DESC',
+ 'FIELD_POSITION_ASC',
+ 'FIELD_POSITION_DESC',
+ 'VALUE_ASC',
+ 'VALUE_DESC',
+ 'MANUAL',
+ ])
+ .optional(),
+ secondaryAxisManualSortOrder: z.array(z.string()).optional(),
+ displayDataLabel: z.boolean().optional().default(false),
+ filter: z.record(z.string(), z.unknown()).optional(),
+ })
+ .refine(
+ (data) =>
+ data.primaryAxisOrderBy !== 'MANUAL' ||
+ (Array.isArray(data.primaryAxisManualSortOrder) &&
+ data.primaryAxisManualSortOrder.length > 0),
+ {
+ message:
+ 'primaryAxisManualSortOrder must be a non-empty array when primaryAxisOrderBy is MANUAL',
+ path: ['primaryAxisManualSortOrder'],
+ },
+ )
+ .refine(
+ (data) =>
+ data.secondaryAxisOrderBy !== 'MANUAL' ||
+ (Array.isArray(data.secondaryAxisManualSortOrder) &&
+ data.secondaryAxisManualSortOrder.length > 0),
+ {
+ message:
+ 'secondaryAxisManualSortOrder must be a non-empty array when secondaryAxisOrderBy is MANUAL',
+ path: ['secondaryAxisManualSortOrder'],
+ },
+ );
// Graph configuration schema for PIE charts
-const pieChartConfigSchema = z.object({
- graphType: z.literal(GraphType.PIE_CHART),
- aggregateFieldMetadataId: z.string().uuid(),
- aggregateOperation: z.nativeEnum(AggregateOperations),
- groupByFieldMetadataId: z.string().uuid().describe('Field UUID to slice by'),
- orderBy: z
- .enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
- .optional(),
- displayDataLabel: z.boolean().optional().default(true),
- filter: z.record(z.string(), z.unknown()).optional(),
-});
+const pieChartConfigSchema = z
+ .object({
+ graphType: z.literal(GraphType.PIE_CHART),
+ aggregateFieldMetadataId: z.string().uuid(),
+ aggregateOperation: z.nativeEnum(AggregateOperations),
+ groupByFieldMetadataId: z
+ .string()
+ .uuid()
+ .describe('Field UUID to slice by'),
+ orderBy: z
+ .enum([
+ 'FIELD_ASC',
+ 'FIELD_DESC',
+ 'FIELD_POSITION_ASC',
+ 'FIELD_POSITION_DESC',
+ 'VALUE_ASC',
+ 'VALUE_DESC',
+ 'MANUAL',
+ ])
+ .optional(),
+ manualSortOrder: z.array(z.string()).optional(),
+ displayDataLabel: z.boolean().optional().default(true),
+ filter: z.record(z.string(), z.unknown()).optional(),
+ })
+ .refine(
+ (data) =>
+ data.orderBy !== 'MANUAL' ||
+ (Array.isArray(data.manualSortOrder) && data.manualSortOrder.length > 0),
+ {
+ message:
+ 'manualSortOrder must be a non-empty array when orderBy is MANUAL',
+ path: ['manualSortOrder'],
+ },
+ );
// Iframe configuration
const iframeConfigSchema = z.object({
diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/constants/widget-configuration-gql-fields.constant.ts b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/constants/widget-configuration-gql-fields.constant.ts
index acd48b9465..de9da1d347 100644
--- a/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/constants/widget-configuration-gql-fields.constant.ts
+++ b/packages/twenty-server/test/integration/metadata/suites/page-layout-widget/constants/widget-configuration-gql-fields.constant.ts
@@ -7,10 +7,12 @@ export const WIDGET_CONFIGURATION_GQL_FIELDS = `
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -34,10 +36,12 @@ export const WIDGET_CONFIGURATION_GQL_FIELDS = `
primaryAxisGroupBySubFieldName
primaryAxisDateGranularity
primaryAxisOrderBy
+ primaryAxisManualSortOrder
secondaryAxisGroupByFieldMetadataId
secondaryAxisGroupBySubFieldName
secondaryAxisGroupByDateGranularity
secondaryAxisOrderBy
+ secondaryAxisManualSortOrder
omitNullValues
axisNameDisplay
displayDataLabel
@@ -60,6 +64,7 @@ export const WIDGET_CONFIGURATION_GQL_FIELDS = `
groupBySubFieldName
dateGranularity
orderBy
+ manualSortOrder
displayDataLabel
showCenterMetric
displayLegend
diff --git a/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap
index 684ab827e3..a45ab05c40 100644
--- a/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap
+++ b/packages/twenty-server/test/integration/metadata/suites/page-layout/__snapshots__/successful-page-layout-with-tabs-update.integration-spec.ts.snap
@@ -31,6 +31,7 @@ exports[`Page layout with tabs update should succeed should update page layout w
"firstDayOfTheWeek": 1,
"groupByFieldMetadataId": Any,
"groupBySubFieldName": null,
+ "manualSortOrder": null,
"orderBy": "VALUE_DESC",
"showCenterMetric": true,
"timezone": "UTC",
@@ -120,6 +121,7 @@ exports[`Page layout with tabs update should succeed should update page layout w
"firstDayOfTheWeek": 1,
"groupByFieldMetadataId": Any,
"groupBySubFieldName": null,
+ "manualSortOrder": null,
"orderBy": "VALUE_DESC",
"showCenterMetric": true,
"timezone": "UTC",
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataNumericKind.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataNumericKind.test.ts
new file mode 100644
index 0000000000..ec1cbc7baf
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataNumericKind.test.ts
@@ -0,0 +1,21 @@
+import { FieldMetadataType } from '@/types';
+import { isFieldMetadataNumericKind } from '../isFieldMetadataNumericKind';
+
+describe('isFieldMetadataNumericKind', () => {
+ it.each([
+ FieldMetadataType.NUMBER,
+ FieldMetadataType.NUMERIC,
+ FieldMetadataType.CURRENCY,
+ FieldMetadataType.RATING,
+ FieldMetadataType.POSITION,
+ ])('should return true for %s', (type) => {
+ expect(isFieldMetadataNumericKind(type)).toBe(true);
+ });
+
+ it.each([FieldMetadataType.TEXT, FieldMetadataType.SELECT])(
+ 'should return false for %s',
+ (type) => {
+ expect(isFieldMetadataNumericKind(type)).toBe(false);
+ },
+ );
+});
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSelectKind.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSelectKind.test.ts
new file mode 100644
index 0000000000..20323c525f
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSelectKind.test.ts
@@ -0,0 +1,18 @@
+import { FieldMetadataType } from '@/types';
+import { isFieldMetadataSelectKind } from '../isFieldMetadataSelectKind';
+
+describe('isFieldMetadataSelectKind', () => {
+ it.each([FieldMetadataType.SELECT, FieldMetadataType.MULTI_SELECT])(
+ 'should return true for %s',
+ (type) => {
+ expect(isFieldMetadataSelectKind(type)).toBe(true);
+ },
+ );
+
+ it.each([FieldMetadataType.TEXT, FieldMetadataType.NUMBER])(
+ 'should return false for %s',
+ (type) => {
+ expect(isFieldMetadataSelectKind(type)).toBe(false);
+ },
+ );
+});
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataTextKind.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataTextKind.test.ts
new file mode 100644
index 0000000000..47837389e3
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataTextKind.test.ts
@@ -0,0 +1,19 @@
+import { FieldMetadataType } from '@/types';
+import { isFieldMetadataTextKind } from '../isFieldMetadataTextKind';
+
+describe('isFieldMetadataTextKind', () => {
+ it.each([
+ FieldMetadataType.TEXT,
+ FieldMetadataType.RICH_TEXT,
+ FieldMetadataType.RICH_TEXT_V2,
+ ])('should return true for %s', (type) => {
+ expect(isFieldMetadataTextKind(type)).toBe(true);
+ });
+
+ it.each([FieldMetadataType.NUMBER, FieldMetadataType.SELECT])(
+ 'should return false for %s',
+ (type) => {
+ expect(isFieldMetadataTextKind(type)).toBe(false);
+ },
+ );
+});
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/index.ts b/packages/twenty-shared/src/utils/fieldMetadata/index.ts
index 8925770707..78130df8d6 100644
--- a/packages/twenty-shared/src/utils/fieldMetadata/index.ts
+++ b/packages/twenty-shared/src/utils/fieldMetadata/index.ts
@@ -1 +1,5 @@
export * from './isFieldMetadataDateKind';
+export * from './isFieldMetadataNumericKind';
+export * from './isFieldMetadataSelectKind';
+export * from './isFieldMetadataTextKind';
+
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataNumericKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataNumericKind.ts
new file mode 100644
index 0000000000..b0fc77de12
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataNumericKind.ts
@@ -0,0 +1,15 @@
+import { FieldMetadataType } from '@/types';
+
+const NUMBER_FIELD_TYPES: FieldMetadataType[] = [
+ FieldMetadataType.NUMBER,
+ FieldMetadataType.NUMERIC,
+ FieldMetadataType.CURRENCY,
+ FieldMetadataType.RATING,
+ FieldMetadataType.POSITION,
+];
+
+export const isFieldMetadataNumericKind = (
+ fieldMetadataType: FieldMetadataType,
+): boolean => {
+ return NUMBER_FIELD_TYPES.includes(fieldMetadataType);
+};
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSelectKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSelectKind.ts
new file mode 100644
index 0000000000..5969acfaa0
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSelectKind.ts
@@ -0,0 +1,10 @@
+import { FieldMetadataType } from '@/types';
+
+export const isFieldMetadataSelectKind = (
+ fieldMetadataType: FieldMetadataType,
+): boolean => {
+ return (
+ fieldMetadataType === FieldMetadataType.SELECT ||
+ fieldMetadataType === FieldMetadataType.MULTI_SELECT
+ );
+};
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataTextKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataTextKind.ts
new file mode 100644
index 0000000000..7790a59dfa
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataTextKind.ts
@@ -0,0 +1,13 @@
+import { FieldMetadataType } from '@/types';
+
+const TEXT_FIELD_TYPES: FieldMetadataType[] = [
+ FieldMetadataType.TEXT,
+ FieldMetadataType.RICH_TEXT,
+ FieldMetadataType.RICH_TEXT_V2,
+];
+
+export const isFieldMetadataTextKind = (
+ fieldMetadataType: FieldMetadataType,
+): boolean => {
+ return TEXT_FIELD_TYPES.includes(fieldMetadataType);
+};
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index d2e4a922ca..3a0385a040 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -37,6 +37,9 @@ export { evalFromContext } from './evalFromContext';
export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields';
export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name';
export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind';
+export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind';
+export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
+export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
export { checkIfShouldSkipFiltering } from './filter/checkIfShouldSkipFiltering';
export { computeGqlOperationFilterForEmails } from './filter/compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForEmails';
diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
index 775c81f0f7..2c9e40aba8 100644
--- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
+++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts
@@ -319,8 +319,13 @@ export {
IconShield,
IconSitemap,
IconSlash,
+ IconSortAscending,
+ IconSortAscendingLetters,
+ IconSortAscendingNumbers,
IconSortAZ,
IconSortDescending,
+ IconSortDescendingLetters,
+ IconSortDescendingNumbers,
IconSortZA,
IconSparkles,
IconSpy,
diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts
index 5e7c88c9de..20906ecd9b 100644
--- a/packages/twenty-ui/src/display/index.ts
+++ b/packages/twenty-ui/src/display/index.ts
@@ -385,8 +385,13 @@ export {
IconShield,
IconSitemap,
IconSlash,
+ IconSortAscending,
+ IconSortAscendingLetters,
+ IconSortAscendingNumbers,
IconSortAZ,
IconSortDescending,
+ IconSortDescendingLetters,
+ IconSortDescendingNumbers,
IconSortZA,
IconSparkles,
IconSpy,