Files
twenty/packages/twenty-server/src/modules/dashboard/chart-data/services/chart-data-query.service.ts
T
Raphaël Bosi d1c6b8ee72 Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f



Charts grouped by a relation without a sub-field rendered raw FK UUIDs
on axis ticks, legends and tooltips. The server now batch-resolves the
grouped record ids to their label identifier through a permission-scoped
query and formats every bucket with the record's display name.
Unresolvable records (deleted or not readable) render as Unknown and
their ids are stripped from the response payload. Same-named records get
an ordinal suffix so their buckets don't merge. Covers bar, line and
pie, plain and morph relations.

```mermaid
flowchart TD
    A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"]
    B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"]
    C --> D["filterOutEmptyChartBuckets"]
    D --> E{"Bare relation axis?<br/>(no sub-field)"}

    subgraph RL["ChartRelationLabelService.resolveRelationLabels"]
        direction TB
        G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"]
        G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"]
        G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"]
    end

    E -- No --> H["formatDimensionValue per bucket"]
    E -- Yes --> G1
    G4 --> H
    H --> I["Strip unresolved ids from<br/>formattedToRawLookup"]
    I --> J["Chart DTO to frontend"]
```

The chart settings sub-field dropdown gains a Record option to group by
the related record itself, and now only offers sub-fields the backend
accepts (system fields like a workspace member's updatedBy were
selectable but rejected at query time). Chart-data errors are now logged
server-side.

Also fixes two latent bugs on this path: sorting a bare-relation chart
by field threw `Cannot orderBy unknown field: agentId`, and the pie
chart truncated slices before sorting. The AI dashboard tool guidance
and the seeded dashboards no longer force the sub-field workaround.

The group-by query orders buckets by the related record's label
identifier at the database level (the engine now accepts ordering by a
target field when grouping by its id), so with more than 100 distinct
related records the surviving buckets match the label order.
2026-07-24 14:12:22 +00:00

