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
@@ -771,6 +771,7 @@ type ViewFilter {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
viewId: UUID!
workspaceId: UUID!
createdAt: DateTime!
@@ -3416,6 +3417,7 @@ input CreateViewFilterInput {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
viewId: UUID!
}
@@ -3434,6 +3436,7 @@ input UpdateViewFilterInputUpdates {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
}
input DeleteViewFilterInput {
@@ -3522,6 +3525,7 @@ input UpsertViewWidgetViewFilterInput {
viewFilterGroupId: UUID
positionInViewFilterGroup: Float
subFieldName: String
relationTargetFieldMetadataId: UUID
}
input UpsertViewWidgetViewFilterGroupInput {
@@ -523,6 +523,7 @@ export interface ViewFilter {
viewFilterGroupId?: Scalars['UUID']
positionInViewFilterGroup?: Scalars['Float']
subFieldName?: Scalars['String']
relationTargetFieldMetadataId?: Scalars['UUID']
viewId: Scalars['UUID']
workspaceId: Scalars['UUID']
createdAt: Scalars['DateTime']
@@ -3451,6 +3452,7 @@ export interface ViewFilterGenqlSelection{
viewFilterGroupId?: boolean | number
positionInViewFilterGroup?: boolean | number
subFieldName?: boolean | number
relationTargetFieldMetadataId?: boolean | number
viewId?: boolean | number
workspaceId?: boolean | number
createdAt?: boolean | number
@@ -5973,7 +5975,7 @@ export interface CreateViewFilterGroupInput {id?: (Scalars['UUID'] | null),paren
export interface UpdateViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null),viewId?: (Scalars['UUID'] | null)}
export interface CreateViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),viewId: Scalars['UUID']}
export interface CreateViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null),viewId: Scalars['UUID']}
export interface UpdateViewFilterInput {
/** The id of the view filter to update */
@@ -5981,7 +5983,7 @@ id: Scalars['UUID'],
/** The view filter to update */
update: UpdateViewFilterInputUpdates}
export interface UpdateViewFilterInputUpdates {fieldMetadataId?: (Scalars['UUID'] | null),operand?: (ViewFilterOperand | null),value?: (Scalars['JSON'] | null),viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpdateViewFilterInputUpdates {fieldMetadataId?: (Scalars['UUID'] | null),operand?: (ViewFilterOperand | null),value?: (Scalars['JSON'] | null),viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null)}
export interface DeleteViewFilterInput {
/** The id of the view filter to delete. */
@@ -6013,7 +6015,7 @@ viewFieldId?: (Scalars['UUID'] | null),
/** The field metadata id. Used to create a new view field when viewFieldId is not provided. */
fieldMetadataId?: (Scalars['UUID'] | null),isVisible: Scalars['Boolean'],position: Scalars['Float'],size?: (Scalars['Float'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null)}
export interface UpsertViewWidgetViewFilterInput {id?: (Scalars['UUID'] | null),fieldMetadataId: Scalars['UUID'],operand?: (ViewFilterOperand | null),value: Scalars['JSON'],viewFilterGroupId?: (Scalars['UUID'] | null),positionInViewFilterGroup?: (Scalars['Float'] | null),subFieldName?: (Scalars['String'] | null),relationTargetFieldMetadataId?: (Scalars['UUID'] | null)}
export interface UpsertViewWidgetViewFilterGroupInput {id?: (Scalars['UUID'] | null),parentViewFilterGroupId?: (Scalars['UUID'] | null),logicalOperator?: (ViewFilterGroupLogicalOperator | null),positionInViewFilterGroup?: (Scalars['Float'] | null)}
@@ -1521,6 +1521,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"viewId": [
3
],
@@ -8929,6 +8932,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"viewId": [
3
],
@@ -8966,6 +8972,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"__typename": [
1
]
@@ -9154,6 +9163,9 @@ export default {
"subFieldName": [
1
],
"relationTargetFieldMetadataId": [
3
],
"__typename": [
1
]
File diff suppressed because one or more lines are too long
@@ -89,12 +89,17 @@ export const buildHeadlessCommandContextApi = ({
? (currentWorkspaceMember?.timeZone ?? systemTimeZone)
: systemTimeZone;
const flattenedFieldMetadataItems = objectMetadataItems.flatMap(
(objectMetadataItem) => objectMetadataItem.fields,
);
const graphqlFilter = isDefined(objectMetadataItem)
? computeContextStoreFilters({
contextStoreTargetedRecordsRule: targetedRecordsRule,
contextStoreFilters: filters,
contextStoreFilterGroups: filterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: currentWorkspaceMember?.id,
timeZone: userTimezone,
@@ -72,6 +72,7 @@ export const useFindManyRecordsSelectedInContextStore = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems: allFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
});
@@ -29,6 +29,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters: [],
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
flattenedFieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -73,6 +74,7 @@ describe('computeContextStoreFilters', () => {
contextStoreFilters,
contextStoreFilterGroups: [],
objectMetadataItem: personObjectMetadataItem,
flattenedFieldMetadataItems: personObjectMetadataItem.fields,
filterValueDependencies: mockFilterValueDependencies,
contextStoreAnyFieldFilterValue: '',
});
@@ -1,7 +1,9 @@
import { type ContextStoreTargetedRecordsRule } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
import {
type RecordFilterValueDependencies,
@@ -17,6 +19,7 @@ type ComputeContextStoreFiltersProps = {
contextStoreFilters: RecordFilter[];
contextStoreFilterGroups: RecordFilterGroup[];
objectMetadataItem: EnrichedObjectMetadataItem;
flattenedFieldMetadataItems: FieldMetadataItem[];
filterValueDependencies: RecordFilterValueDependencies;
contextStoreAnyFieldFilterValue: string;
};
@@ -26,6 +29,7 @@ export const computeContextStoreFilters = ({
contextStoreFilters,
contextStoreFilterGroups,
objectMetadataItem,
flattenedFieldMetadataItems,
filterValueDependencies,
contextStoreAnyFieldFilterValue,
}: ComputeContextStoreFiltersProps) => {
@@ -37,12 +41,18 @@ export const computeContextStoreFilters = ({
fields: objectMetadataItem.fields,
});
const fields = augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters: contextStoreFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
});
if (contextStoreTargetedRecordsRule.mode === 'exclusion') {
queryFilter = makeAndFilterVariables([
recordGqlFilterForAnyFieldFilter,
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
fields,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -71,7 +81,7 @@ export const computeContextStoreFilters = ({
},
computeRecordGqlOperationFilter({
filterValueDependencies,
fields: objectMetadataItem?.fields ?? [],
fields,
recordFilters: contextStoreFilters,
recordFilterGroups: contextStoreFilterGroups,
}),
@@ -1,6 +1,10 @@
import { SORTABLE_FIELD_METADATA_TYPES } from '@/object-metadata/constants/SortableFieldMetadataTypes';
import { isHiddenSystemField } from '@/object-metadata/utils/isHiddenSystemField';
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import {
type FieldMetadataType,
type RelationType,
} from '~/generated-metadata/graphql';
type SortableFieldInput = {
isSystem?: boolean | null;
@@ -17,13 +21,9 @@ export const filterSortableFieldMetadataItems = (field: SortableFieldInput) => {
field.type,
);
const isRelationFieldSortable =
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;
return (
!isHiddenSystemField(field) &&
isFieldActive &&
(isFieldTypeSortable || isRelationFieldSortable)
(isFieldTypeSortable || isManyToOneRelationField(field))
);
};
@@ -0,0 +1,12 @@
import { FieldMetadataType, RelationType } from '~/generated-metadata/graphql';
type FieldWithRelation = {
type: FieldMetadataType;
relation?: { type: RelationType } | null;
};
export const isManyToOneRelationField = <T extends FieldWithRelation>(
field: T,
): field is T & { relation: NonNullable<T['relation']> } =>
field.type === FieldMetadataType.RELATION &&
field.relation?.type === RelationType.MANY_TO_ONE;
@@ -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;
@@ -1,5 +1,8 @@
import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import {
computeRecordGqlOperationFilter,
isDefined,
@@ -38,10 +41,20 @@ export const useGraphWidgetQueryCommon = ({
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const widgetRecordFilters = configuration.filter?.recordFilters ?? [];
const gqlOperationFilter = computeRecordGqlOperationFilter({
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: widgetRecordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilters: configuration.filter?.recordFilters ?? [],
recordFilters: widgetRecordFilters,
recordFilterGroups: configuration.filter?.recordFilterGroups ?? [],
});
@@ -7,7 +7,8 @@ import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
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 { usePushFocusForLeafFieldValuePicker } from '@/object-record/advanced-filter/hooks/usePushFocusForLeafFieldValuePicker';
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';
@@ -73,8 +74,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
advancedFilterFieldSelectDropdownId,
);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const [, setObjectFilterDropdownSubMenuFieldType] = useAtomComponentState(
objectFilterDropdownSubMenuFieldTypeComponentState,
@@ -89,6 +90,9 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
fieldMetadataItemIdUsedInDropdownComponentState,
);
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
const handleFieldSelect = (
selectedFieldMetadataItem: FieldMetadataItem,
) => {
@@ -102,13 +106,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
setObjectFilterDropdownSubMenuFieldType(filterType);
setFieldMetadataItemIdUsedInDropdown(selectedFieldMetadataItem.id);
setObjectFilterDropdownIsSelectingCompositeField(true);
} else {
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
recordFilterId,
});
closeAdvancedFilterFieldSelectDropdown();
return;
}
applyAdvancedFilterSourceField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
recordFilterId,
});
pushFocusForLeafFieldValuePicker(selectedFieldMetadataItem);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = filteredFieldMetadataItems.map(
@@ -10,7 +10,7 @@ import { MenuItem } from 'twenty-ui/navigation';
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';
@@ -61,8 +61,8 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
const { closeAdvancedFilterFieldSelectDropdown } =
useAdvancedFilterFieldSelectDropdown(recordFilterId);
const { selectFieldUsedInAdvancedFilterDropdown } =
useSelectFieldUsedInAdvancedFilterDropdown();
const { applyAdvancedFilterCompositeSubField } =
useApplyAdvancedFilterCompositeSubField();
const handleSelectFilter = (
selectedFieldMetadataItem: FieldMetadataItem | null | undefined,
@@ -72,10 +72,10 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectS
return;
}
selectFieldUsedInAdvancedFilterDropdown({
fieldMetadataItemId: selectedFieldMetadataItem.id,
applyAdvancedFilterCompositeSubField({
sourceFieldMetadataItem: selectedFieldMetadataItem,
subFieldName: subFieldName ?? null,
recordFilterId,
subFieldName,
});
closeAdvancedFilterFieldSelectDropdown();
@@ -71,10 +71,7 @@ export const RecordTableSettingsFilters = ({
isWorkflowFindRecords={false}
onUpdate={handleFilterUpdate}
/>
<RecordTableSettingsFiltersInitializeStateEffect
view={view}
objectMetadataItem={objectMetadataItem}
/>
<RecordTableSettingsFiltersInitializeStateEffect view={view} />
</RecordFiltersComponentInstanceContext.Provider>
</RecordFilterGroupsComponentInstanceContext.Provider>
</StyledFilterSettingsContainer>
@@ -1,11 +1,11 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
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';
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 { 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';
@@ -13,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,
@@ -39,6 +37,10 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
currentRecordFilterGroupsComponentState,
);
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const [hasInitializedFilters, setHasInitializedFilters] = useState(false);
const stateAlreadyHasFilters =
@@ -54,10 +56,9 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
return;
}
const filterableFields = getFilterableFields(objectMetadataItem);
const recordFilters = mapViewFiltersToFilters(
view.viewFilters,
filterableFields,
flattenedFieldMetadataItems,
);
setCurrentRecordFilters(recordFilters);
@@ -71,7 +72,7 @@ export const RecordTableSettingsFiltersInitializeStateEffect = ({
setHasInitializedFilters(true);
}, [
view,
objectMetadataItem,
flattenedFieldMetadataItems,
hasInitializedFilters,
stateAlreadyHasFilters,
setCurrentRecordFilters,
@@ -1,12 +1,10 @@
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { FieldMetadataType } from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
export const hasNestedFields = (fieldMetadata: FieldMetadataItem) => {
return (
(fieldMetadata.type === FieldMetadataType.RELATION &&
fieldMetadata.relation?.type === RelationType.MANY_TO_ONE) ||
isManyToOneRelationField(fieldMetadata) ||
isCompositeFieldType(fieldMetadata.type)
);
};
@@ -9,6 +9,7 @@ export const VIEW_FILTER_FRAGMENT = gql`
viewFilterGroupId
positionInViewFilterGroup
subFieldName
relationTargetFieldMetadataId
viewId
createdAt
updatedAt
@@ -125,6 +125,7 @@ describe('useApplyCurrentViewFiltersToCurrentRecordFilters', () => {
label: mockFieldMetadataItem.label,
type: getFilterTypeFromFieldType(mockFieldMetadataItem.type),
subFieldName: null,
relationTargetFieldMetadataId: null,
} satisfies RecordFilter,
]);
});
@@ -1,11 +1,14 @@
import { useContextStoreObjectMetadataItemOrThrow } from '@/context-store/hooks/useContextStoreObjectMetadataItemOrThrow';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
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 { AggregateOperations } from '@/object-record/record-table/constants/AggregateOperations';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters';
import {
computeRecordGqlOperationFilter,
@@ -25,13 +28,21 @@ export const useGetRecordIndexTotalCount = () => {
const { filterValueDependencies } = useFilterValueDependencies();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const recordGroupsVisibilityFilter = useGetViewGroupsFilters();
const filter = computeRecordGqlOperationFilter({
filterValueDependencies,
recordFilters: [...currentRecordFilters, ...recordGroupsVisibilityFilter],
recordFilterGroups: currentRecordFilterGroups,
fields: objectMetadataItem.fields,
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem.fields,
recordFilters: [...currentRecordFilters, ...recordGroupsVisibilityFilter],
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
});
const anyFieldFilterValue = useAtomComponentStateValue(
@@ -1,18 +1,18 @@
import { useRecordIndexContextOrThrow } from '@/object-record/record-index/contexts/RecordIndexContext';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
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,
);
const mapViewFiltersToRecordFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
) => {
const filterableFieldMetadataItems =
getFilterableFields(objectMetadataItem);
return mapViewFiltersToFilters(viewFilters, filterableFieldMetadataItems);
return mapViewFiltersToFilters(viewFilters, flattenedFieldMetadataItems);
};
return { mapViewFiltersToRecordFilters };
@@ -1,9 +1,11 @@
import { MAIN_CONTEXT_STORE_INSTANCE_ID } from '@/context-store/constants/MainContextStoreInstanceId';
import { contextStoreRecordShowParentViewComponentState } from '@/context-store/states/contextStoreRecordShowParentViewComponentState';
import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems';
import { flattenedFieldMetadataItemsSelector } from '@/object-metadata/states/flattenedFieldMetadataItemsSelector';
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
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';
import { getQueryVariablesFromFiltersAndSorts } from '@/views/utils/getQueryVariablesFromFiltersAndSorts';
export const useQueryVariablesFromParentView = ({
@@ -13,6 +15,10 @@ export const useQueryVariablesFromParentView = ({
}) => {
const { objectMetadataItems } = useObjectMetadataItems();
const flattenedFieldMetadataItems = useAtomStateValue(
flattenedFieldMetadataItemsSelector,
);
const contextStoreRecordShowParentView = useAtomComponentStateValue(
contextStoreRecordShowParentViewComponentState,
MAIN_CONTEXT_STORE_INSTANCE_ID,
@@ -27,6 +33,7 @@ export const useQueryVariablesFromParentView = ({
recordSorts: contextStoreRecordShowParentView?.parentViewSorts ?? [],
objectMetadataItem,
objectMetadataItems,
flattenedFieldMetadataItems,
filterValueDependencies,
});
@@ -144,6 +144,8 @@ export const useSaveRecordFiltersAndGroupFiltersToViewFiltersAndGroupFilters =
viewFilterGroupId: viewFilter.viewFilterGroupId,
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
},
}),
);
@@ -158,6 +160,8 @@ export const useSaveRecordFiltersAndGroupFiltersToViewFiltersAndGroupFilters =
positionInViewFilterGroup: viewFilter.positionInViewFilterGroup,
viewFilterGroupId: viewFilter.viewFilterGroupId,
subFieldName: viewFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
},
},
}),
@@ -1,5 +1,6 @@
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { objectFilterDropdownCurrentRecordFilterComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownCurrentRecordFilterComponentState';
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 { useFilterableFieldMetadataItemsInRecordIndexContext } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItemsInRecordIndexContext';
@@ -59,6 +60,15 @@ export const useSetEditableFilterChipDropdownStates = () => {
}),
recordFilter.subFieldName,
);
store.set(
relationTargetFieldMetadataIdUsedInDropdownComponentState.atomFamily({
instanceId: getEditableChipObjectFilterDropdownComponentInstanceId({
recordFilterId: recordFilter.id,
}),
}),
recordFilter.relationTargetFieldMetadataId ?? null,
);
},
[store, filterableFieldMetadataItems],
);
@@ -12,4 +12,5 @@ export type ViewFilter = {
viewFilterGroupId?: string | null;
positionInViewFilterGroup?: number | null;
subFieldName?: string | null;
relationTargetFieldMetadataId?: string | null;
};
@@ -43,6 +43,8 @@ describe('mapViewFiltersToFilters', () => {
type: FieldMetadataType.FULL_NAME,
positionInRecordFilterGroup: undefined,
recordFilterGroupId: undefined,
subFieldName: undefined,
relationTargetFieldMetadataId: null,
},
];
expect(
@@ -12,6 +12,7 @@ export const areViewFiltersEqual = (
'value',
'operand',
'subFieldName',
'relationTargetFieldMetadataId',
];
return propertiesToCompare.every((property) =>
@@ -1,7 +1,9 @@
import { type EnrichedObjectMetadataItem } from '@/object-metadata/types/EnrichedObjectMetadataItem';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { augmentFieldsWithRelationTargets } from '@/object-record/record-filter/utils/augmentFieldsWithRelationTargets';
import { type RecordSort } from '@/object-record/record-sort/types/RecordSort';
import { type RecordFilterValueDependencies } from 'twenty-shared/types';
import { computeRecordGqlOperationFilter } from 'twenty-shared/utils';
@@ -12,6 +14,7 @@ export const getQueryVariablesFromFiltersAndSorts = ({
recordSorts,
objectMetadataItem,
objectMetadataItems = [],
flattenedFieldMetadataItems,
filterValueDependencies,
}: {
recordFilterGroups: RecordFilterGroup[];
@@ -19,10 +22,15 @@ export const getQueryVariablesFromFiltersAndSorts = ({
recordSorts: RecordSort[];
objectMetadataItem: EnrichedObjectMetadataItem;
objectMetadataItems?: EnrichedObjectMetadataItem[];
flattenedFieldMetadataItems: FieldMetadataItem[];
filterValueDependencies: RecordFilterValueDependencies;
}) => {
const filter = computeRecordGqlOperationFilter({
fields: objectMetadataItem?.fields ?? [],
fields: augmentFieldsWithRelationTargets({
baseFields: objectMetadataItem?.fields ?? [],
recordFilters,
allFieldMetadataItems: flattenedFieldMetadataItems,
}),
filterValueDependencies,
recordFilterGroups,
recordFilters,
@@ -13,5 +13,7 @@ export const mapRecordFilterToViewFilter = (
positionInViewFilterGroup: recordFilter.positionInRecordFilterGroup,
viewFilterGroupId: recordFilter.recordFilterGroupId,
subFieldName: recordFilter.subFieldName,
relationTargetFieldMetadataId:
recordFilter.relationTargetFieldMetadataId ?? null,
};
};
@@ -14,27 +14,50 @@ import { type ViewFilter } from '@/views/types/ViewFilter';
export const mapViewFiltersToFilters = (
viewFilters: ViewFilter[] | GqlViewFilter[],
availableFieldMetadataItems: FieldMetadataItem[],
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 filterType = getFilterTypeFromFieldType(
availableFieldMetadataItem.type,
);
const relationTargetFieldMetadataItem = isDefined(
viewFilter.relationTargetFieldMetadataId,
)
? fieldMetadataItems.find(
(fieldMetadataItem) =>
fieldMetadataItem.id === viewFilter.relationTargetFieldMetadataId,
)
: undefined;
const label = isSystemSearchVectorField(availableFieldMetadataItem.name)
// A relation-traversal filter is meaningful only if both ends
// resolve — drop it otherwise (same race-condition handling as the
// source field above) instead of rendering a chip whose filterType
// and label fall back to the relation source, since the dispatcher
// would silently drop the GraphQL filter anyway.
if (
isDefined(viewFilter.relationTargetFieldMetadataId) &&
!isDefined(relationTargetFieldMetadataItem)
) {
return undefined;
}
const filterType = isDefined(relationTargetFieldMetadataItem)
? getFilterTypeFromFieldType(relationTargetFieldMetadataItem.type)
: getFilterTypeFromFieldType(sourceFieldMetadataItem.type);
const label = isSystemSearchVectorField(sourceFieldMetadataItem.name)
? 'Search'
: availableFieldMetadataItem.label;
: isDefined(relationTargetFieldMetadataItem)
? `${sourceFieldMetadataItem.label}${relationTargetFieldMetadataItem.label}`
: sourceFieldMetadataItem.label;
const operand = viewFilter.operand;
@@ -54,6 +77,8 @@ export const mapViewFiltersToFilters = (
label,
type: filterType,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} satisfies RecordFilter;
})
.filter(isDefined);
@@ -0,0 +1,33 @@
import { QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
@RegisteredInstanceCommand('2.6.0', 1798000005000)
export class AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" ADD "relationTargetFieldMetadataId" uuid`,
);
await queryRunner.query(
`CREATE INDEX "IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID" ON "core"."viewFilter" ("relationTargetFieldMetadataId") WHERE "relationTargetFieldMetadataId" IS NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" ADD CONSTRAINT "FK_dbe259395cbd9a54c1c17d12b0b" FOREIGN KEY ("relationTargetFieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" DROP CONSTRAINT "FK_dbe259395cbd9a54c1c17d12b0b"`,
);
await queryRunner.query(
`DROP INDEX "core"."IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID"`,
);
await queryRunner.query(
`ALTER TABLE "core"."viewFilter" DROP COLUMN "relationTargetFieldMetadataId"`,
);
}
}
@@ -42,6 +42,7 @@ import { EncryptSensitiveConfigStorageSlowInstanceCommand } from 'src/database/c
import { EncryptTotpSecretsSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-slow-1798000009000-encrypt-totp-secrets';
import { AddSubFieldNameToViewSortFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1778502963794-add-sub-field-name-to-view-sort';
import { DropPostgresCredentialsTableFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-instance-command-fast-1798500000000-drop-postgres-credentials-table';
import { AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-6/2-6-instance-command-fast-1798000005000-add-relation-target-field-metadata-id-to-view-filter';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -86,4 +87,5 @@ export const INSTANCE_COMMANDS = [
EncryptTotpSecretsSlowInstanceCommand,
AddSubFieldNameToViewSortFastInstanceCommand,
DropPostgresCredentialsTableFastInstanceCommand,
AddRelationTargetFieldMetadataIdToViewFilterFastInstanceCommand,
];
@@ -231,6 +231,8 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
recordFilterGroupId: viewFilter.viewFilterGroupId,
positionInRecordFilterGroup: viewFilter.positionInViewFilterGroup,
subFieldName: viewFilter.subFieldName as CompositeFieldSubFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
};
});
@@ -254,6 +256,33 @@ export class CommonGroupByQueryRunnerService extends CommonBaseQueryRunnerServic
options: field.options as PartialFieldMetadataItemOption[],
}));
// Relation-traversal filters reference target fields on related objects
// that aren't in the source object's field list above. Resolve and add
// them so the shared dispatcher can look them up by id.
const relationTargetFieldsFromFilters = recordFilters
.map((filter) => filter.relationTargetFieldMetadataId)
.filter(isDefined)
.filter((id) => !fields.some((field) => field.id === id))
.map((id) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: id,
flatEntityMaps: flatFieldMetadataMaps,
});
if (!field) return null;
return {
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options as PartialFieldMetadataItemOption[],
};
})
.filter(isDefined);
fields.push(...relationTargetFieldsFromFilters);
const filtersFromView = computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups: recordFilterGroups,
@@ -24,6 +24,7 @@ export const fromViewFilterManifestToUniversalFlatViewFilter = ({
operand: viewFilterManifest.operand,
value: viewFilterManifest.value,
subFieldName: viewFilterManifest.subFieldName ?? null,
relationTargetFieldMetadataUniversalIdentifier: null,
positionInViewFilterGroup:
viewFilterManifest.positionInViewFilterGroup ?? null,
createdAt: now,
@@ -356,6 +356,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma
"viewFilterGroupUniversalIdentifier",
"positionInViewFilterGroup",
"subFieldName",
"relationTargetFieldMetadataUniversalIdentifier",
],
"propertiesToStringify": [
"value",
@@ -669,6 +669,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
toStringify: false,
universalProperty: undefined,
},
relationTargetFieldMetadataId: {
toCompare: true,
toStringify: false,
universalProperty: 'relationTargetFieldMetadataUniversalIdentifier',
},
createdAt: {
toCompare: false,
toStringify: false,
@@ -132,6 +132,9 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = {
view: {
foreignKey: 'viewId',
},
relationTargetFieldMetadata: {
foreignKey: 'relationTargetFieldMetadataId',
},
viewFilterGroup: {
foreignKey: 'viewFilterGroupId',
},
@@ -241,6 +241,13 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = {
isNullable: false,
universalForeignKey: 'viewUniversalIdentifier',
},
relationTargetFieldMetadata: {
metadataName: 'fieldMetadata',
foreignKey: 'relationTargetFieldMetadataId',
inverseOneToManyProperty: null,
isNullable: true,
universalForeignKey: 'relationTargetFieldMetadataUniversalIdentifier',
},
viewFilterGroup: {
metadataName: 'viewFilterGroup',
foreignKey: 'viewFilterGroupId',
@@ -4,10 +4,10 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with
[
"rowLevelPermissionPredicate",
"navigationMenuItem",
"viewFilter",
"commandMenuItem",
"fieldPermission",
"viewField",
"viewFilter",
"objectPermission",
"pageLayoutWidget",
"viewSort",
@@ -7,4 +7,5 @@ export const FLAT_VIEW_FILTER_EDITABLE_PROPERTIES = [
'viewFilterGroupId',
'positionInViewFilterGroup',
'subFieldName',
'relationTargetFieldMetadataId',
] as const satisfies MetadataEntityPropertyName<'viewFilter'>[];
@@ -31,6 +31,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
'viewFilterGroupId',
'operand',
'subFieldName',
'relationTargetFieldMetadataId',
],
);
@@ -41,12 +42,15 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId,
viewId,
viewFilterGroupId: createViewFilterInput.viewFilterGroupId,
relationTargetFieldMetadataId:
createViewFilterInput.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -69,6 +73,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({
positionInViewFilterGroup:
createViewFilterInput.positionInViewFilterGroup ?? null,
subFieldName: createViewFilterInput.subFieldName ?? null,
relationTargetFieldMetadataUniversalIdentifier,
applicationUniversalIdentifier: flatApplication.universalIdentifier,
};
};
@@ -87,5 +87,22 @@ export const fromUpdateViewFilterInputToFlatViewFilterToUpdateOrThrow = ({
viewFilterGroupUniversalIdentifier;
}
if (
updatedEditableFieldProperties.relationTargetFieldMetadataId !== undefined
) {
const { relationTargetFieldMetadataUniversalIdentifier } =
resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
relationTargetFieldMetadataId:
flatViewFilterToUpdate.relationTargetFieldMetadataId,
},
flatEntityMaps: { flatFieldMetadataMaps },
});
flatViewFilterToUpdate.relationTargetFieldMetadataUniversalIdentifier =
relationTargetFieldMetadataUniversalIdentifier;
}
return flatViewFilterToUpdate;
};
@@ -69,6 +69,22 @@ export const fromViewFilterEntityToFlatViewFilter = ({
);
}
let relationTargetFieldMetadataUniversalIdentifier: string | null = null;
if (isDefined(viewFilterEntity.relationTargetFieldMetadataId)) {
relationTargetFieldMetadataUniversalIdentifier =
fieldMetadataIdToUniversalIdentifierMap.get(
viewFilterEntity.relationTargetFieldMetadataId,
) ?? null;
if (!isDefined(relationTargetFieldMetadataUniversalIdentifier)) {
throw new FlatEntityMapsException(
`FieldMetadata with id ${viewFilterEntity.relationTargetFieldMetadataId} not found for viewFilter ${viewFilterEntity.id} (relation target)`,
FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND,
);
}
}
return {
...viewFilterEntityWithoutRelations,
createdAt: viewFilterEntity.createdAt.toISOString(),
@@ -77,6 +93,7 @@ export const fromViewFilterEntityToFlatViewFilter = ({
universalIdentifier: viewFilterEntityWithoutRelations.universalIdentifier,
applicationUniversalIdentifier,
fieldMetadataUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
viewUniversalIdentifier,
};
@@ -49,6 +49,11 @@ export class CreateViewFilterInput {
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
@IsUUID()
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -46,6 +46,11 @@ class UpdateViewFilterInputUpdates {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@InputType()
@@ -34,6 +34,9 @@ export class ViewFilterDTO {
@Field(() => String, { nullable: true })
subFieldName?: string | null;
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string | null;
@Field(() => UUIDScalarType, { nullable: false })
viewId: string;
@@ -23,6 +23,11 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Index('IDX_VIEW_FILTER_WORKSPACE_ID_VIEW_ID', ['workspaceId', 'viewId'])
@Index('IDX_VIEW_FILTER_VIEW_ID', ['viewId'])
@Index('IDX_VIEW_FILTER_FIELD_METADATA_ID', ['fieldMetadataId'])
@Index(
'IDX_VIEW_FILTER_RELATION_TARGET_FIELD_METADATA_ID',
['relationTargetFieldMetadataId'],
{ where: '"relationTargetFieldMetadataId" IS NOT NULL' },
)
export class ViewFilterEntity
extends SyncableEntity
implements Required<ViewFilterEntity>
@@ -59,6 +64,16 @@ export class ViewFilterEntity
@Column({ nullable: true, type: 'text', default: null })
subFieldName: string | null;
@Column({ nullable: true, type: 'uuid', default: null })
relationTargetFieldMetadataId: string | null;
@ManyToOne(() => FieldMetadataEntity, {
onDelete: 'CASCADE',
nullable: true,
})
@JoinColumn({ name: 'relationTargetFieldMetadataId' })
relationTargetFieldMetadata: Relation<FieldMetadataEntity> | null;
@Column({ nullable: false, type: 'uuid' })
viewId: string;
@@ -48,4 +48,9 @@ export class UpsertViewWidgetViewFilterInput {
@IsString()
@Field({ nullable: true })
subFieldName?: string;
@IsOptional()
@IsUUID()
@Field(() => UUIDScalarType, { nullable: true })
relationTargetFieldMetadataId?: string;
}
@@ -267,5 +267,83 @@ describe('ViewQueryParamsService', () => {
// Filter should be effectively empty because the field was deleted
expect(result.filter).toEqual({ and: [] });
});
it('should resolve relation-traversal filters against the target field', async () => {
const relationFieldId = 'relation-field-id';
const targetFieldId = 'target-field-id';
const mockFilterGroupId = 'filter-group-id';
const flatFieldMetadataMapsWithRelation = {
byUniversalIdentifier: {
'relation-universal-id': {
id: relationFieldId,
name: 'company',
type: FieldMetadataType.RELATION,
label: 'Company',
options: null,
universalIdentifier: 'relation-universal-id',
},
'target-universal-id': {
id: targetFieldId,
name: 'name',
type: FieldMetadataType.TEXT,
label: 'Name',
options: null,
universalIdentifier: 'target-universal-id',
},
},
universalIdentifierById: {
[relationFieldId]: 'relation-universal-id',
[targetFieldId]: 'target-universal-id',
},
universalIdentifiersByApplicationId: {},
};
const mockView = {
id: mockViewId,
name: 'People at Acme',
objectMetadataId: mockObjectMetadataId,
type: ViewType.TABLE,
visibility: ViewVisibility.WORKSPACE,
viewFilters: [
{
id: 'filter-id',
fieldMetadataId: relationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
viewFilterGroupId: mockFilterGroupId,
subFieldName: null,
relationTargetFieldMetadataId: targetFieldId,
},
],
viewFilterGroups: [
{
id: mockFilterGroupId,
parentViewFilterGroupId: null,
logicalOperator: ViewFilterGroupLogicalOperator.AND,
},
],
viewSorts: [],
};
viewService.findByIdWithRelations.mockResolvedValue(mockView as any);
flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatObjectMetadataMaps: mockFlatObjectMetadataMaps,
flatFieldMetadataMaps: flatFieldMetadataMapsWithRelation,
} as any,
);
const result = await viewQueryParamsService.resolveViewToQueryParams(
mockViewId,
mockWorkspaceId,
);
// Filter is nested under the relation field name, not flattened
// against the FK column.
expect(result.filter).toEqual({
and: [{ company: { name: { ilike: '%Acme%' } } }],
});
});
});
});
@@ -90,6 +90,8 @@ export class ViewQueryParamsService {
recordFilterGroupId: viewFilter.viewFilterGroupId,
operand: viewFilter.operand,
subFieldName: viewFilter.subFieldName,
relationTargetFieldMetadataId:
viewFilter.relationTargetFieldMetadataId ?? null,
} as RecordFilter;
})
.filter(isDefined);
@@ -105,10 +107,16 @@ export class ViewQueryParamsService {
: RecordFilterGroupLogicalOperator.AND,
}));
const fields = recordFilters
.map((filter) => {
const filterFieldMetadataIds = recordFilters.flatMap((filter) =>
isDefined(filter.relationTargetFieldMetadataId)
? [filter.fieldMetadataId, filter.relationTargetFieldMetadataId]
: [filter.fieldMetadataId],
);
const fields = filterFieldMetadataIds
.map((fieldMetadataId) => {
const field = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: filter.fieldMetadataId,
flatEntityId: fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
@@ -675,12 +675,15 @@ export class ViewWidgetUpsertService {
fieldMetadataUniversalIdentifier,
viewUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -706,6 +709,9 @@ export class ViewWidgetUpsertService {
positionInViewFilterGroup:
inputFilter.positionInViewFilterGroup ?? null,
subFieldName: inputFilter.subFieldName ?? null,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
deletedAt: null,
@@ -719,17 +725,22 @@ export class ViewWidgetUpsertService {
existingFilter.viewFilterGroupId !== inputFilter.viewFilterGroupId ||
existingFilter.positionInViewFilterGroup !==
inputFilter.positionInViewFilterGroup ||
existingFilter.subFieldName !== inputFilter.subFieldName;
existingFilter.subFieldName !== inputFilter.subFieldName ||
existingFilter.relationTargetFieldMetadataId !==
(inputFilter.relationTargetFieldMetadataId ?? null);
if (hasChanged) {
const {
fieldMetadataUniversalIdentifier,
viewFilterGroupUniversalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
} = resolveEntityRelationUniversalIdentifiers({
metadataName: 'viewFilter',
foreignKeyValues: {
fieldMetadataId: inputFilter.fieldMetadataId,
viewFilterGroupId: inputFilter.viewFilterGroupId,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId,
},
flatEntityMaps: {
flatFieldMetadataMaps,
@@ -751,6 +762,9 @@ export class ViewWidgetUpsertService {
existingFilter.positionInViewFilterGroup,
subFieldName:
inputFilter.subFieldName ?? existingFilter.subFieldName,
relationTargetFieldMetadataId:
inputFilter.relationTargetFieldMetadataId ?? null,
relationTargetFieldMetadataUniversalIdentifier,
updatedAt: now,
});
}
@@ -96,6 +96,8 @@ export const createStandardViewFilterFlatMetadata = <
operand,
value,
subFieldName,
relationTargetFieldMetadataId: null,
relationTargetFieldMetadataUniversalIdentifier: null,
positionInViewFilterGroup,
createdAt: now,
updatedAt: now,
@@ -26,12 +26,16 @@ export class CreateViewFilterActionHandlerService extends WorkspaceMigrationRunn
flatApplication,
workspaceId,
}: WorkspaceMigrationActionRunnerArgs<UniversalCreateViewFilterAction>): Promise<FlatCreateViewFilterAction> {
const { fieldMetadataId, viewId, viewFilterGroupId } =
resolveUniversalRelationIdentifiersToIds({
flatEntityMaps: allFlatEntityMaps,
metadataName: action.metadataName,
universalForeignKeyValues: action.flatEntity,
});
const {
fieldMetadataId,
viewId,
viewFilterGroupId,
relationTargetFieldMetadataId,
} = resolveUniversalRelationIdentifiersToIds({
flatEntityMaps: allFlatEntityMaps,
metadataName: action.metadataName,
universalForeignKeyValues: action.flatEntity,
});
const emptyUniversalForeignKeyAggregators =
getUniversalFlatEntityEmptyForeignKeyAggregators({
@@ -45,6 +49,7 @@ export class CreateViewFilterActionHandlerService extends WorkspaceMigrationRunn
fieldMetadataId,
viewId,
viewFilterGroupId,
relationTargetFieldMetadataId,
id: action.id ?? v4(),
applicationId: flatApplication.id,
workspaceId,
@@ -0,0 +1,229 @@
import { findManyObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/find-many-object-metadata.util';
import { createOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/create-one-view-filter.util';
import { destroyOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/destroy-one-view-filter.util';
import { updateOneViewFilter } from 'test/integration/metadata/suites/view-filter/utils/update-one-view-filter.util';
import { createOneView } from 'test/integration/metadata/suites/view/utils/create-one-view.util';
import { destroyOneView } from 'test/integration/metadata/suites/view/utils/destroy-one-view.util';
import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test';
import { ViewFilterOperand, ViewType } from 'twenty-shared/types';
// Round-trip of relation-traversal view filters (the one-hop
// `relationTargetFieldMetadataId` carried alongside the source relation
// field id) through the metadata create + update mutations.
const RELATION_TRAVERSAL_GQL_FIELDS = `
id
fieldMetadataId
operand
value
viewId
subFieldName
relationTargetFieldMetadataId
createdAt
updatedAt
deletedAt
`;
describe('View Filter relation-traversal round-trip should succeed', () => {
let createdViewId: string;
let createdViewFilterId: string;
let personCompanyRelationFieldId: string;
let companyNameFieldId: string;
let companyDomainNameFieldId: string;
beforeAll(async () => {
const { objects } = await findManyObjectMetadata({
expectToFail: false,
input: {
filter: {},
paging: { first: 1000 },
},
gqlFields: `
id
nameSingular
fieldsList {
id
name
type
}
`,
});
jestExpectToBeDefined(objects);
const personObject = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'person',
);
jestExpectToBeDefined(personObject);
const companyObject = objects.find(
(object: { nameSingular: string }) => object.nameSingular === 'company',
);
jestExpectToBeDefined(companyObject);
const personCompanyRelationField = personObject.fieldsList?.find(
(field: { name: string; type: string }) =>
field.name === 'company' && field.type === 'RELATION',
);
jestExpectToBeDefined(personCompanyRelationField);
personCompanyRelationFieldId = personCompanyRelationField.id;
const companyNameField = companyObject.fieldsList?.find(
(field: { name: string }) => field.name === 'name',
);
jestExpectToBeDefined(companyNameField);
companyNameFieldId = companyNameField.id;
const companyDomainNameField = companyObject.fieldsList?.find(
(field: { name: string }) => field.name === 'domainName',
);
jestExpectToBeDefined(companyDomainNameField);
companyDomainNameFieldId = companyDomainNameField.id;
const { data: viewData } = await createOneView({
expectToFail: false,
input: {
name: 'Test View For Relation Traversal Filter',
objectMetadataId: personObject.id,
type: ViewType.TABLE,
icon: 'IconUser',
},
});
createdViewId = viewData?.createView?.id;
jestExpectToBeDefined(createdViewId);
});
afterAll(async () => {
if (createdViewId) {
await destroyOneView({
expectToFail: false,
viewId: createdViewId,
});
}
});
afterEach(async () => {
if (createdViewFilterId) {
await destroyOneViewFilter({
expectToFail: false,
input: { id: createdViewFilterId },
});
createdViewFilterId = '';
}
});
it('should persist relationTargetFieldMetadataId on create', async () => {
const { data } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = data?.createViewFilter?.id;
expect(data.createViewFilter).toMatchObject({
id: expect.any(String),
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: companyNameFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
});
});
it('should default relationTargetFieldMetadataId to null when not provided', async () => {
const { data } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = data?.createViewFilter?.id;
expect(data.createViewFilter).toMatchObject({
id: expect.any(String),
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: null,
});
});
it('should update relationTargetFieldMetadataId on an existing filter', async () => {
const { data: createData } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = createData?.createViewFilter?.id;
jestExpectToBeDefined(createdViewFilterId);
const { data: updateData } = await updateOneViewFilter({
expectToFail: false,
input: {
id: createdViewFilterId,
update: {
relationTargetFieldMetadataId: companyDomainNameFieldId,
},
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
expect(updateData.updateViewFilter).toMatchObject({
id: createdViewFilterId,
fieldMetadataId: personCompanyRelationFieldId,
relationTargetFieldMetadataId: companyDomainNameFieldId,
});
});
it('should preserve relationTargetFieldMetadataId when an unrelated property is updated', async () => {
const { data: createData } = await createOneViewFilter({
expectToFail: false,
input: {
viewId: createdViewId,
fieldMetadataId: personCompanyRelationFieldId,
operand: ViewFilterOperand.CONTAINS,
value: 'Acme',
relationTargetFieldMetadataId: companyNameFieldId,
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
createdViewFilterId = createData?.createViewFilter?.id;
jestExpectToBeDefined(createdViewFilterId);
const { data: updateData } = await updateOneViewFilter({
expectToFail: false,
input: {
id: createdViewFilterId,
update: {
value: 'Acme Corp',
},
},
gqlFields: RELATION_TRAVERSAL_GQL_FIELDS,
});
expect(updateData.updateViewFilter).toMatchObject({
id: createdViewFilterId,
value: 'Acme Corp',
relationTargetFieldMetadataId: companyNameFieldId,
});
});
});
@@ -919,4 +919,92 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
expect(result).toHaveProperty('recordId.in');
});
});
describe('relation traversal', () => {
// The dispatcher should wrap the inner filter under the relation source
// field's GraphQL key and build it against the target field's type
// (not the relation FK's type).
it('should nest filter under source field name when target field is set', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.CONTAINS, 'Acme'),
relationTargetFieldMetadataId: 'f-text',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toEqual({ company: { name: { ilike: '%Acme%' } } });
});
// If the target field is no longer in fieldMetadataItems (e.g. it was
// deleted from the workspace), dropping the filter is the safe path —
// the alternative would silently interpret the text value as a UUID
// list against the relation FK.
it('should return undefined when target field is not found', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.CONTAINS, 'Acme'),
relationTargetFieldMetadataId: 'nonexistent-target',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toBeUndefined();
});
// Emptiness operands must check the target column on the related
// object rather than the FK column on the source record.
it('should apply IS_EMPTY against the target field, not the relation FK', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.IS_EMPTY, ''),
relationTargetFieldMetadataId: 'f-text',
} as RecordFilter,
fieldMetadataItems: fields,
});
// Without traversal, the RELATION case would have produced a filter
// on `companyId`; here we expect the emptiness check to fall on
// `company.name` instead.
expect(result).toEqual({
company: { or: [{ name: { ilike: '' } }, { name: { is: 'NULL' } }] },
});
});
// The target field's per-type switch must run, so number/select/etc.
// targets behave like a direct filter on that field — verified here
// with a SELECT target.
it('should dispatch to target type switch (SELECT target)', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: {
...makeFilter('f-relation', RecordFilterOperand.IS, '["ACTIVE"]'),
relationTargetFieldMetadataId: 'f-select',
} as RecordFilter,
fieldMetadataItems: fields,
});
expect(result).toEqual({ company: { status: { in: ['ACTIVE'] } } });
});
// Without a relationTargetFieldMetadataId the dispatcher must fall
// through to the direct builder — preserving the filter-by-record-id
// behaviour on the relation FK.
it('should keep relation filter-by-id behaviour when no target field is set', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: makeFilter(
'f-relation',
RecordFilterOperand.IS,
'["550e8400-e29b-41d4-a716-446655440000"]',
),
fieldMetadataItems: fields,
});
expect(result).toHaveProperty('companyId.in');
});
});
});
@@ -19,6 +19,7 @@ export type RecordFilter = {
recordFilterGroupId?: string | null;
operand: ViewFilterOperand;
subFieldName?: CompositeFieldSubFieldName | null | undefined;
relationTargetFieldMetadataId?: string | null | undefined;
};
export type RecordFilterGroup = {
@@ -75,11 +75,11 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}: TurnRecordFilterIntoRecordGqlOperationFilterParams):
| RecordGqlOperationFilter
| undefined => {
const correspondingFieldMetadataItem = fieldMetadataItems.find(
const sourceFieldMetadataItem = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
if (!isDefined(correspondingFieldMetadataItem)) {
if (!isDefined(sourceFieldMetadataItem)) {
return;
}
@@ -87,15 +87,66 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return;
}
if (
sourceFieldMetadataItem.type === FieldMetadataType.RELATION &&
isDefined(recordFilter.relationTargetFieldMetadataId)
) {
const targetFieldMetadataItem = fieldMetadataItems.find(
(field) => field.id === recordFilter.relationTargetFieldMetadataId,
);
if (!isDefined(targetFieldMetadataItem)) {
return;
}
const innerFilter = buildDirectFieldGqlOperationFilter({
recordFilter: {
...recordFilter,
fieldMetadataId: targetFieldMetadataItem.id,
relationTargetFieldMetadataId: null,
},
fieldMetadataItem: targetFieldMetadataItem,
filterValueDependencies,
});
if (!isDefined(innerFilter)) {
return;
}
return {
[sourceFieldMetadataItem.name]: innerFilter,
} as RecordGqlOperationFilter;
}
return buildDirectFieldGqlOperationFilter({
recordFilter,
fieldMetadataItem: sourceFieldMetadataItem,
filterValueDependencies,
});
};
type BuildDirectFieldGqlOperationFilterParams = {
filterValueDependencies: RecordFilterValueDependencies;
recordFilter: Omit<RecordFilter, 'id'>;
fieldMetadataItem: FieldShared;
};
const buildDirectFieldGqlOperationFilter = ({
recordFilter,
fieldMetadataItem,
filterValueDependencies,
}: BuildDirectFieldGqlOperationFilterParams):
| RecordGqlOperationFilter
| undefined => {
const shouldComputeEmptinessFilter = checkIfShouldComputeEmptinessFilter({
recordFilterOperand: recordFilter.operand,
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
});
if (shouldComputeEmptinessFilter) {
const emptinessFilter = getEmptyRecordGqlOperationFilter({
operand: recordFilter.operand,
correspondingField: correspondingFieldMetadataItem,
correspondingField: fieldMetadataItem,
recordFilter: recordFilter,
});
@@ -106,23 +157,21 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
const isSubFieldFilter = isNonEmptyString(subFieldName);
const filterType = getFilterTypeFromFieldType(
correspondingFieldMetadataItem.type,
);
const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type);
switch (filterType) {
case 'TEXT':
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
ilike: `%${recordFilter.value}%`,
} as StringFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
ilike: `%${recordFilter.value}%`,
} as StringFilter,
},
@@ -137,7 +186,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.VECTOR_SEARCH:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
search: recordFilter.value,
} as TSVectorFilter,
};
@@ -150,14 +199,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as RawJsonFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as RawJsonFilter,
},
@@ -171,14 +220,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
like: `%${recordFilter.value}%`,
} as FilesFilter,
},
@@ -215,12 +264,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start,
} as DateFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end,
} as DateFilter,
},
@@ -241,19 +290,19 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_IN_PAST:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: nowAsPlainDate,
} as DateFilter,
};
case RecordFilterOperand.IS_IN_FUTURE:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: nowAsPlainDate,
} as DateFilter,
};
case RecordFilterOperand.IS_TODAY: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: nowAsPlainDate,
} as DateFilter,
};
@@ -265,14 +314,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_AFTER: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: plainDateFilter,
} as DateFilter,
};
}
case RecordFilterOperand.IS_BEFORE: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: plainDateFilter,
} as DateFilter,
};
@@ -280,7 +329,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.IS: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: plainDateFilter,
} as DateFilter,
};
@@ -331,12 +380,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start.toInstant().toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end.toInstant().toString(),
} as DateTimeFilter,
},
@@ -357,13 +406,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_IN_PAST:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: now.toInstant().round('minute').toString(),
} as DateTimeFilter,
};
case RecordFilterOperand.IS_IN_FUTURE:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gt: now.toInstant().round('minute').toString(),
} as DateTimeFilter,
};
@@ -371,12 +420,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: getPeriodStart(now, 'DAY').toInstant().toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: getNextPeriodStart(now, 'DAY').toInstant().toString(),
} as DateTimeFilter,
},
@@ -413,12 +462,12 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
and: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: start.toString(),
} as DateTimeFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: end.toString(),
} as DateTimeFilter,
},
@@ -431,14 +480,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS_AFTER: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: resolvedDateTime.toString(),
} as DateTimeFilter,
};
}
case RecordFilterOperand.IS_BEFORE: {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lt: resolvedDateTime.toString(),
} as DateTimeFilter,
};
@@ -454,13 +503,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: convertRatingToRatingValue(parseFloat(recordFilter.value)),
} as RatingFilter,
};
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: convertGreaterThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value),
),
@@ -468,7 +517,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: convertLessThanOrEqualRatingToArrayOfRatingValues(
parseFloat(recordFilter.value),
),
@@ -483,26 +532,26 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
gte: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
lte: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: parseFloat(recordFilter.value),
} as FloatFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: parseFloat(recordFilter.value),
} as FloatFilter,
},
@@ -535,7 +584,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
in: recordIds,
} as RelationFilter,
};
@@ -545,13 +594,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
in: recordIds,
} as RelationFilter,
},
},
{
[correspondingFieldMetadataItem.name + 'Id']: {
[fieldMetadataItem.name + 'Id']: {
is: 'NULL',
} as RelationFilter,
},
@@ -579,7 +628,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (parsedCurrencyCodes.length === 0) return undefined;
const gqlFilter: RecordGqlOperationFilter = {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
currencyCode: { in: parsedCurrencyCodes },
} as CurrencyFilter,
};
@@ -607,26 +656,26 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.GREATER_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { gte: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.LESS_THAN_OR_EQUAL:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { lte: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: { eq: parseFloat(recordFilter.value) * 1000000 },
} as CurrencyFilter,
};
case RecordFilterOperand.IS_NOT:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
amountMicros: {
eq: parseFloat(recordFilter.value) * 1000000,
},
@@ -646,7 +695,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
case 'LINKS': {
return computeGqlOperationFilterForLinks({
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
recordFilter,
subFieldName,
});
@@ -654,7 +703,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case 'FULL_NAME': {
const fullNameFilters = generateILikeFiltersForCompositeFields(
recordFilter.value,
correspondingFieldMetadataItem.name,
fieldMetadataItem.name,
['firstName', 'lastName'],
);
switch (recordFilter.operand) {
@@ -665,7 +714,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
} else {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
@@ -684,7 +733,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} else {
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
@@ -705,42 +754,42 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
ilike: `%${recordFilter.value}%`,
},
} as AddressFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${recordFilter.value}%`,
},
@@ -759,7 +808,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
in: parsedCountryCodes,
} as AddressFilter,
@@ -768,7 +817,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
} as AddressFilter,
@@ -783,7 +832,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
ilike: `%${recordFilter.value}%`,
},
@@ -791,7 +840,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet1: {
is: 'NULL',
},
@@ -803,7 +852,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
ilike: `%${recordFilter.value}%`,
},
@@ -811,7 +860,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressStreet2: {
is: 'NULL',
},
@@ -823,7 +872,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
ilike: `%${recordFilter.value}%`,
},
@@ -831,7 +880,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCity: {
is: 'NULL',
},
@@ -843,7 +892,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
ilike: `%${recordFilter.value}%`,
},
@@ -851,7 +900,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressState: {
is: 'NULL',
},
@@ -863,7 +912,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
ilike: `%${recordFilter.value}%`,
},
@@ -871,7 +920,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressPostcode: {
is: 'NULL',
},
@@ -883,7 +932,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
ilike: `%${recordFilter.value}%`,
},
@@ -891,7 +940,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
is: 'NULL',
},
@@ -918,7 +967,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
in: JSON.parse(recordFilter.value),
} as AddressFilter,
@@ -926,7 +975,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
addressCountry: {
is: 'NULL',
} as AddressFilter,
@@ -940,7 +989,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
} as AddressFilter,
@@ -948,7 +997,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
[subFieldName]: {
is: 'NULL',
} as AddressFilter,
@@ -976,7 +1025,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsAny: nonEmptyOptions,
} as MultiSelectFilter,
});
@@ -984,7 +1033,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
isEmptyArray: true,
} as MultiSelectFilter,
});
@@ -997,18 +1046,18 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsAny: nonEmptyOptions,
} as MultiSelectFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
isEmptyArray: true,
} as MultiSelectFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as MultiSelectFilter,
},
@@ -1034,7 +1083,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: nonEmptyOptions,
} as SelectFilter,
});
@@ -1042,7 +1091,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as SelectFilter,
});
@@ -1056,7 +1105,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (nonEmptyOptions.length > 0) {
conditions.push({
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: nonEmptyOptions,
} as SelectFilter,
},
@@ -1066,7 +1115,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
if (emptyOptions.length > 0) {
conditions.push({
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
is: 'NULL',
} as SelectFilter,
},
@@ -1085,14 +1134,14 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsIlike: `%${recordFilter.value}%`,
} as ArrayFilter,
};
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
containsIlike: `%${recordFilter.value}%`,
} as ArrayFilter,
},
@@ -1114,7 +1163,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
const parsedSources = JSON.parse(recordFilter.value) as string[];
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: parsedSources,
} satisfies RelationFilter,
@@ -1132,7 +1181,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: parsedSources,
} satisfies RelationFilter,
@@ -1141,12 +1190,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
@@ -1177,7 +1222,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
in: workspaceMemberIds,
} satisfies UUIDFilter,
@@ -1188,7 +1233,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
in: workspaceMemberIds,
} satisfies UUIDFilter,
@@ -1196,7 +1241,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
workspaceMemberId: {
is: 'NULL',
} satisfies UUIDFilter,
@@ -1206,12 +1251,8 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
@@ -1227,7 +1268,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
name: {
ilike: `%${recordFilter.value}%`,
},
@@ -1236,7 +1277,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
...(matchingSourceValues.length > 0
? [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: matchingSourceValues,
},
@@ -1252,7 +1293,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
name: {
ilike: `%${recordFilter.value}%`,
},
@@ -1263,7 +1304,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
? [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
source: {
in: matchingSourceValues,
},
@@ -1276,19 +1317,15 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
};
}
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,
);
throw new Error(
`Unknown operand ${recordFilter.operand} for ${fieldForRecordFilter?.label ?? ''} filter`,
`Unknown operand ${recordFilter.operand} for ${fieldMetadataItem.label} filter`,
);
}
}
}
case 'EMAILS': {
return computeGqlOperationFilterForEmails({
correspondingFieldMetadataItem,
correspondingFieldMetadataItem: fieldMetadataItem,
recordFilter,
subFieldName,
});
@@ -1306,21 +1343,21 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1333,7 +1370,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1342,7 +1379,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1353,7 +1390,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1361,7 +1398,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
@@ -1387,7 +1424,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1400,7 +1437,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
@@ -1408,7 +1445,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
},
{
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
@@ -1426,7 +1463,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1435,7 +1472,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
@@ -1452,7 +1489,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.CONTAINS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1461,7 +1498,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
case RecordFilterOperand.DOES_NOT_CONTAIN:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
@@ -1482,7 +1519,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
case 'BOOLEAN': {
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
eq: recordFilter.value === 'true',
} as BooleanFilter,
};
@@ -1495,7 +1532,7 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
switch (recordFilter.operand) {
case RecordFilterOperand.IS:
return {
[correspondingFieldMetadataItem.name]: {
[fieldMetadataItem.name]: {
in: recordIds,
} as UUIDFilter,
};