Implemented LINKS and EMAILS sub-field fitering (#11984)

This PR introduces LINKS and EMAILS sub-field filtering. It's mainly
about the implementation of secondaryLinks and additionalEmails
sub-fields, which are treated like additionalPhones.

There's also a refactor on the computeViewRecordGqlOperationFilter, a
big file that becomes very difficult to read and maintain. This PR
breaks it down into multiple smaller utils. There's still work to be
done to clean it as it is a central part of the record filter module,
this PR lays the foundation.
This commit is contained in:
Lucas Bordeau
2025-05-12 21:30:53 +02:00
committed by GitHub
parent 0fb5ea7d06
commit df3db85f7f
26 changed files with 1129 additions and 521 deletions

View File

@ -3,7 +3,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables';
export const computeContextStoreFilters = (

View File

@ -90,9 +90,9 @@ export const AdvancedFilterSubFieldSelectMenu = ({
return null;
}
const subFieldNames = SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[
objectFilterDropdownSubMenuFieldType
].filterableSubFields.sort((a, b) => a.localeCompare(b));
const subFieldNames =
SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS[objectFilterDropdownSubMenuFieldType]
.filterableSubFields;
const subFieldsAreFilterable =
isDefined(fieldMetadataItemUsedInDropdown) &&

View File

@ -91,6 +91,7 @@ export type AddressFilter = {
export type LinksFilter = {
primaryLinkUrl?: StringFilter;
primaryLinkLabel?: StringFilter;
secondaryLinks?: RawJsonFilter;
};
export type ActorFilter = {
@ -100,6 +101,7 @@ export type ActorFilter = {
export type EmailsFilter = {
primaryEmail?: StringFilter;
additionalEmails?: RawJsonFilter;
};
export type PhonesFilter = {

View File

@ -38,7 +38,7 @@ export const ObjectFilterDropdownTextInput = () => {
};
return (
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer width="auto">
<DropdownMenuInput
ref={handleInputRef}
value={objectFilterDropdownFilterValue}

View File

@ -32,6 +32,11 @@ describe('getOperandsForFilterType', () => {
RecordFilterOperand.IsNot,
];
const actorSourceOperands = [
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,
];
const dateOperands = [
RecordFilterOperand.Is,
RecordFilterOperand.IsRelative,
@ -49,7 +54,12 @@ describe('getOperandsForFilterType', () => {
['FULL_NAME', [...containsOperands, ...emptyOperands]],
['ADDRESS', [...containsOperands, ...emptyOperands]],
['LINKS', [...containsOperands, ...emptyOperands]],
['ACTOR', [...containsOperands, ...emptyOperands]],
['LINKS', [...containsOperands, ...emptyOperands], 'primaryLinkUrl'],
['LINKS', [...containsOperands, ...emptyOperands], 'primaryLinkLabel'],
['LINKS', [...containsOperands, ...emptyOperands], 'secondaryLinks'],
['ACTOR', [...containsOperands, ...emptyOperands], 'name'],
['ACTOR', [...actorSourceOperands, ...emptyOperands], 'source'],
['ACTOR', [...actorSourceOperands, ...emptyOperands]],
[
'CURRENCY',
[...currencyCurrencyCodeOperands, ...emptyOperands],

View File

@ -7,4 +7,11 @@ export const ICON_NAME_BY_SUB_FIELD: Partial<
amountMicros: 'IconNumber95Small',
name: 'IconAlignJustified',
source: 'IconFileArrowLeft',
primaryEmail: 'IconMail',
additionalEmails: 'IconList',
primaryLinkLabel: 'IconLabel',
primaryLinkUrl: 'IconLink',
secondaryLinks: 'IconList',
primaryPhoneCallingCode: 'IconPlus',
additionalPhones: 'IconList',
};

View File

@ -2,7 +2,7 @@ import { FieldCurrencyValue } from '@/object-record/record-field/types/FieldMeta
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { FieldMetadataType } from '~/generated/graphql';
import { getCompaniesMock } from '~/testing/mock-data/companies';
@ -682,7 +682,7 @@ describe('should work as expected for the different field types', () => {
not: {
phones: {
additionalPhones: {
like: `%1234567890%`,
like: '%1234567890%',
},
},
},
@ -865,6 +865,13 @@ describe('should work as expected for the different field types', () => {
},
},
},
{
emails: {
additionalEmails: {
like: '%test@test.com%',
},
},
},
],
},
{
@ -878,42 +885,106 @@ describe('should work as expected for the different field types', () => {
},
},
},
{
or: [
{
not: {
emails: {
additionalEmails: {
like: '%test@test.com%',
},
},
},
},
{
emails: {
additionalEmails: {
is: 'NULL',
},
},
},
],
},
],
},
{
or: [
and: [
{
emails: {
primaryEmail: {
ilike: '',
or: [
{
emails: {
primaryEmail: {
eq: '',
},
},
},
},
{
emails: {
primaryEmail: {
is: 'NULL',
},
},
},
],
},
{
emails: {
primaryEmail: {
is: 'NULL',
or: [
{
emails: {
additionalEmails: {
is: 'NULL',
},
},
},
},
{
emails: {
additionalEmails: {
like: '[]',
},
},
},
],
},
],
},
{
not: {
or: [
and: [
{
emails: {
primaryEmail: {
ilike: '',
or: [
{
emails: {
primaryEmail: {
eq: '',
},
},
},
},
{
emails: {
primaryEmail: {
is: 'NULL',
},
},
},
],
},
{
emails: {
primaryEmail: {
is: 'NULL',
or: [
{
emails: {
additionalEmails: {
is: 'NULL',
},
},
},
},
{
emails: {
additionalEmails: {
like: '[]',
},
},
},
],
},
],
},

View File

@ -6,6 +6,8 @@ const COMPOSITE_TYPES_FILTERABLE = [
'CURRENCY',
'ADDRESS',
'PHONES',
'LINKS',
'EMAILS',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];

View File

@ -0,0 +1,92 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
EmailsFilter,
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeEmptyGqlOperationFilterForEmails = ({
recordFilter,
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
}): RecordGqlOperationFilter => {
const subFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryEmail': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { eq: '' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { is: 'NULL' },
} satisfies EmailsFilter,
},
],
};
}
case 'additionalEmails': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { is: 'NULL' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { like: '[]' },
} satisfies EmailsFilter,
},
],
};
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
}
}
}
return {
and: [
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { eq: '' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: { is: 'NULL' },
} satisfies EmailsFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { is: 'NULL' },
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: { like: '[]' },
} satisfies EmailsFilter,
},
],
},
],
};
};

View File

@ -0,0 +1,122 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
LinksFilter,
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isNonEmptyString } from '@sniptt/guards';
export const computeEmptyGqlOperationFilterForLinks = ({
recordFilter,
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
}): RecordGqlOperationFilter => {
const subFieldName = recordFilter.subFieldName;
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryLinkLabel': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { is: 'NULL' },
} satisfies LinksFilter,
},
],
};
}
case 'primaryLinkUrl': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { is: 'NULL' },
} satisfies LinksFilter,
},
],
};
}
case 'secondaryLinks': {
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { is: 'NULL' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { like: '[]' },
} satisfies LinksFilter,
},
],
};
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
}
}
}
return {
and: [
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: { is: 'NULL' },
} satisfies LinksFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { eq: '' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: { is: 'NULL' },
} satisfies LinksFilter,
},
],
},
{
or: [
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { is: 'NULL' },
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: { like: '[]' },
} satisfies LinksFilter,
},
],
},
],
};
};

