[DASHBOARDS] Add split multi-value fields setting for charts (#17907)

closes https://github.com/twentyhq/twenty/issues/17890



https://github.com/user-attachments/assets/dbf2ebe6-c2da-44d0-846e-d7fa9fac5f43
This commit is contained in:
nitin
2026-02-17 13:59:17 +05:30
committed by GitHub
parent c3d565f266
commit cfc4e0e343
37 changed files with 557 additions and 24 deletions
@@ -6,6 +6,7 @@ import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-m
export type GroupByRegularField = {
fieldMetadata: FlatFieldMetadata;
subFieldName?: string;
shouldUnnest?: boolean;
};
export type GroupByDateField = {
@@ -3,7 +3,10 @@ import {
ObjectRecordGroupByDateGranularity,
} from 'twenty-shared/types';
import { type GroupByDateField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import {
type GroupByDateField,
type GroupByRegularField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-expression.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
@@ -23,6 +26,16 @@ const buildGroupByDateField = (
...overrides,
});
const buildGroupByRegularField = (
overrides: Partial<GroupByRegularField> = {},
): GroupByRegularField => ({
fieldMetadata: {
type: FieldMetadataType.MULTI_SELECT,
name: 'tags',
} as FlatFieldMetadata,
...overrides,
});
describe('getGroupByExpression', () => {
const columnNameWithQuotes = '"company"."createdAt"';
@@ -126,4 +139,36 @@ describe('getGroupByExpression', () => {
expect(result).not.toContain('AT TIME ZONE');
});
});
describe('array unnest handling', () => {
const arrayColumnNameWithQuotes = '"company"."tags"';
it('should unnest array fields when split mode is enabled', () => {
const groupByField = buildGroupByRegularField({
shouldUnnest: true,
});
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes: arrayColumnNameWithQuotes,
});
expect(result).toBe(
`UNNEST(CASE WHEN CARDINALITY(${arrayColumnNameWithQuotes}) > 0 THEN ${arrayColumnNameWithQuotes} ELSE ARRAY[${arrayColumnNameWithQuotes}[1]] END)`,
);
});
it('should keep plain column expression when split mode is disabled', () => {
const groupByField = buildGroupByRegularField({
shouldUnnest: false,
});
const result = getGroupByExpression({
groupByField,
columnNameWithQuotes: arrayColumnNameWithQuotes,
});
expect(result).toBe(arrayColumnNameWithQuotes);
});
});
});
@@ -30,6 +30,10 @@ export const getGroupByExpression = ({
if (
!(isGroupByDateField(groupByField) || isGroupByRelationField(groupByField))
) {
if ('shouldUnnest' in groupByField && groupByField.shouldUnnest) {
return `UNNEST(CASE WHEN CARDINALITY(${columnNameWithQuotes}) > 0 THEN ${columnNameWithQuotes} ELSE ARRAY[${columnNameWithQuotes}[1]] END)`;
}
return columnNameWithQuotes;
}
@@ -12,16 +12,16 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
import {
type GroupByDateField,
type GroupByField,
type GroupByRegularField,
} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import { getGroupByOrderExpression } from 'src/engine/api/common/common-query-runners/utils/get-group-by-order-expression.util';
import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order';
import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util';
import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util';
import { prepareForOrderByRelationFieldParsing } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/prepare-for-order-by-relation-field-parsing.util';
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
import {
type AggregationField,
@@ -31,12 +31,12 @@ import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-er
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { buildFieldMapsFromFlatObjectMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/build-field-maps-from-flat-object-metadata.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
import { findManyFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps.util';
import { type OrderByClause } from './types/order-by-condition.type';
@@ -387,11 +387,11 @@ export class GraphqlQueryOrderGroupByParser {
flatObjectMetadata: FlatObjectMetadata;
fieldMetadata: FlatFieldMetadata;
}): Record<string, OrderByClause> | null => {
const fieldIsInGroupBy = groupByFields.some(
const groupByField = groupByFields.find(
(groupByField) => groupByField.fieldMetadata.id === fieldMetadata.id,
);
if (!fieldIsInGroupBy) {
if (!isDefined(groupByField)) {
throw new UserInputError(
`Cannot order by a field that is not an aggregate nor in groupBy criteria: ${fieldMetadata.name}.`,
);
@@ -404,8 +404,15 @@ export class GraphqlQueryOrderGroupByParser {
return null;
}
const columnNameWithQuotes = `"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"`;
const expression = getGroupByOrderExpression({
groupByField,
columnNameWithQuotes,
});
return {
[`"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"${orderByCasting}`]:
[`${expression}${orderByCasting}`]:
convertOrderByToFindOptionsOrder(orderByDirection),
};
};
@@ -93,6 +93,20 @@ export const parseGroupByArgs = (
}
}
// Handle array unnest fields
if (
typeof fieldNames[fieldName] === 'object' &&
fieldNames[fieldName] !== null &&
'unnest' in fieldNames[fieldName]
) {
groupByFields.push({
fieldMetadata,
subFieldName: undefined,
shouldUnnest: true,
});
continue;
}
// Handle regular fields and composite fields
if (fieldNames[fieldName] === true) {
groupByFields.push({