[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
This commit is contained in:
nitin
2026-01-13 15:55:58 +05:30
committed by GitHub
parent db87da162c
commit e73aa80e73
10 changed files with 613 additions and 4 deletions
@@ -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,
@@ -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,
@@ -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);
});
});
@@ -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,
});
});
});
});
@@ -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,
});
});
});
@@ -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');
});
});
@@ -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;
};
@@ -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,
});
};
@@ -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<string, GroupByRawResult>();
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;
};
@@ -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<string, GroupByRawResult>();
const uniqueSecondDimensionValues = new Set<RawDimensionValue>();
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;
};