From cfc4e0e3439d861847835061cad715df30591d6b Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:59:17 +0530 Subject: [PATCH] [DASHBOARDS] Add split multi-value fields setting for charts (#17907) closes https://github.com/twentyhq/twenty/issues/17890 https://github.com/user-attachments/assets/dbf2ebe6-c2da-44d0-846e-d7fa9fac5f43 --- .../src/generated-metadata/graphql.ts | 3 + .../constants/LineChartSettings.ts | 4 + .../page-layout/constants/PieChartSettings.ts | 2 + .../ChartConfigurationSettingLabels.ts | 2 + .../settings/SplitMultiValueFieldsXSetting.ts | 11 ++ .../settings/SplitMultiValueFieldsYSetting.ts | 11 ++ .../hooks/useChartSettingsValues.ts | 5 + .../types/BarLineChartConvertibleFields.ts | 1 + .../types/ChartConfigurationSettingIds.ts | 6 + .../types/PieChartConvertibleFields.ts | 1 + ...buildChartGroupByFieldConfigUpdate.test.ts | 82 +++++++++- .../__tests__/getBarChartSettings.test.ts | 8 +- .../__tests__/shouldHideChartSetting.test.ts | 151 ++++++++++++++++++ .../buildChartGroupByFieldConfigUpdate.ts | 41 ++++- .../convertBarOrLineChartConfigToPieChart.ts | 1 + .../convertPieChartConfigToBarOrLineChart.ts | 1 + .../page-layout/utils/getBarChartSettings.ts | 4 + .../utils/shouldHideChartSetting.ts | 60 ++++++- .../types/group-by-field.types.ts | 1 + .../get-group-by-expression.util.spec.ts | 47 +++++- .../utils/get-group-by-expression.util.ts | 4 + .../graphql-query-order-group-by.parser.ts | 23 ++- .../utils/parse-group-by-args.util.ts | 14 ++ .../dtos/bar-chart-configuration.dto.ts | 5 + .../dtos/line-chart-configuration.dto.ts | 5 + .../dtos/pie-chart-configuration.dto.ts | 5 + .../services/bar-chart-data.service.ts | 5 +- .../services/chart-data-query.service.ts | 35 +++- .../services/line-chart-data.service.ts | 3 + .../services/pie-chart-data.service.ts | 3 +- .../utils/apply-gap-filling.util.ts | 9 +- .../utils/build-group-by-field-object.util.ts | 6 + .../src/utils/fieldMetadata/index.ts | 1 + .../fieldMetadata/isFieldMetadataArrayKind.ts | 10 ++ packages/twenty-shared/src/utils/index.ts | 1 + .../display/icon/components/TablerIcons.ts | 5 +- packages/twenty-ui/src/display/index.ts | 5 +- 37 files changed, 557 insertions(+), 24 deletions(-) create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting.ts create mode 100644 packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting.ts create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataArrayKind.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6c6c01a5fc..d87c4054a8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -419,6 +419,7 @@ export type BarChartConfiguration = { secondaryAxisGroupBySubFieldName?: Maybe; secondaryAxisManualSortOrder?: Maybe>; secondaryAxisOrderBy?: Maybe; + splitMultiValueFields?: Maybe; timezone?: Maybe; }; @@ -1936,6 +1937,7 @@ export type LineChartConfiguration = { secondaryAxisGroupBySubFieldName?: Maybe; secondaryAxisManualSortOrder?: Maybe>; secondaryAxisOrderBy?: Maybe; + splitMultiValueFields?: Maybe; timezone?: Maybe; }; @@ -3540,6 +3542,7 @@ export type PieChartConfiguration = { manualSortOrder?: Maybe>; orderBy?: Maybe; showCenterMetric?: Maybe; + splitMultiValueFields?: Maybe; timezone?: Maybe; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts index 82e84d75b6..4098bf7541 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/LineChartSettings.ts @@ -16,6 +16,8 @@ import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/se import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting'; import { SHOW_LEGEND_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ShowLegendSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; +import { SPLIT_MULTI_VALUE_FIELDS_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting'; +import { SPLIT_MULTI_VALUE_FIELDS_Y_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting'; import { STACKED_LINES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedLineSettings'; import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; @@ -31,6 +33,7 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [ DATE_GRANULARITY_X_SETTING, PRIMARY_SORT_BY_SETTING, OMIT_NULL_VALUES_SETTING, + SPLIT_MULTI_VALUE_FIELDS_X_SETTING, ], }, { @@ -43,6 +46,7 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [ CUMULATIVE_SETTING, RANGE_MIN_SETTING, RANGE_MAX_SETTING, + SPLIT_MULTI_VALUE_FIELDS_Y_SETTING, ], }, { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/PieChartSettings.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/PieChartSettings.ts index 1749f99130..ac2dc0cd38 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/PieChartSettings.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/PieChartSettings.ts @@ -10,6 +10,7 @@ import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/setti import { HIDE_EMPTY_CATEGORY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/HideEmptyCategorySetting'; import { PRIMARY_SORT_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/PrimarySortBySetting'; import { SHOW_LEGEND_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ShowLegendSetting'; +import { SPLIT_MULTI_VALUE_FIELDS_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting'; import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; export const PIE_CHART_SETTINGS: ChartSettingsGroup[] = [ @@ -23,6 +24,7 @@ export const PIE_CHART_SETTINGS: ChartSettingsGroup[] = [ EACH_SLICE_REPRESENTS_SETTING, PRIMARY_SORT_BY_SETTING, HIDE_EMPTY_CATEGORY_SETTING, + SPLIT_MULTI_VALUE_FIELDS_X_SETTING, ], }, { diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts index 5cd33ebfb2..7f09ec0ea4 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels.ts @@ -28,4 +28,6 @@ export const CHART_CONFIGURATION_SETTING_LABELS = { PREFIX: msg`Prefix`, SUFFIX: msg`Suffix`, CUMULATIVE: msg`Cumulative`, + SPLIT_MULTI_VALUE_FIELDS_X: msg`Split multiple values`, + SPLIT_MULTI_VALUE_FIELDS_Y: msg`Split multiple values`, }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting.ts new file mode 100644 index 0000000000..03ec1291e5 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting.ts @@ -0,0 +1,11 @@ +import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { IconArrowsSplit2 } from 'twenty-ui/display'; + +export const SPLIT_MULTI_VALUE_FIELDS_X_SETTING: ChartSettingsItem = { + isBoolean: true, + Icon: IconArrowsSplit2, + label: CHART_CONFIGURATION_SETTING_LABELS.SPLIT_MULTI_VALUE_FIELDS_X, + id: CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X, +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting.ts new file mode 100644 index 0000000000..4634cb0070 --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting.ts @@ -0,0 +1,11 @@ +import { CHART_CONFIGURATION_SETTING_LABELS } from '@/command-menu/pages/page-layout/constants/settings/ChartConfigurationSettingLabels'; +import { CHART_CONFIGURATION_SETTING_IDS } from '@/command-menu/pages/page-layout/types/ChartConfigurationSettingIds'; +import { type ChartSettingsItem } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; +import { IconArrowsSplit2 } from 'twenty-ui/display'; + +export const SPLIT_MULTI_VALUE_FIELDS_Y_SETTING: ChartSettingsItem = { + isBoolean: true, + Icon: IconArrowsSplit2, + label: CHART_CONFIGURATION_SETTING_LABELS.SPLIT_MULTI_VALUE_FIELDS_Y, + id: CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_Y, +}; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts index 82f84e4515..8d7973d6cf 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/hooks/useChartSettingsValues.ts @@ -257,6 +257,11 @@ export const useChartSettingsValues = ({ return isBarOrLineChart ? (configuration.omitNullValues ?? false) : false; + case CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X: + case CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_Y: + return isBarOrLineChart || isPieChart + ? (configuration.splitMultiValueFields ?? true) + : true; case CHART_CONFIGURATION_SETTING_IDS.HIDE_EMPTY_CATEGORY: return isPieChart ? (configuration.hideEmptyCategory ?? false) : false; case CHART_CONFIGURATION_SETTING_IDS.MIN_RANGE: diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts index 82438e7133..2d6d93e7fe 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/BarLineChartConvertibleFields.ts @@ -8,4 +8,5 @@ export type BarLineChartConvertibleFields = { primaryAxisGroupBySubFieldName?: string | null; primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null; primaryAxisOrderBy?: GraphOrderBy | null; + splitMultiValueFields?: boolean | null; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts index 0e3e220b0d..681621a535 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/ChartConfigurationSettingIds.ts @@ -27,6 +27,8 @@ export enum CHART_CONFIGURATION_SETTING_IDS { PREFIX = 'PREFIX', SUFFIX = 'SUFFIX', CUMULATIVE = 'CUMULATIVE', + SPLIT_MULTI_VALUE_FIELDS_X = 'SPLIT_MULTI_VALUE_FIELDS_X', + SPLIT_MULTI_VALUE_FIELDS_Y = 'SPLIT_MULTI_VALUE_FIELDS_Y', } export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = { @@ -47,4 +49,8 @@ export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = { [CHART_CONFIGURATION_SETTING_IDS.PREFIX]: 'prefix', [CHART_CONFIGURATION_SETTING_IDS.SUFFIX]: 'suffix', [CHART_CONFIGURATION_SETTING_IDS.CUMULATIVE]: 'isCumulative', + [CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X]: + 'splitMultiValueFields', + [CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_Y]: + 'splitMultiValueFields', } as const; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts index 71e029762c..9196403d00 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/types/PieChartConvertibleFields.ts @@ -8,4 +8,5 @@ export type PieChartConvertibleFields = { groupBySubFieldName?: string | null; dateGranularity?: ObjectRecordGroupByDateGranularity | null; orderBy?: GraphOrderBy | null; + splitMultiValueFields?: boolean | null; }; 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 262cd2f28a..2e4dcfe7c5 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,7 +1,12 @@ import { type TypedBarChartConfiguration } from '@/command-menu/pages/page-layout/types/TypedBarChartConfiguration'; +import { type TypedLineChartConfiguration } from '@/command-menu/pages/page-layout/types/TypedLineChartConfiguration'; 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 { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + FieldMetadataType, + ObjectRecordGroupByDateGranularity, +} from 'twenty-shared/types'; import { BarChartGroupMode, GraphOrderBy, @@ -9,6 +14,41 @@ import { } from '~/generated-metadata/graphql'; describe('buildChartGroupByFieldConfigUpdate', () => { + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'object-id', + nameSingular: 'opportunity', + namePlural: 'opportunities', + labelSingular: 'Opportunity', + labelPlural: 'Opportunities', + description: 'An opportunity', + icon: 'IconTargetArrow', + isActive: true, + isSystem: false, + isRemote: false, + isAuditLogged: false, + isLabelSyncedWithName: true, + fields: [ + { + id: 'text-field-id', + name: 'name', + label: 'Name', + type: FieldMetadataType.TEXT, + }, + { + id: 'array-field-id', + name: 'tags', + label: 'Tags', + type: FieldMetadataType.MULTI_SELECT, + }, + { + id: 'array-field-id-2', + name: 'skills', + label: 'Skills', + type: FieldMetadataType.ARRAY, + }, + ], + } as any; + it('sets default orderBy and dateGranularity for primary axis', () => { const result = buildChartGroupByFieldConfigUpdate({ configuration: { @@ -97,4 +137,44 @@ describe('buildChartGroupByFieldConfigUpdate', () => { primaryAxisOrderBy: GraphOrderBy.FIELD_ASC, }); }); + + it('disables split multi-value fields for bar charts when both axes are arrays', () => { + const result = buildChartGroupByFieldConfigUpdate({ + configuration: { + __typename: 'BarChartConfiguration', + configurationType: WidgetConfigurationType.BAR_CHART, + splitMultiValueFields: true, + primaryAxisGroupByFieldMetadataId: 'array-field-id', + } as TypedBarChartConfiguration, + fieldMetadataIdKey: 'secondaryAxisGroupByFieldMetadataId', + subFieldNameKey: 'secondaryAxisGroupBySubFieldName', + fieldId: 'array-field-id-2', + subFieldName: null, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toMatchObject({ + splitMultiValueFields: false, + }); + }); + + it('disables split multi-value fields for line charts when both axes are arrays', () => { + const result = buildChartGroupByFieldConfigUpdate({ + configuration: { + __typename: 'LineChartConfiguration', + configurationType: WidgetConfigurationType.LINE_CHART, + splitMultiValueFields: true, + secondaryAxisGroupByFieldMetadataId: 'array-field-id-2', + } as TypedLineChartConfiguration, + fieldMetadataIdKey: 'primaryAxisGroupByFieldMetadataId', + subFieldNameKey: 'primaryAxisGroupBySubFieldName', + fieldId: 'array-field-id', + subFieldName: null, + objectMetadataItem: mockObjectMetadataItem, + }); + + expect(result).toMatchObject({ + splitMultiValueFields: false, + }); + }); }); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts index 4e5a0271c8..5baef20e04 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/getBarChartSettings.test.ts @@ -31,7 +31,7 @@ describe('getBarChartSettings', () => { ); expect(xAxisGroup).toBeDefined(); - expect(xAxisGroup?.items).toHaveLength(4); + expect(xAxisGroup?.items).toHaveLength(5); expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_X_SETTING.id); expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_X_SETTING.label); expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX); @@ -48,7 +48,7 @@ describe('getBarChartSettings', () => { ); expect(yAxisGroup).toBeDefined(); - expect(yAxisGroup?.items).toHaveLength(7); + expect(yAxisGroup?.items).toHaveLength(8); expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id); expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label); expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY); @@ -80,7 +80,7 @@ describe('getBarChartSettings', () => { ); expect(xAxisGroup).toBeDefined(); - expect(xAxisGroup?.items).toHaveLength(7); + expect(xAxisGroup?.items).toHaveLength(8); expect(xAxisGroup?.items[0].id).toBe(DATA_DISPLAY_Y_SETTING.id); expect(xAxisGroup?.items[0].label).toBe(DATA_DISPLAY_Y_SETTING.label); expect(xAxisGroup?.items[0].Icon).toBe(IconAxisX); @@ -100,7 +100,7 @@ describe('getBarChartSettings', () => { ); expect(yAxisGroup).toBeDefined(); - expect(yAxisGroup?.items).toHaveLength(4); + expect(yAxisGroup?.items).toHaveLength(5); expect(yAxisGroup?.items[0].id).toBe(DATA_DISPLAY_X_SETTING.id); expect(yAxisGroup?.items[0].label).toBe(DATA_DISPLAY_X_SETTING.label); expect(yAxisGroup?.items[0].Icon).toBe(IconAxisY); diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts index a78ae1ef79..ff614f657b 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/__tests__/shouldHideChartSetting.test.ts @@ -485,4 +485,155 @@ describe('shouldHideChartSetting', () => { }); }); }); + + describe('split multi-value fields visibility based on grouped field types', () => { + const splitMultiValueFieldsXItem: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X, + label: msg`Split multiple values`, + Icon: IconChartBar, + isBoolean: true, + isNumberInput: false, + }; + + const splitMultiValueFieldsYItem: ChartSettingsItem = { + id: CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_Y, + label: msg`Split multiple values`, + Icon: IconChartBar, + isBoolean: true, + isNumberInput: false, + }; + + const mockObjectMetadataItem: ObjectMetadataItem = { + id: 'object-id', + nameSingular: 'opportunity', + namePlural: 'opportunities', + labelSingular: 'Opportunity', + labelPlural: 'Opportunities', + description: 'An opportunity', + icon: 'IconTargetArrow', + isActive: true, + isSystem: false, + isRemote: false, + isAuditLogged: false, + isLabelSyncedWithName: true, + fields: [ + { + id: 'text-field-id', + name: 'name', + label: 'Name', + type: FieldMetadataType.TEXT, + }, + { + id: 'array-field-id', + name: 'tags', + label: 'Tags', + type: FieldMetadataType.MULTI_SELECT, + }, + { + id: 'array-field-id-2', + name: 'skills', + label: 'Skills', + type: FieldMetadataType.ARRAY, + }, + ], + } as any; + + it('should show X setting when bar chart primary group by field is an array field', () => { + const barChartConfig: ChartConfiguration = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'array-field-id', + } as any; + + const result = shouldHideChartSetting( + splitMultiValueFieldsXItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + expect(result).toBe(false); + }); + + it('should hide X setting when primary group by field is not an array field', () => { + const barChartConfig: ChartConfiguration = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'text-field-id', + secondaryAxisGroupByFieldMetadataId: 'array-field-id', + } as any; + + const result = shouldHideChartSetting( + splitMultiValueFieldsXItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should show Y setting when secondary group by field is an array field', () => { + const barChartConfig: ChartConfiguration = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'text-field-id', + secondaryAxisGroupByFieldMetadataId: 'array-field-id', + } as any; + + const result = shouldHideChartSetting( + splitMultiValueFieldsYItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + expect(result).toBe(false); + }); + + it('should hide Y setting when secondary group by field is not an array field', () => { + const barChartConfig: ChartConfiguration = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'array-field-id', + secondaryAxisGroupByFieldMetadataId: 'text-field-id', + } as any; + + const result = shouldHideChartSetting( + splitMultiValueFieldsYItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + expect(result).toBe(true); + }); + + it('should hide both X and Y settings when both axes are array fields', () => { + const barChartConfig: ChartConfiguration = { + __typename: 'BarChartConfiguration', + primaryAxisGroupByFieldMetadataId: 'array-field-id', + secondaryAxisGroupByFieldMetadataId: 'array-field-id-2', + } as any; + + const resultX = shouldHideChartSetting( + splitMultiValueFieldsXItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + const resultY = shouldHideChartSetting( + splitMultiValueFieldsYItem, + 'object-id', + true, + barChartConfig, + mockObjectMetadataItem, + ); + + expect(resultX).toBe(true); + expect(resultY).toBe(true); + }); + }); }); 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 58ea650120..41f97db084 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 @@ -4,7 +4,7 @@ import { isFieldOrRelationNestedFieldDateKind } from '@/command-menu/pages/page- import { isWidgetConfigurationOfType } from '@/command-menu/pages/page-layout/utils/isWidgetConfigurationOfType'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isFieldMetadataArrayKind } from 'twenty-shared/utils'; import { BarChartGroupMode, GraphOrderBy } from '~/generated-metadata/graphql'; type BuildChartGroupByFieldConfigUpdateArgs = { @@ -60,6 +60,23 @@ export const buildChartGroupByFieldConfigUpdate = < ? getChartDefaultOrderByForFieldType(fieldMetadataItem?.type) : GraphOrderBy.FIELD_ASC; + const isArrayFieldMetadataId = ( + fieldMetadataId: string | null | undefined, + ): boolean => { + if (!isDefined(fieldMetadataId)) { + return false; + } + + const candidateFieldMetadataItem = objectMetadataItem?.fields?.find( + (field) => field.id === fieldMetadataId, + ); + + return ( + isDefined(candidateFieldMetadataItem) && + isFieldMetadataArrayKind(candidateFieldMetadataItem.type) + ); + }; + if (isPrimaryAxis) { const existingDateGranularity = isBarChart || isLineChart @@ -79,6 +96,11 @@ export const buildChartGroupByFieldConfigUpdate = < !isNewFieldDateType && (isBarChart || isLineChart); + const shouldDisableSplitMultiValueFields = + (isBarChart || isLineChart) && + isArrayFieldMetadataId(fieldId) && + isArrayFieldMetadataId(configuration.secondaryAxisGroupByFieldMetadataId); + return { ...baseConfig, primaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null, @@ -86,6 +108,9 @@ export const buildChartGroupByFieldConfigUpdate = < ? (existingDateGranularity ?? ObjectRecordGroupByDateGranularity.DAY) : null, ...(shouldResetCumulative ? { isCumulative: false } : {}), + ...(shouldDisableSplitMultiValueFields + ? { splitMultiValueFields: false } + : {}), }; } @@ -108,6 +133,10 @@ export const buildChartGroupByFieldConfigUpdate = < } if (isBarChart) { + const shouldDisableSplitMultiValueFields = + isArrayFieldMetadataId(configuration.primaryAxisGroupByFieldMetadataId) && + isArrayFieldMetadataId(fieldId); + return { ...baseConfig, secondaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null, @@ -118,10 +147,17 @@ export const buildChartGroupByFieldConfigUpdate = < groupMode: isDefined(fieldId) ? (configuration.groupMode ?? BarChartGroupMode.STACKED) : null, + ...(shouldDisableSplitMultiValueFields + ? { splitMultiValueFields: false } + : {}), }; } if (isLineChart) { + const shouldDisableSplitMultiValueFields = + isArrayFieldMetadataId(configuration.primaryAxisGroupByFieldMetadataId) && + isArrayFieldMetadataId(fieldId); + return { ...baseConfig, secondaryAxisOrderBy: isDefined(fieldId) ? defaultOrderBy : null, @@ -130,6 +166,9 @@ export const buildChartGroupByFieldConfigUpdate = < ObjectRecordGroupByDateGranularity.DAY) : null, isStacked: isDefined(fieldId) ? (configuration.isStacked ?? true) : null, + ...(shouldDisableSplitMultiValueFields + ? { splitMultiValueFields: false } + : {}), }; } diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts index 9101a25588..f78c3d7e89 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertBarOrLineChartConfigToPieChart.ts @@ -19,5 +19,6 @@ export const convertBarOrLineChartConfigToPieChart = ( groupBySubFieldName: configuration.primaryAxisGroupBySubFieldName, dateGranularity: configuration.primaryAxisDateGranularity, orderBy: configuration.primaryAxisOrderBy, + splitMultiValueFields: configuration.splitMultiValueFields, }; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts index 2af8cc985e..6b006ca9ee 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/convertPieChartConfigToBarOrLineChart.ts @@ -14,5 +14,6 @@ export const convertPieChartConfigToBarOrLineChart = ( primaryAxisGroupBySubFieldName: configuration.groupBySubFieldName, primaryAxisDateGranularity: configuration.dateGranularity, primaryAxisOrderBy: configuration.orderBy, + splitMultiValueFields: configuration.splitMultiValueFields, }; }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts index e52b8ee9d6..972c19252d 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/getBarChartSettings.ts @@ -16,6 +16,8 @@ import { RANGE_MAX_SETTING } from '@/command-menu/pages/page-layout/constants/se import { RANGE_MIN_SETTING } from '@/command-menu/pages/page-layout/constants/settings/RangeMinSetting'; import { SHOW_LEGEND_SETTING } from '@/command-menu/pages/page-layout/constants/settings/ShowLegendSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; +import { SPLIT_MULTI_VALUE_FIELDS_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsXSetting'; +import { SPLIT_MULTI_VALUE_FIELDS_Y_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SplitMultiValueFieldsYSetting'; import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting'; import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; import { IconAxisX, IconAxisY } from 'twenty-ui/display'; @@ -34,6 +36,7 @@ export const getBarChartSettings = ( DATE_GRANULARITY_X_SETTING, PRIMARY_SORT_BY_SETTING, OMIT_NULL_VALUES_SETTING, + SPLIT_MULTI_VALUE_FIELDS_X_SETTING, ]; const secondaryAxisItems = [ @@ -44,6 +47,7 @@ export const getBarChartSettings = ( CUMULATIVE_SETTING, RANGE_MIN_SETTING, RANGE_MAX_SETTING, + SPLIT_MULTI_VALUE_FIELDS_Y_SETTING, ]; const xAxisItems = isHorizontal ? secondaryAxisItems : primaryAxisItems; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/shouldHideChartSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/shouldHideChartSetting.ts index a171fcb06a..cf50e2ef5a 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/shouldHideChartSetting.ts +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/utils/shouldHideChartSetting.ts @@ -6,7 +6,11 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation'; import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind'; import { isNonEmptyString } from '@sniptt/guards'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { + isDefined, + isFieldMetadataArrayKind, + isFieldMetadataDateKind, +} from 'twenty-shared/utils'; const shouldHideDateGranularityBasedOnFieldType = ( fieldMetadataId: string | undefined | null, @@ -102,6 +106,60 @@ export const shouldHideChartSetting = ( } } + if ( + item.id === CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X || + item.id === CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_Y + ) { + const isXAxis = + item.id === CHART_CONFIGURATION_SETTING_IDS.SPLIT_MULTI_VALUE_FIELDS_X; + + let fieldMetadataId: string | null | undefined; + + if (isBarOrLineChart) { + fieldMetadataId = isXAxis + ? configuration.primaryAxisGroupByFieldMetadataId + : configuration.secondaryAxisGroupByFieldMetadataId; + } else if ( + isWidgetConfigurationOfType(configuration, 'PieChartConfiguration') + ) { + fieldMetadataId = isXAxis + ? configuration.groupByFieldMetadataId + : undefined; + } + + if (!isDefined(fieldMetadataId)) { + return true; + } + + if (isBarOrLineChart) { + const primaryField = objectMetadataItem.fields.find( + (field) => + field.id === configuration.primaryAxisGroupByFieldMetadataId, + ); + const secondaryField = objectMetadataItem.fields.find( + (field) => + field.id === configuration.secondaryAxisGroupByFieldMetadataId, + ); + const bothAxesAreArrayFields = + isDefined(primaryField) && + isFieldMetadataArrayKind(primaryField.type) && + isDefined(secondaryField) && + isFieldMetadataArrayKind(secondaryField.type); + + if (bothAxesAreArrayFields === true) { + return true; + } + } + + const groupByField = objectMetadataItem.fields.find( + (field) => field.id === fieldMetadataId, + ); + + return ( + !isDefined(groupByField) || !isFieldMetadataArrayKind(groupByField.type) + ); + } + if (item.id === CHART_CONFIGURATION_SETTING_IDS.SHOW_LEGEND) { if (isWidgetConfigurationOfType(configuration, 'PieChartConfiguration')) { return false; diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/types/group-by-field.types.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/types/group-by-field.types.ts index 3a36d2e895..147665e8e0 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/types/group-by-field.types.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/types/group-by-field.types.ts @@ -6,6 +6,7 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m export type GroupByRegularField = { fieldMetadata: FlatFieldMetadata; subFieldName?: string; + shouldUnnest?: boolean; }; export type GroupByDateField = { diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/__tests__/get-group-by-expression.util.spec.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/__tests__/get-group-by-expression.util.spec.ts index 958c49d7a1..a9bfce99fb 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/__tests__/get-group-by-expression.util.spec.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/__tests__/get-group-by-expression.util.spec.ts @@ -3,7 +3,10 @@ import { ObjectRecordGroupByDateGranularity, } from 'twenty-shared/types'; -import { type GroupByDateField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types'; +import { + type GroupByDateField, + type GroupByRegularField, +} from 'src/engine/api/common/common-query-runners/types/group-by-field.types'; import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -23,6 +26,16 @@ const buildGroupByDateField = ( ...overrides, }); +const buildGroupByRegularField = ( + overrides: Partial = {}, +): GroupByRegularField => ({ + fieldMetadata: { + type: FieldMetadataType.MULTI_SELECT, + name: 'tags', + } as FlatFieldMetadata, + ...overrides, +}); + describe('getGroupByExpression', () => { const columnNameWithQuotes = '"company"."createdAt"'; @@ -126,4 +139,36 @@ describe('getGroupByExpression', () => { expect(result).not.toContain('AT TIME ZONE'); }); }); + + describe('array unnest handling', () => { + const arrayColumnNameWithQuotes = '"company"."tags"'; + + it('should unnest array fields when split mode is enabled', () => { + const groupByField = buildGroupByRegularField({ + shouldUnnest: true, + }); + + const result = getGroupByExpression({ + groupByField, + columnNameWithQuotes: arrayColumnNameWithQuotes, + }); + + expect(result).toBe( + `UNNEST(CASE WHEN CARDINALITY(${arrayColumnNameWithQuotes}) > 0 THEN ${arrayColumnNameWithQuotes} ELSE ARRAY[${arrayColumnNameWithQuotes}[1]] END)`, + ); + }); + + it('should keep plain column expression when split mode is disabled', () => { + const groupByField = buildGroupByRegularField({ + shouldUnnest: false, + }); + + const result = getGroupByExpression({ + groupByField, + columnNameWithQuotes: arrayColumnNameWithQuotes, + }); + + expect(result).toBe(arrayColumnNameWithQuotes); + }); + }); }); diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-expression.util.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-expression.util.ts index e93edd4f4b..d67e3e2bcc 100644 --- a/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-expression.util.ts +++ b/packages/twenty-server/src/engine/api/common/common-query-runners/utils/get-group-by-expression.util.ts @@ -30,6 +30,10 @@ export const getGroupByExpression = ({ if ( !(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField)) ) { + if ('shouldUnnest' in groupByField && groupByField.shouldUnnest) { + return `UNNEST(CASE WHEN CARDINALITY(${columnNameWithQuotes}) > 0 THEN ${columnNameWithQuotes} ELSE ARRAY[${columnNameWithQuotes}[1]] END)`; + } + return columnNameWithQuotes; } diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts index 61e98b0a1d..5b0a292820 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts @@ -12,16 +12,16 @@ import { } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; -import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util'; -import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; -import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util'; import { type GroupByDateField, type GroupByField, type GroupByRegularField, } from 'src/engine/api/common/common-query-runners/types/group-by-field.types'; import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util'; +import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; +import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util'; +import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util'; import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper'; import { type AggregationField, @@ -31,12 +31,12 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; -import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util'; import { type OrderByClause } from './types/order-by-condition.type'; @@ -387,11 +387,11 @@ export class GraphqlQueryOrderGroupByParser { flatObjectMetadata: FlatObjectMetadata; fieldMetadata: FlatFieldMetadata; }): Record | null => { - const fieldIsInGroupBy = groupByFields.some( + const groupByField = groupByFields.find( (groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id, ); - if (!fieldIsInGroupBy) { + if (!isDefined(groupByField)) { throw new UserInputError( `Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`, ); @@ -404,8 +404,15 @@ export class GraphqlQueryOrderGroupByParser { return null; } + const columnNameWithQuotes = `"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"`; + + const expression = getGroupByOrderExpression({ + groupByField, + columnNameWithQuotes, + }); + return { - [`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]: + [`${expression}${orderByCasting}`]: convertOrderByToFindOptionsOrder(orderByDirection), }; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts index b86e3efff1..c2dfab1a8b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/parse-group-by-args.util.ts @@ -93,6 +93,20 @@ export const parseGroupByArgs = ( } } + // Handle array unnest fields + if ( + typeof fieldNames[fieldName] === 'object' && + fieldNames[fieldName] !== null && + 'unnest' in fieldNames[fieldName] + ) { + groupByFields.push({ + fieldMetadata, + subFieldName: undefined, + shouldUnnest: true, + }); + continue; + } + // Handle regular fields and composite fields if (fieldNames[fieldName] === true) { groupByFields.push({ 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 816ccdbe76..695b35a4d7 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 @@ -112,6 +112,11 @@ export class BarChartConfigurationDTO @IsOptional() omitNullValues?: boolean; + @Field(() => Boolean, { nullable: true, defaultValue: true }) + @IsBoolean() + @IsOptional() + splitMultiValueFields?: boolean; + @Field(() => AxisNameDisplay, { nullable: true, defaultValue: AxisNameDisplay.NONE, 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 c108bf16ae..440786e5ba 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 @@ -113,6 +113,11 @@ export class LineChartConfigurationDTO @IsOptional() omitNullValues?: boolean; + @Field(() => Boolean, { nullable: true, defaultValue: true }) + @IsBoolean() + @IsOptional() + splitMultiValueFields?: boolean; + @Field(() => AxisNameDisplay, { nullable: true, defaultValue: AxisNameDisplay.NONE, 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 e95923808f..1dc9e672f3 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 @@ -97,6 +97,11 @@ export class PieChartConfigurationDTO @IsOptional() hideEmptyCategory?: boolean; + @Field(() => Boolean, { nullable: true, defaultValue: true }) + @IsBoolean() + @IsOptional() + splitMultiValueFields?: boolean; + @Field(() => String, { nullable: true }) @IsString() @IsOptional() diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts index c65bfb58f7..5a98f4f2c8 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts @@ -9,8 +9,8 @@ import { } from 'twenty-shared/utils'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { BarChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/bar-chart-configuration.dto'; import { BarChartGroupMode } from 'src/engine/metadata-modules/page-layout-widget/enums/bar-chart-group-mode.enum'; @@ -166,6 +166,7 @@ export class BarChartDataService { secondaryDateGranularity: configuration.secondaryAxisGroupByDateGranularity, secondaryAxisOrderBy: configuration.secondaryAxisOrderBy, + splitMultiValueFields: configuration.splitMultiValueFields, }); if (isTwoDimensional && isDefined(secondaryAxisGroupByField)) { @@ -249,6 +250,7 @@ export class BarChartDataService { omitNullValues: configuration.omitNullValues ?? false, isDescOrder, isTwoDimensional: false, + splitMultiValueFields: configuration.splitMultiValueFields, }); const selectOptions = getSelectOptions(primaryAxisGroupByField); @@ -389,6 +391,7 @@ export class BarChartDataService { omitNullValues: configuration.omitNullValues ?? false, isDescOrder, isTwoDimensional: true, + splitMultiValueFields: configuration.splitMultiValueFields, }); const primarySelectOptions = getSelectOptions(primaryAxisGroupByField); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts index 3787be49e2..54f44f5178 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts @@ -5,7 +5,11 @@ import { ObjectRecordGroupByDateGranularity, OrderByWithGroupBy, } from 'twenty-shared/types'; -import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils'; +import { + isDefined, + isFieldMetadataArrayKind, + isFieldMetadataDateKind, +} from 'twenty-shared/utils'; import { ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; @@ -19,6 +23,11 @@ import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enu import { ChartFilter } from 'src/engine/metadata-modules/page-layout-widget/types/chart-filter.type'; import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant'; import { GRAPH_DEFAULT_ORDER_BY } from 'src/modules/dashboard/chart-data/constants/graph-default-order-by.constant'; +import { + ChartDataException, + ChartDataExceptionCode, + generateChartDataExceptionMessage, +} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception'; import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type'; import { buildAggregateFieldKey } from 'src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util'; import { @@ -51,6 +60,7 @@ type ExecuteGroupByQueryParams = { secondaryDateGranularity?: ObjectRecordGroupByDateGranularity; primaryAxisOrderBy?: GraphOrderBy; secondaryAxisOrderBy?: GraphOrderBy; + splitMultiValueFields?: boolean; }; @Injectable() @@ -79,6 +89,7 @@ export class ChartDataQueryService { secondaryGroupBySubFieldName, secondaryDateGranularity, secondaryAxisOrderBy, + splitMultiValueFields, }: ExecuteGroupByQueryParams): Promise { const gqlOperationFilter = convertChartFilterToGqlOperationFilter({ filter, @@ -111,6 +122,12 @@ export class ChartDataQueryService { const shouldApplyPrimaryDateGranularity = isPrimaryFieldDate || isPrimaryNestedDate; + const shouldSplitMultiValueFields = splitMultiValueFields ?? true; + + const shouldUnnestPrimary = + shouldSplitMultiValueFields && + isFieldMetadataArrayKind(primaryGroupByField.type); + const groupBy: GroupByFieldObject[] = []; groupBy.push( @@ -123,6 +140,7 @@ export class ChartDataQueryService { firstDayOfTheWeek, isNestedDateField: isPrimaryNestedDate, timeZone: userTimezone, + shouldUnnest: shouldUnnestPrimary, }), ); @@ -162,6 +180,20 @@ export class ChartDataQueryService { const shouldApplySecondaryDateGranularity = isSecondaryFieldDate || isSecondaryNestedDate; + const shouldUnnestSecondary = + shouldSplitMultiValueFields && + isFieldMetadataArrayKind(secondaryGroupByField.type); + + if (shouldUnnestPrimary && shouldUnnestSecondary) { + throw new ChartDataException( + generateChartDataExceptionMessage( + ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION, + 'Split multiple values can only be enabled when one grouped field is multi-value.', + ), + ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION, + ); + } + groupBy.push( buildGroupByFieldObject({ fieldMetadata: secondaryGroupByField, @@ -172,6 +204,7 @@ export class ChartDataQueryService { firstDayOfTheWeek, isNestedDateField: isSecondaryNestedDate, timeZone: userTimezone, + shouldUnnest: shouldUnnestSecondary, }), ); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts index 29f0598a76..ba268738f8 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts @@ -167,6 +167,7 @@ export class LineChartDataService { secondaryDateGranularity: configuration.secondaryAxisGroupByDateGranularity, secondaryAxisOrderBy: configuration.secondaryAxisOrderBy, + splitMultiValueFields: configuration.splitMultiValueFields, }); const seriesIdPrefix = buildLineChartSeriesIdPrefix( @@ -256,6 +257,7 @@ export class LineChartDataService { omitNullValues: configuration.omitNullValues ?? false, isDescOrder, isTwoDimensional: false, + splitMultiValueFields: configuration.splitMultiValueFields, }); const selectOptions = getSelectOptions(primaryAxisGroupByField); @@ -385,6 +387,7 @@ export class LineChartDataService { omitNullValues: configuration.omitNullValues ?? false, isDescOrder, isTwoDimensional: true, + splitMultiValueFields: configuration.splitMultiValueFields, }); const primarySelectOptions = getSelectOptions(primaryAxisGroupByField); diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts index 7624de3d61..bc632354ce 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts @@ -8,8 +8,8 @@ import { } from 'twenty-shared/utils'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; @@ -130,6 +130,7 @@ export class PieChartDataService { CalendarStartDay.MONDAY, limit, primaryAxisOrderBy: configuration.orderBy, + splitMultiValueFields: configuration.splitMultiValueFields, }); return this.transformToPieChartData({ diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts index 441b9b95e4..2020bf2c57 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/apply-gap-filling.util.ts @@ -1,5 +1,6 @@ import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types'; import { + isFieldMetadataArrayKind, isFieldMetadataDateKind, isFieldMetadataSelectKind, } from 'twenty-shared/utils'; @@ -23,6 +24,7 @@ type ApplyGapFillingParams = { omitNullValues: boolean; isDescOrder: boolean; isTwoDimensional: boolean; + splitMultiValueFields?: boolean; }; type ApplyGapFillingResult = { @@ -37,6 +39,7 @@ export const applyGapFilling = ({ omitNullValues, isDescOrder, isTwoDimensional, + splitMultiValueFields, }: ApplyGapFillingParams): ApplyGapFillingResult => { if (omitNullValues) { return { data, wasTruncated: false }; @@ -64,11 +67,15 @@ export const applyGapFilling = ({ wasTruncated = dateResult.wasTruncated; } + const isArrayFieldWithoutSplit = + isFieldMetadataArrayKind(primaryAxisGroupByField.type) && + !(splitMultiValueFields ?? true); + const isPrimaryFieldSelect = isFieldMetadataSelectKind( primaryAxisGroupByField.type, ); - if (isPrimaryFieldSelect) { + if (isPrimaryFieldSelect && !isArrayFieldWithoutSplit) { const selectOptions = getSelectOptions(primaryAxisGroupByField); const fillSelectGapsFn = isTwoDimensional diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts index 5d1d15a327..451cda4157 100644 --- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts +++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/build-group-by-field-object.util.ts @@ -78,6 +78,7 @@ export type BuildGroupByFieldObjectParams = { firstDayOfTheWeek?: CalendarStartDay | null; isNestedDateField?: boolean; timeZone?: string; + shouldUnnest?: boolean; }; export const buildGroupByFieldObject = ({ @@ -87,6 +88,7 @@ export const buildGroupByFieldObject = ({ firstDayOfTheWeek, isNestedDateField, timeZone, + shouldUnnest, }: BuildGroupByFieldObjectParams): GroupByFieldObject => { const isRelation = isMorphOrRelationFlatFieldMetadata(fieldMetadata); const isComposite = isCompositeFieldMetadataType(fieldMetadata.type); @@ -156,5 +158,9 @@ export const buildGroupByFieldObject = ({ return { [fieldMetadata.name]: dateGroupByObject }; } + if (shouldUnnest) { + return { [fieldMetadata.name]: { unnest: true } }; + } + return { [fieldMetadata.name]: true }; }; diff --git a/packages/twenty-shared/src/utils/fieldMetadata/index.ts b/packages/twenty-shared/src/utils/fieldMetadata/index.ts index 1fdef3316f..49d1312130 100644 --- a/packages/twenty-shared/src/utils/fieldMetadata/index.ts +++ b/packages/twenty-shared/src/utils/fieldMetadata/index.ts @@ -1,3 +1,4 @@ +export * from './isFieldMetadataArrayKind'; export * from './isFieldMetadataDateKind'; export * from './isFieldMetadataNumericKind'; export * from './isFieldMetadataSelectKind'; diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataArrayKind.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataArrayKind.ts new file mode 100644 index 0000000000..319f818651 --- /dev/null +++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataArrayKind.ts @@ -0,0 +1,10 @@ +import { FieldMetadataType } from '@/types'; + +export const isFieldMetadataArrayKind = ( + fieldMetadataType: FieldMetadataType, +): boolean => { + return ( + fieldMetadataType === FieldMetadataType.MULTI_SELECT || + fieldMetadataType === FieldMetadataType.ARRAY + ); +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index 5f57df7d70..0f936d029f 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -38,6 +38,7 @@ export { CustomError } from './errors/CustomError'; export { evalFromContext } from './evalFromContext'; export { extractAndSanitizeObjectStringFields } from './extractAndSanitizeObjectStringFields'; export { computeMorphRelationFieldName } from './fieldMetadata/compute-morph-relation-field-name'; +export { isFieldMetadataArrayKind } from './fieldMetadata/isFieldMetadataArrayKind'; export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind'; export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind'; export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind'; diff --git a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts index 39931ee16a..8ccc2bc4ba 100644 --- a/packages/twenty-ui/src/display/icon/components/TablerIcons.ts +++ b/packages/twenty-ui/src/display/icon/components/TablerIcons.ts @@ -19,6 +19,7 @@ export { IconArrowRight, IconArrowsDiagonal, IconArrowsSort, + IconArrowsSplit2, IconArrowsVertical, IconArrowUp, IconArrowUpRight, @@ -310,13 +311,13 @@ export { IconReload, IconReorder, IconRepeat, - IconRowInsertBottom, - IconRowInsertTop, IconRestore, IconRobot, IconRocket, IconRotate, IconRotate2, + IconRowInsertBottom, + IconRowInsertTop, IconSearch, IconSend, IconServer, diff --git a/packages/twenty-ui/src/display/index.ts b/packages/twenty-ui/src/display/index.ts index e28eb74606..c91fba14c2 100644 --- a/packages/twenty-ui/src/display/index.ts +++ b/packages/twenty-ui/src/display/index.ts @@ -90,6 +90,7 @@ export { IconArrowRight, IconArrowsDiagonal, IconArrowsSort, + IconArrowsSplit2, IconArrowsVertical, IconArrowUp, IconArrowUpRight, @@ -381,13 +382,13 @@ export { IconReload, IconReorder, IconRepeat, - IconRowInsertBottom, - IconRowInsertTop, IconRestore, IconRobot, IconRocket, IconRotate, IconRotate2, + IconRowInsertBottom, + IconRowInsertTop, IconSearch, IconSend, IconServer,