[DASHBOARDS] Manual and position-based sorting for chart widgets (#16794)

## 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

<!-- CURSOR_SUMMARY -->
---

> [!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`
> 
> <sup>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).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Raphaël Bosi
2026-01-06 11:52:54 +01:00
committed by GitHub
parent 221a6262f0
commit c531cfe0a4
89 changed files with 3592 additions and 683 deletions
@@ -362,12 +362,14 @@ export type BarChartConfiguration = {
primaryAxisDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
primaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
primaryAxisOrderBy?: Maybe<GraphOrderBy>;
rangeMax?: Maybe<Scalars['Float']>;
rangeMin?: Maybe<Scalars['Float']>;
secondaryAxisGroupByDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
secondaryAxisGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
secondaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
secondaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
secondaryAxisOrderBy?: Maybe<GraphOrderBy>;
timezone?: Maybe<Scalars['String']>;
};
@@ -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<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
primaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
primaryAxisOrderBy?: Maybe<GraphOrderBy>;
rangeMax?: Maybe<Scalars['Float']>;
rangeMin?: Maybe<Scalars['Float']>;
secondaryAxisGroupByDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
secondaryAxisGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
secondaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
secondaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
secondaryAxisOrderBy?: Maybe<GraphOrderBy>;
timezone?: Maybe<Scalars['String']>;
};
@@ -3288,6 +3295,7 @@ export type PieChartConfiguration = {
groupByFieldMetadataId: Scalars['UUID'];
groupBySubFieldName?: Maybe<Scalars['String']>;
hideEmptyCategory?: Maybe<Scalars['Boolean']>;
manualSortOrder?: Maybe<Array<Scalars['String']>>;
orderBy?: Maybe<GraphOrderBy>;
showCenterMetric?: Maybe<Scalars['Boolean']>;
timezone?: Maybe<Scalars['String']>;
+15 -2
View File
@@ -362,12 +362,14 @@ export type BarChartConfiguration = {
primaryAxisDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
primaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
primaryAxisOrderBy?: Maybe<GraphOrderBy>;
rangeMax?: Maybe<Scalars['Float']>;
rangeMin?: Maybe<Scalars['Float']>;
secondaryAxisGroupByDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
secondaryAxisGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
secondaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
secondaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
secondaryAxisOrderBy?: Maybe<GraphOrderBy>;
timezone?: Maybe<Scalars['String']>;
};
@@ -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<ObjectRecordGroupByDateGranularity>;
primaryAxisGroupByFieldMetadataId: Scalars['UUID'];
primaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
primaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
primaryAxisOrderBy?: Maybe<GraphOrderBy>;
rangeMax?: Maybe<Scalars['Float']>;
rangeMin?: Maybe<Scalars['Float']>;
secondaryAxisGroupByDateGranularity?: Maybe<ObjectRecordGroupByDateGranularity>;
secondaryAxisGroupByFieldMetadataId?: Maybe<Scalars['UUID']>;
secondaryAxisGroupBySubFieldName?: Maybe<Scalars['String']>;
secondaryAxisManualSortOrder?: Maybe<Array<Scalars['String']>>;
secondaryAxisOrderBy?: Maybe<GraphOrderBy>;
timezone?: Maybe<Scalars['String']>;
};
@@ -3171,6 +3178,7 @@ export type PieChartConfiguration = {
groupByFieldMetadataId: Scalars['UUID'];
groupBySubFieldName?: Maybe<Scalars['String']>;
hideEmptyCategory?: Maybe<Scalars['Boolean']>;
manualSortOrder?: Maybe<Array<Scalars['String']>>;
orderBy?: Maybe<GraphOrderBy>;
showCenterMetric?: Maybe<Scalars['Boolean']>;
timezone?: Maybe<Scalars['String']>;
@@ -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<string> | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array<string> | 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<string> | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array<string> | 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<string> | 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<string> | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array<string> | 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<string> | null, secondaryAxisGroupByFieldMetadataId?: any | null, secondaryAxisGroupBySubFieldName?: string | null, secondaryAxisGroupByDateGranularity?: ObjectRecordGroupByDateGranularity | null, secondaryAxisOrderBy?: GraphOrderBy | null, secondaryAxisManualSortOrder?: Array<string> | 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<string> | 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
@@ -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 (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{t`Reorder options`}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<DraggableList
onDragEnd={handleDragEnd}
draggableItems={
<>
{sortedOptions.map((option, index) => (
<DraggableItem
key={option.value}
draggableId={option.value}
index={index}
isDragDisabled={sortedOptions.length === 1}
itemComponent={
<MenuItemDraggable
showGrip
isDragDisabled={sortedOptions.length === 1}
text={
<Tag
preventShrink
color={option.color}
text={option.label}
/>
}
/>
}
/>
))}
</>
}
/>
</DropdownMenuItemsContainer>
</>
);
};
@@ -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<string, unknown> = {
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 (
<ChartManualSortSubMenuContent
fieldMetadataItem={secondaryAxisField}
axis="secondary"
onBack={() => setIsSubMenuOpen(false)}
/>
);
}
return (
<>
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={AGGREGATE_SORT_BY_OPTIONS.map(
(option) => option.value,
)}
>
{AGGREGATE_SORT_BY_OPTIONS.map((sortOption) => (
<DropdownMenuItemsContainer>
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableOptions.map((option) => option.value)}
>
{availableOptions.map((sortOption) => {
const isManualOption = sortOption.value === GraphOrderBy.MANUAL;
return (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
@@ -87,15 +145,22 @@ export const ChartSortByGroupByFieldDropdownContent = () => {
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);
}}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</>
);
})}
</SelectableList>
</DropdownMenuItemsContainer>
);
};
@@ -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<string, unknown> = {};
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 (
<ChartManualSortSubMenuContent
fieldMetadataItem={primaryAxisField}
axis={'primary'}
onBack={() => setIsSubMenuOpen(false)}
/>
);
}
return (
<DropdownMenuItemsContainer>
<SelectableList
@@ -141,35 +171,46 @@ export const ChartSortBySelectionDropdownContent = () => {
focusId={dropdownId}
selectableItemIdArray={availableOptions.map((option) => option.value)}
>
{availableOptions.map((sortOption) => (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
onEnter={() => {
handleSelect(sortOption.value);
}}
>
<MenuItemSelect
text={getXSortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataIdX: groupByFieldMetadataId ?? '',
groupBySubFieldNameX: groupBySubFieldName as
| CompositeFieldSubFieldName
| undefined,
aggregateFieldMetadataId:
configuration.aggregateFieldMetadataId ?? undefined,
aggregateOperation:
configuration.aggregateOperation ?? undefined,
})}
selected={currentOrderBy === sortOption.value}
focused={selectedItemId === sortOption.value}
LeftIcon={sortOption.icon}
onClick={() => {
{availableOptions.map((sortOption) => {
const isManualOption = sortOption.value === GraphOrderBy.MANUAL;
return (
<SelectableListItem
key={sortOption.value}
itemId={sortOption.value}
onEnter={() => {
handleSelect(sortOption.value);
}}
/>
</SelectableListItem>
))}
>
<MenuItemSelect
text={getXSortOptionLabel({
graphOrderBy: sortOption.value,
groupByFieldMetadataIdX: groupByFieldMetadataId ?? '',
groupBySubFieldNameX: groupBySubFieldName as
| CompositeFieldSubFieldName
| undefined,
aggregateFieldMetadataId:
configuration.aggregateFieldMetadataId ?? undefined,
aggregateOperation:
configuration.aggregateOperation ?? undefined,
})}
selected={currentOrderBy === sortOption.value}
focused={selectedItemId === sortOption.value}
LeftIcon={
sortOption.icon ??
getSortIconForFieldType({
fieldType: primaryAxisField?.type,
orderBy: sortOption.value,
})
}
hasSubMenu={isManualOption}
onClick={() => {
handleSelect(sortOption.value);
}}
/>
</SelectableListItem>
);
})}
</SelectableList>
</DropdownMenuItemsContainer>
);
@@ -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,
},
];
@@ -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,
},
];
@@ -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);
}
};
@@ -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);
}
@@ -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,
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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',
]);
});
});
@@ -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',
]);
});
});
});
@@ -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);
});
});
});
@@ -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');
});
});
@@ -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)
@@ -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;
});
};
@@ -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;
};
@@ -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);
};
@@ -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;
};
@@ -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;
};
@@ -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`;
};
@@ -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
@@ -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 = [
@@ -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 = [
@@ -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 = ({
@@ -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<string>;
};
export const buildTwoDimensionalBarChartData = ({
processedDataPoints,
indexByKey,
}: BuildTwoDimensionalBarChartDataParams): BuildTwoDimensionalBarChartDataResult => {
const dataMap = new Map<string, BarDatum>();
const yValues = new Set<string>();
for (const { xValue, yValue, aggregateValue } of processedDataPoints) {
yValues.add(yValue);
if (!dataMap.has(xValue)) {
dataMap.set(xValue, {
[indexByKey]: xValue,
});
}
const dataItem = dataMap.get(xValue)!;
dataItem[yValue] = aggregateValue;
}
return {
unsortedData: Array.from(dataMap.values()),
yValues,
};
};
@@ -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,
};
};
@@ -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);
@@ -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<string, RawDimensionValue>;
primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFormattedToRawLookup?: Map<string, RawDimensionValue>;
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,
};
};
@@ -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,
@@ -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<string, BarDatum>();
const xValues = new Set<string>();
const yValues = new Set<string>();
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,
};
@@ -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';
@@ -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 = [
@@ -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);
@@ -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);
});
@@ -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<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
for (const { xValue, yValue, aggregateValue } of processedDataPoints) {
const isNewX = !xValueSet.has(xValue);
if (isNewX) {
xValueSet.add(xValue);
allXValues.push(xValue);
}
if (!seriesMap.has(yValue)) {
seriesMap.set(yValue, new Map());
}
seriesMap.get(yValue)!.set(xValue, aggregateValue);
}
const unsortedSeries: LineChartSeries[] = Array.from(seriesMap.entries()).map(
([seriesKey, xToYMap]) => {
const data: LineChartDataPoint[] = allXValues.map((xValue) => ({
x: xValue,
y: xToYMap.get(xValue) ?? 0,
}));
return {
id: seriesKey,
label: seriesKey,
color: color ?? undefined,
data,
};
},
);
return {
unsortedSeries,
};
};
@@ -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,
};
};
@@ -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<LineChartDataPoint['x'], number>(
sortedXValues.map((xValue, index) => [xValue, index]),
);
return series.map((seriesItem) => ({
...seriesItem,
data: seriesItem.data.toSorted((a, b) => {
const indexA = xValueToIndex.get(a.x) ?? 0;
const indexB = xValueToIndex.get(b.x) ?? 0;
return indexA - indexB;
}),
}));
};
@@ -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<string, RawDimensionValue>;
primaryAxisSelectFieldOptions?: FieldMetadataItemOption[] | null;
secondaryAxisFormattedToRawLookup?: Map<string, RawDimensionValue>;
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,
};
};
@@ -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,
@@ -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[] = [
{
@@ -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<string, RawDimensionValue>;
};
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,
};
};
@@ -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,
@@ -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,
@@ -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<string, GroupByRawResult[]> | 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 {
@@ -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);
});
});
});
@@ -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<string, RawDimensionValue> => {
const lookup = new Map<string, RawDimensionValue>();
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<string, RawDimensionValue>();
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);
});
});
});
@@ -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<string, RawDimensionValue>([
['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);
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
@@ -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);
});
});
@@ -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 {
@@ -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 {
@@ -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);
}
@@ -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);
}
};
@@ -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);
}
@@ -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<string, RawDimensionValue>;
};
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,
};
};
@@ -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<string, RawDimensionValue>;
export type ProcessedTwoDimensionalDataPoint = {
xValue: string;
yValue: string;
rawXValue: RawDimensionValue;
rawYValue: RawDimensionValue;
aggregateValue: number;
};
export const transformTwoDimensionalGroupByToLineChartData = ({
export type ProcessTwoDimensionalGroupByResultsOutput = {
processedDataPoints: ProcessedTwoDimensionalDataPoint[];
formattedToRawLookup: Map<string, RawDimensionValue>;
yFormattedToRawLookup: Map<string, RawDimensionValue>;
};
export const processTwoDimensionalGroupByResults = ({
rawResults,
groupByFieldX,
groupByFieldY,
@@ -46,11 +56,7 @@ export const transformTwoDimensionalGroupByToLineChartData = ({
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: TransformTwoDimensionalGroupByToLineChartDataParams): TransformTwoDimensionalGroupByToLineChartDataResult => {
const seriesMap = new Map<string, Map<string, number>>();
const allXValues: string[] = [];
const xValueSet = new Set<string>();
}: 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<string, RawDimensionValue>();
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,
};
};
@@ -0,0 +1,43 @@
import { isDefined } from 'twenty-shared/utils';
type SortByManualOrderParams<T> = {
items: T[];
manualSortOrder: string[];
getRawValue: (item: T) => string | null | undefined;
};
export const sortByManualOrder = <T>({
items,
manualSortOrder,
getRawValue,
}: SortByManualOrderParams<T>): 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;
});
};
@@ -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<T> = {
items: T[];
options: FieldMetadataItemOption[];
formattedToRawLookup: Map<string, RawDimensionValue>;
getFormattedValue: (item: T) => string;
direction: 'ASC' | 'DESC';
};
export const sortBySelectOptionPosition = <T>({
items,
options,
formattedToRawLookup,
getFormattedValue,
direction,
}: SortBySelectOptionPositionParams<T>): T[] => {
const optionValueToPosition = new Map<string, number>();
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;
});
};
@@ -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<T> = {
data: T[];
orderBy?: GraphOrderBy | null;
manualSortOrder?: string[] | null;
formattedToRawLookup: Map<string, RawDimensionValue>;
getFieldValue: (item: T) => string;
getNumericValue: (item: T) => number;
selectFieldOptions?: FieldMetadataItemOption[] | null;
};
export const sortChartData = <T>({
data,
orderBy,
manualSortOrder,
formattedToRawLookup,
getFieldValue,
getNumericValue,
selectFieldOptions,
}: SortChartDataParams<T>): 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<T>({
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<T>({
items: data,
options: selectFieldOptions,
formattedToRawLookup,
getFormattedValue: getFieldValue,
direction: 'DESC',
});
default:
return data;
}
};
@@ -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;
}
};
@@ -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 = <T extends SelectFieldOption>(
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,
});
};
@@ -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<T> = {
items: T[];
orderBy?: GraphOrderBy | null;
manualSortOrder?: string[] | null;
formattedToRawLookup?: Map<string, RawDimensionValue>;
selectFieldOptions?: FieldMetadataItemOption[] | null;
getFormattedValue: (item: T) => string;
};
export const sortSecondaryAxisData = <T>({
items,
orderBy,
manualSortOrder,
formattedToRawLookup,
selectFieldOptions,
getFormattedValue,
}: SortSecondaryAxisDataParams<T>): 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;
}
};
@@ -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<T> = {
data: T[];
orderBy?:
| GraphOrderBy.FIELD_ASC
| GraphOrderBy.FIELD_DESC
| GraphOrderBy.FIELD_POSITION_ASC
| GraphOrderBy.FIELD_POSITION_DESC
| GraphOrderBy.MANUAL;
manualSortOrder?: string[] | null;
formattedToRawLookup: Map<string, RawDimensionValue>;
getFormattedValue: (item: T) => string;
selectFieldOptions?: FieldMetadataItemOption[] | null;
};
export const sortTwoDimensionalChartPrimaryAxisDataByFieldOrManually = <T>({
data,
orderBy,
manualSortOrder,
formattedToRawLookup,
getFormattedValue,
selectFieldOptions,
}: SortTwoDimensionalChartPrimaryAxisDataParams<T>): 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);
}
};
@@ -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()
@@ -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()
@@ -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()
@@ -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, {
@@ -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({
@@ -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
@@ -31,6 +31,7 @@ exports[`Page layout with tabs update should succeed should update page layout w
"firstDayOfTheWeek": 1,
"groupByFieldMetadataId": Any<String>,
"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<String>,
"groupBySubFieldName": null,
"manualSortOrder": null,
"orderBy": "VALUE_DESC",
"showCenterMetric": true,
"timezone": "UTC",
@@ -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);
},
);
});
@@ -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);
},
);
});
@@ -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);
},
);
});
@@ -1 +1,5 @@
export * from './isFieldMetadataDateKind';
export * from './isFieldMetadataNumericKind';
export * from './isFieldMetadataSelectKind';
export * from './isFieldMetadataTextKind';
@@ -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);
};
@@ -0,0 +1,10 @@
import { FieldMetadataType } from '@/types';
export const isFieldMetadataSelectKind = (
fieldMetadataType: FieldMetadataType,
): boolean => {
return (
fieldMetadataType === FieldMetadataType.SELECT ||
fieldMetadataType === FieldMetadataType.MULTI_SELECT
);
};
@@ -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);
};
@@ -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';
@@ -319,8 +319,13 @@ export {
IconShield,
IconSitemap,
IconSlash,
IconSortAscending,
IconSortAscendingLetters,
IconSortAscendingNumbers,
IconSortAZ,
IconSortDescending,
IconSortDescendingLetters,
IconSortDescendingNumbers,
IconSortZA,
IconSparkles,
IconSpy,
+5
View File
@@ -385,8 +385,13 @@ export {
IconShield,
IconSitemap,
IconSlash,
IconSortAscending,
IconSortAscendingLetters,
IconSortAscendingNumbers,
IconSortAZ,
IconSortDescending,
IconSortDescendingLetters,
IconSortDescendingNumbers,
IconSortZA,
IconSparkles,
IconSpy,