Implemented PHONES sub-field filtering (#11953)

This PR implements sub-field filtering for the PHONES field type.

What was tricky was to have filtering work correctly on the
additionalPhones sub-field, which is an array of objects and is treated
as a RawJsonFilter. Now that it works for this sub-field, we can
implement the same logic for other similar sub-field like
additionalEmails and secondaryLinks.
This commit is contained in:
Lucas Bordeau
2025-05-09 14:32:57 +02:00
committed by GitHub
parent d63e53943e
commit 8e07160c84
9 changed files with 373 additions and 65 deletions

View File

@ -83,7 +83,7 @@ export const AdvancedFilterDropdownFilterInput = ({
recordFilter.subFieldName,
) ? (
<>
<ObjectFilterDropdownCurrencySelect dropdownWidth={280} />
<ObjectFilterDropdownCurrencySelect />
</>
) : (
<></>

View File

@ -104,6 +104,8 @@ export type EmailsFilter = {
export type PhonesFilter = {
primaryPhoneNumber?: StringFilter;
primaryPhoneCallingCode?: StringFilter;
additionalPhones?: RawJsonFilter;
};
export type SelectFilter = {

View File

@ -18,13 +18,7 @@ import { MenuItem, MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
export const EMPTY_FILTER_VALUE = '[]';
export const MAX_ITEMS_TO_DISPLAY = 3;
type ObjectFilterDropdownCurrencySelectProps = {
dropdownWidth?: number;
};
export const ObjectFilterDropdownCurrencySelect = ({
dropdownWidth,
}: ObjectFilterDropdownCurrencySelectProps) => {
export const ObjectFilterDropdownCurrencySelect = () => {
const [searchText, setSearchText] = useState('');
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValueV2(
@ -112,7 +106,7 @@ export const ObjectFilterDropdownCurrencySelect = ({
}}
/>
<DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight width={dropdownWidth ?? 200}>
<DropdownMenuItemsContainer hasMaxHeight width="auto">
{filteredSelectedItems?.map((item) => {
return (
<MenuItemMultiSelectAvatar

View File

@ -640,6 +640,20 @@ describe('should work as expected for the different field types', () => {
},
},
},
{
phones: {
primaryPhoneCallingCode: {
ilike: '%1234567890%',
},
},
},
{
phones: {
additionalPhones: {
like: '%1234567890%',
},
},
},
],
},
{
@ -653,6 +667,35 @@ describe('should work as expected for the different field types', () => {
},
},
},
{
not: {
phones: {
primaryPhoneCallingCode: {
ilike: '%1234567890%',
},
},
},
},
{
or: [
{
not: {
phones: {
additionalPhones: {
like: `%1234567890%`,
},
},
},
},
{
phones: {
additionalPhones: {
is: 'NULL',
},
},
},
],
},
],
},
{
@ -661,16 +704,40 @@ describe('should work as expected for the different field types', () => {
or: [
{
phones: {
primaryPhoneNumber: {
is: 'NULL',
},
primaryPhoneNumber: { is: 'NULL' },
},
},
{
phones: {
primaryPhoneNumber: {
ilike: '',
},
primaryPhoneNumber: { ilike: '' },
},
},
],
},
{
or: [
{
phones: {
primaryPhoneCallingCode: { is: 'NULL' },
},
},
{
phones: {
primaryPhoneCallingCode: { ilike: '' },
},
},
],
},
{
or: [
{
phones: {
additionalPhones: { is: 'NULL' },
},
},
{
phones: {
additionalPhones: { like: '[]' },
},
},
],
@ -684,16 +751,40 @@ describe('should work as expected for the different field types', () => {
or: [
{
phones: {
primaryPhoneNumber: {
is: 'NULL',
},
primaryPhoneNumber: { is: 'NULL' },
},
},
{
phones: {
primaryPhoneNumber: {
ilike: '',
},
primaryPhoneNumber: { ilike: '' },
},
},
],
},
{
or: [
{
phones: {
primaryPhoneCallingCode: { is: 'NULL' },
},
},
{
phones: {
primaryPhoneCallingCode: { ilike: '' },
},
},
],
},
{
or: [
{
phones: {
additionalPhones: { is: 'NULL' },
},
},
{
phones: {
additionalPhones: { like: '[]' },
},
},
],

View File

@ -5,6 +5,7 @@ const COMPOSITE_TYPES_FILTERABLE = [
'FULL_NAME',
'CURRENCY',
'ADDRESS',
'PHONES',
] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];

View File

@ -1030,25 +1030,146 @@ export const computeFilterRecordGqlOperationFilter = ({
);
}
case 'PHONES': {
const filterValue = filter.value.replace(/[^0-9]/g, '');
if (!isSubFieldFilter) {
const filterValue = filter.value.replace(/[^0-9]/g, '');
switch (filter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
if (!isNonEmptyString(filterValue)) {
return;
}
switch (filter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingField.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
{
[correspondingField.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
} as PhonesFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
not: {
[correspondingField.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
},
{
not: {
[correspondingField.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
},
{
or: [
{
not: {
[correspondingField.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
} as PhonesFilter,
},
},
{
[correspondingField.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
},
},
],
},
],
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} filter`,
);
}
}
const filterValue = filter.value;
switch (subFieldName) {
case 'additionalPhones': {
switch (filter.operand) {
case RecordFilterOperand.Contains:
return {
or: [
{
[correspondingField.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
} as PhonesFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
or: [
{
not: {
[correspondingField.name]: {
additionalPhones: {
like: `%${filterValue}%`,
},
} as PhonesFilter,
},
},
{
[correspondingField.name]: {
additionalPhones: {
is: 'NULL',
} as PhonesFilter,
},
},
],
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} filter`,
);
}
}
case 'primaryPhoneNumber': {
switch (filter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingField.name]: {
primaryPhoneNumber: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
],
};
case RecordFilterOperand.DoesNotContain:
return {
and: [
{
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingField.name]: {
primaryPhoneNumber: {
@ -1056,12 +1177,42 @@ export const computeFilterRecordGqlOperationFilter = ({
},
} as PhonesFilter,
},
},
],
};
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} filter`,
);
}
}
case 'primaryPhoneCallingCode': {
switch (filter.operand) {
case RecordFilterOperand.Contains:
return {
[correspondingField.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
};
case RecordFilterOperand.DoesNotContain:
return {
not: {
[correspondingField.name]: {
primaryPhoneCallingCode: {
ilike: `%${filterValue}%`,
},
} as PhonesFilter,
},
};
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} filter`,
);
}
}
default:
throw new Error(
`Unknown operand ${filter.operand} for ${filterType} filter`,
`Unknown subfield ${subFieldName} for ${filterType} filter`,
);
}
}

View File

@ -8,6 +8,7 @@ import {
EmailsFilter,
FloatFilter,
MultiSelectFilter,
PhonesFilter,
RatingFilter,
RawJsonFilter,
RecordGqlOperationFilter,
@ -37,7 +38,7 @@ export const getEmptyRecordGqlOperationFilter = ({
const compositeFieldName = recordFilter.subFieldName;
const isCompositeField = isNonEmptyString(compositeFieldName);
const isSubFieldFilter = isNonEmptyString(compositeFieldName);
const filterType = getFilterTypeFromFieldType(correspondingField.type);
@ -51,35 +52,98 @@ export const getEmptyRecordGqlOperationFilter = ({
};
break;
case 'PHONES': {
if (!isCompositeField) {
const phonesFilter = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryPhoneNumber'],
true,
);
if (!isSubFieldFilter) {
emptyRecordFilter = {
and: phonesFilter,
};
break;
} else {
emptyRecordFilter = {
or: [
and: [
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
} as StringFilter,
or: [
{
[correspondingField.name]: {
primaryPhoneNumber: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneNumber: { ilike: '' },
} as PhonesFilter,
},
],
},
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
} as StringFilter,
or: [
{
[correspondingField.name]: {
primaryPhoneCallingCode: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneCallingCode: { ilike: '' },
} as PhonesFilter,
},
],
},
{
or: [
{
[correspondingField.name]: {
additionalPhones: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
additionalPhones: { like: '[]' },
} as PhonesFilter,
},
],
},
],
};
break;
} else {
switch (compositeFieldName) {
case 'primaryPhoneNumber':
case 'primaryPhoneCallingCode': {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
[compositeFieldName]: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
[compositeFieldName]: { ilike: '' },
} as PhonesFilter,
},
],
};
break;
}
case 'additionalPhones': {
emptyRecordFilter = {
or: [
{
[correspondingField.name]: {
additionalPhones: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
additionalPhones: { like: '[]' },
} as PhonesFilter,
},
],
};
break;
}
default: {
throw new Error(
`Unsupported composite field name ${compositeFieldName} for filter type ${filterType}`,
);
}
}
}
break;
}
case 'CURRENCY':
emptyRecordFilter = {
@ -93,7 +157,7 @@ export const getEmptyRecordGqlOperationFilter = ({
};
break;
case 'FULL_NAME': {
if (!isCompositeField) {
if (!isSubFieldFilter) {
const fullNameFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
@ -123,7 +187,7 @@ export const getEmptyRecordGqlOperationFilter = ({
break;
}
case 'LINKS': {
if (!isCompositeField) {
if (!isSubFieldFilter) {
const linksFilters = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
@ -153,7 +217,7 @@ export const getEmptyRecordGqlOperationFilter = ({
break;
}
case 'ADDRESS':
if (!isCompositeField) {
if (!isSubFieldFilter) {
emptyRecordFilter = {
and: [
{

View File

@ -102,9 +102,14 @@ export const SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS = {
subFields: [
'primaryPhoneNumber',
'primaryPhoneCountryCode',
'primaryPhoneCallingCode',
'additionalPhones',
],
filterableSubFields: [
'primaryPhoneNumber',
'primaryPhoneCallingCode',
'additionalPhones',
],
filterableSubFields: ['primaryPhoneNumber', 'primaryPhoneCountryCode'],
labelBySubField: {
primaryPhoneNumber: 'Primary Phone Number',
primaryPhoneCountryCode: 'Primary Phone Country Code',

View File

@ -79,7 +79,7 @@ export const ViewBarFilterDropdownFieldSelectMenu = () => {
selectableItemIdArray={selectableFieldMetadataItemIds}
selectableListInstanceId={FILTER_FIELD_LIST_ID}
>
<DropdownMenuItemsContainer>
<DropdownMenuItemsContainer width="auto">
{selectableVisibleFieldMetadataItems.map(
(visibleFieldMetadataItem) => (
<ViewBarFilterDropdownFieldSelectMenuItem