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
@@ -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 = (
@@ -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) &&
@@ -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 = {
@@ -38,7 +38,7 @@ export const ObjectFilterDropdownTextInput = () => {
};
return (
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer width="auto">
<DropdownMenuInput
ref={handleInputRef}
value={objectFilterDropdownFilterValue}
@@ -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],
@@ -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',
};
@@ -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: '[]',
},
},
},
],
},
],
},
@@ -6,6 +6,8 @@ const COMPOSITE_TYPES_FILTERABLE = [
'CURRENCY',
'ADDRESS',
'PHONES',
'LINKS',
'EMAILS',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];
@@ -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,
},
],
},
],
};
};
@@ -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,
},
],
},
],
};
};
@@ -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;
};
@@ -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;
};
@@ -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`,
);
}
};
@@ -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`,
);
}
};
@@ -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}`,
);
}
};
@@ -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;
};
@@ -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}`);
@@ -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,
@@ -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';
@@ -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';
@@ -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';
@@ -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';
@@ -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',
@@ -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';
@@ -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';