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