Support workspace member Me filter for relation fields in dashboards (#23282)

<img width="3024" height="1484" alt="CleanShot 2026-07-27 at 15 04
22@2x"
src="https://github.com/user-attachments/assets/a57797b7-dbe9-4748-aeff-18667f1f69bb"
/>


Fixes #20225

Workspace member "Me" filters worked for standard actor fields (Created
by / Updated by) in dashboard widgets but not for relation fields
pointing to a workspace member (e.g. "Account owner"). In the
advanced-filter UI, picking such a relation forced a relation traversal
and never produced a filter you could set to "Me".

For a many-to-one relation targeting workspaceMember, the
relation-target sub-menu now offers a "filter by record" entry that
creates a direct relation filter with the same "Me" multi-select picker
used by view filters. Traversal (e.g. "Account owner -> Name") is
preserved. No backend change is needed: the stored value matches view
filters and is already resolved server-side.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23282?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Raphaël Bosi
2026-07-27 15:51:05 +02:00
committed by GitHub
parent 0c57d7c108
commit 590ae069e8
5 changed files with 345 additions and 38 deletions
@@ -1,7 +1,9 @@
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
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 { useApplyAdvancedFilterSourceField } from '@/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField';
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';
@@ -10,6 +12,7 @@ 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 { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
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';
@@ -17,10 +20,13 @@ import { selectedItemIdComponentState } from '@/ui/layout/selectable-list/states
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 { CoreObjectNameSingular } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { IconChevronLeft, useIcons } from 'twenty-ui/icon';
import { IconChevronLeft, IconUserCircle, useIcons } from 'twenty-ui/icon';
import { MenuItem } from 'twenty-ui/navigation';
const RELATION_RECORD_SELECTABLE_ITEM_ID = 'relation-record-select';
type AdvancedFilterRelationTargetFieldSelectMenuProps = {
recordFilterId: string;
};
@@ -45,6 +51,9 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
const { applyAdvancedFilterRelationTargetField } =
useApplyAdvancedFilterRelationTargetField();
const { applyAdvancedFilterSourceField } =
useApplyAdvancedFilterSourceField();
const { pushFocusForLeafFieldValuePicker } =
usePushFocusForLeafFieldValuePicker();
@@ -56,6 +65,11 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
advancedFilterFieldSelectDropdownId,
);
const { objectMetadataItem: workspaceMemberObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const targetObjectMetadataId =
isDefined(sourceFieldMetadataItem) &&
isManyToOneRelationField(sourceFieldMetadataItem)
@@ -72,6 +86,10 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
return null;
}
const isWorkspaceMemberTarget =
sourceFieldMetadataItem.relation.targetObjectMetadata.nameSingular ===
CoreObjectNameSingular.WorkspaceMember;
const handleSubMenuBack = () => {
setObjectFilterDropdownIsSelectingRelationTargetField(false);
};
@@ -91,7 +109,20 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = relationTargetFields.map((field) => field.id);
const handleSelectRelationRecord = () => {
applyAdvancedFilterSourceField({
sourceFieldMetadataItem,
recordFilterId,
});
setObjectFilterDropdownIsSelectingRelationTargetField(false);
closeAdvancedFilterFieldSelectDropdown();
};
const selectableItemIdArray = [
...(isWorkspaceMemberTarget ? [RELATION_RECORD_SELECTABLE_ITEM_ID] : []),
...relationTargetFields.map((field) => field.id),
];
return (
<DropdownContent widthInPixels={GenericDropdownContentWidth.ExtraLarge}>
@@ -111,6 +142,25 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({
selectableItemIdArray={selectableItemIdArray}
selectableListInstanceId={advancedFilterFieldSelectDropdownId}
>
{isWorkspaceMemberTarget && (
<>
<SelectableListItem
itemId={RELATION_RECORD_SELECTABLE_ITEM_ID}
onEnter={handleSelectRelationRecord}
>
<MenuItem
focused={
selectedItemId === RELATION_RECORD_SELECTABLE_ITEM_ID
}
testId="select-filter-relation-record"
onClick={handleSelectRelationRecord}
text={workspaceMemberObjectMetadataItem.labelSingular}
LeftIcon={IconUserCircle}
/>
</SelectableListItem>
<DropdownMenuSeparator />
</>
)}
{relationTargetFields.map((targetField, index) => (
<SelectableListItem
itemId={targetField.id}
@@ -0,0 +1,135 @@
import { fireEvent, render, waitFor } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { AdvancedFilterRelationTargetFieldSelectMenu } from '@/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu';
import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext';
import { fieldMetadataItemIdUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/fieldMetadataItemIdUsedInDropdownComponentState';
import { RecordFiltersComponentInstanceContext } from '@/object-record/record-filter/states/context/RecordFiltersComponentInstanceContext';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { useAtomComponentStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentStateValue';
import { useSetAtomComponentState } from '@/ui/utilities/state/jotai/hooks/useSetAtomComponentState';
import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectMetadataItemOrThrow';
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
const INSTANCE_ID = 'advanced-filter-relation-target-test';
const FILTER_ID = 'relation-target-filter';
const opportunity = getMockObjectMetadataItemOrThrow('opportunity');
const workspaceMemberRelationField = opportunity.fields.find(
(field) =>
isManyToOneRelationField(field) &&
field.relation.targetObjectMetadata.nameSingular === 'workspaceMember',
);
const nonWorkspaceMemberRelationField = opportunity.fields.find(
(field) =>
isManyToOneRelationField(field) &&
field.relation.targetObjectMetadata.nameSingular !== 'workspaceMember',
);
if (!workspaceMemberRelationField || !nonWorkspaceMemberRelationField) {
throw new Error('Missing expected relation fields in opportunity mock');
}
const BaseWrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const CurrentRecordFiltersObserver = () => {
const currentRecordFilters = useAtomComponentStateValue(
currentRecordFiltersComponentState,
);
return (
<div data-testid="current-record-filters">
{JSON.stringify(currentRecordFilters)}
</div>
);
};
const Seed = ({
sourceFieldMetadataId,
children,
}: {
sourceFieldMetadataId: string;
children: React.ReactNode;
}) => {
const setFieldMetadataItemIdUsedInDropdown = useSetAtomComponentState(
fieldMetadataItemIdUsedInDropdownComponentState,
);
const [isSeeded, setIsSeeded] = useState(false);
useEffect(() => {
setFieldMetadataItemIdUsedInDropdown(sourceFieldMetadataId);
setIsSeeded(true);
}, [sourceFieldMetadataId, setFieldMetadataItemIdUsedInDropdown]);
return isSeeded ? <>{children}</> : null;
};
const renderSubMenu = (sourceFieldMetadataId: string) => {
return render(
<BaseWrapper>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<Seed sourceFieldMetadataId={sourceFieldMetadataId}>
<AdvancedFilterRelationTargetFieldSelectMenu
recordFilterId={FILTER_ID}
/>
</Seed>
<CurrentRecordFiltersObserver />
</ObjectFilterDropdownComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</BaseWrapper>,
);
};
describe('AdvancedFilterRelationTargetFieldSelectMenu', () => {
it('shows a "filter by record" entry when the relation target is workspaceMember', async () => {
const { getByTestId } = renderSubMenu(workspaceMemberRelationField.id);
await waitFor(() => {
expect(getByTestId('select-filter-relation-record')).toBeInTheDocument();
});
});
it('does not show the "filter by record" entry for a non-workspaceMember relation', async () => {
const { findByText, queryByTestId } = renderSubMenu(
nonWorkspaceMemberRelationField.id,
);
await findByText(nonWorkspaceMemberRelationField.label);
expect(
queryByTestId('select-filter-relation-record'),
).not.toBeInTheDocument();
});
it('creates a direct RELATION filter (relationTargetFieldMetadataId null) when the record entry is selected', async () => {
const { getByTestId } = renderSubMenu(workspaceMemberRelationField.id);
const recordEntry = await waitFor(() =>
getByTestId('select-filter-relation-record'),
);
fireEvent.click(recordEntry);
await waitFor(() => {
const currentRecordFilters = JSON.parse(
getByTestId('current-record-filters').textContent || '[]',
);
expect(currentRecordFilters).toHaveLength(1);
expect(currentRecordFilters[0]).toMatchObject({
fieldMetadataId: workspaceMemberRelationField.id,
type: 'RELATION',
relationTargetFieldMetadataId: null,
});
});
});
});
@@ -19,11 +19,6 @@ type ApplyAdvancedFilterSourceFieldParams = {
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,
@@ -13,11 +13,13 @@ import { FormBooleanFieldInput } from '@/object-record/record-field/ui/form-type
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
import { FormRelativeDatePicker } from '@/object-record/record-field/ui/form-types/components/FormRelativeDatePicker';
import { FormTextFieldInput } from '@/object-record/record-field/ui/form-types/components/FormTextFieldInput';
import { FormWorkspaceMemberFilterValueInput } from '@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInput';
import {
type FieldMetadata,
type FieldMultiSelectMetadata,
type FieldSelectMetadata,
} from '@/object-record/record-field/ui/types/FieldMetadata';
import { isFieldRelationManyToOne } from '@/object-record/record-field/ui/types/guards/isFieldRelationManyToOne';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { useAtomComponentSelectorValue } from '@/ui/utilities/state/jotai/hooks/useAtomComponentSelectorValue';
@@ -27,7 +29,7 @@ import { stringifyRelativeDateFilter } from '@/views/view-filter-value/utils/str
import { WORKFLOW_TIMEZONE } from '@/workflow/constants/WorkflowTimeZone';
import { isObject, isString } from '@sniptt/guards';
import { useContext } from 'react';
import { FieldMetadataType } from 'twenty-shared/types';
import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
import { isDefined, type RelativeDateFilter } from 'twenty-shared/utils';
import { parseBooleanFromStringValue } from 'twenty-shared/workflow';
import { type JsonValue } from 'type-fest';
@@ -215,6 +217,25 @@ export const AdvancedFilterSidePanelValueFormInput = ({
metadata: fieldDefinition?.metadata as FieldMetadata,
};
const isDirectWorkspaceMemberRelationFilter =
!isDefined(recordFilter.relationTargetFieldMetadataId) &&
isFieldRelationManyToOne(field) &&
field.metadata.relationObjectMetadataNameSingular ===
CoreObjectNameSingular.WorkspaceMember;
if (isDirectWorkspaceMemberRelationFilter) {
return (
<FormWorkspaceMemberFilterValueInput
label=""
defaultValue={recordFilter.value}
onChange={handleChange}
onClear={handleClear}
readonly={readonly}
VariablePicker={VariablePicker}
/>
);
}
const shouldUseUTCTimeZone = isWorkflowFindRecords === true;
const timeZone = shouldUseUTCTimeZone ? WORKFLOW_TIMEZONE : undefined;
@@ -1,6 +1,7 @@
import { render, waitFor } from '@testing-library/react';
import { useEffect, useState } from 'react';
import { isManyToOneRelationField } from '@/object-metadata/utils/isManyToOneRelationField';
import { AdvancedFilterSidePanelValueFormInput } from '@/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput';
import { AdvancedFilterContext } from '@/object-record/advanced-filter/states/context/AdvancedFilterContext';
import { getAdvancedFilterObjectFilterDropdownComponentInstanceId } from '@/object-record/advanced-filter/utils/getAdvancedFilterObjectFilterDropdownComponentInstanceId';
@@ -15,7 +16,7 @@ import { getMockObjectMetadataItemOrThrow } from '~/testing/utils/getMockObjectM
import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper';
jest.mock('@/object-record/record-field/ui/components/FormFieldInput', () => ({
FormFieldInput: () => null,
FormFieldInput: () => <div data-testid="form-field-input" />,
}));
jest.mock(
@@ -29,6 +30,15 @@ jest.mock(
}),
);
jest.mock(
'@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInput',
() => ({
FormWorkspaceMemberFilterValueInput: () => (
<div data-testid="workspace-member-filter-input" />
),
}),
);
const INSTANCE_ID = 'advanced-filter-value-test';
const FILTER_ID = 'relation-select-filter';
@@ -43,8 +53,25 @@ const selectField = opportunity.fields.find(
field.type === FieldMetadataType.SELECT && (field.options?.length ?? 0) > 0,
);
if (!relationField || !selectField) {
throw new Error('Missing relation or select field in opportunity mock');
const workspaceMemberRelationField = opportunity.fields.find(
(field) =>
isManyToOneRelationField(field) &&
field.relation.targetObjectMetadata.nameSingular === 'workspaceMember',
);
const nonWorkspaceMemberRelationField = opportunity.fields.find(
(field) =>
isManyToOneRelationField(field) &&
field.relation.targetObjectMetadata.nameSingular !== 'workspaceMember',
);
if (
!relationField ||
!selectField ||
!workspaceMemberRelationField ||
!nonWorkspaceMemberRelationField
) {
throw new Error('Missing expected fields in opportunity mock');
}
const relationToSelectFilter: RecordFilter = {
@@ -58,9 +85,39 @@ const relationToSelectFilter: RecordFilter = {
label: `${relationField.label}${selectField.label}`,
};
const workspaceMemberRelationFilter: RecordFilter = {
id: FILTER_ID,
fieldMetadataId: workspaceMemberRelationField.id,
relationTargetFieldMetadataId: null,
type: FieldMetadataType.RELATION,
operand: ViewFilterOperand.IS,
value: '',
displayValue: '',
label: workspaceMemberRelationField.label,
};
const nonWorkspaceMemberRelationFilter: RecordFilter = {
id: FILTER_ID,
fieldMetadataId: nonWorkspaceMemberRelationField.id,
relationTargetFieldMetadataId: null,
type: FieldMetadataType.RELATION,
operand: ViewFilterOperand.IS,
value: '',
displayValue: '',
label: nonWorkspaceMemberRelationField.label,
};
const BaseWrapper = getJestMetadataAndApolloMocksWrapper({ apolloMocks: [] });
const Seed = ({ children }: { children: React.ReactNode }) => {
const Seed = ({
recordFilter,
dropdownFieldMetadataId,
children,
}: {
recordFilter: RecordFilter;
dropdownFieldMetadataId: string;
children: React.ReactNode;
}) => {
const setCurrentRecordFilters = useSetAtomComponentState(
currentRecordFiltersComponentState,
);
@@ -72,42 +129,66 @@ const Seed = ({ children }: { children: React.ReactNode }) => {
const [isSeeded, setIsSeeded] = useState(false);
useEffect(() => {
setCurrentRecordFilters([relationToSelectFilter]);
setFieldMetadataItemIdUsedInDropdown(relationField.id);
setCurrentRecordFilters([recordFilter]);
setFieldMetadataItemIdUsedInDropdown(dropdownFieldMetadataId);
setIsSeeded(true);
}, [setCurrentRecordFilters, setFieldMetadataItemIdUsedInDropdown]);
}, [
recordFilter,
dropdownFieldMetadataId,
setCurrentRecordFilters,
setFieldMetadataItemIdUsedInDropdown,
]);
return isSeeded ? <>{children}</> : null;
};
const Wrapper = () => (
<BaseWrapper>
<AdvancedFilterContext.Provider
value={{ objectMetadataItem: opportunity, isWorkflowFindRecords: false }}
>
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
const renderValueInput = ({
recordFilter,
dropdownFieldMetadataId,
}: {
recordFilter: RecordFilter;
dropdownFieldMetadataId: string;
}) => {
return render(
<BaseWrapper>
<AdvancedFilterContext.Provider
value={{
objectMetadataItem: opportunity,
isWorkflowFindRecords: false,
}}
>
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
FILTER_ID,
),
}}
<RecordFiltersComponentInstanceContext.Provider
value={{ instanceId: INSTANCE_ID }}
>
<Seed>
<AdvancedFilterSidePanelValueFormInput recordFilterId={FILTER_ID} />
</Seed>
</ObjectFilterDropdownComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</AdvancedFilterContext.Provider>
</BaseWrapper>
);
<ObjectFilterDropdownComponentInstanceContext.Provider
value={{
instanceId:
getAdvancedFilterObjectFilterDropdownComponentInstanceId(
FILTER_ID,
),
}}
>
<Seed
recordFilter={recordFilter}
dropdownFieldMetadataId={dropdownFieldMetadataId}
>
<AdvancedFilterSidePanelValueFormInput
recordFilterId={FILTER_ID}
/>
</Seed>
</ObjectFilterDropdownComponentInstanceContext.Provider>
</RecordFiltersComponentInstanceContext.Provider>
</AdvancedFilterContext.Provider>
</BaseWrapper>,
);
};
describe('AdvancedFilterSidePanelValueFormInput', () => {
it('resolves select options from the relation target field for a relation-traversal filter', async () => {
const { getByTestId } = render(<Wrapper />);
const { getByTestId } = renderValueInput({
recordFilter: relationToSelectFilter,
dropdownFieldMetadataId: relationField.id,
});
await waitFor(() => {
expect(getByTestId('multiselect-options-count').textContent).toBe(
@@ -115,4 +196,29 @@ describe('AdvancedFilterSidePanelValueFormInput', () => {
);
});
});
it('renders the workspace member "Me" picker for a direct relation-to-workspaceMember filter', async () => {
const { getByTestId } = renderValueInput({
recordFilter: workspaceMemberRelationFilter,
dropdownFieldMetadataId: workspaceMemberRelationField.id,
});
await waitFor(() => {
expect(getByTestId('workspace-member-filter-input')).toBeInTheDocument();
});
});
it('falls through to the generic field input for a direct relation to a non-workspaceMember object', async () => {
const { getByTestId, queryByTestId } = renderValueInput({
recordFilter: nonWorkspaceMemberRelationFilter,
dropdownFieldMetadataId: nonWorkspaceMemberRelationField.id,
});
await waitFor(() => {
expect(getByTestId('form-field-input')).toBeInTheDocument();
});
expect(
queryByTestId('workspace-member-filter-input'),
).not.toBeInTheDocument();
});
});