c27c8c88b0
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"
/>
237 lines
8.7 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|