From e73aa80e730c3e10926d422947e9e214e8aade43 Mon Sep 17 00:00:00 2001 From: nitin <142569587+ehconitin@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:55:58 +0530 Subject: [PATCH] [Dashboards] add primary axis select gap fill for bar and line charts (#17098) closes https://github.com/twentyhq/core-team-issues/issues/2056 https://github.com/user-attachments/assets/44eb2704-9ea9-4f2b-8941-6dd5c12d6276 --- .../transformGroupByDataToBarChartData.ts | 18 +- .../transformGroupByDataToLineChartData.ts | 19 +- .../__tests__/createEmptySelectGroup.test.ts | 48 +++++ .../fillSelectGapsInChartData.test.ts | 133 ++++++++++++++ ...electGapsInOneDimensionalChartData.test.ts | 88 +++++++++ ...electGapsInTwoDimensionalChartData.test.ts | 168 ++++++++++++++++++ .../graph/utils/createEmptySelectGroup.ts | 17 ++ .../graph/utils/fillSelectGapsInChartData.ts | 39 ++++ ...fillSelectGapsInOneDimensionalChartData.ts | 36 ++++ ...fillSelectGapsInTwoDimensionalChartData.ts | 51 ++++++ 10 files changed, 613 insertions(+), 4 deletions(-) create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts index 4abf73e0bb..d0a9f9ad8c 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetBarChart/utils/transformGroupByDataToBarChartData.ts @@ -6,6 +6,7 @@ import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupB import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity'; import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries'; import { fillDateGapsInBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/fillDateGapsInBarChartData'; +import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformOneDimensionalGroupByToBarChartData'; import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/graphWidgetBarChart/utils/transformTwoDimensionalGroupByToBarChartData'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; @@ -20,6 +21,7 @@ import { isFieldMetadataDateKind, type FirstDayOfTheWeek, } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { AxisNameDisplay, BarChartLayout, @@ -244,9 +246,21 @@ export const transformGroupByDataToBarChartData = ({ const filteredResultsWithDateGaps = dateGapFillResult.data; const dateRangeWasTruncated = dateGapFillResult.wasTruncated; + const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT; + const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues; + + const resultsWithAllGapsFilled = shouldApplySelectGapFill + ? fillSelectGapsInChartData({ + data: filteredResultsWithDateGaps, + selectOptions: groupByFieldX.options, + aggregateKeys: [aggregateField.name], + hasSecondDimension: isDefined(groupByFieldY), + }) + : filteredResultsWithDateGaps; + const baseResult = isDefined(groupByFieldY) ? transformTwoDimensionalGroupByToBarChartData({ - rawResults: filteredResultsWithDateGaps, + rawResults: resultsWithAllGapsFilled, groupByFieldX, groupByFieldY, aggregateField, @@ -258,7 +272,7 @@ export const transformGroupByDataToBarChartData = ({ firstDayOfTheWeek, }) : transformOneDimensionalGroupByToBarChartData({ - rawResults: filteredResultsWithDateGaps, + rawResults: resultsWithAllGapsFilled, groupByFieldX, aggregateField, configuration: sanitizedConfiguration, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts index e04ce09e1b..ad070a4619 100644 --- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graphWidgetLineChart/utils/transformGroupByDataToLineChartData.ts @@ -6,6 +6,7 @@ import { getGroupByQueryResultGqlFieldName } from '@/page-layout/utils/getGroupB import { type LineChartSeries } from '@/page-layout/widgets/graph/graphWidgetLineChart/types/LineChartSeries'; import { transformOneDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformOneDimensionalGroupByToLineChartData'; import { transformTwoDimensionalGroupByToLineChartData } from '@/page-layout/widgets/graph/graphWidgetLineChart/utils/transformTwoDimensionalGroupByToLineChartData'; +import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; import { type GraphColorMode } from '@/page-layout/widgets/graph/types/GraphColorMode'; import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; @@ -16,6 +17,7 @@ import { isDefined, isFieldMetadataDateKind, } from 'twenty-shared/utils'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; import { AxisNameDisplay, type LineChartConfiguration, @@ -152,6 +154,19 @@ export const transformGroupByDataToLineChartData = ({ const showDataLabels = configuration.displayDataLabel ?? false; const showLegend = configuration.displayLegend ?? true; + const omitNullValues = configuration.omitNullValues ?? false; + const isSingleSelectField = groupByFieldX.type === FieldMetadataType.SELECT; + const shouldApplySelectGapFill = isSingleSelectField && !omitNullValues; + + const resultsWithSelectGaps = shouldApplySelectGapFill + ? fillSelectGapsInChartData({ + data: filteredResults, + selectOptions: groupByFieldX.options, + aggregateKeys: [aggregateField.name], + hasSecondDimension: isDefined(groupByFieldY), + }) + : filteredResults; + const isDateField = isFieldMetadataDateKind(groupByFieldX.type); const isNestedDateField = isRelationNestedFieldDateKind({ relationField: groupByFieldX, @@ -190,7 +205,7 @@ export const transformGroupByDataToLineChartData = ({ const baseResult = isDefined(groupByFieldY) ? transformTwoDimensionalGroupByToLineChartData({ - rawResults: filteredResults, + rawResults: resultsWithSelectGaps, groupByFieldX, groupByFieldY, aggregateField, @@ -202,7 +217,7 @@ export const transformGroupByDataToLineChartData = ({ firstDayOfTheWeek, }) : transformOneDimensionalGroupByToLineChartData({ - rawResults: filteredResults, + rawResults: resultsWithSelectGaps, groupByFieldX, aggregateField, configuration: sanitizedConfiguration, diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts new file mode 100644 index 0000000000..cefbcf33d1 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/createEmptySelectGroup.test.ts @@ -0,0 +1,48 @@ +import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; + +describe('createEmptySelectGroup', () => { + it('creates record with correct groupByDimensionValues', () => { + const result = createEmptySelectGroup(['A'], ['count']); + + expect(result.groupByDimensionValues).toEqual(['A']); + }); + + it('initializes all aggregateKeys to 0', () => { + const result = createEmptySelectGroup(['A'], ['count']); + + expect(result.count).toBe(0); + }); + + it('handles empty aggregateKeys array', () => { + const result = createEmptySelectGroup(['A'], []); + + expect(result).toEqual({ + groupByDimensionValues: ['A'], + }); + }); + + it('handles multiple aggregate keys', () => { + const result = createEmptySelectGroup(['A'], ['count', 'sum', 'avg']); + + expect(result).toEqual({ + groupByDimensionValues: ['A'], + count: 0, + sum: 0, + avg: 0, + }); + }); + + it('handles null values in dimensionValues', () => { + const result = createEmptySelectGroup(['A', null], ['count']); + + expect(result.groupByDimensionValues).toEqual(['A', null]); + expect(result.count).toBe(0); + }); + + it('handles multiple dimension values', () => { + const result = createEmptySelectGroup(['A', 'X', 'Y'], ['count']); + + expect(result.groupByDimensionValues).toEqual(['A', 'X', 'Y']); + expect(result.count).toBe(0); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts new file mode 100644 index 0000000000..6d11e9fb24 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInChartData.test.ts @@ -0,0 +1,133 @@ +import { fillSelectGapsInChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInChartData'; + +describe('fillSelectGapsInChartData', () => { + const selectOptions = [ + { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, + { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, + { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, + ] as const; + + describe('one-dimensional data', () => { + it('returns data unchanged when selectOptions is undefined', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 5 }, + { groupByDimensionValues: ['B'], count: 3 }, + ]; + + const result = fillSelectGapsInChartData({ + data, + selectOptions: undefined, + aggregateKeys: ['count'], + }); + + expect(result).toEqual(data); + }); + + it('returns data unchanged when selectOptions is null', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 5 }, + { groupByDimensionValues: ['B'], count: 3 }, + ]; + + const result = fillSelectGapsInChartData({ + data, + selectOptions: null, + aggregateKeys: ['count'], + }); + + expect(result).toEqual(data); + }); + + it('returns data unchanged when selectOptions is empty', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 5 }, + { groupByDimensionValues: ['B'], count: 3 }, + ]; + + const result = fillSelectGapsInChartData({ + data, + selectOptions: [], + aggregateKeys: ['count'], + }); + + expect(result).toEqual(data); + }); + + it('returns empty data unchanged', () => { + const result = fillSelectGapsInChartData({ + data: [], + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toEqual([]); + }); + + it('fills missing select options with zero values', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 5 }, + { groupByDimensionValues: ['C'], count: 3 }, + ]; + + const result = fillSelectGapsInChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + groupByDimensionValues: ['A'], + count: 5, + }); + expect(result[1]).toEqual({ + groupByDimensionValues: ['B'], + count: 0, + }); + expect(result[2]).toEqual({ + groupByDimensionValues: ['C'], + count: 3, + }); + }); + }); + + describe('two-dimensional data', () => { + it('fills missing primary axis options for all secondary values', () => { + const data = [ + { groupByDimensionValues: ['A', 'X'], count: 5 }, + { groupByDimensionValues: ['C', 'X'], count: 3 }, + { groupByDimensionValues: ['A', 'Y'], count: 2 }, + ]; + + const result = fillSelectGapsInChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + hasSecondDimension: true, + }); + + expect(result).toHaveLength(6); + + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'A'), + ).toHaveLength(2); + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'B'), + ).toHaveLength(2); + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'C'), + ).toHaveLength(2); + + expect( + result.find( + (r) => + r.groupByDimensionValues[0] === 'B' && + r.groupByDimensionValues[1] === 'X', + ), + ).toEqual({ + groupByDimensionValues: ['B', 'X'], + count: 0, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts new file mode 100644 index 0000000000..4e9f4c9d1e --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInOneDimensionalChartData.test.ts @@ -0,0 +1,88 @@ +import { fillSelectGapsInOneDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData'; + +describe('fillSelectGapsInOneDimensionalChartData', () => { + const selectOptions = [ + { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, + { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, + { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, + ] as const; + + it('fills missing select options with zero values', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 5 }, + { groupByDimensionValues: ['C'], count: 3 }, + ]; + + const result = fillSelectGapsInOneDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ + groupByDimensionValues: ['A'], + count: 5, + }); + expect(result[1]).toEqual({ + groupByDimensionValues: ['B'], + count: 0, + }); + expect(result[2]).toEqual({ + groupByDimensionValues: ['C'], + count: 3, + }); + }); + + it('preserves existing data values', () => { + const data = [ + { groupByDimensionValues: ['A'], count: 10, sum: 100 }, + { groupByDimensionValues: ['B'], count: 20, sum: 200 }, + { groupByDimensionValues: ['C'], count: 30, sum: 300 }, + ]; + + const result = fillSelectGapsInOneDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count', 'sum'], + }); + + expect(result).toHaveLength(3); + expect(result).toEqual(data); + }); + + it('preserves selectOptions order', () => { + const data = [ + { groupByDimensionValues: ['C'], count: 3 }, + { groupByDimensionValues: ['A'], count: 5 }, + ]; + + const result = fillSelectGapsInOneDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(3); + expect(result[0].groupByDimensionValues[0]).toBe('A'); + expect(result[1].groupByDimensionValues[0]).toBe('B'); + expect(result[2].groupByDimensionValues[0]).toBe('C'); + }); + + it('works with multiple aggregate keys', () => { + const data = [{ groupByDimensionValues: ['A'], count: 5, sum: 100 }]; + + const result = fillSelectGapsInOneDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count', 'sum'], + }); + + expect(result).toHaveLength(3); + expect(result[1]).toEqual({ + groupByDimensionValues: ['B'], + count: 0, + sum: 0, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts new file mode 100644 index 0000000000..00338a4381 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/__tests__/fillSelectGapsInTwoDimensionalChartData.test.ts @@ -0,0 +1,168 @@ +import { fillSelectGapsInTwoDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData'; + +describe('fillSelectGapsInTwoDimensionalChartData', () => { + const selectOptions = [ + { id: '1', label: 'Option A', value: 'A', position: 0, color: 'green' }, + { id: '2', label: 'Option B', value: 'B', position: 1, color: 'blue' }, + { id: '3', label: 'Option C', value: 'C', position: 2, color: 'red' }, + ] as const; + + it('fills missing primary axis options for all secondary values', () => { + const data = [ + { groupByDimensionValues: ['A', 'X'], count: 5 }, + { groupByDimensionValues: ['C', 'X'], count: 3 }, + { groupByDimensionValues: ['A', 'Y'], count: 2 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(6); + + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'A'), + ).toHaveLength(2); + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'B'), + ).toHaveLength(2); + expect( + result.filter((r) => r.groupByDimensionValues[0] === 'C'), + ).toHaveLength(2); + }); + + it('creates zero-value entries for missing combinations', () => { + const data = [ + { groupByDimensionValues: ['A', 'X'], count: 5 }, + { groupByDimensionValues: ['C', 'Y'], count: 3 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(6); + + expect( + result.find( + (r) => + r.groupByDimensionValues[0] === 'B' && + r.groupByDimensionValues[1] === 'X', + ), + ).toEqual({ + groupByDimensionValues: ['B', 'X'], + count: 0, + }); + + expect( + result.find( + (r) => + r.groupByDimensionValues[0] === 'A' && + r.groupByDimensionValues[1] === 'Y', + ), + ).toEqual({ + groupByDimensionValues: ['A', 'Y'], + count: 0, + }); + }); + + it('preserves existing combinations', () => { + const data = [ + { groupByDimensionValues: ['A', 'X'], count: 5 }, + { groupByDimensionValues: ['B', 'X'], count: 10 }, + { groupByDimensionValues: ['C', 'X'], count: 15 }, + { groupByDimensionValues: ['A', 'Y'], count: 20 }, + { groupByDimensionValues: ['B', 'Y'], count: 25 }, + { groupByDimensionValues: ['C', 'Y'], count: 30 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(6); + + expect( + result.find( + (r) => + r.groupByDimensionValues[0] === 'A' && + r.groupByDimensionValues[1] === 'X', + ), + ).toEqual({ + groupByDimensionValues: ['A', 'X'], + count: 5, + }); + }); + + it('preserves selectOptions order for primary axis', () => { + const data = [ + { groupByDimensionValues: ['C', 'X'], count: 3 }, + { groupByDimensionValues: ['A', 'X'], count: 5 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + const primaryValues = result.map((r) => r.groupByDimensionValues[0]); + const uniquePrimary = [...new Set(primaryValues)]; + + expect(uniquePrimary[0]).toBe('A'); + expect(uniquePrimary[1]).toBe('B'); + expect(uniquePrimary[2]).toBe('C'); + }); + + it('handles null secondary dimension values', () => { + const data = [ + { groupByDimensionValues: ['A', null], count: 5 }, + { groupByDimensionValues: ['C', null], count: 3 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + expect(result).toHaveLength(3); + + expect( + result.find( + (r) => + r.groupByDimensionValues[0] === 'B' && + r.groupByDimensionValues[1] === null, + ), + ).toEqual({ + groupByDimensionValues: ['B', null], + count: 0, + }); + }); + + it('does NOT fill missing secondary axis values', () => { + const data = [ + { groupByDimensionValues: ['A', 'X'], count: 5 }, + { groupByDimensionValues: ['A', 'Y'], count: 3 }, + ]; + + const result = fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions: [...selectOptions], + aggregateKeys: ['count'], + }); + + const secondaryValues = result.map((r) => r.groupByDimensionValues[1]); + const uniqueSecondary = [...new Set(secondaryValues)]; + + expect(uniqueSecondary).toHaveLength(2); + expect(uniqueSecondary).toContain('X'); + expect(uniqueSecondary).toContain('Y'); + }); +}); diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts new file mode 100644 index 0000000000..cabdefc09c --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/createEmptySelectGroup.ts @@ -0,0 +1,17 @@ +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; + +export const createEmptySelectGroup = ( + dimensionValues: RawDimensionValue[], + aggregateKeys: string[], +): GroupByRawResult => { + const newItem: GroupByRawResult = { + groupByDimensionValues: dimensionValues, + }; + + for (const key of aggregateKeys) { + newItem[key] = 0; + } + + return newItem; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts new file mode 100644 index 0000000000..3739277327 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInChartData.ts @@ -0,0 +1,39 @@ +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { fillSelectGapsInOneDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData'; +import { fillSelectGapsInTwoDimensionalChartData } from '@/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData'; +import { isDefined } from 'twenty-shared/utils'; + +type FillSelectGapsParams = { + data: GroupByRawResult[]; + selectOptions: FieldMetadataItemOption[] | null | undefined; + aggregateKeys: string[]; + hasSecondDimension?: boolean; +}; + +export const fillSelectGapsInChartData = ({ + data, + selectOptions, + aggregateKeys, + hasSecondDimension = false, +}: FillSelectGapsParams): GroupByRawResult[] => { + if ( + !isDefined(selectOptions) || + selectOptions.length === 0 || + data.length === 0 + ) { + return data; + } + + return hasSecondDimension + ? fillSelectGapsInTwoDimensionalChartData({ + data, + selectOptions, + aggregateKeys, + }) + : fillSelectGapsInOneDimensionalChartData({ + data, + selectOptions, + aggregateKeys, + }); +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts new file mode 100644 index 0000000000..67881f8c4b --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInOneDimensionalChartData.ts @@ -0,0 +1,36 @@ +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; +import { isDefined } from 'twenty-shared/utils'; + +type OneDimensionalFillSelectGapsParams = { + data: GroupByRawResult[]; + selectOptions: FieldMetadataItemOption[]; + aggregateKeys: string[]; +}; + +export const fillSelectGapsInOneDimensionalChartData = ({ + data, + selectOptions, + aggregateKeys, +}: OneDimensionalFillSelectGapsParams): GroupByRawResult[] => { + const existingGroupsMap = new Map(); + + for (const item of data) { + const dimensionValue = item.groupByDimensionValues?.[0]; + + if (isDefined(dimensionValue)) { + existingGroupsMap.set(String(dimensionValue), item); + } + } + + const filledData: GroupByRawResult[] = selectOptions.map((option) => { + const existingGroup = existingGroupsMap.get(option.value); + + return isDefined(existingGroup) + ? existingGroup + : createEmptySelectGroup([option.value], aggregateKeys); + }); + + return filledData; +}; diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts new file mode 100644 index 0000000000..0135883763 --- /dev/null +++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/fillSelectGapsInTwoDimensionalChartData.ts @@ -0,0 +1,51 @@ +import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; +import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult'; +import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue'; +import { createEmptySelectGroup } from '@/page-layout/widgets/graph/utils/createEmptySelectGroup'; +import { isDefined } from 'twenty-shared/utils'; + +type TwoDimensionalFillSelectGapsParams = { + data: GroupByRawResult[]; + selectOptions: FieldMetadataItemOption[]; + aggregateKeys: string[]; +}; + +export const fillSelectGapsInTwoDimensionalChartData = ({ + data, + selectOptions, + aggregateKeys, +}: TwoDimensionalFillSelectGapsParams): GroupByRawResult[] => { + const existingGroupsMap = new Map(); + const uniqueSecondDimensionValues = new Set(); + + for (const item of data) { + const primaryDimensionValue = item.groupByDimensionValues?.[0]; + + if (!isDefined(primaryDimensionValue)) { + continue; + } + + const secondDimensionValue = (item.groupByDimensionValues?.[1] ?? + null) as RawDimensionValue; + uniqueSecondDimensionValues.add(secondDimensionValue); + + const key = `${String(primaryDimensionValue)}_${String(secondDimensionValue)}`; + existingGroupsMap.set(key, item); + } + + const filledData = selectOptions.flatMap((option) => + Array.from(uniqueSecondDimensionValues).map((secondDimensionValue) => { + const key = `${option.value}_${String(secondDimensionValue)}`; + const existingGroup = existingGroupsMap.get(key); + + return isDefined(existingGroup) + ? existingGroup + : createEmptySelectGroup( + [option.value, secondDimensionValue], + aggregateKeys, + ); + }), + ); + + return filledData; +};