[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:
+114
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
+42
-38
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
+92
@@ -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);
|
||||
}
|
||||
};
|
||||
+7
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user