refactor(twenty-front): move sub-menu type / constant out of state, extract relation sub-menu

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.
This commit is contained in:
Félix Malfait
2026-05-15 10:31:10 +02:00
parent 49633515d2
commit 41239c9f0e
8 changed files with 131 additions and 85 deletions

View File

@@ -5,6 +5,8 @@ type FieldWithRelation = {
relation?: { type: RelationType } | null;
};
export const isManyToOneRelationField = (field: FieldWithRelation): boolean =>
export const isManyToOneRelationField = <T extends FieldWithRelation>(
field: T,
): field is T & { relation: NonNullable<T['relation']> } =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;

View File

@@ -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';

View File

@@ -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 (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={onBack}
Icon={IconChevronLeft}
/>
}
>
{relationFieldMetadataItem.label}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
focusId={advancedFilterFieldSelectDropdownId}
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{relationTargetFields.map((targetField, index) => (
<SelectableListItem
itemId={targetField.id}
key={`select-filter-relation-${index}`}
onEnter={() => {
onSelectTargetField({
fieldMetadataItem: relationFieldMetadataItem,
relationTargetFieldMetadataItem: targetField,
});
}}
>
<MenuItem
focused={selectedItemId === targetField.id}
key={`select-filter-relation-${index}`}
testId={`select-filter-relation-${index}`}
onClick={() => {
onSelectTargetField({
fieldMetadataItem: relationFieldMetadataItem,
relationTargetFieldMetadataItem: targetField,
});
}}
text={targetField.label}
LeftIcon={getIcon(targetField.icon)}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};

View File

@@ -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 (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleSubMenuBack}
Icon={IconChevronLeft}
/>
}
>
{fieldLabel}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
focusId={advancedFilterFieldSelectDropdownId}
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{relationTargetFields.map((targetField, index) => (
<SelectableListItem
itemId={targetField.id}
key={`select-filter-relation-${index}`}
onEnter={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
relationTargetFieldMetadataItem: targetField,
});
}}
>
<MenuItem
focused={selectedItemId === targetField.id}
key={`select-filter-relation-${index}`}
testId={`select-filter-relation-${index}`}
onClick={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
relationTargetFieldMetadataItem: targetField,
});
}}
text={targetField.label}
LeftIcon={getIcon(targetField.icon)}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
<AdvancedFilterRelationSubMenu
recordFilterId={recordFilterId}
relationFieldMetadataItem={fieldMetadataItemUsedInDropdown}
targetObjectMetadataId={
fieldMetadataItemUsedInDropdown.relation.targetObjectMetadata.id
}
onBack={handleSubMenuBack}
onSelectTargetField={handleSelectFilter}
/>
);
}

View File

@@ -0,0 +1 @@
export const RELATION_SUB_MENU_FIELD_TYPE = 'RELATION' as const;

View File

@@ -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<ObjectFilterDropdownSubMenuFieldType | null>({
key: 'objectFilterDropdownSubMenuFieldTypeComponentState',

View File

@@ -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;

View File

@@ -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';