From c27c8c88b040ee4e19b51e6006747930a9e4410b Mon Sep 17 00:00:00 2001
From: Marie <51697796+ijreilly@users.noreply.github.com>
Date: Tue, 9 Jun 2026 16:08:22 +0200
Subject: [PATCH] Fix various graphs bugs (#21311)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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.
---
.../PageLayoutWidgetErrorDisplay.tsx | 40 +++++
.../PageLayoutWidgetNoDataDisplay.tsx | 24 +--
.../PageLayoutWidgetStatusDisplay.tsx | 36 ++++
.../GraphWidgetAggregateChartRenderer.tsx | 8 +-
.../GraphWidgetBarChartRenderer.tsx | 8 +-
.../GraphWidgetLineChartRenderer.tsx | 6 +
.../GraphWidgetPieChartRenderer.tsx | 8 +-
.../graph/hooks/useGraphWidgetQueryCommon.ts | 13 +-
.../ChartFiltersDeletedFieldsWarning.tsx | 30 ++++
.../components/ChartFiltersSettings.tsx | 17 +-
...rtFiltersSettingsInitializeStateEffect.tsx | 4 +-
...oupByFieldSelectionDropdownContentBase.tsx | 73 +++++++-
...ByFieldSelectionMorphRelationFieldView.tsx | 156 ++++++++++++++++
...GroupByFieldSelectionRelationFieldView.tsx | 163 ++---------------
...ByFieldSelectionTargetObjectFieldsView.tsx | 170 ++++++++++++++++++
.../__tests__/useChartSettingsValues.test.tsx | 89 +++++++++
.../hooks/useChartSettingsValues.ts | 18 +-
...hartRecordFiltersWithDeletedFields.test.ts | 84 +++++++++
...dropChartRecordFiltersWithDeletedFields.ts | 14 ++
.../findChartGroupByFieldMetadataItem.ts | 17 ++
...nested-field-supported-in-group-by.util.ts | 15 +-
...transform-group-by-fields-or-throw.util.ts | 32 +++-
.../common-group-by-query-runner.service.ts | 16 +-
...raphql-query-order-group-by.parser.spec.ts | 146 +++++++++++++++
.../graphql-query-order-group-by.parser.ts | 19 +-
...adata-group-by-gql-input-type.generator.ts | 9 +-
.../zod-schemas/group-by-tool.zod-schema.ts | 8 +-
.../zod-schemas/record-filter.zod-schema.ts | 10 +-
.../is-supported-in-group-by.spec.ts | 89 ---------
.../utils/is-supported-in-group-by.util.ts | 34 ----
...clude-field-from-agent-tool-schema.util.ts | 21 ---
.../validate-relation-subfield.util.spec.ts | 96 ++++++++++
.../utils/validate-relation-subfield.util.ts | 5 +-
.../exceptions/chart-data.exception.ts | 4 +
.../services/bar-chart-data.service.ts | 13 +-
.../services/line-chart-data.service.ts | 13 +-
.../services/pie-chart-data.service.ts | 13 +-
.../wrap-chart-data-query-error.util.spec.ts | 56 ++++++
...data-graphql-api-exception-handler.util.ts | 3 +
.../utils/wrap-chart-data-query-error.util.ts | 39 ++++
.../group-by-resolver.integration-spec.ts | 40 +++++
.../ExcludedFieldNamesFromAgentToolSchema.ts | 5 +
...FieldMetadataTypesNotSupportedInGroupBy.ts | 9 +
packages/twenty-shared/src/constants/index.ts | 2 +
.../isFieldMetadataSupportedInGroupBy.test.ts | 129 +++++++++++++
.../src/utils/fieldMetadata/index.ts | 2 +
.../isFieldMetadataSupportedInGroupBy.ts | 46 +++++
.../shouldExcludeFieldFromAgentToolSchema.ts | 24 +++
packages/twenty-shared/src/utils/index.ts | 2 +
49 files changed, 1464 insertions(+), 414 deletions(-)
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetErrorDisplay.tsx
create mode 100644 packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetStatusDisplay.tsx
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/dropChartRecordFiltersWithDeletedFields.test.ts
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields.ts
create mode 100644 packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem.ts
create mode 100644 packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
delete mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util.ts
delete mode 100644 packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts
create mode 100644 packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/validate-relation-subfield.util.spec.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/wrap-chart-data-query-error.util.spec.ts
create mode 100644 packages/twenty-server/src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util.ts
create mode 100644 packages/twenty-shared/src/constants/ExcludedFieldNamesFromAgentToolSchema.ts
create mode 100644 packages/twenty-shared/src/constants/FieldMetadataTypesNotSupportedInGroupBy.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSupportedInGroupBy.test.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy.ts
create mode 100644 packages/twenty-shared/src/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema.ts
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetErrorDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetErrorDisplay.tsx
new file mode 100644
index 0000000000..c4eb3f83ff
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetErrorDisplay.tsx
@@ -0,0 +1,40 @@
+import { PageLayoutWidgetForbiddenDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetForbiddenDisplay';
+import { PageLayoutWidgetStatusDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetStatusDisplay';
+import { styled } from '@linaria/react';
+import { t } from '@lingui/core/macro';
+import { isGraphqlErrorOfType } from '~/utils/is-graphql-error-of-type.util';
+
+const StyledForbiddenContainer = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+`;
+
+type PageLayoutWidgetErrorDisplayProps = {
+ widgetId: string;
+ error?: unknown;
+};
+
+export const PageLayoutWidgetErrorDisplay = ({
+ widgetId,
+ error,
+}: PageLayoutWidgetErrorDisplayProps) => {
+ if (isGraphqlErrorOfType(error, 'FORBIDDEN')) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay.tsx
index aa3973c86e..350b99bc48 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetNoDataDisplay.tsx
@@ -1,17 +1,10 @@
+import { PageLayoutWidgetStatusDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetStatusDisplay';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
-import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
-import { AppTooltip, Status } from 'twenty-ui-deprecated/display';
import { WidgetType } from '~/generated-metadata/graphql';
-const StyledNoDataContainer = styled.div`
- align-items: center;
- display: flex;
- justify-content: center;
-`;
export const PageLayoutWidgetNoDataDisplay = () => {
const widget = useCurrentWidget();
- const tooltipId = `widget-incomplete-tooltip-${widget.id}`;
const text = widget.type === WidgetType.IFRAME ? t`Invalid URL` : t`No Data`;
const tooltipContent =
@@ -20,15 +13,10 @@ export const PageLayoutWidgetNoDataDisplay = () => {
: t`No data available. Click edit to configure this widget.`;
return (
-
-
-
-
-
-
+
);
};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetStatusDisplay.tsx b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetStatusDisplay.tsx
new file mode 100644
index 0000000000..dba150562d
--- /dev/null
+++ b/packages/twenty-front/src/modules/page-layout/widgets/components/PageLayoutWidgetStatusDisplay.tsx
@@ -0,0 +1,36 @@
+import { styled } from '@linaria/react';
+import { AppTooltip, Status } from 'twenty-ui-deprecated/display';
+import { type ThemeColor } from 'twenty-ui/theme';
+
+const StyledContainer = styled.div`
+ align-items: center;
+ display: flex;
+ justify-content: center;
+`;
+
+type PageLayoutWidgetStatusDisplayProps = {
+ tooltipId: string;
+ text: string;
+ tooltipContent: string;
+ color?: ThemeColor;
+};
+
+export const PageLayoutWidgetStatusDisplay = ({
+ tooltipId,
+ text,
+ tooltipContent,
+ color = 'red',
+}: PageLayoutWidgetStatusDisplayProps) => {
+ return (
+
+
+
+
+
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChartRenderer.tsx b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChartRenderer.tsx
index a0ef48998d..2aeb23989d 100644
--- a/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChartRenderer.tsx
+++ b/packages/twenty-front/src/modules/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChartRenderer.tsx
@@ -1,8 +1,10 @@
+import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
import { useGraphWidgetAggregateQuery } from '@/page-layout/widgets/graph/hooks/useGraphWidgetAggregateQuery';
import { assertAggregateChartWidgetOrThrow } from '@/page-layout/widgets/graph/utils/assertAggregateChartWidget';
import { useCurrentWidget } from '@/page-layout/widgets/hooks/useCurrentWidget';
import { lazy, Suspense } from 'react';
+import { isDefined } from 'twenty-shared/utils';
const GraphWidgetAggregateChart = lazy(() =>
import('@/page-layout/widgets/graph/graph-widget-aggregate-chart/components/GraphWidgetAggregateChart').then(
@@ -17,7 +19,7 @@ export const GraphWidgetAggregateChartRenderer = () => {
assertAggregateChartWidgetOrThrow(widget);
- const { value, loading } = useGraphWidgetAggregateQuery({
+ const { value, loading, error } = useGraphWidgetAggregateQuery({
objectMetadataItemId: widget.objectMetadataId,
configuration: widget.configuration,
});
@@ -26,6 +28,10 @@ export const GraphWidgetAggregateChartRenderer = () => {
return ;
}
+ if (isDefined(error)) {
+ return ;
+ }
+
return (
}>
@@ -46,6 +47,7 @@ export const GraphWidgetBarChartRenderer = () => {
layout,
groupMode,
loading,
+ error,
hasTooManyGroups,
formattedToRawLookup,
colorMode,
@@ -118,6 +120,10 @@ export const GraphWidgetBarChartRenderer = () => {
return ;
}
+ if (isDefined(error)) {
+ return ;
+ }
+
return (
}>
{
showLegend,
hasTooManyGroups,
loading,
+ error,
formattedToRawLookup,
colorMode,
objectMetadataItem,
@@ -127,6 +129,10 @@ export const GraphWidgetLineChartRenderer = () => {
return ;
}
+ if (isDefined(error)) {
+ return ;
+ }
+
return (
}>
import('@/page-layout/widgets/graph/graph-widget-pie-chart/components/GraphWidgetPieChart').then(
@@ -34,6 +35,7 @@ export const GraphWidgetPieChartRenderer = () => {
const {
data,
loading,
+ error,
hasTooManyGroups,
objectMetadataItem,
showLegend,
@@ -91,6 +93,10 @@ export const GraphWidgetPieChartRenderer = () => {
return ;
}
+ if (isDefined(error)) {
+ return ;
+ }
+
return (
}>
field.id),
+ );
+
+ const { recordFilters: sanitizedRecordFilters } =
+ dropChartRecordFiltersWithDeletedFields({
+ chartFilters: configuration.filter ?? {},
+ validFieldMetadataIds: objectFieldMetadataIds,
+ });
const gqlOperationFilter = computeRecordGqlOperationFilter({
fieldMetadataItems: flattenedFieldMetadataItems,
filterValueDependencies,
- recordFilters: widgetRecordFilters,
+ recordFilters: sanitizedRecordFilters ?? [],
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
});
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx
new file mode 100644
index 0000000000..2be91fed65
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning.tsx
@@ -0,0 +1,30 @@
+import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
+import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
+import { t } from '@lingui/core/macro';
+import { SidePanelInformationBanner } from 'twenty-ui-deprecated/display';
+type ChartFiltersDeletedFieldsWarningProps = {
+ validFieldMetadataIds: Set;
+};
+
+export const ChartFiltersDeletedFieldsWarning = ({
+ validFieldMetadataIds,
+}: ChartFiltersDeletedFieldsWarningProps) => {
+ const currentRecordFilters = useAtomComponentStateValue(
+ currentRecordFiltersComponentState,
+ );
+
+ const deletedFieldFiltersCount = currentRecordFilters.filter(
+ (recordFilter) => !validFieldMetadataIds.has(recordFilter.fieldMetadataId),
+ ).length;
+
+ if (deletedFieldFiltersCount === 0) {
+ return null;
+ }
+
+ return (
+
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx
index cf248afdf6..83a682f408 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/ChartFiltersSettings.tsx
@@ -1,3 +1,4 @@
+import { ChartFiltersDeletedFieldsWarning } from '@/side-panel/pages/page-layout/components/ChartFiltersDeletedFieldsWarning';
import { ChartFiltersSettingsInitializeStateEffect } from '@/side-panel/pages/page-layout/components/ChartFiltersSettingsInitializeStateEffect';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
@@ -16,7 +17,8 @@ import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/h
import { styled } from '@linaria/react';
import { t } from '@lingui/core/macro';
import { useStore } from 'jotai';
-import { themeCssVariables } from 'twenty-ui-deprecated/theme-constants';
+import { useMemo } from 'react';
+import { themeCssVariables } from 'twenty-ui/theme-constants';
const StyledChartFiltersPageContainer = styled.div`
display: flex;
@@ -59,6 +61,16 @@ export const ChartFiltersSettings = ({
const store = useStore();
const chartWidgetConfiguration = widget.configuration;
+ const validFieldMetadataIds = useMemo(
+ () =>
+ new Set(
+ objectMetadataItem.fields
+ .filter((fieldMetadataItem) => fieldMetadataItem.isActive)
+ .map((fieldMetadataItem) => fieldMetadataItem.id),
+ ),
+ [objectMetadataItem.fields],
+ );
+
const handleFiltersUpdate = () => {
const existingRecordFilters = store.get(currentRecordFilters);
const existingRecordFilterGroups = store.get(currentRecordFilterGroups);
@@ -84,6 +96,9 @@ export const ChartFiltersSettings = ({
+
(null);
+ const [selectedMorphField, setSelectedMorphField] =
+ useState(null);
+
const { objectMetadataItems } = useObjectMetadataItems();
const { pageLayoutId } = usePageLayoutIdFromContextStore();
@@ -90,7 +98,12 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
if (isFieldRelation(field)) {
return field.relation?.type === RelationType.MANY_TO_ONE;
}
- return true;
+ return isFieldMetadataSupportedInGroupBy({
+ type: field.type,
+ name: field.name,
+ isSystem: field.isSystem ?? false,
+ relationType: field.settings?.relationType ?? null,
+ });
}),
[sourceObjectMetadataItem?.fields, searchQuery],
);
@@ -114,6 +127,11 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
}
const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
+ if (isFieldMorphRelation(fieldMetadataItem)) {
+ setSelectedMorphField(fieldMetadataItem);
+ return;
+ }
+
if (isFieldRelation(fieldMetadataItem)) {
setSelectedRelationField(fieldMetadataItem);
return;
@@ -161,6 +179,31 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
setSelectedRelationField(null);
};
+ const handleBackFromMorph = () => {
+ setSelectedMorphField(null);
+ };
+
+ const handleSelectMorphTargetSubField = ({
+ perTargetFieldId,
+ subFieldName,
+ }: {
+ perTargetFieldId: string;
+ subFieldName: string;
+ }) => {
+ updateCurrentWidgetConfig({
+ configToUpdate: buildChartGroupByFieldConfigUpdate({
+ configuration,
+ fieldMetadataIdKey,
+ subFieldNameKey,
+ fieldId: perTargetFieldId,
+ subFieldName,
+ objectMetadataItem: sourceObjectMetadataItem,
+ objectMetadataItems,
+ }),
+ });
+ closeDropdown();
+ };
+
const handleSelectCompositeSubField = (subFieldName: string) => {
if (!isDefined(selectedCompositeField)) {
return;
@@ -199,6 +242,18 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
closeDropdown();
};
+ if (isDefined(selectedMorphField)) {
+ return (
+
+ );
+ }
+
if (isDefined(selectedRelationField)) {
return (
{
handleSelectField(fieldMetadataItem);
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
new file mode 100644
index 0000000000..2bc1ecdd4d
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView.tsx
@@ -0,0 +1,156 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView';
+import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
+import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
+import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
+import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
+import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
+import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
+import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
+import { t } from '@lingui/core/macro';
+import { useMemo, useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
+import { MenuItem } from 'twenty-ui-deprecated/navigation';
+import { RelationType } from '~/generated-metadata/graphql';
+
+type MorphRelationTarget = {
+ perTargetFieldId: string;
+ targetObjectNameSingular: string;
+ label: string;
+ icon?: string | null;
+};
+
+type ChartGroupByFieldSelectionMorphRelationFieldViewProps = {
+ morphField: FieldMetadataItem;
+ currentFieldMetadataId: string | undefined;
+ currentSubFieldName: string | undefined;
+ onBack: () => void;
+ onSelectTargetSubField: (params: {
+ perTargetFieldId: string;
+ subFieldName: string;
+ }) => void;
+};
+
+export const ChartGroupByFieldSelectionMorphRelationFieldView = ({
+ morphField,
+ currentFieldMetadataId,
+ currentSubFieldName,
+ onBack,
+ onSelectTargetSubField,
+}: ChartGroupByFieldSelectionMorphRelationFieldViewProps) => {
+ const { getIcon } = useIcons();
+
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const [selectedTarget, setSelectedTarget] =
+ useState(null);
+
+ const dropdownId = useAvailableComponentInstanceIdOrThrow(
+ DropdownComponentInstanceContext,
+ );
+
+ const selectedItemId = useAtomComponentStateValue(
+ selectedItemIdComponentState,
+ dropdownId,
+ );
+ const availableTargets = useMemo(() => {
+ return (morphField.morphRelations ?? [])
+ .filter(
+ (morphRelation) => morphRelation.type === RelationType.MANY_TO_ONE,
+ )
+ .map((morphRelation) => {
+ const targetObjectMetadataItem = objectMetadataItems.find(
+ (item) =>
+ item.nameSingular ===
+ morphRelation.targetObjectMetadata.nameSingular,
+ );
+
+ return {
+ perTargetFieldId: morphRelation.sourceFieldMetadata.id,
+ targetObjectNameSingular:
+ morphRelation.targetObjectMetadata.nameSingular,
+ label:
+ targetObjectMetadataItem?.labelSingular ??
+ morphRelation.targetObjectMetadata.nameSingular,
+ icon: targetObjectMetadataItem?.icon,
+ };
+ });
+ }, [morphField.morphRelations, objectMetadataItems]);
+
+ if (isDefined(selectedTarget)) {
+ return (
+ setSelectedTarget(null)}
+ onSelectSubField={(subFieldName) =>
+ onSelectTargetSubField({
+ perTargetFieldId: selectedTarget.perTargetFieldId,
+ subFieldName,
+ })
+ }
+ />
+ );
+ }
+
+ return (
+ <>
+
+ }
+ >
+ {morphField.label}
+
+
+
+ {availableTargets.length === 0 ? (
+
+ ) : (
+ target.perTargetFieldId,
+ )}
+ >
+ {availableTargets.map((target) => (
+ {
+ setSelectedTarget(target);
+ }}
+ >
+
+ ))}
+
+ )}
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
index 8ca05b0f39..3e38974550 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView.tsx
@@ -1,26 +1,5 @@
-import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
-import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
-import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
-import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
-import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
-import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
-import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
-import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
-import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
-import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
-import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
-import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
-import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
-import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
-import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
-import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
-import { t } from '@lingui/core/macro';
-import { useMemo, useState } from 'react';
-import { isDefined } from 'twenty-shared/utils';
-import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
-import { MenuItem, MenuItemSelect } from 'twenty-ui-deprecated/navigation';
-import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
+import { ChartGroupByFieldSelectionTargetObjectFieldsView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView';
type ChartGroupByFieldSelectionRelationFieldViewProps = {
relationField: FieldMetadataItem;
@@ -35,137 +14,15 @@ export const ChartGroupByFieldSelectionRelationFieldView = ({
onBack,
onSelectSubField,
}: ChartGroupByFieldSelectionRelationFieldViewProps) => {
- const { getIcon } = useIcons();
-
- const [searchQuery, setSearchQuery] = useState('');
-
- const [selectedCompositeField, setSelectedCompositeField] =
- useState(null);
-
- const dropdownId = useAvailableComponentInstanceIdOrThrow(
- DropdownComponentInstanceContext,
- );
-
- const selectedItemId = useAtomComponentStateValue(
- selectedItemIdComponentState,
- dropdownId,
- );
-
- const { objectMetadataItems } = useObjectMetadataItems();
-
- const targetObjectNameSingular =
- relationField.relation?.targetObjectMetadata?.nameSingular;
-
- const targetObjectMetadataItem = useMemo(
- () =>
- objectMetadataItems.find(
- (item) => item.nameSingular === targetObjectNameSingular,
- ),
- [objectMetadataItems, targetObjectNameSingular],
- );
-
- const availableFields = useMemo(() => {
- if (!isDefined(targetObjectMetadataItem)) {
- return [];
- }
-
- return filterBySearchQuery({
- items: targetObjectMetadataItem.fields.filter(
- (field) => !isHiddenSystemField(field) && !isFieldRelation(field),
- ),
- searchQuery,
- getSearchableValues: (field) => [field.label, field.name],
- });
- }, [targetObjectMetadataItem, searchQuery]);
-
- const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
- if (isCompositeFieldType(fieldMetadataItem.type)) {
- setSelectedCompositeField(fieldMetadataItem);
- } else {
- onSelectSubField(fieldMetadataItem.name);
- }
- };
-
- const handleSelectCompositeSubField = (compositeSubFieldName: string) => {
- if (!isDefined(selectedCompositeField)) {
- return;
- }
- onSelectSubField(`${selectedCompositeField.name}.${compositeSubFieldName}`);
- };
-
- const handleBackFromComposite = () => {
- setSelectedCompositeField(null);
- };
-
- const [currentNestedFieldName, currentNestedSubFieldName] =
- currentSubFieldName?.split('.') ?? [];
-
- if (isDefined(selectedCompositeField)) {
- return (
-
- );
- }
-
return (
- <>
-
- }
- >
- {relationField.label}
-
- setSearchQuery(event.target.value)}
- value={searchQuery}
- />
-
-
- {availableFields.length === 0 ? (
-
- ) : (
- field.id)}
- >
- {availableFields.map((fieldMetadataItem) => (
- {
- handleSelectField(fieldMetadataItem);
- }}
- >
- {
- handleSelectField(fieldMetadataItem);
- }}
- />
-
- ))}
-
- )}
-
- >
+
);
};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
new file mode 100644
index 0000000000..8de7637e86
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionTargetObjectFieldsView.tsx
@@ -0,0 +1,170 @@
+import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
+import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
+import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
+import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
+import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader';
+import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent';
+import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
+import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
+import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
+import { DropdownComponentInstanceContext } from '@/ui/layout/dropdown/contexts/DropdownComponentInstanceContext';
+import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
+import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
+import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states/selectedItemIdComponentState';
+import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow';
+import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
+import { t } from '@lingui/core/macro';
+import { useMemo, useState } from 'react';
+import { isDefined } from 'twenty-shared/utils';
+import { IconChevronLeft, useIcons } from 'twenty-ui-deprecated/display';
+import { MenuItem, MenuItemSelect } from 'twenty-ui-deprecated/navigation';
+import { filterBySearchQuery } from '~/utils/filterBySearchQuery';
+
+type ChartGroupByFieldSelectionTargetObjectFieldsViewProps = {
+ targetObjectNameSingular?: string;
+ headerLabel: string;
+ currentSubFieldName: string | undefined;
+ onBack: () => void;
+ onSelectSubField: (subFieldName: string) => void;
+};
+
+export const ChartGroupByFieldSelectionTargetObjectFieldsView = ({
+ targetObjectNameSingular,
+ headerLabel,
+ currentSubFieldName,
+ onBack,
+ onSelectSubField,
+}: ChartGroupByFieldSelectionTargetObjectFieldsViewProps) => {
+ const { getIcon } = useIcons();
+
+ const [searchQuery, setSearchQuery] = useState('');
+
+ const [selectedCompositeField, setSelectedCompositeField] =
+ useState(null);
+
+ const dropdownId = useAvailableComponentInstanceIdOrThrow(
+ DropdownComponentInstanceContext,
+ );
+
+ const selectedItemId = useAtomComponentStateValue(
+ selectedItemIdComponentState,
+ dropdownId,
+ );
+
+ const { objectMetadataItems } = useObjectMetadataItems();
+
+ const targetObjectMetadataItem = useMemo(
+ () =>
+ objectMetadataItems.find(
+ (item) => item.nameSingular === targetObjectNameSingular,
+ ),
+ [objectMetadataItems, targetObjectNameSingular],
+ );
+
+ const availableFields = useMemo(() => {
+ if (!isDefined(targetObjectMetadataItem)) {
+ return [];
+ }
+
+ return filterBySearchQuery({
+ items: targetObjectMetadataItem.fields.filter(
+ (field) => !isHiddenSystemField(field) && !isFieldRelation(field),
+ ),
+ searchQuery,
+ getSearchableValues: (field) => [field.label, field.name],
+ });
+ }, [targetObjectMetadataItem, searchQuery]);
+
+ const handleSelectField = (fieldMetadataItem: FieldMetadataItem) => {
+ if (isCompositeFieldType(fieldMetadataItem.type)) {
+ setSelectedCompositeField(fieldMetadataItem);
+ } else {
+ onSelectSubField(fieldMetadataItem.name);
+ }
+ };
+
+ const handleSelectCompositeSubField = (compositeSubFieldName: string) => {
+ if (!isDefined(selectedCompositeField)) {
+ return;
+ }
+ onSelectSubField(`${selectedCompositeField.name}.${compositeSubFieldName}`);
+ };
+
+ const handleBackFromComposite = () => {
+ setSelectedCompositeField(null);
+ };
+
+ const [currentNestedFieldName, currentNestedSubFieldName] =
+ currentSubFieldName?.split('.') ?? [];
+
+ if (isDefined(selectedCompositeField)) {
+ return (
+
+ );
+ }
+
+ return (
+ <>
+
+ }
+ >
+ {headerLabel}
+
+ setSearchQuery(event.target.value)}
+ value={searchQuery}
+ />
+
+
+ {availableFields.length === 0 ? (
+
+ ) : (
+ field.id)}
+ >
+ {availableFields.map((fieldMetadataItem) => (
+ {
+ handleSelectField(fieldMetadataItem);
+ }}
+ >
+ {
+ handleSelectField(fieldMetadataItem);
+ }}
+ />
+
+ ))}
+
+ )}
+
+ >
+ );
+};
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/__tests__/useChartSettingsValues.test.tsx b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/__tests__/useChartSettingsValues.test.tsx
index 5b7dd7ac02..587fc246a8 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/__tests__/useChartSettingsValues.test.tsx
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/__tests__/useChartSettingsValues.test.tsx
@@ -13,6 +13,7 @@ import {
BarChartLayout,
FieldMetadataType,
GraphOrderBy,
+ RelationType,
WidgetConfigurationType,
} from '~/generated-metadata/graphql';
import { useChartSettingsValues } from '@/side-panel/pages/page-layout/hooks/useChartSettingsValues';
@@ -355,6 +356,94 @@ describe('useChartSettingsValues', () => {
});
});
+ describe('Morph relation group-by', () => {
+ // The collapsed morph field's own id is the first target ('field-owner-company');
+ // the chart stores the per-target id of the other target ('field-owner-person'),
+ // which is only reachable via morphRelations[].sourceFieldMetadata.id.
+ const morphObjectMetadataItem: EnrichedObjectMetadataItem = {
+ id: 'obj-morph',
+ nameSingular: 'opportunity',
+ namePlural: 'opportunities',
+ labelSingular: 'Opportunity',
+ labelPlural: 'Opportunities',
+ fields: [
+ {
+ id: 'field-amount',
+ name: 'amount',
+ label: 'Amount',
+ type: FieldMetadataType.NUMBER,
+ },
+ {
+ id: 'field-owner-company',
+ name: 'owner',
+ label: 'Owner',
+ type: FieldMetadataType.MORPH_RELATION,
+ morphRelations: [
+ {
+ type: RelationType.MANY_TO_ONE,
+ sourceFieldMetadata: { id: 'field-owner-company', name: 'owner' },
+ targetObjectMetadata: { nameSingular: 'company' },
+ },
+ {
+ type: RelationType.MANY_TO_ONE,
+ sourceFieldMetadata: { id: 'field-owner-person', name: 'owner' },
+ targetObjectMetadata: { nameSingular: 'person' },
+ },
+ ],
+ },
+ ],
+ } as EnrichedObjectMetadataItem;
+
+ const renderWithMorphObject = (configuration: ChartConfiguration) => {
+ setTestObjectMetadataItemsInMetadataStore(jotaiStore, [
+ morphObjectMetadataItem,
+ ]);
+
+ return renderHook(
+ () =>
+ useChartSettingsValues({
+ objectMetadataId: morphObjectMetadataItem.id,
+ configuration,
+ }),
+ {
+ wrapper: ({ children }) => (
+ {children}
+ ),
+ },
+ );
+ };
+
+ it('should resolve label when grouping by the collapsed morph target', () => {
+ const config = buildBarChartConfiguration({
+ aggregateFieldMetadataId: 'field-amount',
+ primaryAxisGroupByFieldMetadataId: 'field-owner-company',
+ });
+
+ const { result } = renderWithMorphObject(config);
+
+ const value = result.current.getChartSettingsValues(
+ CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
+ );
+
+ expect(value).toBe('Owner');
+ });
+
+ it('should resolve label when grouping by the non-collapsed morph target', () => {
+ const config = buildBarChartConfiguration({
+ aggregateFieldMetadataId: 'field-amount',
+ primaryAxisGroupByFieldMetadataId: 'field-owner-person',
+ });
+
+ const { result } = renderWithMorphObject(config);
+
+ const value = result.current.getChartSettingsValues(
+ CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_X,
+ );
+
+ expect(value).toBe('Owner');
+ });
+ });
+
describe('No configuration', () => {
it('should return undefined function when configuration is undefined', () => {
const { result } = renderUseChartSettingsValues(undefined as any);
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useChartSettingsValues.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useChartSettingsValues.ts
index 51a3bf6756..bd738f81fb 100644
--- a/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useChartSettingsValues.ts
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/hooks/useChartSettingsValues.ts
@@ -2,6 +2,7 @@ import { useGraphGroupBySortOptionLabels } from '@/side-panel/pages/page-layout/
import { useGraphXSortOptionLabels } from '@/side-panel/pages/page-layout/hooks/useGraphXSortOptionLabels';
import { type ChartConfiguration } from '@/side-panel/pages/page-layout/types/ChartConfiguration';
import { CHART_CONFIGURATION_SETTING_IDS } from '@/side-panel/pages/page-layout/types/ChartConfigurationSettingIds';
+import { findChartGroupByFieldMetadataItem } from '@/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem';
import { getChartAxisNameDisplayOptions } from '@/side-panel/pages/page-layout/utils/getChartAxisNameDisplayOptions';
import { getChartFilterRulesCount } from '@/side-panel/pages/page-layout/utils/getChartFilterRulesCount';
import { getDateGranularityLabel } from '@/side-panel/pages/page-layout/utils/getDateGranularityLabel';
@@ -74,11 +75,17 @@ export const useChartSettingsValues = ({
}
const groupByFieldX = isDefined(groupByFieldXId)
- ? objectMetadataItem?.fields.find((field) => field.id === groupByFieldXId)
+ ? findChartGroupByFieldMetadataItem({
+ fields: objectMetadataItem?.fields,
+ fieldMetadataId: groupByFieldXId,
+ })
: undefined;
const groupByFieldY = isDefined(groupByFieldYId)
- ? objectMetadataItem?.fields.find((field) => field.id === groupByFieldYId)
+ ? findChartGroupByFieldMetadataItem({
+ fields: objectMetadataItem?.fields,
+ fieldMetadataId: groupByFieldYId,
+ })
: undefined;
const groupBySubFieldNameXLabel =
@@ -199,9 +206,10 @@ export const useChartSettingsValues = ({
}
case CHART_CONFIGURATION_SETTING_IDS.DATA_ON_DISPLAY_PIE_CHART: {
const pieChartGroupByField = isDefined(finalGroupByFieldYId)
- ? objectMetadataItem?.fields.find(
- (field) => field.id === finalGroupByFieldYId,
- )
+ ? findChartGroupByFieldMetadataItem({
+ fields: objectMetadataItem?.fields,
+ fieldMetadataId: finalGroupByFieldYId,
+ })
: undefined;
const pieChartGroupBySubFieldNameLabel =
isDefined(finalGroupBySubFieldNameY) &&
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/dropChartRecordFiltersWithDeletedFields.test.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/dropChartRecordFiltersWithDeletedFields.test.ts
new file mode 100644
index 0000000000..37616800ac
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/__tests__/dropChartRecordFiltersWithDeletedFields.test.ts
@@ -0,0 +1,84 @@
+import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
+
+import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
+import { dropChartRecordFiltersWithDeletedFields } from '@/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields';
+
+describe('dropChartRecordFiltersWithDeletedFields', () => {
+ it('should drop record filters referencing fields that no longer exist', () => {
+ const chartFilters: ChartFilters = {
+ recordFilters: [
+ { id: 'filter-1', fieldMetadataId: 'valid-field' },
+ { id: 'filter-2', fieldMetadataId: 'deleted-field' },
+ ],
+ recordFilterGroups: [
+ {
+ id: 'root',
+ parentRecordFilterGroupId: undefined,
+ logicalOperator: RecordFilterGroupLogicalOperator.AND,
+ },
+ ],
+ } as ChartFilters;
+
+ const result = dropChartRecordFiltersWithDeletedFields({
+ chartFilters,
+ validFieldMetadataIds: new Set(['valid-field']),
+ });
+
+ expect(result.recordFilters).toEqual([
+ { id: 'filter-1', fieldMetadataId: 'valid-field' },
+ ]);
+ });
+
+ it('should preserve record filter groups', () => {
+ const chartFilters: ChartFilters = {
+ recordFilters: [],
+ recordFilterGroups: [
+ {
+ id: 'root',
+ parentRecordFilterGroupId: undefined,
+ logicalOperator: RecordFilterGroupLogicalOperator.AND,
+ },
+ ],
+ } as ChartFilters;
+
+ const result = dropChartRecordFiltersWithDeletedFields({
+ chartFilters,
+ validFieldMetadataIds: new Set(),
+ });
+
+ expect(result.recordFilterGroups).toEqual([
+ {
+ id: 'root',
+ parentRecordFilterGroupId: undefined,
+ logicalOperator: RecordFilterGroupLogicalOperator.AND,
+ },
+ ]);
+ });
+
+ it('should keep all record filters when every field is still valid', () => {
+ const chartFilters: ChartFilters = {
+ recordFilters: [
+ { id: 'filter-1', fieldMetadataId: 'field-a' },
+ { id: 'filter-2', fieldMetadataId: 'field-b' },
+ ],
+ } as ChartFilters;
+
+ const result = dropChartRecordFiltersWithDeletedFields({
+ chartFilters,
+ validFieldMetadataIds: new Set(['field-a', 'field-b']),
+ });
+
+ expect(result.recordFilters).toHaveLength(2);
+ });
+
+ it('should handle undefined record filters', () => {
+ const chartFilters: ChartFilters = {} as ChartFilters;
+
+ const result = dropChartRecordFiltersWithDeletedFields({
+ chartFilters,
+ validFieldMetadataIds: new Set(['field-a']),
+ });
+
+ expect(result.recordFilters).toEqual([]);
+ });
+});
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields.ts
new file mode 100644
index 0000000000..bda247383e
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields.ts
@@ -0,0 +1,14 @@
+import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
+
+export const dropChartRecordFiltersWithDeletedFields = ({
+ chartFilters,
+ validFieldMetadataIds,
+}: {
+ chartFilters: ChartFilters;
+ validFieldMetadataIds: Set;
+}): ChartFilters => ({
+ ...chartFilters,
+ recordFilters: (chartFilters.recordFilters ?? []).filter((recordFilter) =>
+ validFieldMetadataIds.has(recordFilter.fieldMetadataId),
+ ),
+});
diff --git a/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem.ts b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem.ts
new file mode 100644
index 0000000000..86a3738341
--- /dev/null
+++ b/packages/twenty-front/src/modules/side-panel/pages/page-layout/utils/findChartGroupByFieldMetadataItem.ts
@@ -0,0 +1,17 @@
+import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
+
+export const findChartGroupByFieldMetadataItem = ({
+ fields,
+ fieldMetadataId,
+}: {
+ fields: FieldMetadataItem[] | undefined;
+ fieldMetadataId: string;
+}): FieldMetadataItem | undefined =>
+ fields?.find(
+ (field) =>
+ field.id === fieldMetadataId ||
+ (field.morphRelations ?? []).some(
+ (morphRelation) =>
+ morphRelation.sourceFieldMetadata.id === fieldMetadataId,
+ ),
+ );
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util.ts
index 35207a6112..37fa2bb654 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-relation-nested-field-supported-in-group-by.util.ts
@@ -1,5 +1,7 @@
+import { isFieldMetadataSupportedInGroupBy } from 'twenty-shared/utils';
+
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
-import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
+import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
export const isRelationNestedFieldSupportedInGroupBy = ({
nestedFieldName,
@@ -12,5 +14,14 @@ export const isRelationNestedFieldSupportedInGroupBy = ({
return true;
}
- return isFlatFieldMetadataSupportedInGroupBy(nestedFieldMetadata);
+ const relationType = isMorphOrRelationFlatFieldMetadata(nestedFieldMetadata)
+ ? nestedFieldMetadata.settings.relationType
+ : null;
+
+ return isFieldMetadataSupportedInGroupBy({
+ type: nestedFieldMetadata.type,
+ name: nestedFieldMetadata.name,
+ isSystem: nestedFieldMetadata.isSystem,
+ relationType,
+ });
};
diff --git a/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util.ts b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util.ts
index 2f58954941..46f494e85f 100644
--- a/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util.ts
+++ b/packages/twenty-server/src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-group-by-fields-or-throw.util.ts
@@ -1,25 +1,28 @@
import { FieldMetadataType } from 'twenty-shared/types';
-import { isDefined, isPlainObject } from 'twenty-shared/utils';
+import {
+ isDefined,
+ isFieldMetadataSupportedInGroupBy,
+ isPlainObject,
+} from 'twenty-shared/utils';
-import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
+import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
+import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
+import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
import {
CommonQueryRunnerException,
CommonQueryRunnerExceptionCode,
} from 'src/engine/api/common/common-query-runners/errors/common-query-runner.exception';
import { STANDARD_ERROR_MESSAGE } from 'src/engine/api/common/common-query-runners/errors/standard-error-message.constant';
-import { isGroupByDateFieldDefinition } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/is-group-by-date-field-definition.util';
-import { validateAndTransformRelationGroupByFieldOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-and-transform-relation-group-by-field-or-throw.util';
-import { validateSingleKeyForGroupByOrThrow } from 'src/engine/api/common/common-args-processors/group-by-arg-processor/utils/validate-single-key-for-group-by-or-throw.util';
+import { type GroupByField } from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
import {
ObjectRecordGroupByForAtomicField,
ObjectRecordGroupByForCompositeField,
ObjectRecordGroupByForDateField,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
+import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
+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 { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
-import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
-import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.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';
@@ -136,7 +139,18 @@ const validateAndTransformSingleGroupByFieldOrThrow = ({
flatFieldMetadataMaps,
});
- if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
+ const relationType = isMorphOrRelationFlatFieldMetadata(fieldMetadata)
+ ? fieldMetadata.settings.relationType
+ : null;
+
+ if (
+ !isFieldMetadataSupportedInGroupBy({
+ type: fieldMetadata.type,
+ name: fieldMetadata.name,
+ isSystem: fieldMetadata.isSystem,
+ relationType,
+ })
+ ) {
throw new CommonQueryRunnerException(
`Field "${fieldName}" is not supported in groupBy`,
CommonQueryRunnerExceptionCode.INVALID_QUERY_INPUT,
diff --git a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
index f5206ad2dc..1b394cf747 100644
--- a/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
+++ b/packages/twenty-server/src/engine/api/common/common-query-runners/common-group-by-query-runner.service.ts
@@ -87,11 +87,9 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
authContext,
} = queryRunnerContext;
- const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
+ const objectAlias = getObjectAlias(flatObjectMetadata);
- let queryBuilder = repository.createQueryBuilder(
- objectMetadataNameSingular,
- );
+ let queryBuilder = repository.createQueryBuilder(objectAlias);
const groupByFields =
this.groupByArgProcessor.validateAndTransformGroupByFieldsOrThrow({
@@ -101,8 +99,6 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
flatFieldMetadataMaps,
});
- const objectAlias = getObjectAlias(flatObjectMetadata);
-
this.addJoinForGroupByOnRelationFields({
queryBuilder,
groupByFields,
@@ -126,12 +122,12 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: args.selectedFieldsResult.aggregate,
queryBuilder,
- objectMetadataNameSingular,
+ objectMetadataNameSingular: objectAlias,
});
const groupByDefinitions = getGroupByDefinitions({
groupByFields,
- objectMetadataNameSingular,
+ objectMetadataNameSingular: objectAlias,
});
groupByDefinitions.forEach((groupByColumn, index) => {
@@ -308,7 +304,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
workspaceId: string;
commonQueryParser: GraphqlQueryParser;
}): Promise {
- const objectMetadataNameSingular = flatObjectMetadata.nameSingular;
+ const objectAlias = getObjectAlias(flatObjectMetadata);
if (args.viewId) {
appliedFilters = await this.addFiltersFromView({
@@ -322,7 +318,7 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
commonQueryParser.applyFilterToBuilder(
queryBuilder,
- objectMetadataNameSingular,
+ objectAlias,
appliedFilters,
);
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
new file mode 100644
index 0000000000..520cb443f0
--- /dev/null
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/graphql-query-order-group-by.parser.spec.ts
@@ -0,0 +1,146 @@
+import {
+ FieldMetadataType,
+ ObjectRecordGroupByDateGranularity,
+ OrderByDirection,
+} from 'twenty-shared/types';
+
+import {
+ type GroupByDateField,
+ type GroupByRegularField,
+} from 'src/engine/api/common/common-query-runners/types/group-by-field.types';
+import { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.util';
+import { GraphqlQueryOrderGroupByParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser';
+import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
+import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
+import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
+import { computeTableName } from 'src/engine/utils/compute-table-name.util';
+
+// The query builder aliases the FROM table with `getObjectAlias` (= nameSingular),
+// NOT the physical table name (which is `_`-prefixed for custom objects via
+// `computeTableName`). These tests pin order-by clauses to that alias so the
+// parser stays in sync if the alias convention ever changes.
+describe('GraphqlQueryOrderGroupByParser - object alias in order-by clauses', () => {
+ const OBJECT_ID = 'rocket-object-id';
+ const OBJECT_UNIVERSAL_ID = 'rocket-object-universal-id';
+ const NAME_FIELD_ID = 'rocket-name-field-id';
+ const NAME_FIELD_UNIVERSAL_ID = 'rocket-name-field-universal-id';
+ const DATE_FIELD_ID = 'rocket-launched-at-field-id';
+ const DATE_FIELD_UNIVERSAL_ID = 'rocket-launched-at-field-universal-id';
+
+ const nameField = getFlatFieldMetadataMock({
+ universalIdentifier: NAME_FIELD_UNIVERSAL_ID,
+ objectMetadataId: OBJECT_ID,
+ type: FieldMetadataType.TEXT,
+ id: NAME_FIELD_ID,
+ name: 'name',
+ });
+
+ const launchedAtField = getFlatFieldMetadataMock({
+ universalIdentifier: DATE_FIELD_UNIVERSAL_ID,
+ objectMetadataId: OBJECT_ID,
+ type: FieldMetadataType.DATE,
+ id: DATE_FIELD_ID,
+ name: 'launchedAt',
+ });
+
+ // Custom object: physical table is `_rocket`, while the alias is `rocket`.
+ const flatObjectMetadata = getFlatObjectMetadataMock({
+ universalIdentifier: OBJECT_UNIVERSAL_ID,
+ id: OBJECT_ID,
+ nameSingular: 'rocket',
+ namePlural: 'rockets',
+ isCustom: true,
+ fieldIds: [NAME_FIELD_ID, DATE_FIELD_ID],
+ });
+
+ const flatFieldMetadataMaps = {
+ byUniversalIdentifier: {
+ [NAME_FIELD_UNIVERSAL_ID]: nameField,
+ [DATE_FIELD_UNIVERSAL_ID]: launchedAtField,
+ },
+ universalIdentifierById: {
+ [NAME_FIELD_ID]: NAME_FIELD_UNIVERSAL_ID,
+ [DATE_FIELD_ID]: DATE_FIELD_UNIVERSAL_ID,
+ },
+ universalIdentifiersByApplicationId: {},
+ } as unknown as FlatEntityMaps;
+
+ const flatObjectMetadataMaps = {
+ byUniversalIdentifier: {
+ [OBJECT_UNIVERSAL_ID]: flatObjectMetadata,
+ },
+ universalIdentifierById: {
+ [OBJECT_ID]: OBJECT_UNIVERSAL_ID,
+ },
+ universalIdentifiersByApplicationId: {},
+ } as unknown as FlatEntityMaps;
+
+ const objectAlias = getObjectAlias(flatObjectMetadata);
+ const physicalTableName = computeTableName(
+ flatObjectMetadata.nameSingular,
+ flatObjectMetadata.isCustom,
+ );
+
+ const buildParser = () =>
+ new GraphqlQueryOrderGroupByParser(
+ flatObjectMetadata,
+ flatObjectMetadataMaps,
+ flatFieldMetadataMaps,
+ );
+
+ it('uses the object alias as the table prefix for a custom object', () => {
+ // Guards the assumption being tested: a custom object has a `_`-prefixed
+ // physical table that is different from its query alias.
+ expect(physicalTableName).toBe('_rocket');
+ expect(objectAlias).toBe('rocket');
+ expect(physicalTableName).not.toBe(objectAlias);
+ });
+
+ it('prefixes a scalar group-by order-by with the alias, not the physical table', () => {
+ const parser = buildParser();
+
+ const groupByField: GroupByRegularField = { fieldMetadata: nameField };
+
+ const result = parser.parse({
+ orderBy: [{ name: OrderByDirection.AscNullsFirst }],
+ groupByFields: [groupByField],
+ });
+
+ expect(result).toHaveLength(1);
+
+ const orderByKey = Object.keys(result[0])[0];
+
+ expect(orderByKey).toBe(`"${objectAlias}"."name"`);
+ expect(orderByKey).not.toContain(physicalTableName);
+ });
+
+ it('prefixes a date-granularity group-by order-by with the alias, not the physical table', () => {
+ const parser = buildParser();
+
+ const groupByField: GroupByDateField = {
+ fieldMetadata: launchedAtField,
+ dateGranularity: ObjectRecordGroupByDateGranularity.NONE,
+ };
+
+ const result = parser.parse({
+ orderBy: [
+ {
+ launchedAt: {
+ orderBy: OrderByDirection.AscNullsFirst,
+ granularity: ObjectRecordGroupByDateGranularity.NONE,
+ },
+ },
+ ],
+ groupByFields: [groupByField],
+ });
+
+ expect(result).toHaveLength(1);
+
+ const orderByKey = Object.keys(result[0])[0];
+
+ expect(orderByKey).toContain(`"${objectAlias}".`);
+ expect(orderByKey).not.toContain(physicalTableName);
+ });
+});
diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
index 5b0a292820..9fcbfb3d69 100644
--- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
+++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order-group-by.parser.ts
@@ -18,6 +18,7 @@ import {
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 { getObjectAlias } from 'src/engine/api/common/common-query-runners/utils/get-object-alias-for-group-by.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';
@@ -45,6 +46,7 @@ export class GraphqlQueryOrderGroupByParser {
private flatObjectMetadataMaps: FlatEntityMaps;
private flatFieldMetadataMaps: FlatEntityMaps;
private fieldIdByName: Record;
+ private objectAlias: string;
constructor(
flatObjectMetadata: FlatObjectMetadata,
@@ -54,6 +56,7 @@ export class GraphqlQueryOrderGroupByParser {
this.flatObjectMetadata = flatObjectMetadata;
this.flatObjectMetadataMaps = flatObjectMetadataMaps;
this.flatFieldMetadataMaps = flatFieldMetadataMaps;
+ this.objectAlias = getObjectAlias(flatObjectMetadata);
const fieldMaps = buildFieldMapsFromFlatObjectMetadata(
flatFieldMetadataMaps,
@@ -85,7 +88,6 @@ export class GraphqlQueryOrderGroupByParser {
const parsedAggregateOrderBy = this.parseAggregateOrderByArg(
availableAggregations,
orderByArg,
- this.flatObjectMetadata,
);
parsedOrderBy.push(parsedAggregateOrderBy);
@@ -114,7 +116,6 @@ export class GraphqlQueryOrderGroupByParser {
this.parseObjectRecordOrderByForScalarField({
groupByFields,
orderByArg,
- flatObjectMetadata: this.flatObjectMetadata,
fieldMetadata,
});
@@ -170,7 +171,6 @@ export class GraphqlQueryOrderGroupByParser {
this.parseObjectRecordOrderByForCompositeField({
groupByFields,
orderByArg,
- flatObjectMetadata: this.flatObjectMetadata,
fieldMetadata,
});
@@ -339,7 +339,6 @@ export class GraphqlQueryOrderGroupByParser {
private parseAggregateOrderByArg = (
availableAggregations: Record,
orderByArg: AggregateOrderByWithGroupByField,
- flatObjectMetadata: FlatObjectMetadata,
): Record => {
const aggregate = orderByArg.aggregate;
@@ -359,7 +358,7 @@ export class GraphqlQueryOrderGroupByParser {
const aggregateExpression = ProcessAggregateHelper.getAggregateExpression(
aggregateField,
- flatObjectMetadata.nameSingular,
+ this.objectAlias,
);
if (!isDefined(aggregateExpression)) {
@@ -379,12 +378,10 @@ export class GraphqlQueryOrderGroupByParser {
private parseObjectRecordOrderByForScalarField = ({
groupByFields,
orderByArg,
- flatObjectMetadata,
fieldMetadata,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByForScalarField;
- flatObjectMetadata: FlatObjectMetadata;
fieldMetadata: FlatFieldMetadata;
}): Record | null => {
const groupByField = groupByFields.find(
@@ -404,7 +401,7 @@ export class GraphqlQueryOrderGroupByParser {
return null;
}
- const columnNameWithQuotes = `"${flatObjectMetadata.nameSingular}"."${fieldMetadata.name}"`;
+ const columnNameWithQuotes = `"${this.objectAlias}"."${fieldMetadata.name}"`;
const expression = getGroupByOrderExpression({
groupByField,
@@ -420,12 +417,10 @@ export class GraphqlQueryOrderGroupByParser {
private parseObjectRecordOrderByForCompositeField = ({
groupByFields,
orderByArg,
- flatObjectMetadata,
fieldMetadata,
}: {
groupByFields: GroupByField[];
orderByArg: ObjectRecordOrderByForCompositeField;
- flatObjectMetadata: FlatObjectMetadata;
fieldMetadata: FlatFieldMetadata;
}): Record | null => {
const fieldName = Object.keys(orderByArg)[0];
@@ -458,7 +453,7 @@ export class GraphqlQueryOrderGroupByParser {
return parseCompositeFieldForOrder(
fieldMetadata,
orderBySubField,
- flatObjectMetadata.nameSingular,
+ this.objectAlias,
);
};
@@ -497,7 +492,7 @@ export class GraphqlQueryOrderGroupByParser {
);
}
- const columnNameWithQuotes = `"${
+ const columnNameWithQuotes = `"${this.objectAlias}"."${
formatColumnNamesFromCompositeFieldAndSubfields(
associatedGroupByField.fieldMetadata.name,
associatedGroupByField.subFieldName
diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
index ee11b27755..87b199ab93 100644
--- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
+++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/object-metadata-group-by-gql-input-type.generator.ts
@@ -9,7 +9,11 @@ import {
isInputObjectType,
} from 'graphql';
import { FieldMetadataType } from 'twenty-shared/types';
-import { isDefined, pascalCase } from 'twenty-shared/utils';
+import {
+ isDefined,
+ isFieldMetadataSupportedInGroupBy,
+ pascalCase,
+} from 'twenty-shared/utils';
import { GqlInputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/enums/gql-input-type-definition-kind.enum';
import { GROUP_BY_DATE_GRANULARITY_INPUT_KEY } from 'src/engine/api/graphql/workspace-schema-builder/graphql-type-generators/input-types/group-by-input/group-by-date-granularity-gql-input-type.generator';
@@ -20,7 +24,6 @@ import { type SchemaGenerationContext } from 'src/engine/api/graphql/workspace-s
import { computeCompositeFieldInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-composite-field-input-type-key.util';
import { computeObjectMetadataInputTypeKey } from 'src/engine/api/graphql/workspace-schema-builder/utils/compute-stored-gql-type-key-utils/compute-object-metadata-input-type.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
-import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
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';
@@ -63,7 +66,7 @@ export class ObjectMetadataGroupByGqlInputTypeGenerator {
const allGeneratedFields: GraphQLInputFieldConfigMap = {};
for (const fieldMetadata of fields) {
- if (!isFlatFieldMetadataSupportedInGroupBy(fieldMetadata)) {
+ if (!isFieldMetadataSupportedInGroupBy(fieldMetadata)) {
continue;
}
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts
index c7df57829d..357e4d32aa 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/group-by-tool.zod-schema.ts
@@ -6,7 +6,10 @@ import {
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
-import { isFieldMetadataDateKind } from 'twenty-shared/utils';
+import {
+ isFieldMetadataDateKind,
+ isFieldMetadataSupportedInGroupBy,
+} from 'twenty-shared/utils';
import { z } from 'zod';
import { getAvailableAggregationsFromObjectFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-available-aggregations-from-object-fields.util';
@@ -15,7 +18,6 @@ import { resolveAggregateFieldKey } from 'src/engine/core-modules/record-crud/ut
import { generateRecordFilterSchema } from 'src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
-import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
const dateGranularityValues = Object.values(
@@ -62,7 +64,7 @@ const buildGroupByEntriesAndDescriptions = (
continue;
}
- if (!isFlatFieldMetadataSupportedInGroupBy(field)) {
+ if (!isFieldMetadataSupportedInGroupBy(field)) {
continue;
}
diff --git a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts
index 08818473f2..b23fb72a9c 100644
--- a/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts
+++ b/packages/twenty-server/src/engine/core-modules/record-crud/zod-schemas/record-filter.zod-schema.ts
@@ -3,11 +3,11 @@ import {
RelationType,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
+import { shouldExcludeFieldFromAgentToolSchema } from 'twenty-shared/utils';
import { z } from 'zod';
import { type ObjectMetadataForToolSchema } from 'src/engine/core-modules/record-crud/types/object-metadata-for-tool-schema.type';
import { generateFieldFilterZodSchema } from 'src/engine/core-modules/record-crud/zod-schemas/field-filters.zod-schema';
-import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
// Builds the per-field filter shape and full recursive filter schema
@@ -28,11 +28,11 @@ export const generateRecordFilterSchema = ({
objectMetadata.fields.forEach((field) => {
if (
- shouldExcludeFieldFromAgentToolSchema(
- field,
- true,
+ shouldExcludeFieldFromAgentToolSchema({
+ fieldName: field.name,
+ isSystem: field.isSystem,
additionalExcludedFieldNames,
- )
+ })
) {
return;
}
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-supported-in-group-by.spec.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-supported-in-group-by.spec.ts
index cbcd3ddb19..a598ab4d3e 100644
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-supported-in-group-by.spec.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/__tests__/is-supported-in-group-by.spec.ts
@@ -1,15 +1,7 @@
import { type CompositeProperty, FieldMetadataType } from 'twenty-shared/types';
-import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { getGroupableSubFieldsForCompositeType } from 'src/engine/metadata-modules/field-metadata/utils/get-groupable-sub-fields-for-composite-type.util';
import { isCompositePropertySupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-property-supported-in-group-by.util';
-import { isFlatFieldMetadataSupportedInGroupBy } from 'src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util';
-
-const buildFlatFieldMetadata = (
- type: FieldMetadataType,
- name = 'field',
- isSystem = false,
-): FlatFieldMetadata => ({ type, name, isSystem }) as FlatFieldMetadata;
const buildCompositeProperty = (
type: FieldMetadataType,
@@ -21,87 +13,6 @@ const buildCompositeProperty = (
isRequired: false,
});
-describe('isFlatFieldMetadataSupportedInGroupBy', () => {
- it('returns false for low-level field types', () => {
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.RAW_JSON),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.FILES),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.POSITION),
- ),
- ).toBe(false);
- });
-
- it('returns true for regular field types', () => {
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.TEXT),
- ),
- ).toBe(true);
- });
-
- it('returns false for internal/system field names', () => {
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.TEXT, 'id'),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'deletedAt'),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.TS_VECTOR, 'searchVector'),
- ),
- ).toBe(false);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.ACTOR, 'createdBy'),
- ),
- ).toBe(false);
- });
-
- it('returns true for createdAt and updatedAt date fields even if system', () => {
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'createdAt', true),
- ),
- ).toBe(true);
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(FieldMetadataType.DATE_TIME, 'updatedAt', true),
- ),
- ).toBe(true);
- });
-
- it('returns false for other system fields', () => {
- expect(
- isFlatFieldMetadataSupportedInGroupBy(
- buildFlatFieldMetadata(
- FieldMetadataType.TEXT,
- 'customSystemField',
- true,
- ),
- ),
- ).toBe(false);
- });
-});
-
describe('isCompositePropertySupportedInGroupBy', () => {
it('returns false for hidden or raw_json composite properties', () => {
expect(
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util.ts
deleted file mode 100644
index cd90d1211c..0000000000
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/is-supported-in-group-by.util.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import { FieldMetadataType } from 'twenty-shared/types';
-import { isFieldMetadataDateKind } from 'twenty-shared/utils';
-
-import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
-import { shouldExcludeFieldFromAgentToolSchema } from 'src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util';
-
-const NON_GROUPABLE_FIELD_TYPES = new Set([
- FieldMetadataType.TS_VECTOR,
- FieldMetadataType.RAW_JSON,
- FieldMetadataType.FILES,
- FieldMetadataType.POSITION,
-]);
-
-const ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES = new Set([
- 'createdAt',
- 'updatedAt',
-]);
-
-export const isFlatFieldMetadataSupportedInGroupBy = (
- fieldMetadata: FlatFieldMetadata,
-): boolean => {
- const isAlwaysGroupableSystemDateField =
- ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES.has(fieldMetadata.name) &&
- isFieldMetadataDateKind(fieldMetadata.type);
-
- if (
- !isAlwaysGroupableSystemDateField &&
- shouldExcludeFieldFromAgentToolSchema(fieldMetadata)
- ) {
- return false;
- }
-
- return !NON_GROUPABLE_FIELD_TYPES.has(fieldMetadata.type);
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts
deleted file mode 100644
index 3d5d035859..0000000000
--- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/utils/should-exclude-field-from-agent-tool-schema.util.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { type FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
-import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
-
-export const shouldExcludeFieldFromAgentToolSchema = (
- field: FieldMetadataEntity | FlatFieldMetadata,
- excludeId = true,
- additionalExcludedFieldNames: string[] = [],
-): boolean => {
- const excludedFieldNames = [
- 'deletedAt',
- 'searchVector',
- 'createdBy',
- ...additionalExcludedFieldNames,
- ];
-
- if (excludeId) {
- excludedFieldNames.push('id');
- }
-
- return excludedFieldNames.includes(field.name) || field.isSystem;
-};
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/validate-relation-subfield.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/validate-relation-subfield.util.spec.ts
new file mode 100644
index 0000000000..a3eab3a27d
--- /dev/null
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/__tests__/validate-relation-subfield.util.spec.ts
@@ -0,0 +1,96 @@
+import { FieldMetadataType } from 'twenty-shared/types';
+
+import { getFlatFieldMetadataMock } from 'src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock';
+import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
+import { validateRelationSubfield } from 'src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util';
+
+const PET_OBJECT_ID = 'pet-object-id';
+const ROCKET_OBJECT_ID = 'rocket-object-id';
+const SURVEY_RESULT_OBJECT_ID = 'survey-result-object-id';
+
+const buildFieldsByObjectId = (
+ fields: FlatFieldMetadata[],
+): Map => {
+ const map = new Map();
+
+ for (const field of fields) {
+ const existing = map.get(field.objectMetadataId) ?? [];
+
+ existing.push(field);
+ map.set(field.objectMetadataId, existing);
+ }
+
+ return map;
+};
+
+describe('validateRelationSubfield', () => {
+ it('should resolve the concrete target for a per-target morph field with multiple targets', () => {
+ const morphId = 'polymorphic-owner-morph-id';
+
+ const morphToRocket = getFlatFieldMetadataMock({
+ universalIdentifier: 'morph-rocket',
+ objectMetadataId: PET_OBJECT_ID,
+ type: FieldMetadataType.MORPH_RELATION,
+ name: 'polymorphicOwnerRocket',
+ morphId,
+ relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
+ });
+ const morphToSurveyResult = getFlatFieldMetadataMock({
+ universalIdentifier: 'morph-survey-result',
+ objectMetadataId: PET_OBJECT_ID,
+ type: FieldMetadataType.MORPH_RELATION,
+ name: 'polymorphicOwnerSurveyResult',
+ morphId,
+ relationTargetObjectMetadataId: SURVEY_RESULT_OBJECT_ID,
+ });
+ const rocketNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'rocket-name',
+ objectMetadataId: ROCKET_OBJECT_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'name',
+ });
+
+ const allFields = [morphToRocket, morphToSurveyResult, rocketNameField];
+
+ expect(() =>
+ validateRelationSubfield({
+ field: morphToRocket,
+ subFieldName: 'name',
+ paramName: morphToRocket.name,
+ allFields,
+ fieldsByObjectId: buildFieldsByObjectId(allFields),
+ }),
+ ).not.toThrow();
+ });
+
+ it('should throw when the nested subfield does not exist on the resolved target', () => {
+ const morphId = 'polymorphic-owner-morph-id';
+
+ const morphToRocket = getFlatFieldMetadataMock({
+ universalIdentifier: 'morph-rocket',
+ objectMetadataId: PET_OBJECT_ID,
+ type: FieldMetadataType.MORPH_RELATION,
+ name: 'polymorphicOwnerRocket',
+ morphId,
+ relationTargetObjectMetadataId: ROCKET_OBJECT_ID,
+ });
+ const rocketNameField = getFlatFieldMetadataMock({
+ universalIdentifier: 'rocket-name',
+ objectMetadataId: ROCKET_OBJECT_ID,
+ type: FieldMetadataType.TEXT,
+ name: 'name',
+ });
+
+ const allFields = [morphToRocket, rocketNameField];
+
+ expect(() =>
+ validateRelationSubfield({
+ field: morphToRocket,
+ subFieldName: 'nonExistingField',
+ paramName: morphToRocket.name,
+ allFields,
+ fieldsByObjectId: buildFieldsByObjectId(allFields),
+ }),
+ ).toThrow('not found');
+ });
+});
diff --git a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts
index 4f23a712ca..df1f20c2d1 100644
--- a/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/page-layout-widget/utils/validate-relation-subfield.util.ts
@@ -40,7 +40,10 @@ export const validateRelationSubfield = ({
let targetObjectId = field.relationTargetObjectMetadataId ?? null;
- if (field.type === FieldMetadataType.MORPH_RELATION) {
+ if (
+ field.type === FieldMetadataType.MORPH_RELATION &&
+ !isDefined(targetObjectId)
+ ) {
targetObjectId = resolveMorphTargetObjectId({ field, allFields });
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts b/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts
index 3cab26a4ff..0fd487ae9d 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/exceptions/chart-data.exception.ts
@@ -11,6 +11,7 @@ export enum ChartDataExceptionCode {
FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND',
QUERY_EXECUTION_FAILED = 'QUERY_EXECUTION_FAILED',
TRANSFORMATION_FAILED = 'TRANSFORMATION_FAILED',
+ PERMISSION_DENIED = 'PERMISSION_DENIED',
}
const getChartDataExceptionUserFriendlyMessage = (
@@ -29,6 +30,8 @@ const getChartDataExceptionUserFriendlyMessage = (
return msg`Query execution failed.`;
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
return msg`Transformation failed.`;
+ case ChartDataExceptionCode.PERMISSION_DENIED:
+ return msg`You do not have permission to view this data.`;
default:
assertUnreachable(code);
}
@@ -58,6 +61,7 @@ export const generateChartDataExceptionMessage = (
[ChartDataExceptionCode.FIELD_METADATA_NOT_FOUND]: `Field metadata not found${context ? `: ${context}` : ''}`,
[ChartDataExceptionCode.QUERY_EXECUTION_FAILED]: `Query execution failed${context ? `: ${context}` : ''}`,
[ChartDataExceptionCode.TRANSFORMATION_FAILED]: `Transformation failed${context ? `: ${context}` : ''}`,
+ [ChartDataExceptionCode.PERMISSION_DENIED]: `Permission denied${context ? `: ${context}` : ''}`,
};
return messages[code];
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
index deef3b5d2d..ca98ffa91d 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/bar-chart-data.service.ts
@@ -37,6 +37,7 @@ import { processOneDimensionalResults } from 'src/modules/dashboard/chart-data/u
import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/utils/process-two-dimensional-results.util';
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
+import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
type GetBarChartDataParams = {
workspaceId: string;
@@ -188,17 +189,7 @@ export class BarChartDataService {
firstDayOfTheWeek,
});
} catch (error) {
- if (error instanceof ChartDataException) {
- throw error;
- }
-
- throw new ChartDataException(
- generateChartDataExceptionMessage(
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- `Bar chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- );
+ throw wrapChartDataQueryError(error, 'Bar chart data retrieval failed');
}
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts
index 282b9c0d4e..7c1d5b6059 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/line-chart-data.service.ts
@@ -36,6 +36,7 @@ import { processTwoDimensionalResults } from 'src/modules/dashboard/chart-data/u
import { sortChartDataIfNeeded } from 'src/modules/dashboard/chart-data/utils/sort-chart-data-if-needed.util';
import { sortSecondaryAxisData } from 'src/modules/dashboard/chart-data/utils/sort-secondary-axis-data.util';
import { buildLineChartSeriesIdPrefix } from 'src/modules/dashboard/chart-data/utils/build-line-chart-series-id-prefix.util';
+import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
type GetLineChartDataParams = {
workspaceId: string;
@@ -196,17 +197,7 @@ export class LineChartDataService {
seriesIdPrefix,
});
} catch (error) {
- if (error instanceof ChartDataException) {
- throw error;
- }
-
- throw new ChartDataException(
- generateChartDataExceptionMessage(
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- `Line chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- );
+ throw wrapChartDataQueryError(error, 'Line chart data retrieval failed');
}
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts
index 4a48783b5f..f5eef90654 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/services/pie-chart-data.service.ts
@@ -26,6 +26,7 @@ import { getFieldMetadata } from 'src/modules/dashboard/chart-data/utils/get-fie
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;
@@ -143,17 +144,7 @@ export class PieChartDataService {
CalendarStartDay.MONDAY,
});
} catch (error) {
- if (error instanceof ChartDataException) {
- throw error;
- }
-
- throw new ChartDataException(
- generateChartDataExceptionMessage(
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- `Pie chart data retrieval failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
- ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
- );
+ throw wrapChartDataQueryError(error, 'Pie chart data retrieval failed');
}
}
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/wrap-chart-data-query-error.util.spec.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/wrap-chart-data-query-error.util.spec.ts
new file mode 100644
index 0000000000..baf1bcaa93
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/__tests__/wrap-chart-data-query-error.util.spec.ts
@@ -0,0 +1,56 @@
+import {
+ PermissionsException,
+ PermissionsExceptionCode,
+} from 'src/engine/metadata-modules/permissions/permissions.exception';
+import {
+ ChartDataException,
+ ChartDataExceptionCode,
+} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
+import { wrapChartDataQueryError } from 'src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util';
+
+describe('wrapChartDataQueryError', () => {
+ it('should pass through an existing ChartDataException unchanged', () => {
+ const original = new ChartDataException(
+ 'Object metadata not found',
+ ChartDataExceptionCode.OBJECT_METADATA_NOT_FOUND,
+ );
+
+ const result = wrapChartDataQueryError(original, 'Bar chart');
+
+ expect(result).toBe(original);
+ });
+
+ it('should map a record-level permission error to PERMISSION_DENIED', () => {
+ const permissionError = new PermissionsException(
+ 'Entity performing the request does not have permission',
+ PermissionsExceptionCode.PERMISSION_DENIED,
+ );
+
+ const result = wrapChartDataQueryError(permissionError, 'Bar chart');
+
+ expect(result).toBeInstanceOf(ChartDataException);
+ expect(result.code).toBe(ChartDataExceptionCode.PERMISSION_DENIED);
+ });
+
+ it('should wrap an unknown error as QUERY_EXECUTION_FAILED with the context prefix', () => {
+ const result = wrapChartDataQueryError(
+ new Error('boom'),
+ 'Bar chart data retrieval failed',
+ );
+
+ expect(result).toBeInstanceOf(ChartDataException);
+ expect(result.code).toBe(ChartDataExceptionCode.QUERY_EXECUTION_FAILED);
+ expect(result.message).toContain('Bar chart data retrieval failed: boom');
+ });
+
+ it('should not map a non-permission-denied PermissionsException to PERMISSION_DENIED', () => {
+ const otherPermissionError = new PermissionsException(
+ 'Method not allowed',
+ PermissionsExceptionCode.METHOD_NOT_ALLOWED,
+ );
+
+ const result = wrapChartDataQueryError(otherPermissionError, 'Bar chart');
+
+ expect(result.code).toBe(ChartDataExceptionCode.QUERY_EXECUTION_FAILED);
+ });
+});
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts
index 8410d6539e..75587427d8 100644
--- a/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/chart-data-graphql-api-exception-handler.util.ts
@@ -1,6 +1,7 @@
import { assertUnreachable } from 'twenty-shared/utils';
import {
+ ForbiddenError,
InternalServerError,
NotFoundError,
UserInputError,
@@ -19,6 +20,8 @@ export const chartDataGraphqlApiExceptionHandler = (error: Error) => {
throw new NotFoundError(error.message);
case ChartDataExceptionCode.INVALID_WIDGET_CONFIGURATION:
throw new UserInputError(error.message);
+ case ChartDataExceptionCode.PERMISSION_DENIED:
+ throw new ForbiddenError(error.message);
case ChartDataExceptionCode.QUERY_EXECUTION_FAILED:
case ChartDataExceptionCode.TRANSFORMATION_FAILED:
throw new InternalServerError(error.message);
diff --git a/packages/twenty-server/src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util.ts b/packages/twenty-server/src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util.ts
new file mode 100644
index 0000000000..8a5c0d30b3
--- /dev/null
+++ b/packages/twenty-server/src/modules/dashboard/chart-data/utils/wrap-chart-data-query-error.util.ts
@@ -0,0 +1,39 @@
+import {
+ PermissionsException,
+ PermissionsExceptionCode,
+} from 'src/engine/metadata-modules/permissions/permissions.exception';
+import {
+ ChartDataException,
+ ChartDataExceptionCode,
+ generateChartDataExceptionMessage,
+} from 'src/modules/dashboard/chart-data/exceptions/chart-data.exception';
+
+export const wrapChartDataQueryError = (
+ error: unknown,
+ contextPrefix: string,
+): ChartDataException => {
+ if (error instanceof ChartDataException) {
+ return error;
+ }
+
+ if (
+ error instanceof PermissionsException &&
+ error.code === PermissionsExceptionCode.PERMISSION_DENIED
+ ) {
+ return new ChartDataException(
+ generateChartDataExceptionMessage(
+ ChartDataExceptionCode.PERMISSION_DENIED,
+ error.message,
+ ),
+ ChartDataExceptionCode.PERMISSION_DENIED,
+ );
+ }
+
+ return new ChartDataException(
+ generateChartDataExceptionMessage(
+ ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
+ `${contextPrefix}: ${error instanceof Error ? error.message : String(error)}`,
+ ),
+ ChartDataExceptionCode.QUERY_EXECUTION_FAILED,
+ );
+};
diff --git a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
index 3274b70223..f94d81dff8 100644
--- a/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
+++ b/packages/twenty-server/test/integration/graphql/suites/group-by-resolver.integration-spec.ts
@@ -1147,6 +1147,46 @@ describe('group-by resolver (integration)', () => {
expect(wednesdayGroup.totalCount).toBe(1);
});
+ it('orders by a base-object date field while grouping by a relation field without ambiguous column error', async () => {
+ // Grouping by the company relation joins the company table (which also
+ // has a createdAt column). Ordering by the base object's own createdAt
+ // must qualify the column with the object table name, otherwise the
+ // "createdAt" reference is ambiguous across the joined tables.
+ const response = await makeGraphqlAPIRequest(
+ groupByOperationFactory({
+ objectMetadataSingularName: 'person',
+ objectMetadataPluralName: 'people',
+ groupBy: [
+ {
+ createdAt: {
+ granularity: 'DAY_OF_THE_WEEK',
+ },
+ },
+ {
+ company: {
+ createdAt: {
+ granularity: 'DAY_OF_THE_WEEK',
+ },
+ },
+ },
+ ],
+ orderBy: [
+ {
+ createdAt: {
+ granularity: 'DAY_OF_THE_WEEK',
+ orderBy: 'AscNullsFirst',
+ },
+ },
+ ],
+ filter: filter2025,
+ }),
+ );
+
+ expect(response.body.errors).toBeUndefined();
+ expect(response.body.data.peopleGroupBy).toBeDefined();
+ expect(Array.isArray(response.body.data.peopleGroupBy)).toBe(true);
+ });
+
it('groups by one relation field - company createdAt with WEEK granularity and weekStartDay SUNDAY', async () => {
const response = await makeGraphqlAPIRequest(
groupByOperationFactory({
diff --git a/packages/twenty-shared/src/constants/ExcludedFieldNamesFromAgentToolSchema.ts b/packages/twenty-shared/src/constants/ExcludedFieldNamesFromAgentToolSchema.ts
new file mode 100644
index 0000000000..e45791da4a
--- /dev/null
+++ b/packages/twenty-shared/src/constants/ExcludedFieldNamesFromAgentToolSchema.ts
@@ -0,0 +1,5 @@
+export const EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA = [
+ 'deletedAt',
+ 'searchVector',
+ 'createdBy',
+] as const;
diff --git a/packages/twenty-shared/src/constants/FieldMetadataTypesNotSupportedInGroupBy.ts b/packages/twenty-shared/src/constants/FieldMetadataTypesNotSupportedInGroupBy.ts
new file mode 100644
index 0000000000..a3ecc83051
--- /dev/null
+++ b/packages/twenty-shared/src/constants/FieldMetadataTypesNotSupportedInGroupBy.ts
@@ -0,0 +1,9 @@
+import { FieldMetadataType } from '@/types';
+
+export const FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY =
+ new Set([
+ FieldMetadataType.TS_VECTOR,
+ FieldMetadataType.RAW_JSON,
+ FieldMetadataType.FILES,
+ FieldMetadataType.POSITION,
+ ]);
diff --git a/packages/twenty-shared/src/constants/index.ts b/packages/twenty-shared/src/constants/index.ts
index 92f42aa883..ffe53cdb02 100644
--- a/packages/twenty-shared/src/constants/index.ts
+++ b/packages/twenty-shared/src/constants/index.ts
@@ -29,8 +29,10 @@ export type { DocumentationPath } from './DocumentationPaths';
export { DOCUMENTATION_PATHS } from './DocumentationPaths';
export type { DocumentationSupportedLanguage } from './DocumentationSupportedLanguages';
export { DOCUMENTATION_SUPPORTED_LANGUAGES } from './DocumentationSupportedLanguages';
+export { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from './ExcludedFieldNamesFromAgentToolSchema';
export { FIELD_FOR_TOTAL_COUNT_AGGREGATE_OPERATION } from './FieldForTotalCountAggregateOperation';
export { MAX_OPTIONS_TO_DISPLAY } from './FieldMetadataMaxOptionsToDisplay';
+export { FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY } from './FieldMetadataTypesNotSupportedInGroupBy';
export { FIELD_RESTRICTED_ADDITIONAL_PERMISSIONS_REQUIRED } from './FieldRestrictedAdditionalPermissionsRequired';
export { FILES_FIELD_MAX_NUMBER_OF_VALUES } from './FilesFieldMaxNumberOfValues';
export { GIN_COMPATIBLE_FIELD_TYPES } from './GinCompatibleFieldTypes';
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSupportedInGroupBy.test.ts b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSupportedInGroupBy.test.ts
new file mode 100644
index 0000000000..5316d4fc53
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/__tests__/isFieldMetadataSupportedInGroupBy.test.ts
@@ -0,0 +1,129 @@
+import { FieldMetadataType, RelationType } from '@/types';
+import { isFieldMetadataSupportedInGroupBy } from '@/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy';
+
+describe('isFieldMetadataSupportedInGroupBy', () => {
+ it('returns false for field types not supported in groupBy', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.RAW_JSON,
+ name: 'rawJsonField',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.TS_VECTOR,
+ name: 'tsVectorField',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.FILES,
+ name: 'filesField',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.POSITION,
+ name: 'position',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns true for regular field types', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.SELECT,
+ name: 'stage',
+ isSystem: false,
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for ONE_TO_MANY relation fields', () => {
+ // ONE_TO_MANY relations keep their foreign key on the target object, so
+ // there is no column on this object to group by.
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.MORPH_RELATION,
+ name: 'polymorphicHelperRockets',
+ isSystem: false,
+ relationType: RelationType.ONE_TO_MANY,
+ }),
+ ).toBe(false);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.RELATION,
+ name: 'opportunities',
+ isSystem: false,
+ relationType: RelationType.ONE_TO_MANY,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns true for MANY_TO_ONE relation fields', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.MORPH_RELATION,
+ name: 'polymorphicOwnerRocket',
+ isSystem: false,
+ relationType: RelationType.MANY_TO_ONE,
+ }),
+ ).toBe(true);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ isSystem: false,
+ relationType: RelationType.MANY_TO_ONE,
+ }),
+ ).toBe(true);
+ });
+
+ it('returns false for internal field names', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.TEXT,
+ name: 'id',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.DATE_TIME,
+ name: 'deletedAt',
+ isSystem: false,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns false for system fields', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.TEXT,
+ name: 'customSystemField',
+ isSystem: true,
+ }),
+ ).toBe(false);
+ });
+
+ it('returns true for createdAt and updatedAt even when system', () => {
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.DATE_TIME,
+ name: 'createdAt',
+ isSystem: true,
+ }),
+ ).toBe(true);
+ expect(
+ isFieldMetadataSupportedInGroupBy({
+ type: FieldMetadataType.DATE_TIME,
+ name: 'updatedAt',
+ isSystem: true,
+ }),
+ ).toBe(true);
+ });
+});
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/index.ts b/packages/twenty-shared/src/utils/fieldMetadata/index.ts
index f9bb5184a0..11e6150d7b 100644
--- a/packages/twenty-shared/src/utils/fieldMetadata/index.ts
+++ b/packages/twenty-shared/src/utils/fieldMetadata/index.ts
@@ -3,4 +3,6 @@ export * from './isFieldMetadataDateKind';
export * from './isFieldMetadataEligibleForFieldsWidget';
export * from './isFieldMetadataNumericKind';
export * from './isFieldMetadataSelectKind';
+export * from './isFieldMetadataSupportedInGroupBy';
export * from './isFieldMetadataTextKind';
+export * from './shouldExcludeFieldFromAgentToolSchema';
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy.ts b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy.ts
new file mode 100644
index 0000000000..b5f52a7f0c
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/isFieldMetadataSupportedInGroupBy.ts
@@ -0,0 +1,46 @@
+import { FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY } from '@/constants';
+import { FieldMetadataType, RelationType } from '@/types';
+import { isFieldMetadataDateKind } from '@/utils/fieldMetadata/isFieldMetadataDateKind';
+import { shouldExcludeFieldFromAgentToolSchema } from '@/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
+
+const ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES = new Set([
+ 'createdAt',
+ 'updatedAt',
+]);
+
+const RELATION_FIELD_METADATA_TYPES = new Set([
+ FieldMetadataType.RELATION,
+ FieldMetadataType.MORPH_RELATION,
+]);
+
+export const isFieldMetadataSupportedInGroupBy = ({
+ type,
+ name,
+ isSystem,
+ relationType,
+}: {
+ type: FieldMetadataType;
+ name: string;
+ isSystem: boolean;
+ relationType?: RelationType | null;
+}): boolean => {
+ const isAlwaysGroupableSystemDateField =
+ ALWAYS_GROUPABLE_SYSTEM_DATE_FIELD_NAMES.has(name) &&
+ isFieldMetadataDateKind(type);
+
+ if (
+ !isAlwaysGroupableSystemDateField &&
+ shouldExcludeFieldFromAgentToolSchema({ fieldName: name, isSystem })
+ ) {
+ return false;
+ }
+
+ if (
+ RELATION_FIELD_METADATA_TYPES.has(type) &&
+ relationType === RelationType.ONE_TO_MANY
+ ) {
+ return false;
+ }
+
+ return !FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY.has(type);
+};
diff --git a/packages/twenty-shared/src/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema.ts b/packages/twenty-shared/src/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema.ts
new file mode 100644
index 0000000000..e7471624c4
--- /dev/null
+++ b/packages/twenty-shared/src/utils/fieldMetadata/shouldExcludeFieldFromAgentToolSchema.ts
@@ -0,0 +1,24 @@
+import { EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA } from '@/constants';
+
+export const shouldExcludeFieldFromAgentToolSchema = ({
+ fieldName,
+ isSystem,
+ excludeId = true,
+ additionalExcludedFieldNames = [],
+}: {
+ fieldName: string;
+ isSystem: boolean;
+ excludeId?: boolean;
+ additionalExcludedFieldNames?: string[];
+}): boolean => {
+ const excludedFieldNames = [
+ ...EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA,
+ ...additionalExcludedFieldNames,
+ ];
+
+ if (excludeId) {
+ excludedFieldNames.push('id');
+ }
+
+ return excludedFieldNames.includes(fieldName) || isSystem;
+};
diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts
index 24fb003ecb..d6e8717830 100644
--- a/packages/twenty-shared/src/utils/index.ts
+++ b/packages/twenty-shared/src/utils/index.ts
@@ -55,7 +55,9 @@ export { isFieldMetadataDateKind } from './fieldMetadata/isFieldMetadataDateKind
export { isFieldMetadataEligibleForFieldsWidget } from './fieldMetadata/isFieldMetadataEligibleForFieldsWidget';
export { isFieldMetadataNumericKind } from './fieldMetadata/isFieldMetadataNumericKind';
export { isFieldMetadataSelectKind } from './fieldMetadata/isFieldMetadataSelectKind';
+export { isFieldMetadataSupportedInGroupBy } from './fieldMetadata/isFieldMetadataSupportedInGroupBy';
export { isFieldMetadataTextKind } from './fieldMetadata/isFieldMetadataTextKind';
+export { shouldExcludeFieldFromAgentToolSchema } from './fieldMetadata/shouldExcludeFieldFromAgentToolSchema';
export { extractFolderPathFilenameAndTypeOrThrow } from './files/extractFolderPathFilenameAndTypeOrThrow.util';
export { checkIfShouldComputeEmptinessFilter } from './filter/checkIfShouldComputeEmptinessFilter';
export { computeGqlOperationFilterForEmails } from './filter/compute-record-gql-operation-filter/for-composite-field/computeGqlOperationFilterForEmails';