From 1656bb556894083d1bab40bec70cf177b17539fb Mon Sep 17 00:00:00 2001
From: Arun <184187559+carbonFibreCode@users.noreply.github.com>
Date: Tue, 10 Mar 2026 18:38:18 +0530
Subject: [PATCH] [Feat] : add source to actor fields (#18118)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
fixes #18099
Simple implementation of the matchingSourceValues to be searched in the
ACTOR case in turnRecordFilterIntoRecordGqlOperationFilter
---------
Co-authored-by: Arun kumar
Co-authored-by: Lucas Bordeau
---
.../utils/isRecordMatchingFilter.ts | 7 ++
.../field/display/components/ActorDisplay.tsx | 4 +-
.../dropdown/components/DropdownMenuInput.tsx | 3 +-
.../components/EditableFilterChip.tsx | 2 +
...rls-row-level-permission-predicate.util.ts | 7 ++
...RecordFilterIntoGqlOperationFilter.test.ts | 104 +++++++++++++++++-
.../turnRecordFilterIntoGqlOperationFilter.ts | 36 +++++-
7 files changed, 154 insertions(+), 9 deletions(-)
diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
index b96ba62c02..5e54b8f89f 100644
--- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
+++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts
@@ -377,6 +377,13 @@ export const isRecordMatchingFilter = ({
});
}
+ if (isDefined(actorFilter.source)) {
+ return isMatchingSelectFilter({
+ selectFilter: actorFilter.source,
+ value: record[filterKey].source,
+ });
+ }
+
return (
actorFilter.name === undefined ||
isMatchingStringFilter({
diff --git a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
index a31bbf8a21..c1a45890af 100644
--- a/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
+++ b/packages/twenty-front/src/modules/ui/field/display/components/ActorDisplay.tsx
@@ -7,7 +7,6 @@ import { AvatarOrIcon, Chip } from 'twenty-ui/components';
import {
IconApi,
IconCalendar,
- IconCsv,
IconGmail,
IconGoogleCalendar,
IconMail,
@@ -16,6 +15,7 @@ import {
IconPlug,
IconRobot,
IconSettingsAutomation,
+ IconUpload,
IconWebhook,
} from 'twenty-ui/display';
@@ -49,7 +49,7 @@ export const ActorDisplay = ({
case 'API':
return IconApi;
case 'IMPORT':
- return IconCsv;
+ return IconUpload;
case 'EMAIL':
return PROVIDORS_ICON_MAPPING.EMAIL[context?.provider ?? 'default'];
case 'CALENDAR':
diff --git a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx
index 9ef8fa2ce4..f92f104649 100644
--- a/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx
+++ b/packages/twenty-front/src/modules/ui/layout/dropdown/components/DropdownMenuInput.tsx
@@ -9,8 +9,8 @@ import 'react-phone-number-input/style.css';
import { useRegisterInputEvents } from '@/object-record/record-field/ui/meta-types/input/hooks/useRegisterInputEvents';
import { isDefined } from 'twenty-shared/utils';
-import { useCombinedRefs } from '~/hooks/useCombinedRefs';
import { themeCssVariables } from 'twenty-ui/theme-constants';
+import { useCombinedRefs } from '~/hooks/useCombinedRefs';
const StyledInput = styled.input<{
withRightComponent?: boolean;
@@ -35,6 +35,7 @@ const StyledInput = styled.input<{
height: 32px;
outline: none;
padding: ${themeCssVariables.spacing[0]} ${themeCssVariables.spacing[2]};
+ padding-left: ${themeCssVariables.spacing[1]};
padding-right: ${({ withRightComponent }) =>
withRightComponent ? '32px' : '0'};
position: relative;
diff --git a/packages/twenty-front/src/modules/views/editable-chip/components/EditableFilterChip.tsx b/packages/twenty-front/src/modules/views/editable-chip/components/EditableFilterChip.tsx
index 959047fd8c..caa0ba56ac 100644
--- a/packages/twenty-front/src/modules/views/editable-chip/components/EditableFilterChip.tsx
+++ b/packages/twenty-front/src/modules/views/editable-chip/components/EditableFilterChip.tsx
@@ -8,6 +8,7 @@ import { useGetRecordFilterChipLabelValue } from '@/views/hooks/useGetRecordFilt
import { isNonEmptyString } from '@sniptt/guards';
import { useIcons } from 'twenty-ui/display';
+import { FieldMetadataType } from '~/generated-metadata/graphql';
type EditableFilterChipProps = {
recordFilter: RecordFilter;
@@ -34,6 +35,7 @@ export const EditableFilterChip = ({
const subFieldLabel =
isCompositeFieldType(fieldMetadataItem.type) &&
+ fieldMetadataItem.type !== FieldMetadataType.ACTOR &&
isNonEmptyString(recordFilterSubFieldName) &&
isValidSubFieldName(recordFilterSubFieldName)
? getCompositeSubFieldLabel(
diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts
index 6465bc2b0d..0240cb3219 100644
--- a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts
+++ b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts
@@ -374,6 +374,13 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
case FieldMetadataType.ACTOR: {
const actorFilter = filterValue as ActorFilter;
+ if (isDefined(actorFilter.source)) {
+ return isMatchingSelectFilter({
+ selectFilter: actorFilter.source,
+ value: recordFieldValue.source,
+ });
+ }
+
return (
actorFilter.name === undefined ||
isMatchingStringFilter({
diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
index fe7ce1ef46..60f46595cb 100644
--- a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
+++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts
@@ -2,8 +2,8 @@ import {
FieldMetadataType,
ViewFilterOperand as RecordFilterOperand,
} from '@/types';
-import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
import { type RecordFilter } from '@/utils';
+import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/utils/filter/turnRecordFilterIntoGqlOperationFilter';
const fields = [
{ id: 'f-text', name: 'name', type: FieldMetadataType.TEXT, label: 'Name' },
@@ -709,18 +709,114 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => {
});
describe('ACTOR filter', () => {
- it('should handle CONTAINS operand', () => {
+ it('should handle CONTAINS with a value matching a source - includes source in filter', () => {
const result = turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: makeFilter(
'f-actor',
RecordFilterOperand.CONTAINS,
- 'Admin',
+ 'api',
),
fieldMetadataItems: fields,
});
- expect(result).toBeDefined();
+ expect(result).toEqual({
+ or: [
+ {
+ actor: {
+ name: { ilike: '%api%' },
+ },
+ },
+ {
+ actor: {
+ source: { in: ['API'] },
+ },
+ },
+ ],
+ });
+ });
+
+ it('should handle CONTAINS with no matching source - no empty {} or [] in filter', () => {
+ const result = turnRecordFilterIntoRecordGqlOperationFilter({
+ filterValueDependencies,
+ recordFilter: makeFilter(
+ 'f-actor',
+ RecordFilterOperand.CONTAINS,
+ 'xyz123',
+ ),
+ fieldMetadataItems: fields,
+ });
+
+ expect(result).toEqual({
+ or: [
+ {
+ actor: {
+ name: { ilike: '%xyz123%' },
+ },
+ },
+ ],
+ });
+ const json = JSON.stringify(result);
+ expect(json).not.toContain('[]');
+ expect(json).not.toContain('{}');
+ });
+
+ it('should handle DOES_NOT_CONTAIN with a value matching a source - includes not source in filter', () => {
+ const result = turnRecordFilterIntoRecordGqlOperationFilter({
+ filterValueDependencies,
+ recordFilter: makeFilter(
+ 'f-actor',
+ RecordFilterOperand.DOES_NOT_CONTAIN,
+ 'api',
+ ),
+ fieldMetadataItems: fields,
+ });
+
+ expect(result).toEqual({
+ and: [
+ {
+ not: {
+ actor: {
+ name: { ilike: '%api%' },
+ },
+ },
+ },
+ {
+ not: {
+ actor: {
+ source: { in: ['API'] },
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ it('should handle DOES_NOT_CONTAIN with no matching source - no empty {} or [] in filter', () => {
+ const result = turnRecordFilterIntoRecordGqlOperationFilter({
+ filterValueDependencies,
+ recordFilter: makeFilter(
+ 'f-actor',
+ RecordFilterOperand.DOES_NOT_CONTAIN,
+ 'xyz123',
+ ),
+ fieldMetadataItems: fields,
+ });
+
+ expect(result).toEqual({
+ and: [
+ {
+ not: {
+ actor: {
+ name: { ilike: '%xyz123%' },
+ },
+ },
+ },
+ ],
+ });
+ const json = JSON.stringify(result);
+ expect(json).not.toContain('[]');
+ expect(json).not.toContain('{}');
});
});
diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
index e41bb4b1df..39fb0d7884 100644
--- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
+++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts
@@ -2,6 +2,7 @@ import { isNonEmptyString } from '@sniptt/guards';
import { Temporal } from 'temporal-polyfill';
import {
+ FieldActorSource,
FieldMetadataType,
ViewFilterOperand as RecordFilterOperand,
type ActorFilter,
@@ -1218,8 +1219,13 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
}
}
+ const matchingSourceValues = Object.values(FieldActorSource).filter(
+ (actorSource) =>
+ actorSource.toLowerCase().includes(recordFilter.value.toLowerCase()),
+ );
+
switch (recordFilter.operand) {
- case RecordFilterOperand.CONTAINS:
+ case RecordFilterOperand.CONTAINS: {
return {
or: [
{
@@ -1229,9 +1235,21 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
},
} satisfies ActorFilter,
},
+ ...(matchingSourceValues.length > 0
+ ? [
+ {
+ [correspondingFieldMetadataItem.name]: {
+ source: {
+ in: matchingSourceValues,
+ },
+ } satisfies ActorFilter,
+ },
+ ]
+ : []),
],
};
- case RecordFilterOperand.DOES_NOT_CONTAIN:
+ }
+ case RecordFilterOperand.DOES_NOT_CONTAIN: {
return {
and: [
{
@@ -1243,8 +1261,22 @@ export const turnRecordFilterIntoRecordGqlOperationFilter = ({
} satisfies ActorFilter,
},
},
+ ...(matchingSourceValues.length > 0
+ ? [
+ {
+ not: {
+ [correspondingFieldMetadataItem.name]: {
+ source: {
+ in: matchingSourceValues,
+ },
+ } satisfies ActorFilter,
+ },
+ },
+ ]
+ : []),
],
};
+ }
default: {
const fieldForRecordFilter = fieldMetadataItems.find(
(field) => field.id === recordFilter.fieldMetadataId,