Files
twenty/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/generateGroupByQueryVariablesFromBarOrLineChartConfiguration.ts
T
Raphaël Bosi c531cfe0a4 [DASHBOARDS] Manual and position-based sorting for chart widgets (#16794)
## Description

SELECT fields have a defined option order that users expect to see
reflected in charts.
This PR allows sorting by that position and also enables custom manual
ordering.

## Video QA

### Reordering on primary axis


https://github.com/user-attachments/assets/994f515e-19cb-4a5e-b745-e8c77e92ae0b


### Reordering on secondary axis


https://github.com/user-attachments/assets/444c16f2-1920-4dc4-8b42-312d520ab43b

Note: The colors in the graph will match the colors of the select
options, but this will be done in another PR

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Introduces new sort modes and UI for chart groupings, with full FE/BE
support and updated GraphQL schema.
> 
> - Extend `GraphOrderBy` with `FIELD_POSITION_ASC/DESC` and `MANUAL`;
add corresponding fields in configs: `primaryAxisManualSortOrder`,
`secondaryAxisManualSortOrder`, and `manualSortOrder` (pie)
> - New UI: dropdown options filtered by field type, icons, and a
draggable submenu (`ChartManualSortSubMenuContent`) to reorder select
options; integrates with widget edit flow
> - Sorting logic added/refactored: `sortChartData`,
`sortByManualOrder`, `sortBySelectOptionPosition`,
`sortLineChartSeries`, plus updates to bar/line/pie transformers to
honor new modes and manual orders
> - Default behaviors: select fields default to `FIELD_POSITION_ASC`;
query variable builders skip `orderBy` when using manual/position sorts
> - Update GraphQL generated types/fragments/queries and backend
DTOs/schemas to persist new fields; add tests for sorting utilities and
snapshots; add sorting icons in `twenty-ui`
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
78c9b56c0f1f2d45f7f8b270bb59ca599a005abe. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Lucas Bordeau <bordeau.lucas@gmail.com>
2026-01-06 11:52:54 +01:00

176 lines
5.6 KiB
TypeScript

import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from '@/page-layout/widgets/graph/constants/GraphDefaultDateGranularity';
import { GRAPH_DEFAULT_ORDER_BY } from '@/page-layout/widgets/graph/constants/GraphDefaultOrderBy';
import { getGroupByOrderBy } from '@/page-layout/widgets/graph/utils/getGroupByOrderBy';
import { isRelationNestedFieldDateKind } from '@/page-layout/widgets/graph/utils/isRelationNestedFieldDateKind';
import {
type AggregateOrderByWithGroupByField,
type ObjectRecordOrderByForCompositeField,
type ObjectRecordOrderByForRelationField,
type ObjectRecordOrderByForScalarField,
type ObjectRecordOrderByWithGroupByDateField,
} from 'twenty-shared/types';
import { isDefined, isFieldMetadataDateKind } from 'twenty-shared/utils';
import {
type BarChartConfiguration,
type LineChartConfiguration,
} from '~/generated/graphql';
import {
buildGroupByFieldObject,
type GroupByFieldObject,
} from './buildGroupByFieldObject';
export const generateGroupByQueryVariablesFromBarOrLineChartConfiguration = ({
objectMetadataItem,
objectMetadataItems,
chartConfiguration,
aggregateOperation,
limit,
firstDayOfTheWeek,
userTimeZone,
}: {
objectMetadataItem: ObjectMetadataItem;
objectMetadataItems: ObjectMetadataItem[];
chartConfiguration: BarChartConfiguration | LineChartConfiguration;
aggregateOperation?: string;
limit?: number;
firstDayOfTheWeek?: number;
userTimeZone?: string;
}) => {
const groupByFieldXId = chartConfiguration.primaryAxisGroupByFieldMetadataId;
const groupByFieldYId =
chartConfiguration.secondaryAxisGroupByFieldMetadataId;
const groupBySubFieldNameX =
chartConfiguration.primaryAxisGroupBySubFieldName ?? undefined;
const groupBySubFieldNameY =
chartConfiguration.secondaryAxisGroupBySubFieldName ?? undefined;
const groupByFieldX = objectMetadataItem.fields.find(
(field) => field.id === groupByFieldXId,
);
const groupByFieldY = isDefined(groupByFieldYId)
? objectMetadataItem.fields.find((field) => field.id === groupByFieldYId)
: undefined;
if (!isDefined(groupByFieldX) || !isDefined(groupByFieldXId)) {
throw new Error(
`Field with id ${groupByFieldXId} not found in object metadata`,
);
}
const isFieldXDate = isFieldMetadataDateKind(groupByFieldX.type);
const isFieldXNestedDate = isRelationNestedFieldDateKind({
relationField: groupByFieldX,
relationNestedFieldName: groupBySubFieldNameX,
objectMetadataItems,
});
const shouldApplyDateGranularityX = isFieldXDate || isFieldXNestedDate;
const groupBy: Array<GroupByFieldObject> = [];
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldX,
subFieldName: groupBySubFieldNameX,
dateGranularity: shouldApplyDateGranularityX
? (chartConfiguration.primaryAxisDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isFieldXNestedDate,
timeZone: userTimeZone,
}),
);
if (isDefined(groupByFieldY)) {
const isFieldYDate = isFieldMetadataDateKind(groupByFieldY.type);
const isFieldYNestedDate = isRelationNestedFieldDateKind({
relationField: groupByFieldY,
relationNestedFieldName: groupBySubFieldNameY,
objectMetadataItems,
});
const shouldApplyDateGranularityY = isFieldYDate || isFieldYNestedDate;
groupBy.push(
buildGroupByFieldObject({
field: groupByFieldY,
subFieldName: groupBySubFieldNameY,
dateGranularity: shouldApplyDateGranularityY
? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isFieldYNestedDate,
timeZone: userTimeZone,
}),
);
}
const orderBy: Array<
| AggregateOrderByWithGroupByField
| ObjectRecordOrderByForScalarField
| ObjectRecordOrderByWithGroupByDateField
| ObjectRecordOrderByForCompositeField
| ObjectRecordOrderByForRelationField
> = [];
const primaryAxisOrderBy = getGroupByOrderBy({
graphOrderBy:
chartConfiguration.primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
groupByField: groupByFieldX,
groupBySubFieldName: chartConfiguration.primaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity: shouldApplyDateGranularityX
? (chartConfiguration.primaryAxisDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
});
if (isDefined(primaryAxisOrderBy)) {
orderBy.push(primaryAxisOrderBy);
}
if (isDefined(groupByFieldY)) {
const isFieldYDateForOrderBy = isFieldMetadataDateKind(groupByFieldY.type);
const isFieldYNestedDateForOrderBy = isRelationNestedFieldDateKind({
relationField: groupByFieldY,
relationNestedFieldName: groupBySubFieldNameY,
objectMetadataItems,
});
const shouldApplyDateGranularityYForOrderBy =
isFieldYDateForOrderBy || isFieldYNestedDateForOrderBy;
const secondaryAxisOrderBy = getGroupByOrderBy({
graphOrderBy:
chartConfiguration.secondaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
groupByField: groupByFieldY,
groupBySubFieldName: chartConfiguration.secondaryAxisGroupBySubFieldName,
aggregateOperation,
dateGranularity: shouldApplyDateGranularityYForOrderBy
? (chartConfiguration.secondaryAxisGroupByDateGranularity ??
GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
});
if (isDefined(secondaryAxisOrderBy)) {
orderBy.push(secondaryAxisOrderBy);
}
}
return {
groupBy,
orderBy,
...(isDefined(limit) && { limit }),
};
};