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,