diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx index d06a8f68c8..95b7123dfa 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/components/ChartSettings.tsx @@ -107,15 +107,11 @@ export const ChartSettings = ({ widget }: { widget: PageLayoutWidget }) => { const handleToggleChange = () => { const configKey = - item.id === CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS + item.id in CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP ? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[ - CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS + item.id as keyof typeof CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP ] - : item.id === CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS - ? CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP[ - CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS - ] - : item.id; + : item.id; setSelectedItemId(item.id); 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 d4543d8cca..ce553dc1b8 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 @@ -6,6 +6,7 @@ import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constan import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting'; import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; +import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { type ChartSettingsGroup } from '@/command-menu/pages/page-layout/types/ChartSettingsGroup'; @@ -17,7 +18,11 @@ export const LINE_CHART_SETTINGS: ChartSettingsGroup[] = [ }, { heading: 'X axis', - items: [DATA_DISPLAY_X_SETTING, SORT_BY_X_SETTING], + items: [ + DATA_DISPLAY_X_SETTING, + SORT_BY_X_SETTING, + OMIT_NULL_VALUES_SETTING, + ], }, { heading: 'Y axis', 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 cba4f8c376..5961e603ca 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 @@ -14,4 +14,5 @@ export const CHART_CONFIGURATION_SETTING_LABELS = { EACH_SLICE_REPRESENTS: msg`Each slice represents`, AXIS_NAME: msg`Axis name`, STACKED_BARS: msg`Stacked bars`, + OMIT_NULL_VALUES: msg`Omit zero values`, }; diff --git a/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting.ts b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting.ts new file mode 100644 index 0000000000..4cd1f1412c --- /dev/null +++ b/packages/twenty-front/src/modules/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting.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 { IconEyeOff } from 'twenty-ui/display'; + +export const OMIT_NULL_VALUES_SETTING: ChartSettingsItem = { + isBoolean: true, + Icon: IconEyeOff, + label: CHART_CONFIGURATION_SETTING_LABELS.OMIT_NULL_VALUES, + id: CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES, +}; 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 4cfe58b07e..401a0425ab 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 @@ -155,6 +155,10 @@ export const useChartSettingsValues = ({ return 'groupMode' in configuration ? configuration.groupMode !== 'GROUPED' : true; + case CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES: + return 'omitNullValues' in configuration + ? (configuration.omitNullValues ?? false) + : false; default: return ''; } 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 4290ac1b22..53aba1dfba 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 @@ -13,9 +13,11 @@ export enum CHART_CONFIGURATION_SETTING_IDS { EACH_SLICE_REPRESENTS = 'EACH_SLICE_REPRESENTS', AXIS_NAME = 'AXIS_NAME', STACKED_BARS = 'STACKED_BARS', + OMIT_NULL_VALUES = 'OMIT_NULL_VALUES', } export const CHART_CONFIGURATION_SETTING_TO_CONFIG_KEY_MAP = { [CHART_CONFIGURATION_SETTING_IDS.DATA_LABELS]: 'displayDataLabel', [CHART_CONFIGURATION_SETTING_IDS.STACKED_BARS]: 'groupMode', + [CHART_CONFIGURATION_SETTING_IDS.OMIT_NULL_VALUES]: 'omitNullValues', } as const; 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 b8e389982b..da72372690 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 @@ -6,6 +6,7 @@ import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constan import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting'; import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; +import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting'; @@ -21,11 +22,12 @@ describe('getBarChartSettings', () => { const xAxisGroup = result.find((group) => group.heading === 'X axis'); expect(xAxisGroup).toBeDefined(); - expect(xAxisGroup?.items).toHaveLength(2); + expect(xAxisGroup?.items).toHaveLength(3); 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); expect(xAxisGroup?.items[1]).toEqual(SORT_BY_X_SETTING); + expect(xAxisGroup?.items[2]).toEqual(OMIT_NULL_VALUES_SETTING); }); it('should place secondary axis items under "Y axis" heading', () => { @@ -74,11 +76,12 @@ describe('getBarChartSettings', () => { const yAxisGroup = result.find((group) => group.heading === 'Y axis'); expect(yAxisGroup).toBeDefined(); - expect(yAxisGroup?.items).toHaveLength(2); + expect(yAxisGroup?.items).toHaveLength(3); 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); expect(yAxisGroup?.items[1]).toEqual(SORT_BY_X_SETTING); + expect(yAxisGroup?.items[2]).toEqual(OMIT_NULL_VALUES_SETTING); }); it('should have all expected groups in correct order', () => { 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 f10d2d3f83..9456287c34 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 @@ -6,6 +6,7 @@ import { DATA_DISPLAY_Y_SETTING } from '@/command-menu/pages/page-layout/constan import { DATA_LABELS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/DataLabelsSetting'; import { FILTER_SETTING } from '@/command-menu/pages/page-layout/constants/settings/FilterSetting'; import { GROUP_BY_SETTING } from '@/command-menu/pages/page-layout/constants/settings/GroupBySetting'; +import { OMIT_NULL_VALUES_SETTING } from '@/command-menu/pages/page-layout/constants/settings/OmitNullValuesSetting'; import { SORT_BY_GROUP_BY_FIELD_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByGroupByFieldSetting'; import { SORT_BY_X_SETTING } from '@/command-menu/pages/page-layout/constants/settings/SortByXSetting'; import { STACKED_BARS_SETTING } from '@/command-menu/pages/page-layout/constants/settings/StackedBarsSetting'; @@ -24,6 +25,7 @@ export const getBarChartSettings = ( const primaryAxisItems = [ { ...DATA_DISPLAY_X_SETTING, Icon: dataDisplayXIcon }, SORT_BY_X_SETTING, + OMIT_NULL_VALUES_SETTING, ]; const secondaryAxisItems = [ diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutGraphWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutGraphWidget.ts index 594b0b73c0..b70487ce49 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutGraphWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutGraphWidget.ts @@ -117,6 +117,8 @@ export const useCreatePageLayoutGraphWidget = ( y: position.y, w: position.w, h: position.h, + minW: minimumSize.w, + minH: minimumSize.h, }; const updatedLayouts = getUpdatedTabLayouts( diff --git a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutIframeWidget.ts b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutIframeWidget.ts index a29b07b231..2b50366e3a 100644 --- a/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutIframeWidget.ts +++ b/packages/twenty-front/src/modules/page-layout/hooks/useCreatePageLayoutIframeWidget.ts @@ -59,11 +59,11 @@ export const useCreatePageLayoutIframeWidget = ( } const widgetId = uuidv4(); - const defaultSize = { w: 6, h: 6 }; + const defaultIframeSize = { w: 6, h: 6 }; const minimumSize = DEFAULT_WIDGET_SIZE.minimum; const position = getDefaultWidgetPosition( pageLayoutDraggedArea, - defaultSize, + defaultIframeSize, minimumSize, ); @@ -86,6 +86,8 @@ export const useCreatePageLayoutIframeWidget = ( y: position.y, w: position.w, h: position.h, + minW: minimumSize.w, + minH: minimumSize.h, }; const updatedLayouts = getUpdatedTabLayouts( diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap index 0632267999..6362cae809 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/__snapshots__/generateGroupByQuery.test.ts.snap @@ -6,12 +6,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr $groupBy: [PersonGroupByInput!] $filter: PersonFilterInput $orderBy: [PersonOrderByWithGroupByInput!] + $omitNullValues: Boolean $viewId: UUID ) { peopleGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy + omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues @@ -26,12 +28,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a $groupBy: [OpportunityGroupByInput!] $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] + $omitNullValues: Boolean $viewId: UUID ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy + omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues @@ -49,12 +53,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for single agg $groupBy: [OpportunityGroupByInput!] $filter: OpportunityFilterInput $orderBy: [OpportunityOrderByWithGroupByInput!] + $omitNullValues: Boolean $viewId: UUID ) { opportunitiesGroupBy( groupBy: $groupBy filter: $filter orderBy: $orderBy + omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts index 62545b7c9e..0d07749613 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQuery.ts @@ -20,12 +20,14 @@ export const generateGroupByQuery = ({ $groupBy: [${capitalizedSingular}GroupByInput!] $filter: ${capitalizedSingular}FilterInput $orderBy: [${capitalizedSingular}OrderByWithGroupByInput!] + $omitNullValues: Boolean $viewId: UUID ) { ${queryFieldName}( groupBy: $groupBy filter: $filter orderBy: $orderBy + omitNullValues: $omitNullValues viewId: $viewId ) { groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''} diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts index d8d22c5095..b56ba18d5d 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarChartConfiguration.ts @@ -101,5 +101,6 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({ return { groupBy, ...(orderBy.length > 0 && { orderBy }), + ...(barChartConfiguration.omitNullValues ? { omitNullValues: true } : {}), }; }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts index 3853626b10..a742226d97 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/group-by/resolvers/graphql-query-group-by-resolver.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CompositeFieldSubFieldName, + FieldMetadataType, ObjectRecord, PartialFieldMetadataItemOption, RecordFilterGroupLogicalOperator, @@ -25,6 +26,7 @@ import { IGroupByConnection } from 'src/engine/api/graphql/workspace-query-runne import { type WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-query-runner/interfaces/query-runner-option.interface'; import { GroupByResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface'; +import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant'; import { formatResultWithGroupByDimensionValues } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/format-result-with-group-by-dimension-values.util'; import { getGroupByExpression } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/get-group-by-expression.util'; import { isGroupByDateField } from 'src/engine/api/graphql/graphql-query-runner/group-by/resolvers/utils/is-group-by-date-field.util'; @@ -39,6 +41,7 @@ import { ViewFilterService } from 'src/engine/metadata-modules/view-filter/servi import { ViewEntity } from 'src/engine/metadata-modules/view/entities/view.entity'; import { ViewService } from 'src/engine/metadata-modules/view/services/view.service'; import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util'; + @Injectable() export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolverService< GroupByResolverArgs, @@ -135,6 +138,32 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver } }); + if (executionArgs.args.omitNullValues) { + const aggregateFields = + executionArgs.graphqlQuerySelectedFieldsResult.aggregate ?? {}; + + Object.values(aggregateFields).forEach((aggregationField) => { + const aggregateExpression = + ProcessAggregateHelper.getAggregateExpression( + aggregationField, + objectMetadataNameSingular, + ); + + if (aggregateExpression) { + queryBuilder.andHaving(`${aggregateExpression} IS NOT NULL`); + + const isNumericReturningAggregate = this.isNumericReturningAggregate( + aggregationField.aggregateOperation, + aggregationField.fromFieldType, + ); + + if (isNumericReturningAggregate) { + queryBuilder.andHaving(`${aggregateExpression} != 0`); + } + } + }); + } + executionArgs.graphqlQueryParser.applyGroupByOrderToBuilder( queryBuilder, executionArgs.args.orderBy ?? [], @@ -237,6 +266,39 @@ export class GraphqlQueryGroupByResolverService extends GraphqlQueryBaseResolver return appliedFilters; } + private isNumericReturningAggregate( + operation: AggregateOperations, + fromFieldType: FieldMetadataType, + ): boolean { + if ( + operation === AggregateOperations.COUNT || + operation === AggregateOperations.COUNT_UNIQUE_VALUES || + operation === AggregateOperations.COUNT_EMPTY || + operation === AggregateOperations.COUNT_NOT_EMPTY || + operation === AggregateOperations.COUNT_TRUE || + operation === AggregateOperations.COUNT_FALSE || + operation === AggregateOperations.PERCENTAGE_EMPTY || + operation === AggregateOperations.PERCENTAGE_NOT_EMPTY + ) { + return true; + } + + if ( + operation === AggregateOperations.MIN || + operation === AggregateOperations.MAX || + operation === AggregateOperations.AVG || + operation === AggregateOperations.SUM + ) { + return [ + FieldMetadataType.NUMBER, + FieldMetadataType.NUMERIC, + FieldMetadataType.CURRENCY, + ].includes(fromFieldType); + } + + return false; + } + async validate( _args: GroupByResolverArgs, _options: WorkspaceQueryRunnerOptions, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts index cd1e988a1f..487a5a632a 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface.ts @@ -74,6 +74,7 @@ export interface GroupByResolverArgs { groupBy: ObjectRecordGroupBy; viewId?: string; orderBy?: OrderByWithGroupBy; + omitNullValues?: boolean; } export interface UpdateOneResolverArgs< diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts index f1034c7f9a..610a11fa21 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/get-resolver-args.util.ts @@ -177,6 +177,10 @@ export const getResolverArgs = ( isNullable: true, isArray: true, }, + omitNullValues: { + type: GraphQLBoolean, + isNullable: true, + }, viewId: { type: UUIDScalarType, isNullable: true, diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts index 408eb316f5..55891779b0 100644 --- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolvers.integration-spec.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'crypto'; +import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants'; import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants'; import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; import { createViewFilterGroupOperationFactory } from 'test/integration/graphql/utils/create-view-filter-group-operation-factory.util'; @@ -8,9 +9,9 @@ import { destroyOneOperationFactory } from 'test/integration/graphql/utils/destr import { groupByOperationFactory } from 'test/integration/graphql/utils/group-by-operation-factory.util'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util'; -import { ViewFilterOperand } from 'twenty-shared/types'; import { createOneCoreViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-core-view-filter.util'; import { jestExpectToBeDefined } from 'test/utils/expect-to-be-defined.util.test'; +import { ViewFilterOperand } from 'twenty-shared/types'; import { type FieldMetadataDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto'; import { type ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; @@ -547,4 +548,190 @@ describe('group-by resolvers (integration)', () => { ); }); }); + + describe('omitNullValues', () => { + describe('numeric aggregates', () => { + const zeroGroupCompanyId1 = randomUUID(); + const zeroGroupCompanyId2 = randomUUID(); + const positiveGroupCompanyId = randomUUID(); + const zeroCity = 'ZeroCity'; + const positiveCity = 'PositiveCity'; + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: zeroGroupCompanyId1, + name: 'Zero City One', + address: { addressCity: zeroCity }, + employees: 0, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: zeroGroupCompanyId2, + name: 'Zero City Two', + address: { addressCity: zeroCity }, + employees: 0, + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS, + data: { + id: positiveGroupCompanyId, + name: 'Positive City One', + address: { addressCity: positiveCity }, + employees: 8, + }, + }), + ); + }); + + afterAll(async () => { + for (const id of [ + zeroGroupCompanyId1, + zeroGroupCompanyId2, + positiveGroupCompanyId, + ]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('filters out groups with zero numeric aggregates when enabled', async () => { + const baseOperation = { + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + groupBy: [{ address: { addressCity: true } }], + gqlFields: ` + sumEmployees + `, + }; + + const responseWithoutFilter = await makeGraphqlAPIRequest( + groupByOperationFactory(baseOperation), + ); + + expect(responseWithoutFilter.body.errors).toBeUndefined(); + + const groupsWithoutFilter = + responseWithoutFilter.body.data.companiesGroupBy; + + const zeroAggregateGroup = groupsWithoutFilter.find( + (group: any) => group.groupByDimensionValues?.[0] === zeroCity, + ); + + expect(zeroAggregateGroup).toBeDefined(); + expect(zeroAggregateGroup.sumEmployees).toBe(0); + + const responseWithFilter = await makeGraphqlAPIRequest( + groupByOperationFactory({ + ...baseOperation, + omitNullValues: true, + }), + ); + + expect(responseWithFilter.body.errors).toBeUndefined(); + + const groupsWithFilter = responseWithFilter.body.data.companiesGroupBy; + + expect( + groupsWithFilter.find( + (group: any) => group.groupByDimensionValues?.[0] === zeroCity, + ), + ).toBeUndefined(); + + const positiveAggregateGroup = groupsWithFilter.find( + (group: any) => group.groupByDimensionValues?.[0] === positiveCity, + ); + + expect(positiveAggregateGroup).toBeDefined(); + expect(positiveAggregateGroup.sumEmployees).toBeGreaterThan(0); + }); + }); + + describe('non-numeric aggregates', () => { + const dateCityPersonId1 = randomUUID(); + const dateCityPersonId2 = randomUUID(); + const dateCity = 'DateCity'; + + beforeAll(async () => { + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: dateCityPersonId1, + name: { firstName: 'Date', lastName: 'One' }, + city: dateCity, + createdAt: '2025-04-01T00:00:00.000Z', + }, + }), + ); + await makeGraphqlAPIRequest( + createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: PERSON_GQL_FIELDS, + data: { + id: dateCityPersonId2, + name: { firstName: 'Date', lastName: 'Two' }, + city: dateCity, + createdAt: '2025-04-02T00:00:00.000Z', + }, + }), + ); + }); + + afterAll(async () => { + for (const id of [dateCityPersonId1, dateCityPersonId2]) { + await makeGraphqlAPIRequest( + destroyOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id', + recordId: id, + }), + ); + } + }); + + it('does not throw error for date aggregates when enabled', async () => { + const response = await makeGraphqlAPIRequest( + groupByOperationFactory({ + objectMetadataSingularName: 'person', + objectMetadataPluralName: 'people', + groupBy: [{ city: true }], + gqlFields: ` + minCreatedAt + `, + omitNullValues: true, + }), + ); + + expect(response.body.errors).toBeUndefined(); + + const groups = response.body.data.peopleGroupBy; + + const dateCityGroup = groups.find( + (group: any) => group.groupByDimensionValues?.[0] === dateCity, + ); + + expect(dateCityGroup).toBeDefined(); + expect(dateCityGroup.minCreatedAt).toBe('2025-04-01T00:00:00.000Z'); + }); + }); + }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts index 573439b1c5..1c4902a6ee 100644 --- a/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/group-by-operation-factory.util.ts @@ -9,6 +9,7 @@ type GroupByOperationFactoryParams = { orderBy?: object[]; viewId?: string; gqlFields?: string; + omitNullValues?: boolean; }; export const groupByOperationFactory = ({ @@ -19,10 +20,11 @@ export const groupByOperationFactory = ({ orderBy = [], viewId, gqlFields, + omitNullValues, }: GroupByOperationFactoryParams) => ({ query: gql` - query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID) { - ${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId) { + query ${capitalize(objectMetadataPluralName)}GroupBy($groupBy: [${capitalize(objectMetadataSingularName)}GroupByInput!]!, $filter: ${capitalize(objectMetadataSingularName)}FilterInput, $orderBy: [${capitalize(objectMetadataSingularName)}OrderByWithGroupByInput!], $viewId: UUID, $omitNullValues: Boolean) { + ${objectMetadataPluralName}GroupBy(groupBy: $groupBy, filter: $filter, orderBy: $orderBy, viewId: $viewId, omitNullValues: $omitNullValues) { ${gqlFields ? gqlFields : ''} groupByDimensionValues totalCount @@ -34,5 +36,6 @@ export const groupByOperationFactory = ({ filter, orderBy, ...(viewId && { viewId }), + omitNullValues: omitNullValues ?? null, }, });