View File

@ -0,0 +1,31 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions';
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
export const checkIfShouldComputeEmptinessFilter = ({
recordFilter,
correspondingFieldMetadataItem,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<FieldMetadataItem, 'type'>;
}) => {
const isAnEmptinessOperand = isEmptinessOperand(recordFilter.operand);
const filterTypesThatHaveNoEmptinessOperand: FilterableFieldType[] = [
'BOOLEAN',
];
const filterType = getFilterTypeFromFieldType(
correspondingFieldMetadataItem.type,
);
const filterHasEmptinessOperands =
!filterTypesThatHaveNoEmptinessOperand.includes(filterType);
const shouldComputeEmptinessFilter =
filterHasEmptinessOperands && isAnEmptinessOperand;
return shouldComputeEmptinessFilter;
};

View File

@ -0,0 +1,26 @@
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { isEmptinessOperand } from '@/object-record/record-filter/utils/isEmptinessOperand';
import { isDefined } from 'twenty-shared/utils';
export const checkIfShouldSkipFiltering = ({
recordFilter,
}: {
recordFilter: RecordFilter;
}) => {
const isAnEmptinessOperand = isEmptinessOperand(recordFilter.operand);
const isDateOperandWithoutValue = [
RecordFilterOperand.IsInPast,
RecordFilterOperand.IsInFuture,
RecordFilterOperand.IsToday,
].includes(recordFilter.operand);
const isFilterValueEmpty =
!isDefined(recordFilter.value) || recordFilter.value === '';
const shouldSkipFiltering =
!isAnEmptinessOperand && !isDateOperandWithoutValue && isFilterValueEmpty;
return shouldSkipFiltering;
};

