refactor(filters): split advanced-filter sub-menu, derive relation target on demand

- RecordFilter only carries relationTargetFieldMetadataId; derive label/type at render
- Split AdvancedFilterSubFieldSelectMenu into Composite + RelationTargetField components
- Dedicated objectFilterDropdownIsSelectingRelationTargetField signal (no RELATION sentinel)
- mapViewFiltersToFilters takes a single fieldMetadataItems list
- Server view-query-params and common-group-by stop embedding resolved relation target
- Lift focus-stack push out of useSelectFieldUsedInAdvancedFilterDropdown
This commit is contained in:
Félix Malfait
2026-05-15 11:09:10 +02:00
parent 41239c9f0e
commit 858ae62b67
27 changed files with 317 additions and 337 deletions

View File

@@ -1,16 +1,6 @@
import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
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 { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
@@ -23,21 +13,26 @@ import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/Com
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 { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { t } from '@lingui/core/macro';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type AdvancedFilterSubFieldSelectMenuProps = {
type AdvancedFilterCompositeSubFieldSelectMenuProps = {
recordFilterId: string;
};
export const AdvancedFilterSubFieldSelectMenu = ({
export const AdvancedFilterCompositeSubFieldSelectMenu = ({
recordFilterId,
}: AdvancedFilterSubFieldSelectMenuProps) => {
}: AdvancedFilterCompositeSubFieldSelectMenuProps) => {
const { getIcon } = useIcons();
const fieldMetadataItemUsedInDropdown = useAtomComponentSelectorValue(
@@ -63,20 +58,14 @@ export const AdvancedFilterSubFieldSelectMenu = ({
const handleSelectFilter = ({
fieldMetadataItem,
subFieldName,
relationTargetFieldMetadataItem,
}: {
fieldMetadataItem: FieldMetadataItem;
subFieldName?: CompositeFieldSubFieldName | null;
relationTargetFieldMetadataItem?: FieldMetadataItem | null;
}) => {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: fieldMetadataItem.id,
recordFilterId,
subFieldName,
relationTargetFieldMetadataItem,
// The value picker that opens next is for a sub-field or target
// field, not the source — its dropdown manages its own focus.
skipFocusPush: true,
});
closeAdvancedFilterFieldSelectDropdown();
@@ -99,35 +88,8 @@ export const AdvancedFilterSubFieldSelectMenu = ({
return null;
}
if (
objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE &&
isDefined(fieldMetadataItemUsedInDropdown) &&
isManyToOneRelationField(fieldMetadataItemUsedInDropdown)
) {
return (
<AdvancedFilterRelationSubMenu
recordFilterId={recordFilterId}
relationFieldMetadataItem={fieldMetadataItemUsedInDropdown}
targetObjectMetadataId={
fieldMetadataItemUsedInDropdown.relation.targetObjectMetadata.id
}
onBack={handleSubMenuBack}
onSelectTargetField={handleSelectFilter}
/>
);
}
const compositeSubMenuFieldType =
objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE
? null
: objectFilterDropdownSubMenuFieldType;
if (!isDefined(compositeSubMenuFieldType)) {
return null;
}
const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[
compositeSubMenuFieldType
objectFilterDropdownSubMenuFieldType
].subFields
.filter((subField) => subField.isFilterable === true)
.map((subField) => subField.subFieldName);
@@ -216,7 +178,7 @@ export const AdvancedFilterSubFieldSelectMenu = ({
});
}}
text={getCompositeSubFieldLabel(
compositeSubMenuFieldType,
objectFilterDropdownSubMenuFieldType,
subFieldName,
)}
LeftIcon={getIcon(

View File

@@ -1,7 +1,9 @@
import { AdvancedFilterCompositeSubFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterCompositeSubFieldSelectMenu';
import { AdvancedFilterFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectMenu';
import { AdvancedFilterSubFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterSubFieldSelectMenu';
import { AdvancedFilterRelationTargetFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
type AdvancedFilterFieldSelectDropdownContentProps = {
recordFilterId: string;
@@ -10,16 +12,29 @@ type AdvancedFilterFieldSelectDropdownContentProps = {
export const AdvancedFilterFieldSelectDropdownContent = ({
recordFilterId,
}: AdvancedFilterFieldSelectDropdownContentProps) => {
const [objectFilterDropdownIsSelectingCompositeField] = useAtomComponentState(
const isSelectingCompositeField = useAtomComponentStateValue(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const shouldShowCompositeSelectionSubMenu =
objectFilterDropdownIsSelectingCompositeField;
return shouldShowCompositeSelectionSubMenu ? (
<AdvancedFilterSubFieldSelectMenu recordFilterId={recordFilterId} />
) : (
<AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />
const isSelectingRelationTargetField = useAtomComponentStateValue(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
if (isSelectingRelationTargetField) {
return (
<AdvancedFilterRelationTargetFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
if (isSelectingCompositeField) {
return (
<AdvancedFilterCompositeSubFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
return <AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />;
};

View File

@@ -16,9 +16,8 @@ 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 { RELATION_SUB_MENU_FIELD_TYPE } from '@/object-record/object-filter-dropdown/constants/RelationSubMenuFieldType';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
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';
@@ -26,6 +25,8 @@ import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/h
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useLingui } from '@lingui/react/macro';
@@ -102,10 +103,17 @@ export const AdvancedFilterFieldSelectMenu = ({
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const [, setObjectFilterDropdownIsSelectingRelationTargetField] =
useAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const handleFieldMetadataItemSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -115,30 +123,50 @@ export const AdvancedFilterFieldSelectMenu = ({
selectedFieldMetadataItem.type,
);
const subMenuType: ObjectFilterDropdownSubMenuFieldType | null =
isManyToOneRelationField(selectedFieldMetadataItem)
? RELATION_SUB_MENU_FIELD_TYPE
: isCompositeFilterableFieldType(filterType)
? filterType
: null;
const isRelationTraversalField = isManyToOneRelationField(
selectedFieldMetadataItem,
);
const compositeSubMenuFieldType =
!isRelationTraversalField && isCompositeFilterableFieldType(filterType)
? filterType
: null;
// Sub-menus (composite or relation traversal) drive their own focus
// scope; pushing the source field id on the focus stack would shadow
// their selectable list hotkeys.
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
skipFocusPush: subMenuType !== null,
});
if (subMenuType === null) {
closeAdvancedFilterFieldSelectDropdown();
if (isRelationTraversalField) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingRelationTargetField(true);
return;
}
setObjectFilterDropdownSubMenuFieldType(subMenuType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
if (compositeSubMenuFieldType !== null) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownSubMenuFieldType(compositeSubMenuFieldType);
setObjectFilterDropdownIsSelectingCompositeField(true);
return;
}
// Leaf RELATION/SELECT fields open a value picker keyed by the source
// field id — push it on the focus stack so the picker's keyboard
// hotkeys are active when it opens.
if (
selectedFieldMetadataItem.type === 'RELATION' ||
selectedFieldMetadataItem.type === 'SELECT'
) {
pushFocusItemToFocusStack({
focusId: selectedFieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: selectedFieldMetadataItem.id,
},
});
}
closeAdvancedFilterFieldSelectDropdown();
};
const shouldShowVisibleFields = visibleFieldMetadataItems.length > 0;

View File

@@ -1,97 +0,0 @@
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

@@ -0,0 +1,151 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
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 { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/display';
import { MenuItem } from 'twenty-ui/navigation';
type AdvancedFilterRelationTargetFieldSelectMenuProps = {
recordFilterId: string;
};
export const AdvancedFilterRelationTargetFieldSelectMenu = ({
recordFilterId,
}: AdvancedFilterRelationTargetFieldSelectMenuProps) => {
const { getIcon } = useIcons();
const sourceFieldMetadataItem = useAtomComponentSelectorValue(
fieldMetadataItemUsedInDropdownComponentSelector,
);
const setIsSelectingRelationTargetField = useSetAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { advancedFilterFieldSelectDropdownId } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
advancedFilterFieldSelectDropdownId,
);
const targetObjectMetadataId =
isDefined(sourceFieldMetadataItem) &&
isManyToOneRelationField(sourceFieldMetadataItem)
? sourceFieldMetadataItem.relation.targetObjectMetadata.id
: null;
const { filterableFieldMetadataItems: relationTargetFields } =
useFilterableFieldMetadataItems(targetObjectMetadataId ?? '');
if (
!isDefined(sourceFieldMetadataItem) ||
!isManyToOneRelationField(sourceFieldMetadataItem)
) {
return null;
}
const handleSubMenuBack = () => {
setIsSelectingRelationTargetField(false);
};
const handleSelectTargetField = (
relationTargetFieldMetadataItem: FieldMetadataItem,
) => {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: sourceFieldMetadataItem.id,
recordFilterId,
relationTargetFieldMetadataItem,
});
// Leaf RELATION/SELECT target fields open a value picker keyed by the
// target field id — push it on the focus stack so the picker's
// keyboard hotkeys are active when it opens.
if (
relationTargetFieldMetadataItem.type === 'RELATION' ||
relationTargetFieldMetadataItem.type === 'SELECT'
) {
pushFocusItemToFocusStack({
focusId: relationTargetFieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: relationTargetFieldMetadataItem.id,
},
});
}
setIsSelectingRelationTargetField(false);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = relationTargetFields.map((field) => field.id);
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
<DropdownMenuHeader
StartComponent={
<DropdownMenuHeaderLeftComponent
onClick={handleSubMenuBack}
Icon={IconChevronLeft}
/>
}
>
{sourceFieldMetadataItem.label}
</DropdownMenuHeader>
<DropdownMenuItemsContainer>
<SelectableList
focusId={advancedFilterFieldSelectDropdownId}
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{relationTargetFields.map((targetField, index) => (
<SelectableListItem
itemId={targetField.id}
key={`select-filter-relation-${index}`}
onEnter={() => {
handleSelectTargetField(targetField);
}}
>
<MenuItem
focused={selectedItemId === targetField.id}
key={`select-filter-relation-${index}`}
testId={`select-filter-relation-${index}`}
onClick={() => {
handleSelectTargetField(targetField);
}}
text={targetField.label}
LeftIcon={getIcon(targetField.icon)}
/>
</SelectableListItem>
))}
</SelectableList>
</DropdownMenuItemsContainer>
</DropdownContent>
);
};

View File

@@ -82,7 +82,7 @@ export const AdvancedFilterValueInput = ({
setObjectFilterDropdownCurrentRecordFilter(recordFilter);
setFieldMetadataItemIdUsedInDropdown(recordFilter.fieldMetadataId);
setRelationTargetFieldMetadataIdUsedInDropdown(
recordFilter.relationTargetField?.id ?? null,
recordFilter.relationTargetFieldMetadataId ?? null,
);
};

View File

@@ -15,8 +15,6 @@ import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { isCompositeTypeNonFilterableByAnySubField } from '@/object-record/record-filter/utils/isCompositeTypeNonFilterableByAnySubField';
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
@@ -26,10 +24,6 @@ type SelectFilterParams = {
recordFilterId: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
relationTargetFieldMetadataItem?: FieldMetadataItem | null | undefined;
// Set when the next step is another dropdown that manages its own focus
// (e.g. composite or relation-traversal sub-menus). The default push of
// the source field id on the focus stack would shadow it.
skipFocusPush?: boolean;
};
export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
@@ -49,8 +43,6 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
currentRecordFiltersComponentState,
);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
@@ -75,7 +67,6 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
recordFilterId,
subFieldName,
relationTargetFieldMetadataItem,
skipFocusPush,
}: SelectFilterParams) => {
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItemId);
@@ -86,20 +77,6 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
return;
}
if (
skipFocusPush !== true &&
(fieldMetadataItem.type === 'RELATION' ||
fieldMetadataItem.type === 'SELECT')
) {
pushFocusItemToFocusStack({
focusId: fieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: fieldMetadataItem.id,
},
});
}
const isRelationTraversal = isDefined(relationTargetFieldMetadataItem);
const filterType = getFilterTypeFromFieldType(
@@ -157,13 +134,8 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
? `${fieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: fieldMetadataItem.label;
const relationTargetField = isRelationTraversal
? {
id: relationTargetFieldMetadataItem.id,
name: relationTargetFieldMetadataItem.name,
type: relationTargetFieldMetadataItem.type,
label: relationTargetFieldMetadataItem.label,
}
const relationTargetFieldMetadataId = isRelationTraversal
? relationTargetFieldMetadataItem.id
: null;
const newAdvancedFilter = {
@@ -178,12 +150,12 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
type: filterType,
label,
subFieldName: subFieldNameToUse,
relationTargetField,
relationTargetFieldMetadataId,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(subFieldNameToUse);
setRelationTargetFieldMetadataIdUsedInDropdown(
relationTargetField?.id ?? null,
relationTargetFieldMetadataId,
);
setObjectFilterDropdownSearchInput('');

View File

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

View File

@@ -0,0 +1,9 @@
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { createAtomComponentState } from '@/ui/utilities/state/jotai/utils/createAtomComponentState';
export const objectFilterDropdownIsSelectingRelationTargetFieldComponentState =
createAtomComponentState<boolean>({
key: 'objectFilterDropdownIsSelectingRelationTargetFieldComponentState',
defaultValue: false,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
});

View File

@@ -156,7 +156,8 @@ export const useGetRecordFilterDisplayValue = () => {
}
const { fieldMetadataItem } = getFieldMetadataItemByIdOrThrow(
recordFilter.relationTargetField?.id ?? recordFilter.fieldMetadataId,
recordFilter.relationTargetFieldMetadataId ??
recordFilter.fieldMetadataId,
);
const fieldMetadataItemOptions = fieldMetadataItem.options;

View File

@@ -1,6 +1,3 @@
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;
export type ObjectFilterDropdownSubMenuFieldType = CompositeFilterableFieldType;

View File

@@ -1,7 +1,6 @@
import { type FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import {
type FieldMetadataType,
type FilterableAndTSVectorFieldType,
type ViewFilterOperand,
} from 'twenty-shared/types';
@@ -25,16 +24,7 @@ export type RecordFilter = {
positionInRecordFilterGroup?: number | null;
label: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
// Resolved at filter construction time (frontend mapping or chip edit) so
// the GraphQL builder doesn't need a cross-object field lookup. Null for
// non-relation filters; also null when the target field can no longer be
// resolved (e.g. it was deleted).
relationTargetField?: {
id: string;
name: string;
type: FieldMetadataType;
label: string;
} | null;
relationTargetFieldMetadataId?: string | null;
rlsDynamicValue?: RLSDynamicValue | null;
};

View File

@@ -31,7 +31,6 @@ import { hasInitializedCurrentRecordFieldsComponentFamilyState } from '@/views/s
import { hasInitializedCurrentRecordFiltersComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordFiltersComponentFamilyState';
import { hasInitializedCurrentRecordSortsComponentFamilyState } from '@/views/states/hasInitializedCurrentRecordSortsComponentFamilyState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFieldToRecordField } from '@/views/utils/mapViewFieldToRecordField';
import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
@@ -129,13 +128,11 @@ export const useLoadRecordIndexStates = () => {
.map(mapViewFieldToRecordField)
.filter(isDefined);
const allFilterableFields = getFilterableFields(objectMetadataItem);
const flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector.atom,
);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
allFilterableFields,
flattenedFieldMetadataItems,
);
@@ -145,7 +142,6 @@ export const useLoadRecordIndexStates = () => {
const contextStoreFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);

View File

@@ -24,6 +24,8 @@ import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/Gene
import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList';
import { SelectableListItem } from '@/ui/layout/selectable-list/components/SelectableListItem';
import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useContext } from 'react';
@@ -89,6 +91,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const handleFieldSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -102,13 +106,31 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
});
closeAdvancedFilterFieldSelectDropdown();
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
});
// RELATION/SELECT leaf fields open a value picker keyed by the
// source field id — push it on the focus stack so the picker's
// keyboard hotkeys are active when it opens.
if (
selectedFieldMetadataItem.type === FieldMetadataType.RELATION ||
selectedFieldMetadataItem.type === FieldMetadataType.SELECT
) {
pushFocusItemToFocusStack({
focusId: selectedFieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: selectedFieldMetadataItem.id,
},
});
}
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = filteredFieldMetadataItems.map(

View File

@@ -13,7 +13,6 @@ 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 } 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';
@@ -95,10 +94,7 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
advancedFilterFieldSelectDropdownId,
);
if (
!isDefined(objectFilterDropdownSubMenuFieldType) ||
objectFilterDropdownSubMenuFieldType === RELATION_SUB_MENU_FIELD_TYPE
) {
if (!isDefined(objectFilterDropdownSubMenuFieldType)) {
return null;
}

View File

@@ -71,10 +71,7 @@ export const RecordTableSettingsFilters = ({
isWorkflowFindRecords={false}
onUpdate={handleFilterUpdate}
/>
<RecordTableSettingsFiltersInitializeStateEffect
view={view}
objectMetadataItem={objectMetadataItem}
/>
<RecordTableSettingsFiltersInitializeStateEffect view={view} />
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</StyledFilterSettingsContainer>

View File

@@ -1,5 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useSetAdvancedFilterDropdownStates } from '@/object-record/advanced-filter/hooks/useSetAdvancedFilterDropdownAllRowsStates';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
@@ -7,7 +6,6 @@ import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/use
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { type View } from '@/views/types/View';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
import { useEffect, useState } from 'react';
@@ -15,12 +13,10 @@ import { isDefined } from 'twenty-shared/utils';
type RecordTableSettingsFiltersInitializeStateEffectProps = {
view: View;
objectMetadataItem: EnrichedObjectMetadataItem;
};
export const RecordTableSettingsFiltersInitializeStateEffect = ({
view,
objectMetadataItem,
}: RecordTableSettingsFiltersInitializeStateEffectProps) => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
@@ -60,10 +56,8 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
return;
}
const filterableFields = getFilterableFields(objectMetadataItem);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFields,
flattenedFieldMetadataItems,
);
@@ -78,7 +72,6 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
setHasInitializedFilters(true);
}, [
view,
objectMetadataItem,
flattenedFieldMetadataItems,
hasInitializedFilters,
stateAlreadyHasFilters,

View File

@@ -125,7 +125,7 @@ describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => {
label: mockFieldMetadataItem.label,
type: getFilterTypeFromFieldType(mockFieldMetadataItem.type),
subFieldName: null,
relationTargetField: null,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter,
]);
});

View File

@@ -1,14 +1,10 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { type ViewFilter as GqlViewFilter } from '~/generated-metadata/graphql';
import { type ViewFilter } from '@/views/types/ViewFilter';
import { getFilterableFields } from '@/views/utils/getFilterableFields';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';
export const useMapViewFiltersToFilters = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
@@ -16,13 +12,7 @@ export const useMapViewFiltersToFilters = () => {
const mapViewFiltersToRecordFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
) => {
const filterableFieldMetadataItems =
getFilterableFields(objectMetadataItem);
return mapViewFiltersToFilters(
viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);
return mapViewFiltersToFilters(viewFilters, flattenedFieldMetadataItems);
};
return { mapViewFiltersToRecordFilters };

View File

@@ -67,7 +67,7 @@ export const useSetEditableFilterChipDropdownStates = () => {
recordFilterId: recordFilter.id,
}),
}),
recordFilter.relationTargetField?.id ?? null,
recordFilter.relationTargetFieldMetadataId ?? null,
);
},
[store, filterableFieldMetadataItems],

View File

@@ -44,7 +44,7 @@ describe('mapViewFiltersToFilters', () => {
positionInRecordFilterGroup: undefined,
recordFilterGroupId: undefined,
subFieldName: undefined,
relationTargetField: null,
relationTargetFieldMetadataId: null,
},
];
expect(

View File

@@ -13,6 +13,7 @@ export const mapRecordFilterToViewFilter = (
positionInViewFilterGroup: recordFilter.positionInRecordFilterGroup,
viewFilterGroupId: recordFilter.recordFilterGroupId,
subFieldName: recordFilter.subFieldName,
relationTargetFieldMetadataId: recordFilter.relationTargetField?.id ?? null,
relationTargetFieldMetadataId:
recordFilter.relationTargetFieldMetadataId ?? null,
};
};

View File

@@ -14,25 +14,24 @@ import { type ViewFilter } from '@/views/types/ViewFilter';
export const mapViewFiltersToFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
availableFieldMetadataItems: FieldMetadataItem[],
allFieldMetadataItems: FieldMetadataItem[] = availableFieldMetadataItems,
fieldMetadataItems: FieldMetadataItem[],
): RecordFilter[] => {
return viewFilters
.map((viewFilter) => {
const availableFieldMetadataItem = availableFieldMetadataItems.find(
const sourceFieldMetadataItem = fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === viewFilter.fieldMetadataId,
);
if (!isDefined(availableFieldMetadataItem)) {
// Todo: we we don't throw an error yet as we have race condition on view change
if (!isDefined(sourceFieldMetadataItem)) {
// Todo: we don't throw an error yet as we have race condition on view change
return undefined;
}
const relationTargetFieldMetadataItem = isDefined(
viewFilter.relationTargetFieldMetadataId,
)
? allFieldMetadataItems.find(
? fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === viewFilter.relationTargetFieldMetadataId,
)
@@ -40,13 +39,13 @@ export const mapViewFiltersToFilters = (
const filterType = isDefined(relationTargetFieldMetadataItem)
? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type)
: getFilterTypeFromFieldType(availableFieldMetadataItem.type);
: getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
const label = isSystemSearchVectorField(sourceFieldMetadataItem.name)
? 'Search'
: isDefined(relationTargetFieldMetadataItem)
? `${availableFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: availableFieldMetadataItem.label;
? `${sourceFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: sourceFieldMetadataItem.label;
const operand = viewFilter.operand;
@@ -66,14 +65,8 @@ export const mapViewFiltersToFilters = (
label,
type: filterType,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetField: isDefined(relationTargetFieldMetadataItem)
? {
id: relationTargetFieldMetadataItem.id,
name: relationTargetFieldMetadataItem.name,
type: relationTargetFieldMetadataItem.type,
label: relationTargetFieldMetadataItem.label,
}
: null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} satisfies RecordFilter;
})
.filter(isDefined);

View File

@@ -222,34 +222,17 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
);
}
const relationTargetField = isDefined(
viewFilter.relationTargetFieldMetadataId,
)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFilter.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
})
: null;
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: convertViewFilterValueToString(viewFilter.value),
type: getFilterTypeFromFieldType(
relationTargetField?.type ?? fieldMetadata.type,
),
type: getFilterTypeFromFieldType(fieldMetadata.type),
operand: viewFilter.operand,
recordFilterGroupId: viewFilter.viewFilterGroupId,
positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetField: isDefined(relationTargetField)
? {
id: relationTargetField.id,
name: relationTargetField.name,
type: relationTargetField.type,
label: relationTargetField.label,
}
: null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
};
});

View File

@@ -82,31 +82,16 @@ export class ViewQueryParamsService {
if (!field) return null;
const relationTargetField = isDefined(
viewFilter.relationTargetFieldMetadataId,
)
? findFlatEntityByIdInFlatEntityMaps({
flatEntityId: viewFilter.relationTargetFieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
})
: null;
return {
id: viewFilter.id,
fieldMetadataId: viewFilter.fieldMetadataId,
value: viewFilter.value ?? '',
type: relationTargetField?.type ?? field.type,
type: field.type,
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
relationTargetField: isDefined(relationTargetField)
? {
id: relationTargetField.id,
name: relationTargetField.name,
type: relationTargetField.type,
label: relationTargetField.label,
}
: null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} as RecordFilter;
})
.filter(isDefined);

View File

@@ -1,6 +1,5 @@
import {
type CompositeFieldSubFieldName,
type FieldMetadataType,
type FilterableAndTSVectorFieldType,
type PartialFieldMetadataItem,
RecordFilterGroupLogicalOperator,
@@ -20,12 +19,7 @@ export type RecordFilter = {
recordFilterGroupId?: string | null;
operand: ViewFilterOperand;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
// Resolved at filter construction time so traversal doesn't need a
// cross-object field lookup downstream.
relationTargetField?:
| { id: string; name: string; type: FieldMetadataType; label: string }
| null
| undefined;
relationTargetFieldMetadataId?: string | null | undefined;
};
export type RecordFilterGroup = {

View File

@@ -89,25 +89,28 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
// Must run before the emptiness shortcut so an "is empty" filter on
// `company.name` is evaluated against `Company.name`, not the FK column.
// If `relationTargetField` is missing the filter is dropped, not fed
// through the legacy relation-by-record path — that path parses `value`
// as a UUID list and would silently mishandle a target-field value like
// "Acme", risking broadening of destructive operations.
// If the target field can't be resolved the filter is dropped rather than
// fed to the per-type switch as a RELATION (which would parse `value` as
// a UUID list and silently mishandle target-field values like "Acme").
if (
correspondingFieldMetadataItem.type === FieldMetadataType.RELATION &&
isDefined(recordFilter.relationTargetField)
isDefined(recordFilter.relationTargetFieldMetadataId)
) {
const targetField = recordFilter.relationTargetField;
const targetFieldMetadataItem = fieldMetadataItems.find(
(field) => field.id === recordFilter.relationTargetFieldMetadataId,
);
if (!isDefined(targetFieldMetadataItem)) {
return;
}
const innerFilter = turnRecordFilterIntoRecordGqlOperationFilter({
recordFilter: {
...recordFilter,
fieldMetadataId: targetField.id,
relationTargetField: null,
fieldMetadataId: targetFieldMetadataItem.id,
relationTargetFieldMetadataId: null,
},
// Inject the target so the recursive lookup succeeds even when the
// caller only supplied the current object's fields.
fieldMetadataItems: [...fieldMetadataItems, targetField],
fieldMetadataItems,
filterValueDependencies,
});