Skip to content

Commit c125421

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

File tree

11 files changed

+287
-2
lines changed

11 files changed

+287
-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: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
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, selectedRecordIds } =
50+
jsonRelationFilterValueSchema
51+
.catch({
52+
isCurrentWorkspaceMemberSelected: false,
53+
selectedRecordIds: arrayOfUuidOrVariableSchema.parse(
54+
objectFilterDropdownFilterValue,
55+
),
56+
})
57+
.parse(objectFilterDropdownFilterValue);
58+
59+
const { loading, filteredSelectedRecords, recordsToSelect, selectedRecords } =
60+
useRecordsForSelect({
61+
searchFilterText: objectFilterDropdownSearchInput,
62+
selectedIds: selectedRecordIds,
63+
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
64+
limit: 10,
65+
});
66+
67+
const currentWorkspaceMemberSelectableItem: SelectableItem = {
68+
id: CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID,
69+
name: 'Me',
70+
isSelected: isCurrentWorkspaceMemberSelected ?? false,
71+
AvatarIcon: IconUserCircle,
72+
};
73+
74+
const pinnedSelectableItems: SelectableItem[] = [
75+
currentWorkspaceMemberSelectableItem,
76+
];
77+
78+
const filteredPinnedSelectableItems = pinnedSelectableItems.filter((item) =>
79+
item.name
80+
.toLowerCase()
81+
.includes(objectFilterDropdownSearchInput.toLowerCase()),
82+
);
83+
84+
const handleMultipleRecordSelectChange = (
85+
itemToSelect: SelectableItem,
86+
isNewSelectedValue: boolean,
87+
) => {
88+
if (loading) {
89+
return;
90+
}
91+
92+
const isItemCurrentWorkspaceMember =
93+
itemToSelect.id === CURRENT_WORKSPACE_MEMBER_SELECTABLE_ITEM_ID;
94+
95+
const selectedRecordIdsWithAddedRecord = [
96+
...selectedRecordIds,
97+
itemToSelect.id,
98+
];
99+
100+
const selectedRecordIdsWithRemovedRecord = selectedRecordIds.filter(
101+
(id) => id !== itemToSelect.id,
102+
);
103+
104+
const newSelectedRecordIds = isItemCurrentWorkspaceMember
105+
? selectedRecordIds
106+
: isNewSelectedValue
107+
? selectedRecordIdsWithAddedRecord
108+
: selectedRecordIdsWithRemovedRecord;
109+
110+
const newIsCurrentWorkspaceMemberSelected = isItemCurrentWorkspaceMember
111+
? isNewSelectedValue
112+
: isCurrentWorkspaceMemberSelected;
113+
114+
const selectedRecordNames = [
115+
...recordsToSelect,
116+
...selectedRecords,
117+
...filteredSelectedRecords,
118+
]
119+
.filter(
120+
(record, index, self) =>
121+
self.findIndex((r) => r.id === record.id) === index,
122+
)
123+
.filter((record) => newSelectedRecordIds.includes(record.id))
124+
.map((record) => record.name);
125+
126+
const selectedPinnedItemNames = newIsCurrentWorkspaceMemberSelected
127+
? [currentWorkspaceMemberSelectableItem.name]
128+
: [];
129+
130+
const selectedItemNames = [
131+
...selectedPinnedItemNames,
132+
...selectedRecordNames,
133+
];
134+
135+
const filterDisplayValue =
136+
selectedItemNames.length > MAX_WORKSPACE_MEMBERS_TO_DISPLAY
137+
? `${selectedItemNames.length} workspace members`
138+
: selectedItemNames.join(', ');
139+
140+
if (isDefined(selectedOperandInDropdown)) {
141+
const newFilterValue =
142+
newSelectedRecordIds.length > 0 || newIsCurrentWorkspaceMemberSelected
143+
? JSON.stringify({
144+
isCurrentWorkspaceMemberSelected:
145+
newIsCurrentWorkspaceMemberSelected,
146+
selectedRecordIds: newSelectedRecordIds,
147+
} satisfies RelationFilterValue)
148+
: '';
149+
150+
applyObjectFilterDropdownFilterValue(newFilterValue, filterDisplayValue);
151+
}
152+
};
153+
154+
return (
155+
<>
156+
{filteredPinnedSelectableItems.length > 0 && (
157+
<>
158+
<ObjectFilterDropdownRecordPinnedItems
159+
selectableItems={filteredPinnedSelectableItems}
160+
onChange={handleMultipleRecordSelectChange}
161+
/>
162+
<DropdownMenuSeparator />
163+
</>
164+
)}
165+
<MultipleSelectDropdown
166+
selectableListId="object-filter-actor-select-id"
167+
focusId={dropdownId}
168+
itemsToSelect={recordsToSelect}
169+
filteredSelectedItems={filteredSelectedRecords}
170+
selectedItems={selectedRecords}
171+
onChange={handleMultipleRecordSelectChange}
172+
searchFilter={objectFilterDropdownSearchInput}
173+
loadingItems={loading}
174+
/>
175+
</>
176+
);
177+
};

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)