d1c6b8ee72
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.
275 lines
9.7 KiB
TypeScript
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,
|
|
}),
|
|
}));
|
|
}
|
|
}
|