[DASHBOARDS] Use a time scale when the primary axis is a date on the Bar Chart (#15932)

Closes https://github.com/twentyhq/core-team-issues/issues/1891

Create empty buckets according to the date granularity

Video QA:


https://github.com/user-attachments/assets/86c0f817-35b3-4bab-b093-d11491684b82

Note: We would also need to create empty buckets for cyclic
granularities (DAY_OF_THE_WEEK, MONTH_OF_THE_YEAR, QUARTER_OF_THE_YEAR).
TODO:
- Always order the cyclic granularities Monday -> Sunday (take
firstDayOfTheWeek into account), January -> December, Q1 -> Q4. For now
they are returned by the backend in alphabetical order, which doesn't
make much sense
- Remove the translation into the user's locale of these granularities
from the backend because otherwise we can't reconstruct the missing days
or month in the frontend since they will be translated

---------

Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
This commit is contained in:
Raphaël Bosi
2025-11-21 10:24:19 +01:00
committed by GitHub
parent c737042209
commit fdb4e4ef10
24 changed files with 567 additions and 13 deletions
@@ -7,12 +7,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for empty aggr
$filter: PersonFilterInput
$orderBy: [PersonOrderByWithGroupByInput!]
$viewId: UUID
$limit: Int
) {
peopleGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
limit: $limit
) {
groupByDimensionValues
}
@@ -27,12 +29,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for multiple a
$filter: OpportunityFilterInput
$orderBy: [OpportunityOrderByWithGroupByInput!]
$viewId: UUID
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
limit: $limit
) {
groupByDimensionValues
totalCount
@@ -50,12 +54,14 @@ exports[`generateGroupByQuery should generate valid GraphQL query for single agg
$filter: OpportunityFilterInput
$orderBy: [OpportunityOrderByWithGroupByInput!]
$viewId: UUID
$limit: Int
) {
opportunitiesGroupBy(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
limit: $limit
) {
groupByDimensionValues
sumAmountAmountMicros
@@ -1,144 +0,0 @@
import { GraphOrderBy } from '~/generated/graphql';
import { sortBarChartDataBySecondaryDimensionSum } from '../sortBarChartDataBySecondaryDimensionSum';
describe('sortBarChartDataBySecondaryDimensionSum', () => {
const mockData = [
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Berlin', Open: 8, Closed: 7 },
];
const keys = ['Open', 'Closed'];
describe('VALUE_DESC sorting', () => {
it('should sort by totals in descending order', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'Berlin', Open: 8, Closed: 7 },
]);
});
});
describe('VALUE_ASC sorting', () => {
it('should sort by totals in ascending order', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.VALUE_ASC,
});
expect(result).toEqual([
{ city: 'Paris', Open: 5, Closed: 10 },
{ city: 'Berlin', Open: 8, Closed: 7 },
{ city: 'London', Open: 20, Closed: 2 },
]);
});
});
describe('FIELD_ASC/FIELD_DESC (non-value sorting)', () => {
it('should not sort when orderBy is FIELD_ASC', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.FIELD_ASC,
});
expect(result).toEqual(mockData);
});
it('should not sort when orderBy is FIELD_DESC', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: mockData,
keys,
orderBy: GraphOrderBy.FIELD_DESC,
});
expect(result).toEqual(mockData);
});
});
describe('edge cases', () => {
it('should handle data with missing keys in some items', () => {
const dataWithMissing = [
{ city: 'Paris', Open: 5 } as any,
{ city: 'London', Open: 20, Closed: 2 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithMissing,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 2 },
{ city: 'Paris', Open: 5 },
]);
});
it('should handle data with zero values', () => {
const dataWithZeros = [
{ city: 'Paris', Open: 0, Closed: 10 },
{ city: 'London', Open: 20, Closed: 0 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithZeros,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([
{ city: 'London', Open: 20, Closed: 0 },
{ city: 'Paris', Open: 0, Closed: 10 },
]);
});
it('should handle empty data array', () => {
const result = sortBarChartDataBySecondaryDimensionSum({
data: [],
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual([]);
});
it('should handle single item', () => {
const singleItem = [{ city: 'Paris', Open: 5, Closed: 10 }];
const result = sortBarChartDataBySecondaryDimensionSum({
data: singleItem,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual(singleItem);
});
});
describe('stability with equal totals', () => {
it('should maintain original order for items with equal totals', () => {
const dataWithEqualTotals = [
{ city: 'Paris', Open: 10, Closed: 5 },
{ city: 'Berlin', Open: 7, Closed: 8 },
{ city: 'Madrid', Open: 9, Closed: 6 },
];
const result = sortBarChartDataBySecondaryDimensionSum({
data: dataWithEqualTotals,
keys,
orderBy: GraphOrderBy.VALUE_DESC,
});
expect(result).toEqual(dataWithEqualTotals);
});
});
});
@@ -1,114 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
AggregateOperations,
GraphOrderBy,
GraphType,
type BarChartConfiguration,
} from '~/generated/graphql';
import { transformTwoDimensionalGroupByToBarChartData } from '../transformTwoDimensionalGroupByToBarChartData';
describe('transformTwoDimensionalGroupByToBarChartData', () => {
const mockGroupByFieldX = {
id: 'field-x',
name: 'createdAt',
type: FieldMetadataType.DATE,
label: 'Created At',
} as FieldMetadataItem;
const mockGroupByFieldY = {
id: 'field-y',
name: 'stage',
type: FieldMetadataType.SELECT,
label: 'Stage',
} as FieldMetadataItem;
const mockAggregateField = {
id: 'field-aggregate',
name: 'amount',
type: FieldMetadataType.NUMBER,
label: 'Amount',
} as FieldMetadataItem;
const mockObjectMetadataItem = {
id: 'object-1',
nameSingular: 'opportunity',
namePlural: 'opportunities',
fields: [mockGroupByFieldX, mockGroupByFieldY, mockAggregateField],
} as ObjectMetadataItem;
const mockConfiguration: BarChartConfiguration = {
__typename: 'BarChartConfiguration',
graphType: GraphType.VERTICAL_BAR,
aggregateFieldMetadataId: 'field-aggregate',
aggregateOperation: AggregateOperations.SUM,
primaryAxisGroupByFieldMetadataId: 'field-x',
secondaryAxisGroupByFieldMetadataId: 'field-y',
secondaryAxisOrderBy: GraphOrderBy.FIELD_DESC,
};
it('should order keys correctly despite unordered raw results', () => {
// This test demonstrates the key ordering issue described in the user query
// Raw results where "CUSTOMER" appears before "NEW" in the data stream despite orderBy,
// bc there is no "NEW" group before october 21st and the results are primarily ordered by date ASC
// but we want them ordered alphabetically-reversed
const rawResults: GroupByRawResult[] = [
{
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'SCREENING'],
sumAmount: 75000000000,
},
{
groupByDimensionValues: ['2025-10-16T00:00:00.000Z', 'PROPOSAL'],
sumAmount: 380000000000,
},
{
groupByDimensionValues: ['2025-10-17T00:00:00.000Z', 'CUSTOMER'],
sumAmount: 720000000000,
},
{
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'PROPOSAL'],
sumAmount: 580000000000,
},
{
groupByDimensionValues: ['2025-10-21T00:00:00.000Z', 'NEW'],
sumAmount: 125000000000,
},
];
const result = transformTwoDimensionalGroupByToBarChartData({
rawResults,
groupByFieldX: mockGroupByFieldX,
groupByFieldY: mockGroupByFieldY,
aggregateField: mockAggregateField,
configuration: mockConfiguration,
aggregateOperation: 'sumAmount',
objectMetadataItem: mockObjectMetadataItem,
});
expect(result.keys).toEqual(['SCREENING', 'PROPOSAL', 'NEW', 'CUSTOMER']);
expect(result.series).toEqual([
{ key: 'SCREENING', label: 'SCREENING' },
{ key: 'PROPOSAL', label: 'PROPOSAL' },
{ key: 'NEW', label: 'NEW' },
{ key: 'CUSTOMER', label: 'CUSTOMER' },
]);
expect(result.data).toHaveLength(3);
expect(result.data[0]).toEqual({
createdAt: 'Oct 16, 2025',
SCREENING: 75000000000,
PROPOSAL: 380000000000,
});
expect(result.data[1]).toEqual({
createdAt: 'Oct 17, 2025',
CUSTOMER: 720000000000,
});
expect(result.data[2]).toEqual({
createdAt: 'Oct 21, 2025',
PROPOSAL: 580000000000,
NEW: 125000000000,
});
});
});
@@ -21,12 +21,14 @@ export const generateGroupByQuery = ({
$filter: ${capitalizedSingular}FilterInput
$orderBy: [${capitalizedSingular}OrderByWithGroupByInput!]
$viewId: UUID
$limit: Int
) {
${queryFieldName}(
groupBy: $groupBy
filter: $filter
orderBy: $orderBy
viewId: $viewId
limit: $limit
) {
groupByDimensionValues${aggregateOperations.length > 0 ? `\n ${aggregateOperations.join('\n ')}` : ''}
}
@@ -14,10 +14,12 @@ export const generateGroupByQueryVariablesFromChartConfiguration = ({
objectMetadataItem,
chartConfiguration,
aggregateOperation,
limit,
}: {
objectMetadataItem: ObjectMetadataItem;
chartConfiguration: GroupByChartConfiguration;
aggregateOperation?: string;
limit?: number;
}) => {
const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId;
@@ -107,5 +109,6 @@ export const generateGroupByQueryVariablesFromChartConfiguration = ({
return {
groupBy,
...(orderBy.length > 0 && { orderBy }),
...(isDefined(limit) && { limit }),
};
};
@@ -1,46 +0,0 @@
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { isDefined } from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
type SortBarChartDataBySecondaryDimensionSumParams = {
data: BarChartDataItem[];
keys: string[];
orderBy: GraphOrderBy;
};
export const sortBarChartDataBySecondaryDimensionSum = ({
data,
keys,
orderBy,
}: SortBarChartDataBySecondaryDimensionSumParams): BarChartDataItem[] => {
if (
orderBy !== GraphOrderBy.VALUE_ASC &&
orderBy !== GraphOrderBy.VALUE_DESC
) {
return data;
}
const dataWithSecondaryDimensionSums = data.map((barChartDataItem) => {
const secondaryDimensionSum = keys.reduce((sumAccumulator, segmentKey) => {
const segmentValue = barChartDataItem[segmentKey];
if (isDefined(segmentValue) && typeof segmentValue === 'number') {
return sumAccumulator + segmentValue;
}
return sumAccumulator;
}, 0);
return { barChartDataItem, secondaryDimensionSum };
});
dataWithSecondaryDimensionSums.sort((a, b) => {
if (orderBy === GraphOrderBy.VALUE_ASC) {
return a.secondaryDimensionSum - b.secondaryDimensionSum;
} else {
return b.secondaryDimensionSum - a.secondaryDimensionSum;
}
});
return dataWithSecondaryDimensionSums.map(
({ barChartDataItem }) => barChartDataItem,
);
};
@@ -1,176 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { getGroupByQueryName } from '@/page-layout/utils/getGroupByQueryName';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { BarChartLayout } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartLayout';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { filterGroupByResults } from '@/page-layout/widgets/graph/utils/filterGroupByResults';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { transformOneDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformOneDimensionalGroupByToBarChartData';
import { transformTwoDimensionalGroupByToBarChartData } from '@/page-layout/widgets/graph/utils/transformTwoDimensionalGroupByToBarChartData';
import { isDefined } from 'twenty-shared/utils';
import { GraphType } from '~/generated-metadata/graphql';
import {
AxisNameDisplay,
type BarChartConfiguration,
} from '~/generated/graphql';
type TransformGroupByDataToBarChartDataParams = {
groupByData: Record<string, GroupByRawResult[]> | null | undefined;
objectMetadataItem: ObjectMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
};
type TransformGroupByDataToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
xAxisLabel?: string;
yAxisLabel?: string;
showDataLabels: boolean;
layout?: BarChartLayout;
hasTooManyGroups: boolean;
};
const EMPTY_BAR_CHART_RESULT: TransformGroupByDataToBarChartDataResult = {
data: [],
indexBy: '',
keys: [],
series: [],
xAxisLabel: undefined,
yAxisLabel: undefined,
showDataLabels: false,
layout: BarChartLayout.VERTICAL,
hasTooManyGroups: false,
};
export const transformGroupByDataToBarChartData = ({
groupByData,
objectMetadataItem,
configuration,
aggregateOperation,
}: TransformGroupByDataToBarChartDataParams): TransformGroupByDataToBarChartDataResult => {
if (!isDefined(groupByData)) {
return EMPTY_BAR_CHART_RESULT;
}
const groupByFieldX = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.primaryAxisGroupByFieldMetadataId,
);
const groupByFieldY = isDefined(
configuration.secondaryAxisGroupByFieldMetadataId,
)
? objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.secondaryAxisGroupByFieldMetadataId,
)
: undefined;
const aggregateField = objectMetadataItem.fields.find(
(field: FieldMetadataItem) =>
field.id === configuration.aggregateFieldMetadataId,
);
if (!isDefined(groupByFieldX) || !isDefined(aggregateField)) {
return {
...EMPTY_BAR_CHART_RESULT,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL,
};
}
const primaryAxisSubFieldName =
configuration.primaryAxisGroupBySubFieldName ?? undefined;
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName,
});
const queryName = getGroupByQueryName(objectMetadataItem);
const rawResults = groupByData[queryName];
if (!isDefined(rawResults) || !Array.isArray(rawResults)) {
return {
...EMPTY_BAR_CHART_RESULT,
indexBy: indexByKey,
layout:
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL,
};
}
const filteredResults = filterGroupByResults({
rawResults,
filterOptions: {
rangeMin: configuration.rangeMin ?? undefined,
rangeMax: configuration.rangeMax ?? undefined,
omitNullValues: configuration.omitNullValues ?? false,
},
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
const showXAxis =
configuration.axisNameDisplay === AxisNameDisplay.X ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const showYAxis =
configuration.axisNameDisplay === AxisNameDisplay.Y ||
configuration.axisNameDisplay === AxisNameDisplay.BOTH;
const xAxisLabel = showXAxis ? groupByFieldX.label : undefined;
const yAxisLabel = showYAxis
? `${getAggregateOperationLabel(configuration.aggregateOperation)} of ${aggregateField.label}`
: undefined;
const showDataLabels = configuration.displayDataLabel ?? false;
const baseResult = isDefined(groupByFieldY)
? transformTwoDimensionalGroupByToBarChartData({
rawResults: filteredResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
})
: transformOneDimensionalGroupByToBarChartData({
rawResults: filteredResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
});
const layout =
configuration.graphType === GraphType.HORIZONTAL_BAR
? BarChartLayout.HORIZONTAL
: BarChartLayout.VERTICAL;
return {
...baseResult,
xAxisLabel,
yAxisLabel,
showDataLabels,
layout,
};
};
@@ -1,100 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { GRAPH_DEFAULT_COLOR } from '@/page-layout/widgets/graph/constants/GraphDefaultColor.constant';
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
type TransformOneDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformOneDimensionalGroupByToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
hasTooManyGroups: boolean;
};
export const transformOneDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformOneDimensionalGroupByToBarChartDataParams): TransformOneDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
const aggregateValueKey =
indexByKey === aggregateField.name
? `${aggregateField.name}-aggregate`
: aggregateField.name;
// TODO: Add a limit to the query instead of slicing here (issue: twentyhq/core-team-issues#1600)
const limitedResults = rawResults.slice(0, BAR_CHART_MAXIMUM_NUMBER_OF_BARS);
const data: BarChartDataItem[] = limitedResults.map((result) => {
const dimensionValues = result.groupByDimensionValues;
const xValue = isDefined(dimensionValues?.[0])
? formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
subFieldName:
configuration.primaryAxisGroupBySubFieldName ?? undefined,
})
: '';
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
return {
[indexByKey]: xValue,
[aggregateValueKey]: aggregateValue,
};
});
const series: BarChartSeries[] = [
{
key: aggregateValueKey,
label: aggregateField.label,
color: (configuration.color ?? GRAPH_DEFAULT_COLOR) as GraphColor,
},
];
return {
data,
indexBy: indexByKey,
keys: [aggregateValueKey],
series,
hasTooManyGroups: rawResults.length > BAR_CHART_MAXIMUM_NUMBER_OF_BARS,
};
};
@@ -1,155 +0,0 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { BAR_CHART_MAXIMUM_NUMBER_OF_BARS } from '@/page-layout/widgets/graph/graphWidgetBarChart/constants/BarChartMaximumNumberOfBars.constant';
import { type BarChartDataItem } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartDataItem';
import { type BarChartSeries } from '@/page-layout/widgets/graph/graphWidgetBarChart/types/BarChartSeries';
import { type GraphColor } from '@/page-layout/widgets/graph/types/GraphColor';
import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { getFieldKey } from '@/page-layout/widgets/graph/utils/getFieldKey';
import { getSortedKeys } from '@/page-layout/widgets/graph/utils/getSortedKeys';
import { sortBarChartDataBySecondaryDimensionSum } from '@/page-layout/widgets/graph/utils/sortBarChartDataBySecondaryDimensionSum';
import { isDefined } from 'twenty-shared/utils';
import {
BarChartGroupMode,
type BarChartConfiguration,
} from '~/generated/graphql';
type TransformTwoDimensionalGroupByToBarChartDataParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
groupByFieldY: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: BarChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
};
type TransformTwoDimensionalGroupByToBarChartDataResult = {
data: BarChartDataItem[];
indexBy: string;
keys: string[];
series: BarChartSeries[];
hasTooManyGroups: boolean;
};
export const transformTwoDimensionalGroupByToBarChartData = ({
rawResults,
groupByFieldX,
groupByFieldY,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
}: TransformTwoDimensionalGroupByToBarChartDataParams): TransformTwoDimensionalGroupByToBarChartDataResult => {
const indexByKey = getFieldKey({
field: groupByFieldX,
subFieldName: primaryAxisSubFieldName ?? undefined,
});
const dataMap = new Map<string, BarChartDataItem>();
const xValues = new Set<string>();
const yValues = new Set<string>();
let hasTooManyGroups = false;
rawResults.forEach((result) => {
const dimensionValues = result.groupByDimensionValues;
if (!isDefined(dimensionValues) || dimensionValues.length < 2) return;
const xValue = formatDimensionValue({
value: dimensionValues[0],
fieldMetadata: groupByFieldX,
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: configuration.primaryAxisGroupBySubFieldName ?? undefined,
});
const yValue = formatDimensionValue({
value: dimensionValues[1],
fieldMetadata: groupByFieldY,
dateGranularity:
configuration.secondaryAxisGroupByDateGranularity ?? undefined,
subFieldName: configuration.secondaryAxisGroupBySubFieldName ?? undefined,
});
// TODO: Add a limit to the query instead of checking here (issue: twentyhq/core-team-issues#1600)
const isNewX = !xValues.has(xValue);
const isNewY = !yValues.has(yValue);
if (configuration.groupMode === BarChartGroupMode.STACKED) {
if (isNewX && xValues.size >= BAR_CHART_MAXIMUM_NUMBER_OF_BARS) {
hasTooManyGroups = true;
return;
}
}
if (configuration.groupMode === BarChartGroupMode.GROUPED) {
const totalUniqueDimensions = xValues.size * yValues.size;
const additionalDimensions =
(isNewX ? 1 : 0) * yValues.size + (isNewY ? 1 : 0) * xValues.size;
if (
totalUniqueDimensions + additionalDimensions >
BAR_CHART_MAXIMUM_NUMBER_OF_BARS
) {
hasTooManyGroups = true;
return;
}
}
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
if (!isDefined(aggregateValue)) return;
xValues.add(xValue);
yValues.add(yValue);
if (!dataMap.has(xValue)) {
dataMap.set(xValue, {
[indexByKey]: xValue,
});
}
const dataItem = dataMap.get(xValue)!;
dataItem[yValue] = aggregateValue;
});
// Sorting needed because yValues may be unordered despite BE orderBy, if there are empty groups
const keys = getSortedKeys({
orderByY: configuration.secondaryAxisOrderBy,
yValues: Array.from(yValues),
});
const series: BarChartSeries[] = keys.map((key) => ({
key,
label: key,
color: configuration.color as GraphColor,
}));
const unsortedData = Array.from(dataMap.values());
const data = isDefined(configuration.primaryAxisOrderBy)
? sortBarChartDataBySecondaryDimensionSum({
data: unsortedData,
keys,
orderBy: configuration.primaryAxisOrderBy,
})
: unsortedData;
return {
data,
indexBy: indexByKey,
keys,
series,
hasTooManyGroups,
};
};