View File

@ -0,0 +1,153 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import {
EmailsFilter,
RecordGqlOperationFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { isNonEmptyString } from '@sniptt/guards';
export const computeGqlOperationFilterForEmails = ({
recordFilter,
correspondingFieldMetadataItem,
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
subFieldName: CompositeFieldSubFieldName | null | undefined;
}): RecordGqlOperationFilter => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryEmail': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
case 'additionalEmails': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
is: 'NULL',
},
} satisfies EmailsFilter,
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
}
}
}
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryEmail: {
ilike: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
like: `%${recordFilter.value}%`,
},
} satisfies EmailsFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
additionalEmails: {
is: 'NULL',
},
} satisfies EmailsFilter,
},
],
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
};

View File

@ -0,0 +1,166 @@
import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { LinksFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterOperand } from '@/object-record/record-filter/types/RecordFilterOperand';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { isNonEmptyString } from '@sniptt/guards';
export const computeGqlOperationFilterForLinks = ({
recordFilter,
correspondingFieldMetadataItem,
subFieldName,
}: {
recordFilter: RecordFilter;
correspondingFieldMetadataItem: Pick<FieldMetadataItem, 'name' | 'type'>;
subFieldName: CompositeFieldSubFieldName | null | undefined;
}) => {
const isSubFieldFilter = isNonEmptyString(subFieldName);
if (isSubFieldFilter) {
switch (subFieldName) {
case 'primaryLinkLabel':
case 'primaryLinkUrl': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingFieldMetadataItem.name]: {
[subFieldName]: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
case 'secondaryLinks': {
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
is: 'NULL',
},
} satisfies LinksFilter,
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
}
default: {
throw new Error(`Unknown subfield name ${subFieldName}`);
}
}
}
switch (recordFilter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryLinkLabel: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
not: {
[correspondingFieldMetadataItem.name]: {
primaryLinkUrl: {
ilike: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
or: [
{
not: {
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
like: `%${recordFilter.value}%`,
},
} satisfies LinksFilter,
},
},
{
[correspondingFieldMetadataItem.name]: {
secondaryLinks: {
is: 'NULL',
},
} satisfies LinksFilter,
},
],
},
],
};
default:
throw new Error(
`Unknown operand ${recordFilter.operand} for ${correspondingFieldMetadataItem.type} filter`,
);
}
};

View File

