[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>
This commit is contained in:
@@ -46,57 +46,168 @@ const aggregateChartConfigSchema = z.object({
|
||||
});
|
||||
|
||||
// Graph configuration schema for BAR charts
|
||||
const barChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.BAR_CHART),
|
||||
aggregateFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to aggregate'),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to group by on primary axis'),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
displayLegend: z.boolean().optional().default(true),
|
||||
layout: z
|
||||
.enum(['VERTICAL', 'HORIZONTAL'])
|
||||
.optional()
|
||||
.default('VERTICAL')
|
||||
.describe('Layout orientation for bar charts'),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
const barChartConfigSchema = z
|
||||
.object({
|
||||
graphType: z.literal(GraphType.BAR_CHART),
|
||||
aggregateFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to aggregate'),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to group by on primary axis'),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum([
|
||||
'FIELD_ASC',
|
||||
'FIELD_DESC',
|
||||
'FIELD_POSITION_ASC',
|
||||
'FIELD_POSITION_DESC',
|
||||
'VALUE_ASC',
|
||||
'VALUE_DESC',
|
||||
'MANUAL',
|
||||
])
|
||||
.optional(),
|
||||
primaryAxisManualSortOrder: z.array(z.string()).optional(),
|
||||
secondaryAxisOrderBy: z
|
||||
.enum([
|
||||
'FIELD_ASC',
|
||||
'FIELD_DESC',
|
||||
'FIELD_POSITION_ASC',
|
||||
'FIELD_POSITION_DESC',
|
||||
'VALUE_ASC',
|
||||
'VALUE_DESC',
|
||||
'MANUAL',
|
||||
])
|
||||
.optional(),
|
||||
secondaryAxisManualSortOrder: z.array(z.string()).optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
displayLegend: z.boolean().optional().default(true),
|
||||
layout: z
|
||||
.enum(['VERTICAL', 'HORIZONTAL'])
|
||||
.optional()
|
||||
.default('VERTICAL')
|
||||
.describe('Layout orientation for bar charts'),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.primaryAxisOrderBy !== 'MANUAL' ||
|
||||
(Array.isArray(data.primaryAxisManualSortOrder) &&
|
||||
data.primaryAxisManualSortOrder.length > 0),
|
||||
{
|
||||
message:
|
||||
'primaryAxisManualSortOrder must be a non-empty array when primaryAxisOrderBy is MANUAL',
|
||||
path: ['primaryAxisManualSortOrder'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.secondaryAxisOrderBy !== 'MANUAL' ||
|
||||
(Array.isArray(data.secondaryAxisManualSortOrder) &&
|
||||
data.secondaryAxisManualSortOrder.length > 0),
|
||||
{
|
||||
message:
|
||||
'secondaryAxisManualSortOrder must be a non-empty array when secondaryAxisOrderBy is MANUAL',
|
||||
path: ['secondaryAxisManualSortOrder'],
|
||||
},
|
||||
);
|
||||
|
||||
// Graph configuration schema for LINE charts
|
||||
const lineChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.LINE_CHART),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z.string().uuid(),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
const lineChartConfigSchema = z
|
||||
.object({
|
||||
graphType: z.literal(GraphType.LINE_CHART),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
primaryAxisGroupByFieldMetadataId: z.string().uuid(),
|
||||
secondaryAxisGroupByFieldMetadataId: z.string().uuid().optional(),
|
||||
primaryAxisOrderBy: z
|
||||
.enum([
|
||||
'FIELD_ASC',
|
||||
'FIELD_DESC',
|
||||
'FIELD_POSITION_ASC',
|
||||
'FIELD_POSITION_DESC',
|
||||
'VALUE_ASC',
|
||||
'VALUE_DESC',
|
||||
'MANUAL',
|
||||
])
|
||||
.optional(),
|
||||
primaryAxisManualSortOrder: z.array(z.string()).optional(),
|
||||
secondaryAxisOrderBy: z
|
||||
.enum([
|
||||
'FIELD_ASC',
|
||||
'FIELD_DESC',
|
||||
'FIELD_POSITION_ASC',
|
||||
'FIELD_POSITION_DESC',
|
||||
'VALUE_ASC',
|
||||
'VALUE_DESC',
|
||||
'MANUAL',
|
||||
])
|
||||
.optional(),
|
||||
secondaryAxisManualSortOrder: z.array(z.string()).optional(),
|
||||
displayDataLabel: z.boolean().optional().default(false),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.primaryAxisOrderBy !== 'MANUAL' ||
|
||||
(Array.isArray(data.primaryAxisManualSortOrder) &&
|
||||
data.primaryAxisManualSortOrder.length > 0),
|
||||
{
|
||||
message:
|
||||
'primaryAxisManualSortOrder must be a non-empty array when primaryAxisOrderBy is MANUAL',
|
||||
path: ['primaryAxisManualSortOrder'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) =>
|
||||
data.secondaryAxisOrderBy !== 'MANUAL' ||
|
||||
(Array.isArray(data.secondaryAxisManualSortOrder) &&
|
||||
data.secondaryAxisManualSortOrder.length > 0),
|
||||
{
|
||||
message:
|
||||
'secondaryAxisManualSortOrder must be a non-empty array when secondaryAxisOrderBy is MANUAL',
|
||||
path: ['secondaryAxisManualSortOrder'],
|
||||
},
|
||||
);
|
||||
|
||||
// Graph configuration schema for PIE charts
|
||||
const pieChartConfigSchema = z.object({
|
||||
graphType: z.literal(GraphType.PIE_CHART),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
groupByFieldMetadataId: z.string().uuid().describe('Field UUID to slice by'),
|
||||
orderBy: z
|
||||
.enum(['FIELD_ASC', 'FIELD_DESC', 'VALUE_ASC', 'VALUE_DESC'])
|
||||
.optional(),
|
||||
displayDataLabel: z.boolean().optional().default(true),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
});
|
||||
const pieChartConfigSchema = z
|
||||
.object({
|
||||
graphType: z.literal(GraphType.PIE_CHART),
|
||||
aggregateFieldMetadataId: z.string().uuid(),
|
||||
aggregateOperation: z.nativeEnum(AggregateOperations),
|
||||
groupByFieldMetadataId: z
|
||||
.string()
|
||||
.uuid()
|
||||
.describe('Field UUID to slice by'),
|
||||
orderBy: z
|
||||
.enum([
|
||||
'FIELD_ASC',
|
||||
'FIELD_DESC',
|
||||
'FIELD_POSITION_ASC',
|
||||
'FIELD_POSITION_DESC',
|
||||
'VALUE_ASC',
|
||||
'VALUE_DESC',
|
||||
'MANUAL',
|
||||
])
|
||||
.optional(),
|
||||
manualSortOrder: z.array(z.string()).optional(),
|
||||
displayDataLabel: z.boolean().optional().default(true),
|
||||
filter: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.orderBy !== 'MANUAL' ||
|
||||
(Array.isArray(data.manualSortOrder) && data.manualSortOrder.length > 0),
|
||||
{
|
||||
message:
|
||||
'manualSortOrder must be a non-empty array when orderBy is MANUAL',
|
||||
path: ['manualSortOrder'],
|
||||
},
|
||||
);
|
||||
|
||||
// Iframe configuration
|
||||
const iframeConfigSchema = z.object({
|
||||
|
||||
Reference in New Issue
Block a user