Files
twenty/packages/twenty-front/src/modules/page-layout/widgets/graph/utils/processOneDimensionalGroupByResults.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

113 lines
3.9 KiB
TypeScript

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 { type GroupByRawResult } from '@/page-layout/widgets/graph/types/GroupByRawResult';
import { type RawDimensionValue } from '@/page-layout/widgets/graph/types/RawDimensionValue';
import { buildFormattedToRawLookup } from '@/page-layout/widgets/graph/utils/buildFormattedToRawLookup';
import { computeAggregateValueFromGroupByResult } from '@/page-layout/widgets/graph/utils/computeAggregateValueFromGroupByResult';
import { formatDimensionValue } from '@/page-layout/widgets/graph/utils/formatDimensionValue';
import { formatPrimaryDimensionValues } from '@/page-layout/widgets/graph/utils/formatPrimaryDimensionValues';
import { type ObjectRecordGroupByDateGranularity } from 'twenty-shared/types';
import { type FirstDayOfTheWeek, isDefined } from 'twenty-shared/utils';
type OneDimensionalChartConfiguration = {
primaryAxisDateGranularity?: ObjectRecordGroupByDateGranularity | null;
primaryAxisGroupBySubFieldName?: string | null;
aggregateOperation: string;
};
type ProcessOneDimensionalGroupByResultsParams = {
rawResults: GroupByRawResult[];
groupByFieldX: FieldMetadataItem;
aggregateField: FieldMetadataItem;
configuration: OneDimensionalChartConfiguration;
aggregateOperation: string;
objectMetadataItem: ObjectMetadataItem;
primaryAxisSubFieldName?: string | null;
userTimezone: string;
firstDayOfTheWeek: FirstDayOfTheWeek;
};
export type ProcessedOneDimensionalDataPoint = {
xValue: string;
rawXValue: RawDimensionValue;
aggregateValue: number;
};
export type ProcessOneDimensionalGroupByResultsOutput = {
processedDataPoints: ProcessedOneDimensionalDataPoint[];
formattedToRawLookup: Map<string, RawDimensionValue>;
};
export const processOneDimensionalGroupByResults = ({
rawResults,
groupByFieldX,
aggregateField,
configuration,
aggregateOperation,
objectMetadataItem,
primaryAxisSubFieldName,
userTimezone,
firstDayOfTheWeek,
}: ProcessOneDimensionalGroupByResultsParams): ProcessOneDimensionalGroupByResultsOutput => {
const formattedValues = formatPrimaryDimensionValues({
groupByRawResults: rawResults,
primaryAxisGroupByField: groupByFieldX,
primaryAxisDateGranularity:
configuration.primaryAxisDateGranularity ?? undefined,
primaryAxisGroupBySubFieldName: primaryAxisSubFieldName ?? undefined,
userTimezone,
firstDayOfTheWeek,
});
const formattedToRawLookup = buildFormattedToRawLookup(formattedValues);
const processedDataPoints: ProcessedOneDimensionalDataPoint[] = [];
for (const result of rawResults) {
const dimensionValues = result.groupByDimensionValues;
if (!isDefined(dimensionValues) || dimensionValues.length < 1) {
continue;
}
const rawXValue = dimensionValues[0] as RawDimensionValue;
const xValue = formatDimensionValue({
value: rawXValue,
fieldMetadata: groupByFieldX,
dateGranularity: configuration.primaryAxisDateGranularity ?? undefined,
subFieldName: primaryAxisSubFieldName ?? undefined,
userTimezone,
firstDayOfTheWeek,
});
if (isDefined(rawXValue)) {
formattedToRawLookup.set(xValue, rawXValue);
}
const aggregateValue = computeAggregateValueFromGroupByResult({
rawResult: result,
aggregateField,
aggregateOperation:
configuration.aggregateOperation as unknown as ExtendedAggregateOperations,
aggregateOperationFromRawResult: aggregateOperation,
objectMetadataItem,
});
if (!isDefined(aggregateValue)) {
continue;
}
processedDataPoints.push({
xValue,
rawXValue,
aggregateValue,
});
}
return {
processedDataPoints,
formattedToRawLookup,
};
};