[GroupBy] Allow sorting in bar chart (#15097)

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

From a technical perspective, we can add more ordering options, such as
the ability to combine two sorts on the X axis, e.g. sort by Close date
ASC and then by Sum ASC, which will sort groups that have the same close
date between themselves depending on their sum ASC. @Bonapara could you
provide design if you want this to be implemented (quite short on our
hand i think - maybe in V2 though)?


https://github.com/user-attachments/assets/6ef21fe1-9d8f-43c0-bfa2-f6fc6341cacf

---------

Co-authored-by: ehconitin <nitinkoche03@gmail.com>
This commit is contained in:
Marie
2025-10-15 14:11:14 +02:00
committed by GitHub
parent 436084f8f4
commit f65783f900
83 changed files with 453 additions and 247 deletions
@@ -0,0 +1,114 @@
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 {
ExtendedAggregateOperations,
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: ExtendedAggregateOperations.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,
});
});
});
@@ -1,29 +1,23 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration, GraphOrderBy } from '~/generated/graphql';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import {
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderByForCompositeField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
import { buildGroupByFieldObject } from './buildGroupByFieldObject';
const _mapOrderByToDirection = (orderByEnum: GraphOrderBy) => {
switch (orderByEnum) {
case GraphOrderBy.FIELD_ASC:
return 'AscNullsLast';
case GraphOrderBy.FIELD_DESC:
return 'DescNullsLast';
case GraphOrderBy.VALUE_ASC:
return 'AscNullsLast';
case GraphOrderBy.VALUE_DESC:
return 'DescNullsLast';
default:
assertUnreachable(orderByEnum);
}
};
export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
objectMetadataItem,
barChartConfiguration,
aggregateOperation,
}: {
objectMetadataItem: ObjectMetadataItem;
barChartConfiguration: BarChartConfiguration;
aggregateOperation?: string;
}) => {
const groupByFieldXId =
barChartConfiguration.primaryAxisGroupByFieldMetadataId;
@@ -71,31 +65,41 @@ export const generateGroupByQueryVariablesFromBarChartConfiguration = ({
);
}
const orderBy: Array<Record<string, string>> = [];
const orderBy: Array<
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForCompositeField
> = [];
// TODO: Add orderBy back in when the backend is ready
// if (isDefined(barChartConfiguration.primaryAxisOrderBy)) {
// orderBy.push({
// [groupByFieldX.name]: mapOrderByToDirection(
// barChartConfiguration.primaryAxisOrderBy!,
// ),
// });
// }
// if (
// isDefined(groupByFieldY) &&
// isDefined(barChartConfiguration.secondaryAxisOrderBy)
// ) {
// orderBy.push({
// [groupByFieldY.name]: mapOrderByToDirection(
// barChartConfiguration.secondaryAxisOrderBy!,
// ),
// });
// }
if (isDefined(barChartConfiguration.primaryAxisOrderBy)) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.primaryAxisOrderBy,
groupByField: groupByFieldX,
groupBySubFieldName:
barChartConfiguration.primaryAxisGroupBySubFieldName,
aggregateOperation,
}),
);
}
if (
isDefined(groupByFieldY) &&
isDefined(barChartConfiguration.secondaryAxisOrderBy)
) {
orderBy.push(
getGroupByOrderBy({
graphOrderBy: barChartConfiguration.secondaryAxisOrderBy,
groupByField: groupByFieldY,
groupBySubFieldName:
barChartConfiguration.secondaryAxisGroupBySubFieldName,
aggregateOperation,
}),
);
}
return {
groupBy,
// TODO: Add filters
...(orderBy.length > 0 && { orderBy }),
};
};
@@ -0,0 +1,92 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity.constant';
import {
OrderByDirection,
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderByForCompositeField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
} from 'twenty-shared/types';
import {
assertUnreachable,
isDefined,
isFieldMetadataDateKind,
} from 'twenty-shared/utils';
import { GraphOrderBy } from '~/generated/graphql';
const mapOrderByToDirection = (orderByEnum: GraphOrderBy): OrderByDirection => {
switch (orderByEnum) {
case GraphOrderBy.FIELD_ASC:
return OrderByDirection.AscNullsLast;
case GraphOrderBy.FIELD_DESC:
return OrderByDirection.DescNullsLast;
case GraphOrderBy.VALUE_ASC:
return OrderByDirection.AscNullsLast;
case GraphOrderBy.VALUE_DESC:
return OrderByDirection.DescNullsLast;
default:
assertUnreachable(orderByEnum);
}
};
export const getGroupByOrderBy = ({
graphOrderBy,
groupByField,
groupBySubFieldName,
aggregateOperation,
}: {
graphOrderBy: GraphOrderBy;
groupByField: FieldMetadataItem;
groupBySubFieldName?: string | null;
aggregateOperation?: string;
}):
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForCompositeField => {
switch (graphOrderBy) {
case GraphOrderBy.FIELD_ASC:
case GraphOrderBy.FIELD_DESC: {
if (isCompositeFieldType(groupByField.type)) {
if (!isDefined(groupBySubFieldName)) {
throw new Error(
`Group by subFieldName is required for composite fields (field: ${groupByField.name})`,
);
}
return {
[groupByField.name]: {
[groupBySubFieldName]: mapOrderByToDirection(graphOrderBy),
},
};
} else if (isFieldMetadataDateKind(groupByField.type)) {
return {
[groupByField.name]: {
orderBy: mapOrderByToDirection(graphOrderBy),
granularity: GRAPH_DEFAULT_DATE_GRANULARITY,
},
};
} else {
return {
[groupByField.name]: mapOrderByToDirection(graphOrderBy),
};
}
}
case GraphOrderBy.VALUE_ASC:
case GraphOrderBy.VALUE_DESC: {
if (!isDefined(aggregateOperation)) {
throw new Error(
`Aggregate operation not found (field: ${groupByField.name})`,
);
}
return {
aggregate: {
[aggregateOperation]: mapOrderByToDirection(graphOrderBy),
},
};
}
default:
assertUnreachable(graphOrderBy);
}
};
@@ -0,0 +1,18 @@
import { GraphOrderBy } from '~/generated/graphql';
export const getSortedKeys = ({
orderByY,
yValues,
}: {
orderByY?: GraphOrderBy | null;
yValues: string[];
}) => {
switch (orderByY) {
case GraphOrderBy.FIELD_ASC:
return Array.from(yValues).sort((a, b) => a.localeCompare(b));
case GraphOrderBy.FIELD_DESC:
return Array.from(yValues).sort((a, b) => b.localeCompare(a));
default:
return Array.from(yValues);
}
};
@@ -8,6 +8,7 @@ import { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupBy
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 { isDefined } from 'twenty-shared/utils';
import { type BarChartConfiguration } from '~/generated/graphql';
@@ -101,7 +102,12 @@ export const transformTwoDimensionalGroupByToBarChartData = ({
dataItem[yValue] = aggregateValue;
});
const keys = Array.from(yValues);
// 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,