From 590ae069e8aadd6a4df1738fd3a47ce7d380b250 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Bosi?= <71827178+bosiraphael@users.noreply.github.com> Date: Mon, 27 Jul 2026 15:51:05 +0200 Subject: [PATCH] Support workspace member Me filter for relation fields in dashboards (#23282) CleanShot 2026-07-27 at 15 04
22@2x 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. Review in cubic --- ...cedFilterRelationTargetFieldSelectMenu.tsx | 54 +++++- ...lterRelationTargetFieldSelectMenu.test.tsx | 135 ++++++++++++++ .../useApplyAdvancedFilterSourceField.ts | 5 - .../AdvancedFilterSidePanelValueFormInput.tsx | 23 ++- ...ncedFilterSidePanelValueFormInput.test.tsx | 166 ++++++++++++++---- 5 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/advanced-filter/components/__tests__/AdvancedFilterRelationTargetFieldSelectMenu.test.tsx diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx index d3b96dd77d..3662c2b974 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRelationTargetFieldSelectMenu.tsx @@ -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 ( @@ -111,6 +142,25 @@ export const AdvancedFilterRelationTargetFieldSelectMenu = ({ selectableItemIdArray={selectableItemIdArray} selectableListInstanceId={advancedFilterFieldSelectDropdownId} > + {isWorkspaceMemberTarget && ( + <> + + + + + + )} {relationTargetFields.map((targetField, index) => ( + 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 ( +
+ {JSON.stringify(currentRecordFilters)} +
+ ); +}; + +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( + + + + + + + + + + , + ); +}; + +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, + }); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts index 402b24a9bd..d4f1c6b317 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useApplyAdvancedFilterSourceField.ts @@ -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, diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx index 459d8e4c32..20baac982c 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/AdvancedFilterSidePanelValueFormInput.tsx @@ -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 ( + + ); + } + const shouldUseUTCTimeZone = isWorkflowFindRecords === true; const timeZone = shouldUseUTCTimeZone ? WORKFLOW_TIMEZONE : undefined; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx index 8714174662..a2ab1fcda1 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/side-panel/components/__tests__/AdvancedFilterSidePanelValueFormInput.test.tsx @@ -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: () =>
, })); jest.mock( @@ -29,6 +30,15 @@ jest.mock( }), ); +jest.mock( + '@/object-record/record-field/ui/form-types/components/FormWorkspaceMemberFilterValueInput', + () => ({ + FormWorkspaceMemberFilterValueInput: () => ( +
+ ), + }), +); + 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 = () => ( - - - { + return render( + + - - - - - - - - -); + + + + + + + + , + ); +}; describe('AdvancedFilterSidePanelValueFormInput', () => { it('resolves select options from the relation target field for a relation-traversal filter', async () => { - const { getByTestId } = render(); + 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(); + }); });