@ -0,0 +1,83 @@
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { Field } from '~/generated/graphql';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { RecordFilterGroupLogicalOperator } from '@/object-record/record-filter-group/types/RecordFilterGroupLogicalOperator';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter';
import { isDefined } from 'twenty-shared/utils';
export const turnRecordFilterGroupsIntoGqlOperationFilter = (
filterValueDependencies: RecordFilterValueDependencies,
filters: RecordFilter[],
fields: Pick<Field, 'id' | 'name' | 'type'>[],
recordFilterGroups: RecordFilterGroup[],
currentRecordFilterGroupId?: string,
): RecordGqlOperationFilter | undefined => {
const currentRecordFilterGroup = recordFilterGroups.find(
(recordFilterGroup) => recordFilterGroup.id === currentRecordFilterGroupId,
);
if (!isDefined(currentRecordFilterGroup)) {
return;
}
const recordFiltersInGroup = filters.filter(
(filter) => filter.recordFilterGroupId === currentRecordFilterGroupId,
);
const groupRecordGqlOperationFilters = recordFiltersInGroup
.map((recordFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: recordFilter,
fieldMetadataItems: fields,
}),
)
.filter(isDefined);
const subGroupRecordGqlOperationFilters = recordFilterGroups
.filter(
(recordFilterGroup) =>
recordFilterGroup.parentRecordFilterGroupId ===
currentRecordFilterGroupId,
)
.map((subRecordFilterGroup) =>
turnRecordFilterGroupsIntoGqlOperationFilter(
filterValueDependencies,
filters,
fields,
recordFilterGroups,
subRecordFilterGroup.id,
),
)
.filter(isDefined);
if (
currentRecordFilterGroup.logicalOperator ===
RecordFilterGroupLogicalOperator.AND
) {
return {
and: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else if (
currentRecordFilterGroup.logicalOperator ===
RecordFilterGroupLogicalOperator.OR
) {
return {
or: [
...groupRecordGqlOperationFilters,
...subGroupRecordGqlOperationFilters,
],
};
} else {
throw new Error(
`Unknown logical operator ${currentRecordFilterGroup.logicalOperator}`,
);
}
};

View File

@ -0,0 +1,66 @@
import { RecordGqlOperationFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { Field } from '~/generated/graphql';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup';
import { turnRecordFilterGroupsIntoGqlOperationFilter } from '@/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterGroupIntoGqlOperationFilter';
import { turnRecordFilterIntoRecordGqlOperationFilter } from '@/object-record/record-filter/utils/compute-record-gql-operation-filter/turnRecordFilterIntoGqlOperationFilter';
import { isDefined } from 'twenty-shared/utils';
export const computeRecordGqlOperationFilter = ({
fields,
filterValueDependencies,
recordFilters,
recordFilterGroups,
}: {
filterValueDependencies: RecordFilterValueDependencies;
recordFilters: RecordFilter[];
fields: Pick<Field, 'id' | 'name' | 'type'>[];
recordFilterGroups: RecordFilterGroup[];
}): RecordGqlOperationFilter => {
const regularRecordGqlOperationFilter: RecordGqlOperationFilter[] =
recordFilters
.filter((filter) => !isDefined(filter.recordFilterGroupId))
.map((regularFilter) =>
turnRecordFilterIntoRecordGqlOperationFilter({
filterValueDependencies,
recordFilter: regularFilter,
fieldMetadataItems: fields,
}),
)
.filter(isDefined);
const outermostFilterGroupId = recordFilterGroups.find(
(recordFilterGroup) => !recordFilterGroup.parentRecordFilterGroupId,
)?.id;
const advancedRecordGqlOperationFilter =
turnRecordFilterGroupsIntoGqlOperationFilter(
filterValueDependencies,
recordFilters,
fields,
recordFilterGroups,
outermostFilterGroupId,
);
const recordGqlOperationFilters = [
...regularRecordGqlOperationFilter,
advancedRecordGqlOperationFilter,
].filter(isDefined);
if (recordGqlOperationFilters.length === 0) {
return {};
}
if (recordGqlOperationFilters.length === 1) {
return recordGqlOperationFilters[0];
}
const recordGqlOperationFilter = {
and: recordGqlOperationFilters,
};
return recordGqlOperationFilter;
};

View File

@ -5,7 +5,6 @@ import {
ArrayFilter,
CurrencyFilter,
DateFilter,
EmailsFilter,
FloatFilter,
MultiSelectFilter,
PhonesFilter,
@ -15,9 +14,10 @@ import {
RelationFilter,
SelectFilter,
StringFilter,
URLFilter,
} from '@/object-record/graphql/types/RecordGqlOperationFilter';
import { RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { computeEmptyGqlOperationFilterForEmails } from '@/object-record/record-filter/utils/compute-empty-record-gql-operation-filter/for-composite-field/computeEmptyGqlOperationFilterForEmails';
import { computeEmptyGqlOperationFilterForLinks } from '@/object-record/record-filter/utils/compute-empty-record-gql-operation-filter/for-composite-field/computeEmptyGqlOperationFilterForLinks';
import { ViewFilterOperand } from '@/views/types/ViewFilterOperand';
import { isNonEmptyString } from '@sniptt/guards';
import { Field } from '~/generated/graphql';
@ -187,33 +187,10 @@ export const getEmptyRecordGqlOperationFilter = ({
break;
}
case 'LINKS': {
if (!isSubFieldFilter) {
const linksFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryLinkLabel', 'primaryLinkUrl'],
true,
);
emptyRecordFilter = {
and: linksFilters,
};
} else {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
} as URLFilter,
},
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
} as URLFilter,
},
],
};
}
emptyRecordFilter = computeEmptyGqlOperationFilterForLinks({
correspondingFieldMetadataItem: correspondingField,
recordFilter,
});
break;
}
case 'ADDRESS':
@ -401,20 +378,10 @@ export const getEmptyRecordGqlOperationFilter = ({
};
break;
case 'EMAILS':
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
primaryEmail: { ilike: '' },
} as EmailsFilter,
},
{
[correspondingField.name]: {
primaryEmail: { is: 'NULL' },
} as EmailsFilter,
},
],
};
emptyRecordFilter = computeEmptyGqlOperationFilterForEmails({
correspondingFieldMetadataItem: correspondingField,
recordFilter,
});
break;
default:
throw new Error(`Unsupported empty filter type ${filterType}`);

