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