feat(twenty-front): relation traversal in filter dropdown (stacked) (#20533)

**Stacked on #20527** 




https://github.com/user-attachments/assets/48995655-401a-4c35-8094-e88da8408bdd





## Summary

Surfaces the one-hop relation traversal added in #20527 through the
existing **composite sub-field dropdown pattern**. Clicking a
MANY_TO_ONE relation field in the "+ Filter" picker now opens the same
second-level dropdown that composite fields (FULL_NAME, ADDRESS,
CURRENCY, etc.) already use — populated with the target object's
filterable fields. Picking one (e.g. `Company → Name`) builds a filter
that serializes to the nested GraphQL filter the backend now accepts: `{
company: { name: { ilike: "%X%" } } }`.

No new components. The whole feature reuses
`AdvancedFilterSubFieldSelectMenu` + the existing
`subFieldNameUsedInDropdownComponentState` + the existing `MenuItem
hasSubMenu` indicator. Only the conditions that gate the sub-menu (and
the sub-menu's content for relations) were broadened.

## What landed

| File | Change |
|---|---|
| `ObjectFilterDropdownFilterSelectMenuItem` | Sub-menu chevron now
shows on MANY_TO_ONE relations (`isManyToOneRelationField` util). |
| `AdvancedFilterFieldSelectMenu` | Relation clicks open the sub-menu
alongside composite clicks. |
| `AdvancedFilterSubFieldSelectMenu` | New branch: when the sub-menu
type is `'RELATION'`, render the target object's filterable fields via
`useFilterableFieldMetadataItems(targetObjectMetadataId)`. Composite
logic untouched. |
| `objectFilterDropdownSubMenuFieldType` state | Widened to accept a
`'RELATION'` sentinel. Role-permissions sub-field menu narrows it back
out (it doesn't traverse relations). |
| `useSelectFieldUsedInAdvancedFilterDropdown` | New optional
`targetFieldMetadataItem` arg. When present, the stored RecordFilter's
`type` is the target field's type so the operand picker and value input
render the target's operands (`'TEXT'` operators when filtering
`company.name`, etc.). |
| `turnRecordFilterIntoGqlOperationFilter` (shared) | When the filter
targets a `RELATION` field with a `subFieldName`, synthesize a
field-metadata for the target, recurse to build the inner filter, then
wrap it under the relation field's name → `{ relationName: {
targetFieldName: { ...operator } } }`. |

`RecordFilter.subFieldName` stays narrowly typed as
`CompositeFieldSubFieldName` so the wide downstream consumers
(`shouldShowFilterTextInput`, composite handlers in the serializer,
etc.) don't change. The relation target field's name is stored through a
narrowly-scoped cast at the dropdown's storage point — the serializer
checks `filter.type === 'RELATION'` before interpreting it as a target
field name, so the cast can't be mis-read by composite-only code paths.

## Test plan

- [ ] Open a table view on People, click "+ Filter", click "Company" →
sub-menu opens with Company's filterable fields
- [ ] Pick "Name" → operand picker shows TEXT operators (Contains,
Equals, …)
- [ ] Type "Airbnb" → filter applies, table shows people whose company
name contains "Airbnb"
- [ ] Verify network tab: the GraphQL filter variable is `{ company: {
name: { ilike: "%Airbnb%" } } }`
- [ ] Same flow with a composite target field (e.g. `Company →
annualRecurringRevenue → amountMicros`) — should work end-to-end
(backend supports composite-within-relation; #20527 has an integration
test covering this)
- [ ] Composite fields (FULL_NAME, ADDRESS) still open their normal
sub-menu and filter correctly — no regression
- [ ] Role-permissions field-select sub-field menu is unaffected (it
bails out early on the RELATION sentinel)

## Out of scope

- ONE_TO_MANY traversal (no backend support yet)
- Aggregates (`people.count > 5`)
- Persisting relation-traversal filters into a saved view (ViewFilter
has no `relationPath` column yet; that's a separate slice)
- REST API DSL changes
- AI Tools

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Félix Malfait
2026-05-15 16:42:47 +02:00
committed by GitHub
parent eca92ca559
commit c938fbf4d6
88 changed files with 1809 additions and 414 deletions
@@ -1,13 +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 { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterCompositeSubField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterCompositeSubField';
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';
@@ -20,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(
@@ -54,21 +52,20 @@ export const AdvancedFilterSubFieldSelectMenu = ({
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterCompositeSubField } =
useApplyAdvancedFilterCompositeSubField();
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
subFieldName?: CompositeFieldSubFieldName | null | undefined,
) => {
if (!isDefined(selectedFieldMetadataItem)) {
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
const handleSelectFilter = ({
fieldMetadataItem,
subFieldName,
}: {
fieldMetadataItem: FieldMetadataItem;
subFieldName?: CompositeFieldSubFieldName | null;
}) => {
applyAdvancedFilterCompositeSubField({
sourceFieldMetadataItem: fieldMetadataItem,
subFieldName: subFieldName ?? null,
recordFilterId,
subFieldName,
});
closeAdvancedFilterFieldSelectDropdown();
@@ -132,36 +129,42 @@ export const AdvancedFilterSubFieldSelectMenu = ({
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{compositeFieldTypeIsFilterableByAnySubField && (
<SelectableListItem
itemId="-1"
key={`select-filter-${-1}`}
onEnter={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
}}
>
<MenuItem
{compositeFieldTypeIsFilterableByAnySubField &&
isDefined(fieldMetadataItemUsedInDropdown) && (
<SelectableListItem
itemId="-1"
key={`select-filter-${-1}`}
testId={`select-filter-${-1}`}
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter(fieldMetadataItemUsedInDropdown);
onEnter={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
});
}}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)}
text={t`Any ${fieldLabel} field`}
/>
</SelectableListItem>
)}
>
<MenuItem
key={`select-filter-${-1}`}
testId={`select-filter-${-1}`}
focused={selectedItemId === '-1'}
onClick={() => {
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
});
}}
LeftIcon={getIcon(fieldMetadataItemUsedInDropdown.icon)}
text={t`Any ${fieldLabel} field`}
/>
</SelectableListItem>
)}
{subFieldsAreFilterable &&
isDefined(fieldMetadataItemUsedInDropdown) &&
subFieldNames.map((subFieldName, index) => (
<SelectableListItem
itemId={subFieldName}
key={`select-filter-${index}`}
onEnter={() => {
handleSelectFilter(
fieldMetadataItemUsedInDropdown,
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
subFieldName,
);
});
}}
>
<MenuItem
@@ -169,12 +172,10 @@ export const AdvancedFilterSubFieldSelectMenu = ({
key={`select-filter-${index}`}
testId={`select-filter-${index}`}
onClick={() => {
if (isDefined(fieldMetadataItemUsedInDropdown)) {
handleSelectFilter(
fieldMetadataItemUsedInDropdown,
subFieldName,
);
}
handleSelectFilter({
fieldMetadataItem: fieldMetadataItemUsedInDropdown,
subFieldName,
});
}}
text={getCompositeSubFieldLabel(
objectFilterDropdownSubMenuFieldType,
@@ -182,7 +183,7 @@ export const AdvancedFilterSubFieldSelectMenu = ({
)}
LeftIcon={getIcon(
ICON_NAME_BY_SUB_FIELD[subFieldName] ??
fieldMetadataItemUsedInDropdown?.icon,
fieldMetadataItemUsedInDropdown.icon,
)}
/>
</SelectableListItem>
@@ -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,31 @@ type AdvancedFilterFieldSelectDropdownContentProps = {
export const AdvancedFilterFieldSelectDropdownContent = ({
recordFilterId,
}: AdvancedFilterFieldSelectDropdownContentProps) => {
const [objectFilterDropdownIsSelectingCompositeField] = useAtomComponentState(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const objectFilterDropdownIsSelectingCompositeField =
useAtomComponentStateValue(
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const shouldShowCompositeSelectionSubMenu =
objectFilterDropdownIsSelectingCompositeField;
const objectFilterDropdownIsSelectingRelationTargetField =
useAtomComponentStateValue(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
return shouldShowCompositeSelectionSubMenu ? (
<AdvancedFilterSubFieldSelectMenu recordFilterId={recordFilterId} />
) : (
<AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />
);
if (objectFilterDropdownIsSelectingRelationTargetField) {
return (
<AdvancedFilterRelationTargetFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
if (objectFilterDropdownIsSelectingCompositeField) {
return (
<AdvancedFilterCompositeSubFieldSelectMenu
recordFilterId={recordFilterId}
/>
);
}
return <AdvancedFilterFieldSelectMenu recordFilterId={recordFilterId} />;
};
@@ -11,17 +11,20 @@ import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
import { useSelectFieldUsedInAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useSelectFieldUsedInAdvancedFilterDropdown';
import { useApplyAdvancedFilterSourceField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
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 { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState';
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';
import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems';
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentState';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
@@ -87,8 +90,8 @@ export const AdvancedFilterFieldSelectMenu = ({
advancedFilterFieldSelectDropdownId,
);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const [, setObjectFilterDropdownSubMenuFieldType] = useAtomComponentState(
objectFilterDropdownSubMenuFieldTypeComponentState,
@@ -99,10 +102,18 @@ export const AdvancedFilterFieldSelectMenu = ({
objectFilterDropdownIsSelectingCompositeFieldComponentState,
);
const [, setObjectFilterDropdownIsSelectingRelationTargetField] =
useAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const handleFieldMetadataItemSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -112,19 +123,41 @@ export const AdvancedFilterFieldSelectMenu = ({
selectedFieldMetadataItem.type,
);
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
const isRelationTraversalField = isManyToOneRelationField(
selectedFieldMetadataItem,
);
const compositeSubMenuFieldType =
!isRelationTraversalField && isCompositeFilterableFieldType(filterType)
? filterType
: null;
// For sub-menu paths (composite or relation traversal) we only stage
// the source field in the dropdown state and open the sub-menu —
// upsertRecordFilter happens in the sub-menu's hook once the user
// makes the final choice. Otherwise navigating back from the sub-menu
// would leave an orphan partial filter in the chip list.
if (isRelationTraversalField) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingRelationTargetField(true);
return;
}
if (compositeSubMenuFieldType !== null) {
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownSubMenuFieldType(compositeSubMenuFieldType);
setObjectFilterDropdownIsSelectingCompositeField(true);
return;
}
applyAdvancedFilterSourceField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
recordFilterId,
});
if (isCompositeFilterableFieldType(filterType)) {
setObjectFilterDropdownSubMenuFieldType(filterType);
pushFocusForLeafFieldValuePicker(selectedFieldMetadataItem);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
closeAdvancedFilterFieldSelectDropdown();
}
closeAdvancedFilterFieldSelectDropdown();
};
const shouldShowVisibleFields = visibleFieldMetadataItems.length > 0;
@@ -0,0 +1,146 @@
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 { useApplyAdvancedFilterRelationTargetField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterRelationTargetField';
import { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
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 { 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 setObjectFilterDropdownIsSelectingRelationTargetField =
useSetAtomComponentState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
);
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { applyAdvancedFilterRelationTargetField } =
useApplyAdvancedFilterRelationTargetField();
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const { advancedFilterFieldSelectDropdownId } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const selectedItemId = useAtomComponentStateValue(
selectedItemIdComponentState,
advancedFilterFieldSelectDropdownId,
);
const targetObjectMetadataId =
isDefined(sourceFieldMetadataItem) &&
isManyToOneRelationField(sourceFieldMetadataItem)
? sourceFieldMetadataItem.relation.targetObjectMetadata.id
: null;
const { filterableFieldMetadataItems: allTargetFields } =
useFilterableFieldMetadataItems(targetObjectMetadataId ?? '');
// The backend supports a single hop only. Exclude many-to-one relations
// from the target list so the user can't compose multi-hop traversals
// (e.g. Person → Company → ParentCompany) that the dispatcher would
// collapse back to a filter-by-id on the intermediate relation.
const relationTargetFields = allTargetFields.filter(
(field) => !isManyToOneRelationField(field),
);
if (
!isDefined(sourceFieldMetadataItem) ||
!isManyToOneRelationField(sourceFieldMetadataItem)
) {
return null;
}
const handleSubMenuBack = () => {
setObjectFilterDropdownIsSelectingRelationTargetField(false);
};
const handleSelectTargetField = (
relationTargetFieldMetadataItem: FieldMetadataItem,
) => {
applyAdvancedFilterRelationTargetField({
sourceFieldMetadataItem,
relationTargetFieldMetadataItem,
recordFilterId,
});
pushFocusForLeafFieldValuePicker(relationTargetFieldMetadataItem);
setObjectFilterDropdownIsSelectingRelationTargetField(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>
);
};
@@ -6,6 +6,7 @@ import { shouldShowFilterTextInput } from '@/object-record/advanced-filter/utils
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
@@ -55,6 +56,12 @@ export const AdvancedFilterValueInput = ({
dropdownId,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
dropdownId,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
dropdownId,
@@ -74,6 +81,9 @@ export const AdvancedFilterValueInput = ({
const handleFilterValueDropdownOpen = () => {
setObjectFilterDropdownCurrentRecordFilter(recordFilter);
setFieldMetadataItemIdUsedInDropdown(recordFilter.fieldMetadataId);
setRelationTargetFieldMetadataIdUsedInDropdown(
recordFilter.relationTargetFieldMetadataId ?? null,
);
};
const filterType = recordFilter.type;
@@ -1,31 +1,30 @@
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType';
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';
type SelectFilterParams = {
fieldMetadataItemId: string;
type ApplyAdvancedFilterCompositeSubFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
subFieldName: CompositeFieldSubFieldName | null;
recordFilterId: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
};
export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
// Creates an advanced filter from a composite source field together with
// the user's chosen sub-field (e.g. address city, name first name). The
// filter type is still driven by the source field's composite type.
export const useApplyAdvancedFilterCompositeSubField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
@@ -38,54 +37,34 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
objectFilterDropdownSearchInputComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const { getFieldMetadataItemByIdOrThrow } =
useGetFieldMetadataItemByIdOrThrow();
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const selectFieldUsedInAdvancedFilterDropdown = ({
fieldMetadataItemId,
recordFilterId,
const applyAdvancedFilterCompositeSubField = ({
sourceFieldMetadataItem,
subFieldName,
}: SelectFilterParams) => {
setFieldMetadataItemIdUsedInDropdown(fieldMetadataItemId);
recordFilterId,
}: ApplyAdvancedFilterCompositeSubFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const { fieldMetadataItem } =
getFieldMetadataItemByIdOrThrow(fieldMetadataItemId);
if (!isDefined(fieldMetadataItem)) {
return;
}
if (
fieldMetadataItem.type === 'RELATION' ||
fieldMetadataItem.type === 'SELECT'
) {
pushFocusItemToFocusStack({
focusId: fieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: fieldMetadataItem.id,
},
});
}
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const firstOperand = getRecordFilterOperands({
filterType,
@@ -109,30 +88,9 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
(recordFilter) => recordFilter.id === recordFilterId,
);
const isCompositeFilterOnAnySubField =
isCompositeFieldType(filterType) && !isDefined(subFieldName);
const compositeFilterNonFilterableByAnySubField =
isCompositeTypeNonFilterableByAnySubField(filterType);
let subFieldNameForNonFilterableWithAny:
| CompositeFieldSubFieldName
| undefined
| null = subFieldName;
if (
isCompositeFilterOnAnySubField &&
compositeFilterNonFilterableByAnySubField
) {
subFieldNameForNonFilterableWithAny =
getDefaultSubFieldNameForCompositeFilterableFieldType(filterType);
}
const subFieldNameToUse =
subFieldName ?? subFieldNameForNonFilterableWithAny;
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: fieldMetadataItem.id,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
@@ -140,11 +98,13 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: fieldMetadataItem.label,
subFieldName: subFieldNameToUse,
label: sourceFieldMetadataItem.label,
subFieldName,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(subFieldNameToUse);
setSubFieldNameUsedInDropdown(subFieldName);
setRelationTargetFieldMetadataIdUsedInDropdown(null);
setObjectFilterDropdownSearchInput('');
@@ -153,6 +113,6 @@ export const useSelectFieldUsedInAdvancedFilterDropdown = () => {
};
return {
selectFieldUsedInAdvancedFilterDropdown,
applyAdvancedFilterCompositeSubField,
};
};
@@ -0,0 +1,122 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
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';
type ApplyAdvancedFilterRelationTargetFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
relationTargetFieldMetadataItem: FieldMetadataItem;
recordFilterId: string;
};
// Creates an advanced filter that traverses a relation: the source is the
// relation field, the filter operates on a target field of the related
// object. Filter type comes from the target field; the label combines
// both labels so the chip reads "Source → Target".
export const useApplyAdvancedFilterRelationTargetField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownSearchInput = useSetAtomComponentState(
objectFilterDropdownSearchInputComponentState,
);
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const applyAdvancedFilterRelationTargetField = ({
sourceFieldMetadataItem,
relationTargetFieldMetadataItem,
recordFilterId,
}: ApplyAdvancedFilterRelationTargetFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(
relationTargetFieldMetadataItem.type,
);
const firstOperand = getRecordFilterOperands({
filterType,
subFieldName: null,
})?.[0];
if (!isDefined(firstOperand)) {
throw new Error(
`No valid operand found for relation-traversal filter type: ${filterType}`,
);
}
setSelectedOperandInDropdown(firstOperand);
const { value, displayValue } = getInitialFilterValue(
filterType,
firstOperand,
);
const existingRecordFilter = currentRecordFilters.find(
(recordFilter) => recordFilter.id === recordFilterId,
);
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
recordFilterGroupId: existingRecordFilter?.recordFilterGroupId,
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: `${sourceFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`,
subFieldName: null,
relationTargetFieldMetadataId: relationTargetFieldMetadataItem.id,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(null);
setRelationTargetFieldMetadataIdUsedInDropdown(
relationTargetFieldMetadataItem.id,
);
setObjectFilterDropdownSearchInput('');
setObjectFilterDropdownCurrentRecordFilter(newAdvancedFilter);
upsertRecordFilter(newAdvancedFilter);
};
return {
applyAdvancedFilterRelationTargetField,
};
};
@@ -0,0 +1,115 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { useGetInitialFilterValue } from '@/object-record/object-filter-dropdown/hooks/useGetInitialFilterValue';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUpsertRecordFilter';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands';
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';
type ApplyAdvancedFilterSourceFieldParams = {
sourceFieldMetadataItem: FieldMetadataItem;
recordFilterId: string;
};
// Creates a leaf advanced filter from a source field selection. Composite
// and many-to-one relation sources are handled by their own specialized
// hooks (useApplyAdvancedFilterCompositeSubField,
// useApplyAdvancedFilterRelationTargetField) — callers branch to those
// before reaching this hook.
export const useApplyAdvancedFilterSourceField = () => {
const setSelectedOperandInDropdown = useSetAtomComponentState(
selectedOperandInDropdownComponentState,
);
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownSearchInput = useSetAtomComponentState(
objectFilterDropdownSearchInputComponentState,
);
const setSubFieldNameUsedInDropdown = useSetAtomComponentState(
subFieldNameUsedInDropdownComponentState,
);
const setRelationTargetFieldMetadataIdUsedInDropdown =
useSetAtomComponentState(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const setObjectFilterDropdownCurrentRecordFilter = useSetAtomComponentState(
objectFilterDropdownCurrentRecordFilterComponentState,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
const { upsertRecordFilter } = useUpsertRecordFilter();
const { getInitialFilterValue } = useGetInitialFilterValue();
const applyAdvancedFilterSourceField = ({
sourceFieldMetadataItem,
recordFilterId,
}: ApplyAdvancedFilterSourceFieldParams) => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataItem.id);
const filterType = getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const firstOperand = getRecordFilterOperands({
filterType,
subFieldName: null,
})?.[0];
if (!isDefined(firstOperand)) {
throw new Error(`No valid operand found for filter type: ${filterType}`);
}
setSelectedOperandInDropdown(firstOperand);
const { value, displayValue } = getInitialFilterValue(
filterType,
firstOperand,
);
const existingRecordFilter = currentRecordFilters.find(
(recordFilter) => recordFilter.id === recordFilterId,
);
const newAdvancedFilter = {
id: recordFilterId,
fieldMetadataId: sourceFieldMetadataItem.id,
displayValue,
operand: firstOperand,
value,
recordFilterGroupId: existingRecordFilter?.recordFilterGroupId,
positionInRecordFilterGroup:
existingRecordFilter?.positionInRecordFilterGroup,
type: filterType,
label: sourceFieldMetadataItem.label,
subFieldName: null,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter;
setSubFieldNameUsedInDropdown(null);
setRelationTargetFieldMetadataIdUsedInDropdown(null);
setObjectFilterDropdownSearchInput('');
setObjectFilterDropdownCurrentRecordFilter(newAdvancedFilter);
upsertRecordFilter(newAdvancedFilter);
};
return {
applyAdvancedFilterSourceField,
};
};
@@ -0,0 +1,32 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { usePushFocusItemToFocusStack } from '@/ui/utilities/focus/hooks/usePushFocusItemToFocusStack';
import { FocusComponentType } from '@/ui/utilities/focus/types/FocusComponentType';
// RELATION and SELECT leaf fields open a value picker keyed by the field
// id once the user finishes choosing the field — push it on the focus
// stack so the picker's keyboard hotkeys are active when it opens. Other
// field types use input-style pickers that don't rely on the focus stack.
export const usePushFocusForLeafFieldValuePicker = () => {
const { pushFocusItemToFocusStack } = usePushFocusItemToFocusStack();
const pushFocusForLeafFieldValuePicker = (
fieldMetadataItem: FieldMetadataItem,
) => {
if (
fieldMetadataItem.type !== 'RELATION' &&
fieldMetadataItem.type !== 'SELECT'
) {
return;
}
pushFocusItemToFocusStack({
focusId: fieldMetadataItem.id,
component: {
type: FocusComponentType.DROPDOWN,
instanceId: fieldMetadataItem.id,
},
});
};
return { pushFocusForLeafFieldValuePicker };
};
@@ -1,6 +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-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';
@@ -27,9 +28,9 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({
const Icon = getIcon(fieldMetadataItemToSelect.icon);
const shouldShowSubMenu = isCompositeFieldType(
fieldMetadataItemToSelect.type,
);
const shouldShowSubMenu =
isCompositeFieldType(fieldMetadataItemToSelect.type) ||
isManyToOneRelationField(fieldMetadataItemToSelect);
const handleClick = () => {
resetSelectedItem();
@@ -1,7 +1,10 @@
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
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';
import { useApplyObjectFilterDropdownOperand } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownOperand';
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
import { relationTargetFieldMetadataIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/relationTargetFieldMetadataIdUsedInDropdownComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel';
@@ -13,6 +16,7 @@ import { DropdownMenuInnerSelect } from '@/ui/layout/dropdown/components/Dropdow
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { type SelectOption } from 'twenty-ui/input';
@@ -32,11 +36,34 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
subFieldNameUsedInDropdownComponentState,
);
const operandsForFilterType = isDefined(fieldMetadataItemUsedInDropdown)
const relationTargetFieldMetadataIdUsedInDropdown =
useAtomComponentStateValue(
relationTargetFieldMetadataIdUsedInDropdownComponentState,
);
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
// The target field may have been deleted from the workspace since the
// filter was saved — return null and let the parent skip rendering
// rather than throwing.
const relationTargetFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? getFieldMetadataItemById({
fieldMetadataId: relationTargetFieldMetadataIdUsedInDropdown,
objectMetadataItems,
}).fieldMetadataItem
: null;
const effectiveFieldMetadataItem = isDefined(
relationTargetFieldMetadataIdUsedInDropdown,
)
? relationTargetFieldMetadataItem
: fieldMetadataItemUsedInDropdown;
const operandsForFilterType = isDefined(effectiveFieldMetadataItem)
? getRecordFilterOperands({
filterType: getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
),
filterType: getFilterTypeFromFieldType(effectiveFieldMetadataItem.type),
subFieldName: subFieldNameUsedInDropdown,
})
: [];
@@ -70,13 +97,13 @@ export const ObjectFilterDropdownInnerSelectOperandDropdown = () => {
if (
!isDefined(selectedOperandInDropdown) ||
!isDefined(fieldMetadataItemUsedInDropdown)
!isDefined(effectiveFieldMetadataItem)
) {
return null;
}
const filterType = getFilterTypeFromFieldType(
fieldMetadataItemUsedInDropdown.type,
effectiveFieldMetadataItem.type,
);
const isDateFilter = DATE_FILTER_TYPES.includes(filterType);
@@ -5,6 +5,7 @@ import { objectFilterDropdownAnyFieldSearchIsSelectedComponentState } from '@/ob
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState';
import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState';
import { objectFilterDropdownIsSelectingRelationTargetFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingRelationTargetFieldComponentState';
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
@@ -45,6 +46,12 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
componentInstanceId,
);
const objectFilterDropdownIsSelectingRelationTargetField =
useAtomComponentStateCallbackState(
objectFilterDropdownIsSelectingRelationTargetFieldComponentState,
componentInstanceId,
);
const objectFilterDropdownCurrentRecordFilter =
useAtomComponentStateCallbackState(
objectFilterDropdownCurrentRecordFilterComponentState,
@@ -56,6 +63,7 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
store.set(selectedOperandInDropdown, null);
store.set(objectFilterDropdownFilterIsSelected, false);
store.set(objectFilterDropdownIsSelectingCompositeField, false);
store.set(objectFilterDropdownIsSelectingRelationTargetField, false);
store.set(fieldMetadataItemIdUsedInDropdown, null);
store.set(objectFilterDropdownCurrentRecordFilter, null);
store.set(objectFilterDropdownAnyFieldSearchIsSelected, false);
@@ -64,6 +72,7 @@ export const useResetFilterDropdown = (componentInstanceId?: string) => {
selectedOperandInDropdown,
objectFilterDropdownFilterIsSelected,
objectFilterDropdownIsSelectingCompositeField,
objectFilterDropdownIsSelectingRelationTargetField,
fieldMetadataItemIdUsedInDropdown,
objectFilterDropdownCurrentRecordFilter,
objectFilterDropdownAnyFieldSearchIsSelected,
@@ -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,
});
@@ -1,9 +1,9 @@
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 objectFilterDropdownSubMenuFieldTypeComponentState =
createAtomComponentState<CompositeFilterableFieldType | null>({
createAtomComponentState<ObjectFilterDropdownSubMenuFieldType | null>({
key: 'objectFilterDropdownSubMenuFieldTypeComponentState',
defaultValue: null,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
@@ -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 relationTargetFieldMetadataIdUsedInDropdownComponentState =
createAtomComponentState<string | null>({
key: 'relationTargetFieldMetadataIdUsedInDropdownComponentState',
defaultValue: null,
componentInstanceContext: ObjectFilterDropdownComponentInstanceContext,
});
@@ -1,3 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useRecordCalendarContextOrThrow } from '@/object-record/record-calendar/contexts/RecordCalendarContext';
import { useRecordCalendarMonthDaysRange } from '@/object-record/record-calendar/month/hooks/useRecordCalendarMonthDaysRange';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
@@ -6,8 +7,10 @@ import { anyFieldFilterValueComponentState } from '@/object-record/record-filter
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useUserTimezone } from '@/ui/input/components/internal/date/hooks/useUserTimezone';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetCurrentViewOnly } from '@/views/hooks/useGetCurrentViewOnly';
import { t } from '@lingui/core/macro';
import { type Temporal } from 'temporal-polyfill';
@@ -46,6 +49,10 @@ export const useRecordCalendarQueryDateRangeFilter = (
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const anyFieldFilterValue = useAtomComponentStateValue(
anyFieldFilterValueComponentState,
viewBarInstanceId,
@@ -94,15 +101,21 @@ export const useRecordCalendarQueryDateRangeFilter = (
displayValue: `${lastDayOfLastWeek.toString()}`,
};
const calendarRecordFilters = [
...currentRecordFilters,
dateRangeFilterAfter,
dateRangeFilterBefore,
];
const dateRangeFilter = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: [
...currentRecordFilters,
dateRangeFilterAfter,
dateRangeFilterBefore,
],
recordFilters: calendarRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: calendarRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const { recordGqlOperationFilter: anyFieldFilter } =
@@ -156,7 +156,8 @@ export const useGetRecordFilterDisplayValue = () => {
}
const { fieldMetadataItem } = getFieldMetadataItemByIdOrThrow(
recordFilter.fieldMetadataId,
recordFilter.relationTargetFieldMetadataId ??
recordFilter.fieldMetadataId,
);
const fieldMetadataItemOptions = fieldMetadataItem.options;
@@ -0,0 +1,3 @@
import { type CompositeFilterableFieldType } from '@/object-record/record-filter/types/CompositeFilterableFieldType';
export type ObjectFilterDropdownSubMenuFieldType = CompositeFilterableFieldType;
@@ -24,7 +24,7 @@ export type RecordFilter = {
positionInRecordFilterGroup?: number | null;
label: string;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
// RLS-specific: when set, filter compares against current user's field value
relationTargetFieldMetadataId?: string | null;
rlsDynamicValue?: RLSDynamicValue | null;
};
@@ -0,0 +1,41 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isDefined } from 'twenty-shared/utils';
// Relation-traversal filters reference target fields that live on a
// different object than the one being queried, so they aren't in the
// source object's own field list. The shared GraphQL filter dispatcher
// looks the target up by id in `fieldMetadataItems` and drops the filter
// when it can't find it — this helper merges the resolved target fields
// in from the workspace-wide flat list so callers don't silently lose
// relation-traversal filters.
export const augmentFieldsWithRelationTargets = ({
baseFields,
recordFilters,
allFieldMetadataItems,
}: {
baseFields: FieldMetadataItem[];
recordFilters: Pick<RecordFilter, 'relationTargetFieldMetadataId'>[];
allFieldMetadataItems: FieldMetadataItem[];
}): FieldMetadataItem[] => {
const targetFieldIds = new Set(
recordFilters
.map((filter) => filter.relationTargetFieldMetadataId)
.filter(isDefined),
);
if (targetFieldIds.size === 0) {
return baseFields;
}
const baseFieldIds = new Set(baseFields.map((field) => field.id));
const additionalTargetFields = allFieldMetadataItems.filter(
(field) => targetFieldIds.has(field.id) && !baseFieldIds.has(field.id),
);
if (additionalTargetFields.length === 0) {
return baseFields;
}
return [...baseFields, ...additionalTargetFields];
};
@@ -6,11 +6,13 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/hooks/useFindManyRecordIndexTableParams';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { useEffect } from 'react';
@@ -52,11 +54,16 @@ export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect =
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const computedFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -7,6 +7,7 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { useLazyFetchAllRecords } from '@/object-record/hooks/useLazyFetchAllRecords';
import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/object-options-dropdown/constants/ExportTableDataDefaultPageSize';
@@ -18,6 +19,7 @@ import { useFindManyRecordIndexTableParams } from '@/object-record/record-index/
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { ViewType } from '@/views/types/ViewType';
import { isDefined } from 'twenty-shared/utils';
@@ -87,6 +89,10 @@ export const useRecordIndexLazyFetchRecords = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const findManyRecordsParams = useFindManyRecordIndexTableParams(
objectMetadataItem.nameSingular,
recordIndexId,
@@ -101,6 +107,7 @@ export const useRecordIndexLazyFetchRecords = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -1,14 +1,17 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -47,8 +50,16 @@ export const useFindManyRecordIndexTableParams = (
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
filterValueDependencies,
@@ -2,6 +2,7 @@ import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/s
import { useGetFieldMetadataItemByIdOrThrow } from '@/object-metadata/hooks/useGetFieldMetadataItemById';
import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector';
import { availableFieldMetadataItemsForSortFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForSortFamilySelector';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { formatFieldMetadataItemAsColumnDefinition } from '@/object-metadata/utils/formatFieldMetadataItemAsColumnDefinition';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
@@ -30,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';
@@ -128,10 +128,12 @@ export const useLoadRecordIndexStates = () => {
.map(mapViewFieldToRecordField)
.filter(isDefined);
const allFilterableFields = getFilterableFields(objectMetadataItem);
const flattenedFieldMetadataItems = store.get(
flattenedFieldMetadataItemsSelector.atom,
);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
allFilterableFields,
flattenedFieldMetadataItems,
);
const recordFilterGroups = mapViewFilterGroupsToRecordFilterGroups(
@@ -140,7 +142,7 @@ export const useLoadRecordIndexStates = () => {
const contextStoreFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFieldMetadataItems,
flattenedFieldMetadataItems,
);
let recordIndexGroupFieldMetadataItemValue = undefined;
@@ -1,10 +1,12 @@
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { useRelevantRecordsGqlFields } from '@/object-record/record-field/hooks/useRelevantRecordsGqlFields';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector';
import { computeRecordGroupOptionsFilter } from '@/object-record/record-group/utils/computeRecordGroupOptionsFilter';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
@@ -12,6 +14,7 @@ import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
combineFilters,
computeRecordGqlOperationFilter,
@@ -36,11 +39,19 @@ export const useRecordIndexGroupCommonQueryVariables = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,4 +1,5 @@
import { useApolloCoreClient } from '@/object-metadata/hooks/useApolloCoreClient';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { EMPTY_QUERY } from '@/object-record/constants/EmptyQuery';
@@ -8,10 +9,12 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useAggregateGqlFieldsFromRecordIndexGroupAggregates } from '@/object-record/record-index/hooks/useAggregateGqlFieldsFromRecordIndexGroupAggregates';
import { type ExtendedAggregateOperations } from '@/object-record/record-table/types/ExtendedAggregateOperations';
import { buildGroupByFieldObject } from '@/page-layout/widgets/graph/utils/buildGroupByFieldObject';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useQuery } from '@apollo/client/react';
import { useMemo } from 'react';
import { type Nullable } from 'twenty-shared/types';
@@ -46,11 +49,19 @@ export const useRecordIndexGroupsAggregatesGroupBy = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const requestFilters = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const { recordAggregateGqlField } =
@@ -1,9 +1,11 @@
import { useListenToObjectRecordOperationBrowserEvent } from '@/browser-event/hooks/useListenToObjectRecordOperationBrowserEvent';
import { type ObjectRecordOperationBrowserEventDetail } from '@/browser-event/types/ObjectRecordOperationBrowserEventDetail';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { recordIndexHasRecordsComponentSelector } from '@/object-record/record-index/states/selectors/recordIndexHasRecordsComponentSelector';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
@@ -13,6 +15,7 @@ import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEvent
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
import { useAtomComponentStateCallbackState } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateCallbackState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useStore } from 'jotai';
import { useCallback, useMemo } from 'react';
@@ -38,6 +41,10 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
@@ -57,7 +64,11 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -71,6 +82,7 @@ export const RecordTableEmptyHasNewRecordEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
],
);
@@ -1,3 +1,4 @@
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { transformAggregateRawValueIntoAggregateDisplayValue } from '@/object-record/record-aggregate/utils/transformAggregateRawValueIntoAggregateDisplayValue';
import { getAggregateOperationLabel } from '@/object-record/record-board/record-board-column/utils/getAggregateOperationLabel';
@@ -6,6 +7,7 @@ import { currentRecordFilterGroupsComponentState } from '@/object-record/record-
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { anyFieldFilterValueComponentState } from '@/object-record/record-filter/states/anyFieldFilterValueComponentState';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { getRecordAggregateDisplayLabel } from '@/object-record/record-index/utils/getRecordndexAggregateDisplayLabel';
import { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
@@ -45,10 +47,18 @@ export const useAggregateRecordsForRecordTableColumnFooter = (
const dateLocale = useAtomStateValue(dateLocaleState);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const { filterValueDependencies } = useFilterValueDependencies();
const requestFilters = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilterGroups: currentRecordFilterGroups,
recordFilters: currentRecordFilters,
@@ -1,19 +1,26 @@
import { useMemo } from 'react';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';
import { useListenToEventsForQuery } from '@/sse-db-event/hooks/useListenToEventsForQuery';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
export const RecordTableVirtualizedSSESubscribeEffect = () => {
const { objectMetadataItem } = useRecordIndexContextOrThrow();
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
@@ -33,7 +40,11 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
objectNameSingular: objectMetadataItem.nameSingular,
variables: {
filter: computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: currentRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
recordFilters: currentRecordFilters,
recordFilterGroups: currentRecordFilterGroups,
filterValueDependencies,
@@ -47,6 +58,7 @@ export const RecordTableVirtualizedSSESubscribeEffect = () => {
currentRecordFilterGroups,
filterValueDependencies,
currentRecordSorts,
flattenedFieldMetadataItems,
],
);
@@ -4,9 +4,11 @@ import { contextStoreFilterGroupsComponentState } from '@/context-store/states/c
import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState';
import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useIncrementalUpdateManyRecords } from '@/object-record/hooks/useIncrementalUpdateManyRecords';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
type UseUpdateMultipleRecordsActionsProps = {
objectNameSingular: string;
@@ -43,11 +45,16 @@ export const useUpdateMultipleRecordsActions = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const graphqlFilter = computeContextStoreFilters({
contextStoreTargetedRecordsRule,
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -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 = <T extends ObjectRecord>({
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,
});
@@ -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;