From 41239c9f0eedeb91c2cee78614b081774c52f2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Fri, 15 May 2026 10:31:10 +0200 Subject: [PATCH] refactor(twenty-front): move sub-menu type / constant out of state, extract relation sub-menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review nits: 1. `RELATION_SUB_MENU_FIELD_TYPE` and `ObjectFilterDropdownSubMenuFieldType` lived inside the state file. They aren't state — moved to `object-filter-dropdown/constants/` and `record-filter/types/` respectively, alongside the rest of the constants and types. 2. The relation sub-menu branch in `AdvancedFilterSubFieldSelectMenu` computed `targetObjectMetadataId` with an empty-string sentinel so the always-running `useFilterableFieldMetadataItems` hook could accept it. Fragile (the `''` sentinel is unnamed, untyped, and relies on the selector silently returning `[]`). Extracted the relation branch into its own `AdvancedFilterRelationSubMenu` component that only mounts when actually in relation mode — the hook now always receives a real object id, no sentinel. Also: turned `isManyToOneRelationField` into a generic type guard so callers can read `field.relation.targetObjectMetadata.id` after the check without a non-null assertion. --- .../utils/isManyToOneRelationField.ts | 4 +- .../AdvancedFilterFieldSelectMenu.tsx | 8 +- .../AdvancedFilterRelationSubMenu.tsx | 97 +++++++++++++++++++ .../AdvancedFilterSubFieldSelectMenu.tsx | 86 ++++------------ .../constants/RelationSubMenuFieldType.ts | 1 + ...rDropdownSubMenuFieldTypeComponentState.ts | 8 +- .../ObjectFilterDropdownSubMenuFieldType.ts | 6 ++ ...LevelPermissionFieldSelectSubFieldMenu.tsx | 6 +- 8 files changed, 131 insertions(+), 85 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationSubMenu.tsx create mode 100644 packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType.ts diff --git a/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts b/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts index 70489ef8ba8..477de876c09 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/isManyToOneRelationField.ts @@ -5,6 +5,8 @@ type FieldWithRelation = { relation?: { type: RelationType } | null; }; -export const isManyToOneRelationField = (field: FieldWithRelation): boolean => +export const isManyToOneRelationField = ( + field: T, +): field is T & { relation: NonNullable } => 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 bf7e15ccba0..48409771e40 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 @@ -16,11 +16,9 @@ import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/co import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem'; import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; -import { - type ObjectFilterDropdownSubMenuFieldType, - RELATION_SUB_MENU_FIELD_TYPE, - objectFilterDropdownSubMenuFieldTypeComponentState, -} from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { RELATION_SUB_MENU_FIELD_TYPE } from '@/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType'; +import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { type ObjectFilterDropdownSubMenuFieldType } from '@/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType'; import { isCompositeFilterableFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFilterableFieldType'; import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { visibleRecordFieldsComponentSelector } from '@/object-record/record-field/states/visibleRecordFieldsComponentSelector'; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationSubMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationSubMenu.tsx new file mode 100644 index 00000000000..6fb0b2ed04a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationSubMenu.tsx @@ -0,0 +1,97 @@ +import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; +import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +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 { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; +import { IconChevronLeft, useIcons } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; + +type AdvancedFilterRelationSubMenuProps = { + recordFilterId: string; + relationFieldMetadataItem: FieldMetadataItem; + targetObjectMetadataId: string; + onBack: () => void; + onSelectTargetField: (params: { + fieldMetadataItem: FieldMetadataItem; + relationTargetFieldMetadataItem: FieldMetadataItem; + }) => void; +}; + +export const AdvancedFilterRelationSubMenu = ({ + recordFilterId, + relationFieldMetadataItem, + targetObjectMetadataId, + onBack, + onSelectTargetField, +}: AdvancedFilterRelationSubMenuProps) => { + const { getIcon } = useIcons(); + + const { advancedFilterFieldSelectDropdownId } = + useAdvancedFilterFieldSelectDropdown(recordFilterId); + + const selectedItemId = useAtomComponentStateValue( + selectedItemIdComponentState, + advancedFilterFieldSelectDropdownId, + ); + + const { filterableFieldMetadataItems: relationTargetFields } = + useFilterableFieldMetadataItems(targetObjectMetadataId); + + const selectableItemIdArray = relationTargetFields.map((field) => field.id); + + return ( + + + } + > + {relationFieldMetadataItem.label} + + + + {relationTargetFields.map((targetField, index) => ( + { + onSelectTargetField({ + fieldMetadataItem: relationFieldMetadataItem, + relationTargetFieldMetadataItem: targetField, + }); + }} + > + { + onSelectTargetField({ + fieldMetadataItem: relationFieldMetadataItem, + relationTargetFieldMetadataItem: targetField, + }); + }} + text={targetField.label} + LeftIcon={getIcon(targetField.icon)} + /> + + ))} + + + + ); +}; 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 0a20206def5..5fc3a0af8e3 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 @@ -5,19 +5,17 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue'; import { isDefined } from 'twenty-shared/utils'; +import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; +import { AdvancedFilterRelationSubMenu } from '@/object-record/advanced-filter/components/AdvancedFilterRelationSubMenu'; import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown'; +import { RELATION_SUB_MENU_FIELD_TYPE } from '@/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; -import { - RELATION_SUB_MENU_FIELD_TYPE, - objectFilterDropdownSubMenuFieldTypeComponentState, -} from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; -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'; import { isCompositeTypeNonFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeNonFilterableByAnySubField'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; @@ -62,18 +60,6 @@ export const AdvancedFilterSubFieldSelectMenu = ({ const { selectFieldUsedInAdvancedFilterDropdown } = useSelectFieldUsedInAdvancedFilterDropdown(); - const isRelationSubMenu = - objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE && - isDefined(fieldMetadataItemUsedInDropdown) && - isManyToOneRelationField(fieldMetadataItemUsedInDropdown); - - const targetObjectMetadataId = isRelationSubMenu - ? (fieldMetadataItemUsedInDropdown?.relation?.targetObjectMetadata.id ?? '') - : ''; - - const { filterableFieldMetadataItems: relationTargetFields } = - useFilterableFieldMetadataItems(targetObjectMetadataId); - const handleSelectFilter = ({ fieldMetadataItem, subFieldName, @@ -113,57 +99,21 @@ export const AdvancedFilterSubFieldSelectMenu = ({ return null; } - if (isRelationSubMenu && isDefined(fieldMetadataItemUsedInDropdown)) { - const fieldLabel = fieldMetadataItemUsedInDropdown.label; - const selectableItemIdArray = relationTargetFields.map((field) => field.id); - + if ( + objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE && + isDefined(fieldMetadataItemUsedInDropdown) && + isManyToOneRelationField(fieldMetadataItemUsedInDropdown) + ) { return ( - - - } - > - {fieldLabel} - - - - {relationTargetFields.map((targetField, index) => ( - { - handleSelectFilter({ - fieldMetadataItem: fieldMetadataItemUsedInDropdown, - relationTargetFieldMetadataItem: targetField, - }); - }} - > - { - handleSelectFilter({ - fieldMetadataItem: fieldMetadataItemUsedInDropdown, - relationTargetFieldMetadataItem: targetField, - }); - }} - text={targetField.label} - LeftIcon={getIcon(targetField.icon)} - /> - - ))} - - - + ); } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType.ts new file mode 100644 index 00000000000..2173a9fae80 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType.ts @@ -0,0 +1 @@ +export const RELATION_SUB_MENU_FIELD_TYPE = 'RELATION' as const; 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 b6a782ae5a1..b4fa5e195c5 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 @@ -1,13 +1,7 @@ import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; -import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType'; +import { type ObjectFilterDropdownSubMenuFieldType } from '@/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType'; import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState'; -export const RELATION_SUB_MENU_FIELD_TYPE = 'RELATION' as const; - -export type ObjectFilterDropdownSubMenuFieldType = - | CompositeFilterableFieldType - | typeof RELATION_SUB_MENU_FIELD_TYPE; - export const objectFilterDropdownSubMenuFieldTypeComponentState = createAtomComponentState({ key: 'objectFilterDropdownSubMenuFieldTypeComponentState', diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType.ts new file mode 100644 index 00000000000..8650220a01a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/ObjectFilterDropdownSubMenuFieldType.ts @@ -0,0 +1,6 @@ +import { RELATION_SUB_MENU_FIELD_TYPE } from '@/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType'; +import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType'; + +export type ObjectFilterDropdownSubMenuFieldType = + | CompositeFilterableFieldType + | typeof RELATION_SUB_MENU_FIELD_TYPE; 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 d30074210a6..b31b179f5ae 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 @@ -13,10 +13,8 @@ import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-f import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown'; import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; -import { - RELATION_SUB_MENU_FIELD_TYPE, - objectFilterDropdownSubMenuFieldTypeComponentState, -} from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; +import { RELATION_SUB_MENU_FIELD_TYPE } from '@/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType'; +import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { ICON_NAME_BY_SUB_FIELD } from '@/object-record/record-filter/constants/IconNameBySubField'; import { areCompositeTypeSubFieldsFilterable } from '@/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable';