Fix various graphs bugs (#21311)

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

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

<img width="598" height="341" alt="Screenshot 2026-06-08 at 11 53 55"
src="https://github.com/user-attachments/assets/66372811-4a37-40d9-b43a-4af51f89b6e6"
/>
This commit is contained in:
Marie
2026-06-09 16:08:22 +02:00
committed by GitHub
parent 137fe45cf6
commit c27c8c88b0
49 changed files with 1464 additions and 414 deletions
@@ -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 (
<StyledForbiddenContainer>
<PageLayoutWidgetForbiddenDisplay
widgetId={widgetId}
restriction={{ type: null }}
/>
</StyledForbiddenContainer>
);
}
return (
<PageLayoutWidgetStatusDisplay
tooltipId={`widget-error-tooltip-${widgetId}`}
text={t`Error`}
tooltipContent={t`An error occurred while loading this widget.`}
/>
);
};
@@ -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 (
<StyledNoDataContainer>
<div id={tooltipId}>
<Status color="red" text={text} />
</div>
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={tooltipContent}
place="top"
/>
</StyledNoDataContainer>
<PageLayoutWidgetStatusDisplay
tooltipId={`widget-incomplete-tooltip-${widget.id}`}
text={text}
tooltipContent={tooltipContent}
/>
);
};
@@ -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 (
<StyledContainer>
<div id={tooltipId}>
<Status color={color} text={text} />
</div>
<AppTooltip
anchorSelect={`#${tooltipId}`}
content={tooltipContent}
place="top"
/>
</StyledContainer>
);
};
@@ -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 <WidgetSkeletonLoader />;
}
if (isDefined(error)) {
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
}
return (
<Suspense fallback={<WidgetSkeletonLoader />}>
<GraphWidgetAggregateChart
@@ -1,4 +1,5 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { useGraphBarChartWidgetData } from '@/page-layout/widgets/graph/graph-widget-bar-chart/hooks/useGraphBarChartWidgetData';
@@ -15,7 +16,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
import { lazy, Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { getAppPath, isDefined } from 'twenty-shared/utils';
import { AxisNameDisplay } from '~/generated-metadata/graphql';
const GraphWidgetBarChart = lazy(() =>
@@ -46,6 +47,7 @@ export const GraphWidgetBarChartRenderer = () => {
layout,
groupMode,
loading,
error,
hasTooManyGroups,
formattedToRawLookup,
colorMode,
@@ -118,6 +120,10 @@ export const GraphWidgetBarChartRenderer = () => {
return <WidgetSkeletonLoader />;
}
if (isDefined(error)) {
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
}
return (
<Suspense fallback={<WidgetSkeletonLoader />}>
<GraphWidgetChartHasTooManyGroupsEffect
@@ -1,4 +1,5 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { LINE_CHART_CONSTANTS } from '@/page-layout/widgets/graph/graph-widget-line-chart/constants/LineChartConstants';
@@ -45,6 +46,7 @@ export const GraphWidgetLineChartRenderer = () => {
showLegend,
hasTooManyGroups,
loading,
error,
formattedToRawLookup,
colorMode,
objectMetadataItem,
@@ -127,6 +129,10 @@ export const GraphWidgetLineChartRenderer = () => {
return <WidgetSkeletonLoader />;
}
if (isDefined(error)) {
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
}
return (
<Suspense fallback={<WidgetSkeletonLoader />}>
<GraphWidgetChartHasTooManyGroupsEffect
@@ -1,4 +1,5 @@
import { useIsPageLayoutInEditMode } from '@/page-layout/hooks/useIsPageLayoutInEditMode';
import { PageLayoutWidgetErrorDisplay } from '@/page-layout/widgets/components/PageLayoutWidgetErrorDisplay';
import { WidgetSkeletonLoader } from '@/page-layout/widgets/components/WidgetSkeletonLoader';
import { GraphWidgetChartHasTooManyGroupsEffect } from '@/page-layout/widgets/graph/components/GraphWidgetChartHasTooManyGroupsEffect';
import { useGraphPieChartWidgetData } from '@/page-layout/widgets/graph/graph-widget-pie-chart/hooks/useGraphPieChartWidgetData';
@@ -14,7 +15,7 @@ import { indexViewIdFromObjectMetadataItemFamilySelector } from '@/views/states/
import { lazy, Suspense } from 'react';
import { useNavigate } from 'react-router-dom';
import { AppPath } from 'twenty-shared/types';
import { getAppPath } from 'twenty-shared/utils';
import { getAppPath, isDefined } from 'twenty-shared/utils';
const GraphWidgetPieChart = lazy(() =>
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 <WidgetSkeletonLoader />;
}
if (isDefined(error)) {
return <PageLayoutWidgetErrorDisplay widgetId={widget.id} error={error} />;
}
return (
<Suspense fallback={<WidgetSkeletonLoader />}>
<GraphWidgetChartHasTooManyGroupsEffect
@@ -1,6 +1,7 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { dropChartRecordFiltersWithDeletedFields } from '@/side-panel/pages/page-layout/utils/dropChartRecordFiltersWithDeletedFields';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
computeRecordGqlOperationFilter,
@@ -44,12 +45,20 @@ export const useGraphWidgetQueryCommon = ({
flattenedFieldMetadataItemsSelector,
);
const widgetRecordFilters = configuration.filter?.recordFilters ?? [];
const objectFieldMetadataIds = new Set(
objectMetadataItem.fields.map((field: { id: string }) => 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 ?? [],
});
@@ -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<string>;
};
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 (
<SidePanelInformationBanner
variant="warning"
message={t`This rule filters on one or more deleted fields that should be removed.`}
/>
);
};
@@ -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 = ({
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId }}
>
<ChartFiltersDeletedFieldsWarning
validFieldMetadataIds={validFieldMetadataIds}
/>
<AdvancedFilterSidePanelContainer
onUpdate={handleFiltersUpdate}
objectMetadataItem={objectMetadataItem}
@@ -1,8 +1,8 @@
import { hasInitializedChartFiltersComponentState } from '@/side-panel/pages/page-layout/states/hasInitializedChartFiltersComponentState';
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { hasInitializedChartFiltersComponentState } from '@/side-panel/pages/page-layout/states/hasInitializedChartFiltersComponentState';
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useEffect, useState } from 'react';
@@ -1,15 +1,17 @@
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 { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation';
import { isFieldRelation } from '@/object-record/record-field/ui/types/guards/isFieldRelation';
import { ChartGroupByFieldSelectionCompositeFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionCompositeFieldView';
import { ChartGroupByFieldSelectionMorphRelationFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionMorphRelationFieldView';
import { ChartGroupByFieldSelectionRelationFieldView } from '@/side-panel/pages/page-layout/components/dropdown-content/ChartGroupByFieldSelectionRelationFieldView';
import { usePageLayoutIdFromContextStore } from '@/side-panel/pages/page-layout/hooks/usePageLayoutIdFromContextStore';
import { useUpdateCurrentWidgetConfig } from '@/side-panel/pages/page-layout/hooks/useUpdateCurrentWidgetConfig';
import { useWidgetInEditMode } from '@/side-panel/pages/page-layout/hooks/useWidgetInEditMode';
import { type ChartConfiguration } from '@/side-panel/pages/page-layout/types/ChartConfiguration';
import { buildChartGroupByFieldConfigUpdate } from '@/side-panel/pages/page-layout/utils/buildChartGroupByFieldConfigUpdate';
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 { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput';
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
@@ -22,7 +24,10 @@ import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/com
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 {
isDefined,
isFieldMetadataSupportedInGroupBy,
} from 'twenty-shared/utils';
import { useIcons } from 'twenty-ui-deprecated/display';
import { MenuItemSelect } from 'twenty-ui-deprecated/navigation';
import { RelationType } from '~/generated-metadata/graphql';
@@ -49,6 +54,9 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
const [selectedRelationField, setSelectedRelationField] =
useState<FieldMetadataItem | null>(null);
const [selectedMorphField, setSelectedMorphField] =
useState<FieldMetadataItem | null>(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 (
<ChartGroupByFieldSelectionMorphRelationFieldView
morphField={selectedMorphField}
currentFieldMetadataId={currentGroupByFieldMetadataId}
currentSubFieldName={currentSubFieldName}
onBack={handleBackFromMorph}
onSelectTargetSubField={handleSelectMorphTargetSubField}
/>
);
}
if (isDefined(selectedRelationField)) {
return (
<ChartGroupByFieldSelectionRelationFieldView
@@ -261,13 +316,15 @@ export const ChartGroupByFieldSelectionDropdownContentBase = <
selected={
!isCompositeFieldType(fieldMetadataItem.type) &&
!isFieldRelation(fieldMetadataItem) &&
!isFieldMorphRelation(fieldMetadataItem) &&
currentGroupByFieldMetadataId === fieldMetadataItem.id
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={
isCompositeFieldType(fieldMetadataItem.type) ||
isFieldRelation(fieldMetadataItem)
isFieldRelation(fieldMetadataItem) ||
isFieldMorphRelation(fieldMetadataItem)
}
onClick={() => {
handleSelectField(fieldMetadataItem);
@@ -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<MorphRelationTarget | null>(null);
const dropdownId = useAvailableComponentInstanceIdOrThrow(
DropdownComponentInstanceContext,
);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
dropdownId,
);
const availableTargets = useMemo<MorphRelationTarget[]>(() => {
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 (
<ChartGroupByFieldSelectionTargetObjectFieldsView
targetObjectNameSingular={selectedTarget.targetObjectNameSingular}
headerLabel={`${morphField.label} · ${selectedTarget.label}`}
currentSubFieldName={
selectedTarget.perTargetFieldId === currentFieldMetadataId
? currentSubFieldName
: undefined
}
onBack={() => setSelectedTarget(null)}
onSelectSubField={(subFieldName) =>
onSelectTargetSubField({
perTargetFieldId: selectedTarget.perTargetFieldId,
subFieldName,
})
}
/>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{morphField.label}
</DropdownMenuHeader>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableTargets.length === 0 ? (
<MenuItem text={t`No targets available`} />
) : (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableTargets.map(
(target) => target.perTargetFieldId,
)}
>
{availableTargets.map((target) => (
<SelectableListItem
key={target.perTargetFieldId}
itemId={target.perTargetFieldId}
onEnter={() => {
setSelectedTarget(target);
}}
>
<MenuItem
text={target.label}
focused={selectedItemId === target.perTargetFieldId}
LeftIcon={
isDefined(target.icon) ? getIcon(target.icon) : undefined
}
hasSubMenu
onClick={() => {
setSelectedTarget(target);
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
</DropdownMenuItemsContainer>
</>
);
};
@@ -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<FieldMetadataItem | null>(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 (
<ChartGroupByFieldSelectionCompositeFieldView
compositeField={selectedCompositeField}
currentSubFieldName={currentNestedSubFieldName}
onBack={handleBackFromComposite}
onSelectSubField={handleSelectCompositeSubField}
/>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{relationField.label}
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableFields.length === 0 ? (
<MenuItem text={t`No fields available`} />
) : (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={
!isCompositeFieldType(fieldMetadataItem.type) &&
currentNestedFieldName === fieldMetadataItem.name
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
onClick={() => {
handleSelectField(fieldMetadataItem);
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
</DropdownMenuItemsContainer>
</>
<ChartGroupByFieldSelectionTargetObjectFieldsView
targetObjectNameSingular={
relationField.relation?.targetObjectMetadata?.nameSingular
}
headerLabel={relationField.label}
currentSubFieldName={currentSubFieldName}
onBack={onBack}
onSelectSubField={onSelectSubField}
/>
);
};
@@ -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<FieldMetadataItem | null>(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 (
<ChartGroupByFieldSelectionCompositeFieldView
compositeField={selectedCompositeField}
currentSubFieldName={currentNestedSubFieldName}
onBack={handleBackFromComposite}
onSelectSubField={handleSelectCompositeSubField}
/>
);
}
return (
<>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{headerLabel}
</DropdownMenuHeader>
<DropdownMenuSearchInput
autoFocus
type="text"
placeholder={t`Search fields`}
onChange={(event) => setSearchQuery(event.target.value)}
value={searchQuery}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer>
{availableFields.length === 0 ? (
<MenuItem text={t`No fields available`} />
) : (
<SelectableList
selectableListInstanceId={dropdownId}
focusId={dropdownId}
selectableItemIdArray={availableFields.map((field) => field.id)}
>
{availableFields.map((fieldMetadataItem) => (
<SelectableListItem
key={fieldMetadataItem.id}
itemId={fieldMetadataItem.id}
onEnter={() => {
handleSelectField(fieldMetadataItem);
}}
>
<MenuItemSelect
text={fieldMetadataItem.label}
selected={
!isCompositeFieldType(fieldMetadataItem.type) &&
currentNestedFieldName === fieldMetadataItem.name
}
focused={selectedItemId === fieldMetadataItem.id}
LeftIcon={getIcon(fieldMetadataItem.icon)}
hasSubMenu={isCompositeFieldType(fieldMetadataItem.type)}
onClick={() => {
handleSelectField(fieldMetadataItem);
}}
/>
</SelectableListItem>
))}
</SelectableList>
)}
</DropdownMenuItemsContainer>
</>
);
};
@@ -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 }) => (
<JotaiProvider store={jotaiStore}>{children}</JotaiProvider>
),
},
);
};
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);
@@ -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) &&
@@ -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([]);
});
});
@@ -0,0 +1,14 @@
import { type ChartFilters } from '@/side-panel/pages/page-layout/types/ChartFilters';
export const dropChartRecordFiltersWithDeletedFields = ({
chartFilters,
validFieldMetadataIds,
}: {
chartFilters: ChartFilters;
validFieldMetadataIds: Set<string>;
}): ChartFilters => ({
...chartFilters,
recordFilters: (chartFilters.recordFilters ?? []).filter((recordFilter) =>
validFieldMetadataIds.has(recordFilter.fieldMetadataId),
),
});
@@ -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,
),
);
@@ -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,
});
};
@@ -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,
@@ -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<void> {
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,
);
@@ -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<FlatFieldMetadata>;
const flatObjectMetadataMaps = {
byUniversalIdentifier: {
[OBJECT_UNIVERSAL_ID]: flatObjectMetadata,
},
universalIdentifierById: {
[OBJECT_ID]: OBJECT_UNIVERSAL_ID,
},
universalIdentifiersByApplicationId: {},
} as unknown as FlatEntityMaps<FlatObjectMetadata>;
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);
});
});
@@ -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<FlatObjectMetadata>;
private flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
private fieldIdByName: Record<string, string>;
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<string, AggregationField>,
orderByArg: AggregateOrderByWithGroupByField,
flatObjectMetadata: FlatObjectMetadata,
): Record<string, OrderByClause> => {
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<string, OrderByClause> | 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<string, OrderByClause> | 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
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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(
@@ -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>([
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);
};
@@ -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;
};
@@ -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<string, FlatFieldMetadata[]> => {
const map = new Map<string, FlatFieldMetadata[]>();
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');
});
});
@@ -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 });
}
@@ -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];
@@ -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');
}
}
@@ -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');
}
}
@@ -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');
}
}
@@ -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);
});
});
@@ -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);
@@ -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,
);
};
@@ -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({
@@ -0,0 +1,5 @@
export const EXCLUDED_FIELD_NAMES_FROM_AGENT_TOOL_SCHEMA = [
'deletedAt',
'searchVector',
'createdBy',
] as const;
@@ -0,0 +1,9 @@
import { FieldMetadataType } from '@/types';
export const FIELD_METADATA_TYPES_NOT_SUPPORTED_IN_GROUP_BY =
new Set<FieldMetadataType>([
FieldMetadataType.TS_VECTOR,
FieldMetadataType.RAW_JSON,
FieldMetadataType.FILES,
FieldMetadataType.POSITION,
]);
@@ -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';
@@ -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);
});
});
@@ -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';
@@ -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>([
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);
};
@@ -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;
};
@@ -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';