feat: allow many-to-one relations as advanced filter leaves (#21147)
## What
Lets a many-to-one relation be selected as the **leaf** of an advanced
(nested) filter. Previously the nested-field submenu excluded relations,
so you could filter `Opportunities WHERE company.Name contains X` but
not `Opportunities WHERE company.accountOwner = me`.
## How it works
Selecting a relation leaf filters by its **foreign key** —
`company.accountOwnerId = X` — a single hop the backend already resolves
on the joined table (`{ company: { accountOwnerId: { in: [...] } } }`).
It is **not** a multi-hop traversal: filtering on a *scalar field of*
the related record (e.g. `company.accountOwner.name`) stays excluded,
since that needs a second join the backend caps at one hop.
Two changes:
- **`AdvancedFilterRelationTargetFieldSelectMenu`** — stop excluding
many-to-one relations from the nested-field submenu.
- **`ObjectFilterDropdownRecordSelect`** — resolve the record picker's
object from the *leaf* relation's target (e.g. WorkspaceMember,
including the "Me" pin) rather than the source relation's object. The
source-field fallback applies only when there is no leaf.
## Testing
- Added `turnRecordFilterIntoRecordGqlOperationFilter` unit cases
asserting a relation leaf (and `= me`) compiles to the FK form — 59/59.
- typecheck + lint green (twenty-front, twenty-shared).
Seeding an onboarding view that uses this filter will follow in a
separate PR.
This commit is contained in:
+1
-9
@@ -62,17 +62,9 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
|
||||
? sourceFieldMetadataItem.relation.targetObjectMetadata.id
|
||||
: null;
|
||||
|
||||
const { filterableFieldMetadataItems: allTargetFields } =
|
||||
const { filterableFieldMetadataItems: relationTargetFields } =
|
||||
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)
|
||||
|
||||
+71
-18
@@ -1,11 +1,14 @@
|
||||
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
|
||||
import { objectMetadataItemsSelector } from '@/object-metadata/states/objectMetadataItemsSelector';
|
||||
import { getRelationObjectMetadataNameSingular } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
|
||||
import { getFieldMetadataItemById } from '@/object-metadata/utils/getFieldMetadataItemById';
|
||||
import { ObjectFilterDropdownRecordPinnedItems } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordPinnedItems';
|
||||
import { CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID } from '@/object-record/object-filter-dropdown/constants/CurrentWorkspaceMemberSelectableItemId';
|
||||
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
|
||||
import { useObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useObjectFilterDropdownFilterValue';
|
||||
import { fieldMetadataItemUsedInDropdownComponentSelector } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemUsedInDropdownComponentSelector';
|
||||
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 { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
|
||||
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
|
||||
@@ -36,10 +39,19 @@ type ObjectFilterDropdownRecordSelectProps = {
|
||||
dropdownId: string;
|
||||
};
|
||||
|
||||
export const ObjectFilterDropdownRecordSelect = ({
|
||||
type ObjectFilterDropdownRecordSelectContentProps = {
|
||||
objectNameSingular: string;
|
||||
recordFilterId?: string;
|
||||
dropdownId: string;
|
||||
};
|
||||
|
||||
// Owns the record-picker hooks. Rendered only once an object to pick from has
|
||||
// been resolved, so its hooks always run in the same order.
|
||||
const ObjectFilterDropdownRecordSelectContent = ({
|
||||
objectNameSingular,
|
||||
recordFilterId,
|
||||
dropdownId,
|
||||
}: ObjectFilterDropdownRecordSelectProps) => {
|
||||
}: ObjectFilterDropdownRecordSelectContentProps) => {
|
||||
const fieldMetadataItemUsedInFilterDropdown = useAtomComponentSelectorValue(
|
||||
fieldMetadataItemUsedInDropdownComponentSelector,
|
||||
);
|
||||
@@ -75,28 +87,12 @@ export const ObjectFilterDropdownRecordSelect = ({
|
||||
})
|
||||
.parse(objectFilterDropdownFilterValue);
|
||||
|
||||
if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) {
|
||||
throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined');
|
||||
}
|
||||
|
||||
const objectNameSingular = getRelationObjectMetadataNameSingular({
|
||||
field: fieldMetadataItemUsedInFilterDropdown,
|
||||
});
|
||||
|
||||
if (!isDefined(objectNameSingular)) {
|
||||
throw new Error('relationObjectMetadataNameSingular is not defined');
|
||||
}
|
||||
|
||||
const { objectMetadataItem } = useObjectMetadataItem({
|
||||
objectNameSingular,
|
||||
});
|
||||
|
||||
const objectLabelPlural = objectMetadataItem?.labelPlural;
|
||||
|
||||
if (!isDefined(objectNameSingular)) {
|
||||
throw new Error('objectNameSingular is not defined');
|
||||
}
|
||||
|
||||
const firstSimpleRecordFilterForFieldMetadataItemUsedInDropdown =
|
||||
currentRecordFilters.find(
|
||||
(filter) =>
|
||||
@@ -242,3 +238,60 @@ export const ObjectFilterDropdownRecordSelect = ({
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ObjectFilterDropdownRecordSelect = ({
|
||||
recordFilterId,
|
||||
dropdownId,
|
||||
}: ObjectFilterDropdownRecordSelectProps) => {
|
||||
const fieldMetadataItemUsedInFilterDropdown = useAtomComponentSelectorValue(
|
||||
fieldMetadataItemUsedInDropdownComponentSelector,
|
||||
);
|
||||
|
||||
const relationTargetFieldMetadataIdUsedInDropdown =
|
||||
useAtomComponentStateValue(
|
||||
relationTargetFieldMetadataIdUsedInDropdownComponentState,
|
||||
);
|
||||
|
||||
const objectMetadataItems = useAtomStateValue(objectMetadataItemsSelector);
|
||||
|
||||
if (!isDefined(fieldMetadataItemUsedInFilterDropdown)) {
|
||||
throw new Error('fieldMetadataItemUsedInFilterDropdown is not defined');
|
||||
}
|
||||
|
||||
// Nested relation filters pick records from the leaf relation's target object
|
||||
// (company → accountOwner ⇒ WorkspaceMember); direct filters use the source.
|
||||
const relationTargetFieldMetadataItem = isDefined(
|
||||
relationTargetFieldMetadataIdUsedInDropdown,
|
||||
)
|
||||
? getFieldMetadataItemById({
|
||||
fieldMetadataId: relationTargetFieldMetadataIdUsedInDropdown,
|
||||
objectMetadataItems,
|
||||
}).fieldMetadataItem
|
||||
: undefined;
|
||||
|
||||
const effectiveFieldMetadataItem = isDefined(
|
||||
relationTargetFieldMetadataIdUsedInDropdown,
|
||||
)
|
||||
? relationTargetFieldMetadataItem
|
||||
: fieldMetadataItemUsedInFilterDropdown;
|
||||
|
||||
const objectNameSingular = isDefined(effectiveFieldMetadataItem)
|
||||
? getRelationObjectMetadataNameSingular({
|
||||
field: effectiveFieldMetadataItem,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
// A stale filter whose relation-target field was deleted can't resolve an
|
||||
// object to pick records from — render nothing rather than crash.
|
||||
if (!isDefined(objectNameSingular)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ObjectFilterDropdownRecordSelectContent
|
||||
objectNameSingular={objectNameSingular}
|
||||
recordFilterId={recordFilterId}
|
||||
dropdownId={dropdownId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+59
@@ -49,6 +49,12 @@ const fields = [
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Company',
|
||||
},
|
||||
{
|
||||
id: 'f-relation-account-owner',
|
||||
name: 'accountOwner',
|
||||
type: FieldMetadataType.RELATION,
|
||||
label: 'Account Owner',
|
||||
},
|
||||
{
|
||||
id: 'f-bool',
|
||||
name: 'isActive',
|
||||
@@ -1038,5 +1044,58 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
|
||||
|
||||
expect(result).toHaveProperty('companyId.in');
|
||||
});
|
||||
|
||||
// A relation leaf compiles to a single-hop FK compare on the joined table
|
||||
// (company.accountOwnerId), not a second-hop `company.accountOwner.<field>`.
|
||||
it('should resolve a relation target field to its foreign key', () => {
|
||||
const result = turnRecordFilterIntoRecordGqlOperationFilter({
|
||||
filterValueDependencies,
|
||||
recordFilter: {
|
||||
...makeFilter(
|
||||
'f-relation',
|
||||
RecordFilterOperand.IS,
|
||||
'["550e8400-e29b-41d4-a716-446655440000"]',
|
||||
'RELATION',
|
||||
),
|
||||
relationTargetFieldMetadataId: 'f-relation-account-owner',
|
||||
} as RecordFilter,
|
||||
fieldMetadataItemById,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
accountOwnerId: { in: ['550e8400-e29b-41d4-a716-446655440000'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// "= me" (the current workspace member) resolves into that same FK compare.
|
||||
it('should resolve a relation target field set to the current workspace member', () => {
|
||||
const result = turnRecordFilterIntoRecordGqlOperationFilter({
|
||||
filterValueDependencies: {
|
||||
...filterValueDependencies,
|
||||
currentWorkspaceMemberId: '11111111-1111-4111-8111-111111111111',
|
||||
},
|
||||
recordFilter: {
|
||||
...makeFilter(
|
||||
'f-relation',
|
||||
RecordFilterOperand.IS,
|
||||
JSON.stringify({
|
||||
isCurrentWorkspaceMemberSelected: true,
|
||||
selectedRecordIds: [],
|
||||
}),
|
||||
'RELATION',
|
||||
),
|
||||
relationTargetFieldMetadataId: 'f-relation-account-owner',
|
||||
} as RecordFilter,
|
||||
fieldMetadataItemById,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
company: {
|
||||
accountOwnerId: { in: ['11111111-1111-4111-8111-111111111111'] },
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user