Skip to content

Commit 713b5da

Browse files
committed
feat: added workspace member filter for actor fields
Issues: #16619
1 parent 2e62bb1 commit 713b5da

File tree

11 files changed

+295
-2
lines changed

11 files changed

+295
-2
lines changed

packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { ObjectFilterDropdownActorSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownActorSelect';
12
import { ObjectFilterDropdownOptionSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect';
23
import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput';
34
import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect';
@@ -14,6 +15,7 @@ import { ObjectFilterDropdownDateTimeInput } from '@/object-record/object-filter
1415
import { ObjectFilterDropdownTextInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextInput';
1516
import { subFieldNameUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/subFieldNameUsedInDropdownComponentState';
1617
import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField';
18+
import { isFilterOnActorWorkspaceMemberSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorWorkspaceMemberSubField';
1719
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
1820
import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent';
1921
import { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth';
@@ -40,6 +42,9 @@ export const AdvancedFilterDropdownFilterInput = ({
4042
subFieldNameUsedInDropdown,
4143
);
4244

45+
const isActorWorkspaceMemberCompositeFilter =
46+
isFilterOnActorWorkspaceMemberSubField(subFieldNameUsedInDropdown);
47+
4348
return (
4449
<>
4550
{filterType === 'ADDRESS' &&
@@ -68,6 +73,14 @@ export const AdvancedFilterDropdownFilterInput = ({
6873
{filterType === 'ACTOR' &&
6974
(isActorSourceCompositeFilter ? (
7075
<ObjectFilterDropdownSourceSelect dropdownId={filterDropdownId} />
76+
) : isActorWorkspaceMemberCompositeFilter ? (
77+
<DropdownContent
78+
widthInPixels={GenericDropdownContentWidth.ExtraLarge}
79+
>
80+
<ObjectFilterDropdownSearchInput />
81+
<DropdownMenuSeparator />
82+
<ObjectFilterDropdownActorSelect dropdownId={filterDropdownId} />
83+
</DropdownContent>
7184
) : (
7285
<ObjectFilterDropdownTextInput />
7386
))}
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
2+
import { ObjectFilterDropdownRecordPinnedItems } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordPinnedItems';
3+
import { CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID } from '@/object-record/object-filter-dropdown/constants/CurrentWorkspaceMemberSelectableItemId';
4+
import { useApplyObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useApplyObjectFilterDropdownFilterValue';
5+
import { useObjectFilterDropdownFilterValue } from '@/object-record/object-filter-dropdown/hooks/useObjectFilterDropdownFilterValue';
6+
import { objectFilterDropdownSearchInputComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSearchInputComponentState';
7+
import { selectedOperandInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/selectedOperandInDropdownComponentState';
8+
import { MultipleSelectDropdown } from '@/object-record/select/components/MultipleSelectDropdown';
9+
import { useRecordsForSelect } from '@/object-record/select/hooks/useRecordsForSelect';
10+
import { type SelectableItem } from '@/object-record/select/types/SelectableItem';
11+
import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator';
12+
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
13+
import { type RelationFilterValue } from '@/views/view-filter-value/types/RelationFilterValue';
14+
import {
15+
arrayOfUuidOrVariableSchema,
16+
isDefined,
17+
jsonRelationFilterValueSchema,
18+
} from 'twenty-shared/utils';
19+
import { IconUserCircle } from 'twenty-ui/display';
20+
21+
export const EMPTY_ACTOR_FILTER_VALUE: string = JSON.stringify({
22+
isCurrentWorkspaceMemberSelected: false,
23+
selectedRecordIds: [],
24+
} satisfies RelationFilterValue);
25+
26+
export const MAX_WORKSPACE_MEMBERS_TO_DISPLAY = 3;
27+
28+
type ObjectFilterDropdownActorSelectProps = {
29+
dropdownId: string;
30+
};
31+
32+
export const ObjectFilterDropdownActorSelect = ({
33+
dropdownId,
34+
}: ObjectFilterDropdownActorSelectProps) => {
35+
const { objectFilterDropdownFilterValue } =
36+
useObjectFilterDropdownFilterValue();
37+
38+
const { applyObjectFilterDropdownFilterValue } =
39+
useApplyObjectFilterDropdownFilterValue();
40+
41+
const selectedOperandInDropdown = useRecoilComponentValue(
42+
selectedOperandInDropdownComponentState,
43+
);
44+
45+
const objectFilterDropdownSearchInput = useRecoilComponentValue(
46+
objectFilterDropdownSearchInputComponentState,
47+
);
48+
49+
const { isCurrentWorkspaceMemberSelected } = jsonRelationFilterValueSchema
50+
.catch({
51+
isCurrentWorkspaceMemberSelected: false,
52+
selectedRecordIds: arrayOfUuidOrVariableSchema.parse(
53+
objectFilterDropdownFilterValue,
54+
),
55+
})
56+
.parse(objectFilterDropdownFilterValue);
57+
58+
const { selectedRecordIds } = jsonRelationFilterValueSchema
59+
.catch({
60+
isCurrentWorkspaceMemberSelected: false,
61+
selectedRecordIds: arrayOfUuidOrVariableSchema.parse(
62+
objectFilterDropdownFilterValue,
63+
),
64+
})
65+
.parse(objectFilterDropdownFilterValue);
66+
67+
const { loading, filteredSelectedRecords, recordsToSelect, selectedRecords } =
68+
useRecordsForSelect({
69+
searchFilterText: objectFilterDropdownSearchInput,
70+
selectedIds: selectedRecordIds,
71+
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
72+
limit: 10,
73+
});
74+
75+
const currentWorkspaceMemberSelectableItem: SelectableItem = {
76+
id: CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID,
77+
name: 'Me',
78+
isSelected: isCurrentWorkspaceMemberSelected ?? false,
79+
AvatarIcon: IconUserCircle,
80+
};
81+
82+
const pinnedSelectableItems: SelectableItem[] = [
83+
currentWorkspaceMemberSelectableItem,
84+
];
85+
86+
const filteredPinnedSelectableItems = pinnedSelectableItems.filter((item) =>
87+
item.name
88+
.toLowerCase()
89+
.includes(objectFilterDropdownSearchInput.toLowerCase()),
90+
);
91+
92+
const handleMultipleRecordSelectChange = (
93+
itemToSelect: SelectableItem,
94+
isNewSelectedValue: boolean,
95+
) => {
96+
if (loading) {
97+
return;
98+
}
99+
100+
const isItemCurrentWorkspaceMember =
101+
itemToSelect.id === CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID;
102+
103+
const selectedRecordIdsWithAddedRecord = [
104+
...selectedRecordIds,
105+
itemToSelect.id,
106+
];
107+
108+
const selectedRecordIdsWithRemovedRecord = selectedRecordIds.filter(
109+
(id) => id !== itemToSelect.id,
110+
);
111+
112+
const newSelectedRecordIds = isItemCurrentWorkspaceMember
113+
? selectedRecordIds
114+
: isNewSelectedValue
115+
? selectedRecordIdsWithAddedRecord
116+
: selectedRecordIdsWithRemovedRecord;
117+
118+
const newIsCurrentWorkspaceMemberSelected = isItemCurrentWorkspaceMember
119+
? isNewSelectedValue
120+
: isCurrentWorkspaceMemberSelected;
121+
122+
const selectedRecordNames = [
123+
...recordsToSelect,
124+
...selectedRecords,
125+
...filteredSelectedRecords,
126+
]
127+
.filter(
128+
(record, index, self) =>
129+
self.findIndex((r) => r.id === record.id) === index,
130+
)
131+
.filter((record) => newSelectedRecordIds.includes(record.id))
132+
.map((record) => record.name);
133+
134+
const selectedPinnedItemNames = newIsCurrentWorkspaceMemberSelected
135+
? [currentWorkspaceMemberSelectableItem.name]
136+
: [];
137+
138+
const selectedItemNames = [
139+
...selectedPinnedItemNames,
140+
...selectedRecordNames,
141+
];
142+
143+
const filterDisplayValue =
144+
selectedItemNames.length > MAX_WORKSPACE_MEMBERS_TO_DISPLAY
145+
? `${selectedItemNames.length} workspace members`
146+
: selectedItemNames.join(', ');
147+
148+
if (isDefined(selectedOperandInDropdown)) {
149+
const newFilterValue =
150+
newSelectedRecordIds.length > 0 || newIsCurrentWorkspaceMemberSelected
151+
? JSON.stringify({
152+
isCurrentWorkspaceMemberSelected:
153+
newIsCurrentWorkspaceMemberSelected,
154+
selectedRecordIds: newSelectedRecordIds,
155+
} satisfies RelationFilterValue)
156+
: '';
157+
158+
applyObjectFilterDropdownFilterValue(newFilterValue, filterDisplayValue);
159+
}
160+
};
161+
162+
return (
163+
<>
164+
{filteredPinnedSelectableItems.length > 0 && (
165+
<>
166+
<ObjectFilterDropdownRecordPinnedItems
167+
selectableItems={filteredPinnedSelectableItems}
168+
onChange={handleMultipleRecordSelectChange}
169+
/>
170+
<DropdownMenuSeparator />
171+
</>
172+
)}
173+
<MultipleSelectDropdown
174+
selectableListId="object-filter-actor-select-id"
175+
focusId={dropdownId}
176+
itemsToSelect={recordsToSelect}
177+
filteredSelectedItems={filteredSelectedRecords}
178+
selectedItems={selectedRecords}
179+
onChange={handleMultipleRecordSelectChange}
180+
searchFilter={objectFilterDropdownSearchInput}
181+
loadingItems={loading}
182+
/>
183+
</>
184+
);
185+
};

packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getSubMenuOptions.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ export const getSubMenuOptions = (subMenu: FilterableFieldType | null) => {
1414
icon: 'IconId',
1515
type: 'ACTOR',
1616
},
17+
{
18+
name: 'Workspace Member',
19+
icon: 'IconUserCircle',
20+
type: 'WORKSPACE_MEMBER',
21+
},
1722
];
1823
default:
1924
return [];
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import { type FieldActorValue } from '@/object-record/record-field/ui/types/FieldMetadata';
2+
3+
export const isFilterOnActorWorkspaceMemberSubField = (
4+
subFieldName?: string | null | undefined,
5+
) => {
6+
return subFieldName === ('workspaceMemberId' satisfies keyof FieldActorValue);
7+
};

packages/twenty-front/src/modules/object-record/record-filter/constants/IconNameBySubField.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const ICON_NAME_BY_SUB_FIELD: Partial<
77
amountMicros: 'IconNumber95Small',
88
name: 'IconTextSize',
99
source: 'IconTransferIn',
10+
workspaceMemberId: 'IconUserCircle',
1011
primaryEmail: 'IconMail',
1112
additionalEmails: 'IconMailPlus',
1213
primaryLinkLabel: 'IconTextSize',

packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField';
2+
import { isFilterOnActorWorkspaceMemberSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorWorkspaceMemberSubField';
23
import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
34
import {
45
FieldMetadataType,
@@ -202,6 +203,14 @@ export const getRecordFilterOperands = ({
202203
];
203204
}
204205

206+
if (isFilterOnActorWorkspaceMemberSubField(subFieldName)) {
207+
return [
208+
RecordFilterOperand.IS,
209+
RecordFilterOperand.IS_NOT,
210+
...emptyOperands,
211+
];
212+
}
213+
205214
return FILTER_OPERANDS_MAP.ACTOR;
206215
}
207216
case 'ARRAY':

packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,13 @@ export const isRecordMatchingFilter = ({
326326
case FieldMetadataType.ACTOR: {
327327
const actorFilter = filterValue as ActorFilter;
328328

329+
if (actorFilter.workspaceMemberId !== undefined) {
330+
return isMatchingUUIDFilter({
331+
uuidFilter: actorFilter.workspaceMemberId,
332+
value: record[filterKey].workspaceMemberId,
333+
});
334+
}
335+
329336
return (
330337
actorFilter.name === undefined ||
331338
isMatchingStringFilter({

packages/twenty-front/src/modules/settings/data-model/constants/CompositeFieldSubFieldLabel.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export const COMPOSITE_FIELD_SUB_FIELD_LABELS: {
4040
[FieldMetadataType.ACTOR]: {
4141
source: 'Source',
4242
name: 'Name',
43-
workspaceMemberId: 'Workspace Member ID',
43+
workspaceMemberId: 'Workspace Member',
4444
context: 'Context',
4545
},
4646
[FieldMetadataType.RICH_TEXT_V2]: {

packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
464464
COMPOSITE_FIELD_SUB_FIELD_LABELS[FieldMetadataType.ACTOR]
465465
.workspaceMemberId,
466466
isImportable: true,
467-
isFilterable: false,
467+
isFilterable: true,
468468
isIncludedInUniqueConstraint: false,
469469
},
470470
{

packages/twenty-shared/src/types/RecordGqlOperationFilter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ export type LinksFilter = {
103103
export type ActorFilter = {
104104
name?: StringFilter;
105105
source?: SelectFilter;
106+
workspaceMemberId?: UUIDFilter;
106107
};
107108

108109
export type EmailsFilter = {

0 commit comments

Comments
 (0)