275 lines
9.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { CalendarStartDay } from 'twenty-shared/constants';
import {
AggregateOperations,
type ChartFilter,
ObjectRecordGroupByDateGranularity,
OrderByWithGroupBy,
} from 'twenty-shared/types';
import {
isDefined,
isFieldMetadataArrayKind,
isFieldMetadataDateKind,
} from 'twenty-shared/utils';
import { ObjectRecordGroupBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { CommonGroupByQueryRunnerService } from 'src/engine/api/common/common-query-runners/common-group-by-query-runner.service';
import { isUserAuthContext } from 'src/engine/core-modules/auth/guards/is-user-auth-context.guard';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { GraphOrderBy } from 'src/engine/metadata-modules/page-layout-widget/enums/graph-order-by.enum';
import { GRAPH_DEFAULT_DATE_GRANULARITY } from 'src/modules/dashboard/chart-data/constants/graph-default-date-granularity.constant';
import { GRAPH_DEFAULT_ORDER_BY } from 'src/modules/dashboard/chart-data/constants/graph-default-order-by.constant';
import {
ChartDataException,
ChartDataExceptionCode,
generateChartDataExceptionMessage,
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
import { GroupByRawResult } from 'src/modules/dashboard/chart-data/types/group-by-raw-result.type';
import { buildAggregateFieldKey } from 'src/modules/dashboard/chart-data/utils/build-aggregate-field-key.util';
import {
buildGroupByFieldObject,
type GroupByFieldObject,
} from 'src/modules/dashboard/chart-data/utils/build-group-by-field-object.util';
import { convertChartFilterToGqlOperationFilter } from 'src/modules/dashboard/chart-data/utils/convert-chart-filter-to-gql-operation-filter.util';
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
import { getGroupByOrderBy } from 'src/modules/dashboard/chart-data/utils/get-group-by-order-by.util';
import { isRelationNestedFieldDateKind } from 'src/modules/dashboard/chart-data/utils/is-relation-nested-field-date-kind.util';
import { transformAggregateValue } from 'src/modules/dashboard/chart-data/utils/transform-aggregate-value.util';
type ExecuteGroupByQueryParams = {
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
objectIdByNameSingular: Record<string, string>;
authContext: WorkspaceAuthContext;
groupByFieldMetadataId: string;
groupBySubFieldName?: string | null;
aggregateFieldMetadataId: string;
aggregateOperation: AggregateOperations;
filter?: ChartFilter;
dateGranularity?: ObjectRecordGroupByDateGranularity;
userTimezone: string;
firstDayOfTheWeek: CalendarStartDay;
limit: number;
secondaryGroupByFieldMetadataId?: string;
secondaryGroupBySubFieldName?: string | null;
secondaryDateGranularity?: ObjectRecordGroupByDateGranularity;
primaryAxisOrderBy?: GraphOrderBy;
secondaryAxisOrderBy?: GraphOrderBy;
splitMultiValueFields?: boolean;
};
@Injectable()
export class ChartDataQueryService {
constructor(
private readonly commonGroupByQueryRunnerService: CommonGroupByQueryRunnerService,
) {}
async executeGroupByQuery({
flatObjectMetadata,
flatFieldMetadataMaps,
flatObjectMetadataMaps,
objectIdByNameSingular,
authContext,
groupByFieldMetadataId,
groupBySubFieldName,
aggregateFieldMetadataId,
aggregateOperation,
filter,
dateGranularity,
userTimezone,
firstDayOfTheWeek,
limit,
primaryAxisOrderBy,
secondaryGroupByFieldMetadataId,
secondaryGroupBySubFieldName,
secondaryDateGranularity,
secondaryAxisOrderBy,
splitMultiValueFields,
}: ExecuteGroupByQueryParams): Promise<GroupByRawResult[]> {
const currentWorkspaceMemberId = isUserAuthContext(authContext)
? authContext.workspaceMemberId
: undefined;
const gqlOperationFilter = convertChartFilterToGqlOperationFilter({
filter,
flatFieldMetadataMaps,
userTimezone,
currentWorkspaceMemberId,
});
const primaryGroupByField = getFieldMetadata(
groupByFieldMetadataId,
flatFieldMetadataMaps,
);
const aggregateField = getFieldMetadata(
aggregateFieldMetadataId,
flatFieldMetadataMaps,
);
const isPrimaryFieldDate = isFieldMetadataDateKind(
primaryGroupByField.type,
);
const isPrimaryNestedDate = isRelationNestedFieldDateKind({
relationFieldMetadata: primaryGroupByField,
relationNestedFieldName: groupBySubFieldName ?? undefined,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
const shouldApplyPrimaryDateGranularity =
isPrimaryFieldDate || isPrimaryNestedDate;
const shouldSplitMultiValueFields = splitMultiValueFields ?? true;
const shouldUnnestPrimary =
shouldSplitMultiValueFields &&
isFieldMetadataArrayKind(primaryGroupByField.type);
const groupBy: GroupByFieldObject[] = [];
groupBy.push(
buildGroupByFieldObject({
fieldMetadata: primaryGroupByField,
subFieldName: groupBySubFieldName,
dateGranularity: shouldApplyPrimaryDateGranularity
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isPrimaryNestedDate,
timeZone: userTimezone,
shouldUnnest: shouldUnnestPrimary,
}),
);
const orderBy: OrderByWithGroupBy = [];
const primaryOrderBy = getGroupByOrderBy({
graphOrderBy: primaryAxisOrderBy ?? GRAPH_DEFAULT_ORDER_BY,
groupByFieldMetadata: primaryGroupByField,
groupBySubFieldName,
aggregateOperation,
aggregateFieldMetadata: aggregateField,
dateGranularity: shouldApplyPrimaryDateGranularity
? (dateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
if (isDefined(primaryOrderBy)) {
orderBy.push(...primaryOrderBy);
}
if (isDefined(secondaryGroupByFieldMetadataId)) {
const secondaryGroupByField = getFieldMetadata(
secondaryGroupByFieldMetadataId,
flatFieldMetadataMaps,
);
const isSecondaryFieldDate = isFieldMetadataDateKind(
secondaryGroupByField.type,
);
const isSecondaryNestedDate = isRelationNestedFieldDateKind({
relationFieldMetadata: secondaryGroupByField,
relationNestedFieldName: secondaryGroupBySubFieldName ?? undefined,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
const shouldApplySecondaryDateGranularity =
isSecondaryFieldDate || isSecondaryNestedDate;
const shouldUnnestSecondary =
shouldSplitMultiValueFields &&
isFieldMetadataArrayKind(secondaryGroupByField.type);
if (shouldUnnestPrimary && shouldUnnestSecondary) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
'Split multiple values can only be enabled when one grouped field is multi-value.',
),
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
);
}
groupBy.push(
buildGroupByFieldObject({
fieldMetadata: secondaryGroupByField,
subFieldName: secondaryGroupBySubFieldName,
dateGranularity: shouldApplySecondaryDateGranularity
? (secondaryDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
firstDayOfTheWeek,
isNestedDateField: isSecondaryNestedDate,
timeZone: userTimezone,
shouldUnnest: shouldUnnestSecondary,
}),
);
if (isDefined(secondaryAxisOrderBy)) {
const secondaryOrderBy = getGroupByOrderBy({
graphOrderBy: secondaryAxisOrderBy,
groupByFieldMetadata: secondaryGroupByField,
groupBySubFieldName: secondaryGroupBySubFieldName,
aggregateOperation,
aggregateFieldMetadata: aggregateField,
dateGranularity: shouldApplySecondaryDateGranularity
? (secondaryDateGranularity ?? GRAPH_DEFAULT_DATE_GRANULARITY)
: undefined,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
});
if (isDefined(secondaryOrderBy)) {
orderBy.push(...secondaryOrderBy);
}
}
}
const aggregateFieldKey = buildAggregateFieldKey({
aggregateOperation,
aggregateFieldMetadata: aggregateField,
});
const selectedFields = {
[aggregateFieldKey]: true,
groupByDimensionValues: true,
};
const { results } = await this.commonGroupByQueryRunnerService.execute(
{
filter: gqlOperationFilter,
orderBy: orderBy.length > 0 ? orderBy : undefined,
groupBy: groupBy as ObjectRecordGroupBy,
selectedFields,
limit,
},
{
authContext,
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
},
);
return results.map((result) => ({
groupByDimensionValues: result.groupByDimensionValues ?? [],
aggregateValue: transformAggregateValue({
rawValue: result[aggregateFieldKey],
aggregateFieldType: aggregateField.type,
aggregateOperation,
}),
}));
}
}