Files
twenty/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts
T
Marie c27c8c88b0 Fix various graphs bugs (#21311)
Some bugs fixed in this PR
1. From UI any field could be chosen to group the query by it, while for
instance, RAW_JSON type (eg workflowRun.state) is not supported by
PostgreSQL to group a query by. Fix: removed it from the "group by"
fields options in FE + in BE -->
2. The BE check existed (isFlatFieldMetadataSupportedInGroupBy) but the
signature was malformed: it expected`{ fieldMetadataType,
fieldMetadataName, fieldMetadataIsSystem }` while every caller passes a
flat field metadata object with type/name/isSystem. So the check is
mis-wired — at runtime the destructured props are undefined, making it
always return true (validation bypassed). Fixed this.
3. Group by does not work with Morph relations if their direction is
ONE_TO_MANY. Added that constraint.
4. Group by with morph relations were broken even for MANY_TO_ONE,
because a morph is stored as one field per target
(polymorphicOwnerRocket, polymorphicOwnerSurveyResult…), each with its
own join column, but the frontend collapsed them into a single
polymorphicOwner field — so the backend tried to resolve a non-existent
polymorphicOwnerId. Fix: Frontend: added a target picker so you choose
the specific morph target (then its sub-field), storing the real
per-target field id. Backend: fixed validate-relation-subfield to use
the per-target field's own relationTargetObjectMetadataId instead of the
multi-target resolver that returned null.
5. (improvement) When an error occured in the query, the graph showed
"No data". Updated it to "error". (screenshot 1)
6. When a field used as a filter on a graph is deleted, it is not
deleted as a graph filter (which is ok because it would involve parsing
all the graph's configuration json to find whether a field is
referenced; there is no foreign key), which prevented from further
modifying the graph's filters. Fixed this + add an indicator that the
filter is can/should be removed (see screenshot 2)
7. "Ambiguous column name" PG error occurs when ordering by "creation
date" of a related field, because both objects have createdAt field.
Fixed it by adding table alias as prefix.
8. (improvement) While working on #5 I did not understand why we could
directly do `"objectMetadataNameSingular"."columnName" `while I expected
that for custom objects it would have to be
`_objectMetadataNameSingular`. that's simply because we use an alias
from the beginning. To add clarity, within groupBy code I replaced
`objectMetadataNameSingular` with `objectAlias` everywhere it is indeed
inherited from us using objectAlias.

<img width="685" height="391" alt="Screenshot 2026-06-08 at 12 01 45"
src="https://github.com/user-attachments/assets/f2b15ca5-da39-4114-8188-69f58f3c4cbf"
/>

<img width="598" height="341" alt="Screenshot 2026-06-08 at 11 53 55"
src="https://github.com/user-attachments/assets/66372811-4a37-40d9-b43a-4af51f89b6e6"
/>
2026-06-09 16:08:22 +02:00

237 lines
8.7 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { CalendarStartDay } from 'twenty-shared/constants';
import { FirstDayOfTheWeek } from 'twenty-shared/types';
import {
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek,
isDefined,
} from 'twenty-shared/utils';
import { type WorkspaceAuthContext } from 'src/engine/core-modules/auth/types/workspace-auth-context.type';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { PieChartConfigurationDTO } from 'src/engine/metadata-modules/page-layout-widget/dtos/pie-chart-configuration.dto';
import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type';
import { EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS } from 'src/modules/dashboard/chart-data/constants/extra-item-to-detect-too-many-groups.constant';
import { PIE_CHART_MAXIMUM_NUMBER_OF_SLICES } from 'src/modules/dashboard/chart-data/constants/pie-chart-maximum-number-of-slices.constant';
import { PieChartDataDTO } from 'src/modules/dashboard/chart-data/dtos/pie-chart-data.dto';
import {
ChartDataException,
ChartDataExceptionCode,
generateChartDataExceptionMessage,
} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
import { ChartDataQueryService } from 'src/modules/dashboard/chart-data/services/chart-data-query.service';
import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-field-metadata.util';
import { getSelectOptions } from 'src/modules/dashboard/chart-data/utils/get-select-options.util';
import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-one-dimensional-results.util';
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
type GetPieChartDataParams = {
workspaceId: string;
objectMetadataId: string;
configuration: PieChartConfigurationDTO;
authContext: WorkspaceAuthContext;
};
@Injectable()
export class PieChartDataService {
constructor(
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly chartDataQueryService: ChartDataQueryService,
) {}
async getPieChartData({
workspaceId,
objectMetadataId,
configuration,
authContext,
}: GetPieChartDataParams): Promise<PieChartDataDTO> {
try {
if (
configuration.configurationType !== WidgetConfigurationType.PIE_CHART
) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
`Expected PIE_CHART, got ${configuration.configurationType}`,
),
ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION,
);
}
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
if (!isDefined(objectMetadataId)) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
'Widget has no objectMetadataId',
),
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(flatObjectMetadata)) {
throw new ChartDataException(
generateChartDataExceptionMessage(
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
objectMetadataId,
),
ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
);
}
const groupByField = getFieldMetadata(
configuration.groupByFieldMetadataId,
flatFieldMetadataMaps,
);
const limit =
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES +
EXTRA_ITEM_TO_DETECT_TOO_MANY_GROUPS;
const objectIdByNameSingular: Record<string, string> = {};
for (const objMetadata of Object.values(
flatObjectMetadataMaps.byUniversalIdentifier,
)) {
if (isDefined(objMetadata)) {
objectIdByNameSingular[objMetadata.nameSingular] = objMetadata.id;
}
}
const rawResults = await this.chartDataQueryService.executeGroupByQuery({
flatObjectMetadata,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
objectIdByNameSingular,
authContext,
groupByFieldMetadataId: configuration.groupByFieldMetadataId,
groupBySubFieldName: configuration.groupBySubFieldName,
aggregateFieldMetadataId: configuration.aggregateFieldMetadataId,
aggregateOperation: configuration.aggregateOperation,
filter: configuration.filter,
dateGranularity: configuration.dateGranularity,
userTimezone: configuration.timezone ?? 'UTC',
firstDayOfTheWeek:
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
CalendarStartDay.MONDAY,
limit,
primaryAxisOrderBy: configuration.orderBy,
splitMultiValueFields: configuration.splitMultiValueFields,
});
return this.transformToPieChartData({
rawResults,
groupByField,
configuration,
userTimezone: configuration.timezone ?? 'UTC',
firstDayOfTheWeek:
(configuration.firstDayOfTheWeek as CalendarStartDay | undefined) ??
CalendarStartDay.MONDAY,
});
} catch (error) {
throw wrapChartDataQueryError(error, 'Pie chart data retrieval failed');
}
}
private transformToPieChartData({
rawResults,
groupByField,
configuration,
userTimezone,
firstDayOfTheWeek,
}: {
rawResults: Array<{
groupByDimensionValues: unknown[];
aggregateValue: number;
}>;
groupByField: FlatFieldMetadata;
configuration: PieChartConfigurationDTO;
userTimezone: string;
firstDayOfTheWeek: CalendarStartDay;
}): PieChartDataDTO {
const filteredResults = configuration.hideEmptyCategory
? rawResults.filter(
(result) =>
isDefined(result.groupByDimensionValues?.[0]) &&
result.aggregateValue !== 0,
)
: rawResults;
const selectOptions = getSelectOptions(groupByField);
const convertedFirstDayOfTheWeek =
convertCalendarStartDayNonIsoNumberToFirstDayOfTheWeek(
firstDayOfTheWeek,
FirstDayOfTheWeek.SUNDAY,
);
const limitedResults = filteredResults.slice(
0,
PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
);
const {
processedDataPoints: rawProcessedDataPoints,
formattedToRawLookup,
} = processOneDimensionalResults({
rawResults: limitedResults,
primaryAxisGroupByField: groupByField,
dateGranularity: configuration.dateGranularity,
subFieldName: configuration.groupBySubFieldName,
userTimezone,
firstDayOfTheWeek: convertedFirstDayOfTheWeek,
});
const processedDataPoints = rawProcessedDataPoints.map((point) => {
const rawValueString = isDefined(point.rawValue)
? String(point.rawValue)
: null;
return {
id: point.formattedValue,
value: point.aggregateValue,
rawValue: rawValueString,
};
});
const sortedData = sortChartDataIfNeeded({
data: processedDataPoints,
orderBy: configuration.orderBy,
manualSortOrder: configuration.manualSortOrder,
formattedToRawLookup,
getFieldValue: (item) => item.id,
getNumericValue: (item) => item.value,
selectFieldOptions: selectOptions,
fieldType: groupByField.type,
dateGranularity: configuration.dateGranularity,
});
const data = sortedData.map(({ rawValue: _rawValue, ...item }) => item);
return {
data,
showLegend: configuration.displayLegend ?? true,
showDataLabels: configuration.displayDataLabel ?? false,
showCenterMetric: configuration.showCenterMetric ?? true,
hasTooManyGroups:
filteredResults.length > PIE_CHART_MAXIMUM_NUMBER_OF_SLICES,
formattedToRawLookup: Object.fromEntries(formattedToRawLookup),
};
}
}