View File

@ -3,6 +3,7 @@ import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dro
import { FilterableFieldType } from '@/object-record/record-filter/types/FilterableFieldType';
import { CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName';
import { ViewFilterOperand as RecordFilterOperand } from '@/views/types/ViewFilterOperand';
import { isNonEmptyString } from '@sniptt/guards';
import { FieldMetadataType } from 'twenty-shared/types';
import { assertUnreachable } from 'twenty-shared/utils';
@ -147,6 +148,8 @@ export const getRecordFilterOperands = ({
filterType,
subFieldName,
}: GetRecordFilterOperandsParams) => {
const isFilterOnSubField = isNonEmptyString(subFieldName);
switch (filterType) {
case 'TEXT':
case 'EMAILS':
@ -184,7 +187,7 @@ export const getRecordFilterOperands = ({
case 'SELECT':
return FILTER_OPERANDS_MAP.SELECT;
case 'ACTOR': {
if (isFilterOnActorSourceSubField(subFieldName)) {
if (isFilterOnActorSourceSubField(subFieldName) || !isFilterOnSubField) {
return [
RecordFilterOperand.Is,
RecordFilterOperand.IsNot,

View File

@ -3,7 +3,7 @@ import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { useCurrentRecordGroupDefinition } from '@/object-record/record-group/hooks/useCurrentRecordGroupDefinition';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { currentRecordSortsComponentState } from '@/object-record/record-sort/states/currentRecordSortsComponentState';

View File

@ -8,7 +8,7 @@ import { useSetRecordIdsForColumn } from '@/object-record/record-board/hooks/use
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { recordGroupDefinitionFamilyState } from '@/object-record/record-group/states/recordGroupDefinitionFamilyState';
import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields';

View File

@ -5,7 +5,7 @@ import { computeAggregateValueAndLabel } from '@/object-record/record-board/reco
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { recordIndexKanbanAggregateOperationState } from '@/object-record/record-index/states/recordIndexKanbanAggregateOperationState';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { UserContext } from '@/users/contexts/UserContext';

View File

@ -3,7 +3,7 @@ import { computeAggregateValueAndLabel } from '@/object-record/record-board/reco
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { useRecordGroupFilter } from '@/object-record/record-group/hooks/useRecordGroupFilter';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecordTableContextOrThrow } from '@/object-record/record-table/contexts/RecordTableContext';

View File

@ -56,7 +56,7 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
label: 'Emails',
Icon: IllustrationIconMail,
subFields: ['primaryEmail', 'additionalEmails'],
filterableSubFields: ['primaryEmail'],
filterableSubFields: ['primaryEmail', 'additionalEmails'],
labelBySubField: {
primaryEmail: 'Primary Email',
additionalEmails: 'Additional Emails',
@ -81,7 +81,11 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
},
category: 'Basic',
subFields: ['primaryLinkUrl', 'primaryLinkLabel', 'secondaryLinks'],
filterableSubFields: ['primaryLinkUrl', 'primaryLinkLabel'],
filterableSubFields: [
'primaryLinkUrl',
'primaryLinkLabel',
'secondaryLinks',
],
labelBySubField: {
primaryLinkUrl: 'Link URL',
primaryLinkLabel: 'Link Label',

View File

@ -3,7 +3,7 @@ import { useAggregateRecords } from '@/object-record/hooks/useAggregateRecords';
import { currentRecordFilterGroupsComponentState } from '@/object-record/record-filter-group/states/currentRecordFilterGroupsComponentState';
import { useFilterValueDependencies } from '@/object-record/record-filter/hooks/useFilterValueDependencies';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { AGGREGATE_OPERATIONS } from '@/object-record/record-table/constants/AggregateOperations';
import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2';
import { useGetViewGroupsFilters } from '@/views/hooks/useGetViewGroupsFilters';

View File

@ -4,7 +4,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy';
import { RecordFilterValueDependencies } from '@/object-record/record-filter/types/RecordFilterValueDependencies';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter';
import { computeRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeRecordGqlOperationFilter';
import { View } from '@/views/types/View';
import { mapViewFilterGroupsToRecordFilterGroups } from '@/views/utils/mapViewFilterGroupsToRecordFilterGroups';
import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters';