From 06f8c8b5a7bb28b2b896f2e9a4cdec70d9d76511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 14 May 2026 21:28:13 +0200 Subject: [PATCH] refactor: address review feedback and simplify MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review comments: - Move isManyToOneRelationField to @/object-metadata/utils (global helper). Refactor prefillRecord, sanitizeRecordInput, filterSortableFieldMetadataItems and spreadsheetImportHasNestedFields to reuse it. - Drop narrative/redundant comments on RecordFilter, ViewFilter shape, RELATION_SUB_MENU_FIELD_TYPE sentinel and relationTargetFieldMetadataIdUsed state — names are self-describing. Simplifications: - Unify the two select-filter handlers in AdvancedFilterSubFieldSelectMenu behind a single keyword-args entry point. - Collapse isRelationDrillDown/isCompositeDrillDown flags in AdvancedFilterFieldSelectMenu into a single computed sub-menu type. - Drop the redundant `'relationTargetFieldMetadataId' in viewFilter` check in mapViewFiltersToFilters now that ViewFilter shape carries the field. - Drop the over-engineered useMemo around effectiveFieldMetadataItem in ObjectFilterDropdownInnerSelectOperandDropdown — the underlying lookup is a cheap dictionary read. - Reuse the same relationTargetFieldMetadataId for both the filter object and the dropdown state setter in useSelectFieldUsedInAdvancedFilterDropdown. --- .../utils/filterSortableFieldMetadataItems.ts | 9 +- .../utils/isManyToOneRelationField.ts | 10 ++ .../AdvancedFilterFieldSelectMenu.tsx | 28 ++--- .../AdvancedFilterSubFieldSelectMenu.tsx | 113 ++++++++---------- ...SelectFieldUsedInAdvancedFilterDropdown.ts | 14 +-- ...jectFilterDropdownFilterSelectMenuItem.tsx | 2 +- ...lterDropdownInnerSelectOperandDropdown.tsx | 25 ++-- ...rDropdownSubMenuFieldTypeComponentState.ts | 2 - ...dMetadataIdUsedInDropdownComponentState.ts | 4 - .../utils/isManyToOneRelationField.ts | 9 -- .../record-filter/types/RecordFilter.ts | 4 - .../object-record/utils/prefillRecord.ts | 6 +- .../utils/sanitizeRecordInput.ts | 4 +- ...LevelPermissionFieldSelectSubFieldMenu.tsx | 3 - .../utils/spreadsheetImportHasNestedFields.ts | 6 +- .../views/utils/mapViewFiltersToFilters.ts | 12 +- .../entities/view-filter.entity.ts | 12 +- ...RecordFilterGroupIntoGqlOperationFilter.ts | 3 - .../turnRecordFilterIntoGqlOperationFilter.ts | 7 +- 19 files changed, 106 insertions(+), 167 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts delete mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isManyToOneRelationField.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts b/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts index 1db89c508ec..32868e06aad 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/filterSortableFieldMetadataItems.ts @@ -1,6 +1,7 @@ import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes'; import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField'; -import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; +import { type FieldMetadataType, type RelationType } from '~/generated-metadata/graphql'; type SortableFieldInput = { isSystem?: boolean | null; @@ -17,13 +18,9 @@ export const filterSortableFieldMetadataItems = (field: SortableFieldInput) => { field.type, ); - const isRelationFieldSortable = - field.type === FieldMetadataType.RELATION && - field.relation?.type === RelationType.MANY_TO_ONE; - return ( !isHiddenSystemField(field) && isFieldActive && - (isFieldTypeSortable || isRelationFieldSortable) + (isFieldTypeSortable || isManyToOneRelationField(field)) ); }; diff --git a/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts b/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts new file mode 100644 index 00000000000..70489ef8ba8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts @@ -0,0 +1,10 @@ +import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; + +type FieldWithRelation = { + type: FieldMetadataType; + relation?: { type: RelationType } | null; +}; + +export const isManyToOneRelationField = (field: FieldWithRelation): boolean => + field.type === FieldMetadataType.RELATION && + field.relation?.type === RelationType.MANY_TO_ONE; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu.tsx index 654beb765ce..dfda7ed21b2 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu.tsx @@ -22,7 +22,7 @@ import { objectFilterDropdownSubMenuFieldTypeComponentState, } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { isCompositeFilterableFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFilterableFieldType'; -import { isManyToOneRelationField } from '@/object-record/object-filter-dropdown/utils/isManyToOneRelationField'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; @@ -122,23 +122,21 @@ export const AdvancedFilterFieldSelectMenu = ({ recordFilterId, }); - const isRelationDrillDown = isManyToOneRelationField( - selectedFieldMetadataItem, - ); - const isCompositeDrillDown = isCompositeFilterableFieldType(filterType); + const subMenuType: ObjectFilterDropdownSubMenuFieldType | null = + isManyToOneRelationField(selectedFieldMetadataItem) + ? RELATION_SUB_MENU_FIELD_TYPE + : isCompositeFilterableFieldType(filterType) + ? filterType + : null; - if (isCompositeDrillDown || isRelationDrillDown) { - const subMenuType = ( - isRelationDrillDown ? RELATION_SUB_MENU_FIELD_TYPE : filterType - ) as ObjectFilterDropdownSubMenuFieldType; - - setObjectFilterDropdownSubMenuFieldType(subMenuType); - - setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id); - setObjectFilterDropdownIsSelectingCompositeField(true); - } else { + if (subMenuType === null) { closeAdvancedFilterFieldSelectDropdown(); + return; } + + setObjectFilterDropdownSubMenuFieldType(subMenuType); + setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id); + setObjectFilterDropdownIsSelectingCompositeField(true); }; const shouldShowVisibleFields = visibleFieldMetadataItems.length > 0; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx index eedd972be14..6cd7b269fe1 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu.tsx @@ -15,7 +15,7 @@ import { objectFilterDropdownSubMenuFieldTypeComponentState, } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; -import { isManyToOneRelationField } from '@/object-record/object-filter-dropdown/utils/isManyToOneRelationField'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/IconNameBySubField'; import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; import { areCompositeTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable'; @@ -62,8 +62,6 @@ export const AdvancedFilterSubFieldSelectMenu = ({ const { selectFieldUsedInAdvancedFilterDropdown } = useSelectFieldUsedInAdvancedFilterDropdown(); - // Hooks must run unconditionally — pull target object's filterable fields - // even when we're in composite mode (the empty list is harmless). const isRelationSubMenu = objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE && isDefined(fieldMetadataItemUsedInDropdown) && @@ -76,31 +74,20 @@ export const AdvancedFilterSubFieldSelectMenu = ({ const { filterableFieldMetadataItems: relationTargetFields } = useFilterableFieldMetadataItems(targetObjectMetadataId); - const handleSelectCompositeFilter = ( - selectedFieldMetadataItem: FieldMetadataItem | null | undefined, - subFieldName?: CompositeFieldSubFieldName | null | undefined, - ) => { - if (!isDefined(selectedFieldMetadataItem)) { - return; - } - + const handleSelectFilter = ({ + fieldMetadataItem, + subFieldName, + relationTargetFieldMetadataItem, + }: { + fieldMetadataItem: FieldMetadataItem; + subFieldName?: CompositeFieldSubFieldName | null; + relationTargetFieldMetadataItem?: FieldMetadataItem | null; + }) => { selectFieldUsedInAdvancedFilterDropdown({ - fieldMetadataItemId: selectedFieldMetadataItem.id, + fieldMetadataItemId: fieldMetadataItem.id, recordFilterId, subFieldName, - }); - - closeAdvancedFilterFieldSelectDropdown(); - }; - - const handleSelectRelationTargetFilter = ( - relationFieldMetadataItem: FieldMetadataItem, - targetFieldMetadataItem: FieldMetadataItem, - ) => { - selectFieldUsedInAdvancedFilterDropdown({ - fieldMetadataItemId: relationFieldMetadataItem.id, - recordFilterId, - relationTargetFieldMetadataItem: targetFieldMetadataItem, + relationTargetFieldMetadataItem, }); closeAdvancedFilterFieldSelectDropdown(); @@ -150,10 +137,10 @@ export const AdvancedFilterSubFieldSelectMenu = ({ itemId={targetField.id} key={`select-filter-relation-${index}`} onEnter={() => { - handleSelectRelationTargetFilter( - fieldMetadataItemUsedInDropdown, - targetField, - ); + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, + relationTargetFieldMetadataItem: targetField, + }); }} > { - handleSelectRelationTargetFilter( - fieldMetadataItemUsedInDropdown, - targetField, - ); + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, + relationTargetFieldMetadataItem: targetField, + }); }} text={targetField.label} LeftIcon={getIcon(targetField.icon)} @@ -227,36 +214,42 @@ export const AdvancedFilterSubFieldSelectMenu = ({ selectableItemIdArray={selectableItemIdArray} selectableListInstanceId={advancedFilterFieldSelectDropdownId} > - {compositeFieldTypeIsFilterableByAnySubField && ( - { - handleSelectCompositeFilter(fieldMetadataItemUsedInDropdown); - }} - > - { - handleSelectCompositeFilter(fieldMetadataItemUsedInDropdown); + onEnter={() => { + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, + }); }} - LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)} - text={t`Any ${fieldLabel} field`} - /> - - )} + > + { + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, + }); + }} + LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)} + text={t`Any ${fieldLabel} field`} + /> + + )} {subFieldsAreFilterable && + isDefined(fieldMetadataItemUsedInDropdown) && subFieldNames.map((subFieldName, index) => ( { - handleSelectCompositeFilter( - fieldMetadataItemUsedInDropdown, + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, subFieldName, - ); + }); }} > { - if (isDefined(fieldMetadataItemUsedInDropdown)) { - handleSelectCompositeFilter( - fieldMetadataItemUsedInDropdown, - subFieldName, - ); - } + handleSelectFilter({ + fieldMetadataItem: fieldMetadataItemUsedInDropdown, + subFieldName, + }); }} text={getCompositeSubFieldLabel( compositeSubMenuFieldType, @@ -277,7 +268,7 @@ export const AdvancedFilterSubFieldSelectMenu = ({ )} LeftIcon={getIcon( ICON_NAME_BY_SUB_FIELD[subFieldName] ?? - fieldMetadataItemUsedInDropdown?.icon, + fieldMetadataItemUsedInDropdown.icon, )} /> diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts index 7ceab0914f6..05689bd1de1 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown.ts @@ -25,10 +25,6 @@ type SelectFilterParams = { fieldMetadataItemId: string; recordFilterId: string; subFieldName?: CompositeFieldSubFieldName | null | undefined; - // When the user drilled into a MANY_TO_ONE relation and picked a field on - // the target object, this carries that target field. The stored filter - // then operates against the target field's type (so operand picker / - // value input reflect the target, not the relation itself). relationTargetFieldMetadataItem?: FieldMetadataItem | null | undefined; }; @@ -155,6 +151,10 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => { ? `${fieldMetadataItem.label} → ${relationTargetFieldMetadataItem.label}` : fieldMetadataItem.label; + const relationTargetFieldMetadataId = isRelationTraversal + ? relationTargetFieldMetadataItem.id + : null; + const newAdvancedFilter = { id: recordFilterId, fieldMetadataId: fieldMetadataItem.id, @@ -167,14 +167,12 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => { type: filterType, label, subFieldName: subFieldNameToUse, - relationTargetFieldMetadataId: isRelationTraversal - ? relationTargetFieldMetadataItem.id - : null, + relationTargetFieldMetadataId, } satisfies RecordFilter; setSubFieldNameUsedInDropdown(subFieldNameToUse); setRelationTargetFieldMetadataIdUsedInDropdown( - isRelationTraversal ? relationTargetFieldMetadataItem.id : null, + relationTargetFieldMetadataId, ); setObjectFilterDropdownSearchInput(''); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx index 70176f77c60..51d25ada373 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx @@ -1,7 +1,7 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { FILTER_FIELD_LIST_ID } from '@/object-record/object-filter-dropdown/constants/FilterFieldListId'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; -import { isManyToOneRelationField } from '@/object-record/object-filter-dropdown/utils/isManyToOneRelationField'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; import { isSelectedItemIdComponentFamilyState } from '@/ui/layout/selectable-list/states/isSelectedItemIdComponentFamilyState'; import { useAtomComponentFamilyStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentFamilyStateValue'; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx index a992e84a471..915342c14d2 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownInnerSelectOperandDropdown.tsx @@ -1,5 +1,3 @@ -import { useMemo } from 'react'; - import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById'; import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; import { DATE_PICKER_DROPDOWN_CONTENT_WIDTH } from '@/object-record/object-filter-dropdown/constants/DatePickerDropdownContentWidth'; @@ -44,22 +42,13 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => { const { getFieldMetadataItemByIdOrThrow } = useGetFieldMetadataItemByIdOrThrow(); - // For relation traversal the operand list must match the target field's - // type, not the relation field's. Looked up through the global metadata - // store so it works across objects. - const effectiveFieldMetadataItem = useMemo( - () => - isDefined(relationTargetFieldMetadataIdUsedInDropdown) - ? getFieldMetadataItemByIdOrThrow( - relationTargetFieldMetadataIdUsedInDropdown, - ).fieldMetadataItem - : fieldMetadataItemUsedInDropdown, - [ - relationTargetFieldMetadataIdUsedInDropdown, - fieldMetadataItemUsedInDropdown, - getFieldMetadataItemByIdOrThrow, - ], - ); + const effectiveFieldMetadataItem = isDefined( + relationTargetFieldMetadataIdUsedInDropdown, + ) + ? getFieldMetadataItemByIdOrThrow( + relationTargetFieldMetadataIdUsedInDropdown, + ).fieldMetadataItem + : fieldMetadataItemUsedInDropdown; const operandsForFilterType = isDefined(effectiveFieldMetadataItem) ? getRecordFilterOperands({ diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState.ts index c5e5cff6c14..b6a782ae5a1 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState.ts @@ -2,8 +2,6 @@ import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/ob import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; -// Sentinel for the relation-traversal mode of the shared sub-menu state, -// distinguished from `CompositeFilterableFieldType`. export const RELATION_SUB_MENU_FIELD_TYPE = 'RELATION' as const; export type ObjectFilterDropdownSubMenuFieldType = diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState.ts index 7fdcf646f5e..ada04668e5b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState.ts @@ -1,10 +1,6 @@ import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; -// Set when the in-flight filter is a one-hop relation traversal: holds the -// metadata id of the field on the related object that the filter is being -// applied to (e.g. Company.name's field id when filtering People by company -// name). Null for direct-field and composite-sub-field filters. export const relationTargetFieldMetadataIdUsedInDropdownComponentState = createAtomComponentState({ key: 'relationTargetFieldMetadataIdUsedInDropdownComponentState', diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isManyToOneRelationField.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isManyToOneRelationField.ts deleted file mode 100644 index 573fb00322c..00000000000 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isManyToOneRelationField.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; -import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql'; - -// MANY_TO_ONE is the only relation type the backend can currently traverse in -// filters (ONE_TO_MANY would need EXISTS-subquery support), so it's also the -// only one we expose as a drill-down target in the filter dropdown. -export const isManyToOneRelationField = (field: FieldMetadataItem): boolean => - field.type === FieldMetadataType.RELATION && - field.relation?.type === RelationType.MANY_TO_ONE; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts index d6b9e0edeae..bd0fbb7701c 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts @@ -24,11 +24,7 @@ export type RecordFilter = { positionInRecordFilterGroup?: number | null; label: string; subFieldName?: CompositeFieldSubFieldName | null | undefined; - // For one-hop relation traversal: when `fieldMetadataId` references a - // MANY_TO_ONE relation field, this is the metadata id of the field on the - // target object whose value the filter compares against. Null otherwise. relationTargetFieldMetadataId?: string | null; - // RLS-specific: when set, filter compares against current user's field value rlsDynamicValue?: RLSDynamicValue | null; }; diff --git a/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts index f843ca0e08b..9f9a85230cf 100644 --- a/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts +++ b/packages/twenty-front/src/modules/object-record/utils/prefillRecord.ts @@ -2,6 +2,7 @@ import { isUndefined } from '@sniptt/guards'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { generateEmptyFieldValue } from '@/object-record/utils/generateEmptyFieldValue'; import { @@ -26,10 +27,7 @@ export const prefillRecord = ({ const fieldValue = isUndefined(inputValue) ? generateEmptyFieldValue({ fieldMetadataItem }) : inputValue; - if ( - fieldMetadataItem.type === FieldMetadataType.RELATION && - fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE - ) { + if (isManyToOneRelationField(fieldMetadataItem)) { const joinColumnName = computeRelationGqlFieldJoinColumnName({ name: fieldMetadataItem.name, }); diff --git a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts index 7baea23ea26..0e4f47e773b 100644 --- a/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts +++ b/packages/twenty-front/src/modules/object-record/utils/sanitizeRecordInput.ts @@ -1,4 +1,5 @@ import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { isFieldMorphRelation } from '@/object-record/record-field/ui/types/guards/isFieldMorphRelation'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isSystemSearchVectorField } from '@/object-record/utils/isSystemSearchVectorField'; @@ -63,8 +64,7 @@ export const sanitizeRecordInput = ({ if ( isDefined(fieldMetadataItem) && - fieldMetadataItem.type === FieldMetadataType.RELATION && - fieldMetadataItem.relation?.type === RelationType.MANY_TO_ONE && + isManyToOneRelationField(fieldMetadataItem) && !isDefined(recordInput[fieldMetadataItem.name]?.connect?.where) ) { return undefined; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx index 74b8ddaea8c..d30074210a6 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectSubFieldMenu.tsx @@ -97,9 +97,6 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS advancedFilterFieldSelectDropdownId, ); - // The RELATION sentinel is exclusive to the filter dropdown's - // relation-traversal flow — role-permission sub-fields only ever drill - // into composite fields, so bail out early in that case. if ( !isDefined(objectFilterDropdownSubMenuFieldType) || objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE diff --git a/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportHasNestedFields.ts b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportHasNestedFields.ts index cb3661e5936..e0ca867a140 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportHasNestedFields.ts +++ b/packages/twenty-front/src/modules/spreadsheet-import/utils/spreadsheetImportHasNestedFields.ts @@ -1,12 +1,10 @@ import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; -import { FieldMetadataType } from 'twenty-shared/types'; -import { RelationType } from '~/generated-metadata/graphql'; export const hasNestedFields = (fieldMetadata: FieldMetadataItem) => { return ( - (fieldMetadata.type === FieldMetadataType.RELATION && - fieldMetadata.relation?.type === RelationType.MANY_TO_ONE) || + isManyToOneRelationField(fieldMetadata) || isCompositeFieldType(fieldMetadata.type) ); }; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts index 246bf56df73..9d6b4f1553d 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts @@ -15,9 +15,6 @@ import { type ViewFilter } from '@/views/types/ViewFilter'; export const mapViewFiltersToFilters = ( viewFilters: ViewFilter[] | GqlViewFilter[], availableFieldMetadataItems: FieldMetadataItem[], - // All field metadata items across every object, used to resolve relation - // traversal targets that live on a different object than the source field. - // Defaults to `availableFieldMetadataItems` for non-traversal callers. allFieldMetadataItems: FieldMetadataItem[] = availableFieldMetadataItems, ): RecordFilter[] => { return viewFilters @@ -32,12 +29,8 @@ export const mapViewFiltersToFilters = ( return undefined; } - // The codegen-generated `GqlViewFilter` and the local `ViewFilter` - // type don't both expose this field; `in` narrows safely across both. const relationTargetFieldMetadataId = - 'relationTargetFieldMetadataId' in viewFilter - ? (viewFilter.relationTargetFieldMetadataId ?? null) - : null; + viewFilter.relationTargetFieldMetadataId ?? null; const relationTargetFieldMetadataItem = isDefined( relationTargetFieldMetadataId, @@ -48,9 +41,6 @@ export const mapViewFiltersToFilters = ( ) : undefined; - // For relation traversal, the operand picker / value input must match - // the target field's type, and the label must reflect both hops so the - // filter is recognizable in the UI (e.g. "Company → Name"). const filterType = isDefined(relationTargetFieldMetadataItem) ? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type) : getFilterTypeFromFieldType(availableFieldMetadataItem.type); diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts index 83847c407cd..e80bbed4e26 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/entities/view-filter.entity.ts @@ -64,17 +64,13 @@ export class ViewFilterEntity @Column({ nullable: true, type: 'text', default: null }) subFieldName: string | null; - // Stable pointer to a field on the related object for one-hop relation - // traversal filters: when `fieldMetadataId` is a MANY_TO_ONE relation, this - // is the field on the target object whose value is being compared. Null for - // direct-field and composite-sub-field filters. @Column({ nullable: true, type: 'uuid', default: null }) relationTargetFieldMetadataId: string | null; - // ON DELETE SET NULL keeps the row when the target field is deleted, but - // the operand/value stay shaped for the original target. The load path - // therefore drops any filter where `relationTargetFieldMetadataId` has - // become null after a non-null persisted value (deferred to caller). + // ON DELETE SET NULL keeps the row when the target field is deleted: the + // load path drops any filter where relationTargetFieldMetadataId was set + // but is now null, because operand/value are still shaped for the original + // target. @ManyToOne(() => FieldMetadataEntity, { onDelete: 'SET NULL', nullable: true, diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter.ts index c044a0ec5e2..8d7f0810ca0 100644 --- a/packages/twenty-shared/src/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterGroupIntoGqlOperationFilter.ts @@ -19,9 +19,6 @@ export type RecordFilter = { recordFilterGroupId?: string | null; operand: ViewFilterOperand; subFieldName?: CompositeFieldSubFieldName | null | undefined; - // For one-hop relation traversal: when `fieldMetadataId` references a - // MANY_TO_ONE relation field, this is the metadata id of the field on the - // target object whose value the filter compares against. Null otherwise. relationTargetFieldMetadataId?: string | null | undefined; }; diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts index 7d84e4e4b90..44e3fe51c80 100644 --- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts @@ -87,8 +87,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ return; } - // Relation traversal: the filter applies to a field on the related object. - // Run this BEFORE the emptiness shortcut so an "is empty" filter on + // Must run before the emptiness shortcut so an "is empty" filter on // `company.name` is evaluated against `Company.name`, not the FK column. if ( correspondingFieldMetadataItem.type === FieldMetadataType.RELATION && @@ -117,8 +116,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({ [correspondingFieldMetadataItem.name]: innerFilter, } as RecordGqlOperationFilter; } - // Target field not in the provided fieldMetadataItems — fall through to - // the legacy relation-by-record path so the filter still works. + // Target not in fieldMetadataItems — fall through to legacy + // relation-by-record matching. } const shouldComputeEmptinessFilter = checkIfShouldComputeEmptinessFilter({