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, recordFilter.subFieldName,
) ? ( ) ? (
<> <>
<ObjectFilterDropdownCurrencySelect dropdownWidth={280} /> <ObjectFilterDropdownCurrencySelect />
</> </>
) : ( ) : (
<></> <></>

View File

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

View File

@ -18,13 +18,7 @@ import { MenuItem, MenuItemMultiSelectAvatar } from 'twenty-ui/navigation';
export const EMPTY_FILTER_VALUE = '[]'; export const EMPTY_FILTER_VALUE = '[]';
export const MAX_ITEMS_TO_DISPLAY = 3; export const MAX_ITEMS_TO_DISPLAY = 3;
type ObjectFilterDropdownCurrencySelectProps = { export const ObjectFilterDropdownCurrencySelect = () => {
dropdownWidth?: number;
};
export const ObjectFilterDropdownCurrencySelect = ({
dropdownWidth,
}: ObjectFilterDropdownCurrencySelectProps) => {
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValueV2( const objectFilterDropdownCurrentRecordFilter = useRecoilComponentValueV2(
@ -112,7 +106,7 @@ export const ObjectFilterDropdownCurrencySelect = ({
}} }}
/> />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItemsContainer hasMaxHeight width={dropdownWidth ?? 200}> <DropdownMenuItemsContainer hasMaxHeight width="auto">
{filteredSelectedItems?.map((item) => { {filteredSelectedItems?.map((item) => {
return ( return (
<MenuItemMultiSelectAvatar <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: [ or: [
{ {
phones: { phones: {
primaryPhoneNumber: { primaryPhoneNumber: { is: 'NULL' },
is: 'NULL',
},
}, },
}, },
{ {
phones: { phones: {
primaryPhoneNumber: { primaryPhoneNumber: { ilike: '' },
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: [ or: [
{ {
phones: { phones: {
primaryPhoneNumber: { primaryPhoneNumber: { is: 'NULL' },
is: 'NULL',
},
}, },
}, },
{ {
phones: { phones: {
primaryPhoneNumber: { primaryPhoneNumber: { ilike: '' },
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', 'FULL_NAME',
'CURRENCY', 'CURRENCY',
'ADDRESS', 'ADDRESS',
'PHONES',
] satisfies FieldType[]; ] satisfies FieldType[];
type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number]; type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number];

View File

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

View File

@ -8,6 +8,7 @@ import {
EmailsFilter, EmailsFilter,
FloatFilter, FloatFilter,
MultiSelectFilter, MultiSelectFilter,
PhonesFilter,
RatingFilter, RatingFilter,
RawJsonFilter, RawJsonFilter,
RecordGqlOperationFilter, RecordGqlOperationFilter,
@ -37,7 +38,7 @@ export const getEmptyRecordGqlOperationFilter = ({
const compositeFieldName = recordFilter.subFieldName; const compositeFieldName = recordFilter.subFieldName;
const isCompositeField = isNonEmptyString(compositeFieldName); const isSubFieldFilter = isNonEmptyString(compositeFieldName);
const filterType = getFilterTypeFromFieldType(correspondingField.type); const filterType = getFilterTypeFromFieldType(correspondingField.type);
@ -51,35 +52,98 @@ export const getEmptyRecordGqlOperationFilter = ({
}; };
break; break;
case 'PHONES': { case 'PHONES': {
if (!isCompositeField) { if (!isSubFieldFilter) {
const phonesFilter = generateILikeFiltersForCompositeFields(
'',
correspondingField.name,
['primaryPhoneNumber'],
true,
);
emptyRecordFilter = { emptyRecordFilter = {
and: phonesFilter, and: [
};
break;
} else {
emptyRecordFilter = {
or: [
{ {
[correspondingField.name]: { or: [
[compositeFieldName]: { ilike: '' }, {
} as StringFilter, [correspondingField.name]: {
primaryPhoneNumber: { is: 'NULL' },
} as PhonesFilter,
},
{
[correspondingField.name]: {
primaryPhoneNumber: { ilike: '' },
} as PhonesFilter,
},
],
}, },
{ {
[correspondingField.name]: { or: [
[compositeFieldName]: { is: 'NULL' }, {
} as StringFilter, [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': case 'CURRENCY':
emptyRecordFilter = { emptyRecordFilter = {
@ -93,7 +157,7 @@ export const getEmptyRecordGqlOperationFilter = ({
}; };
break; break;
case 'FULL_NAME': { case 'FULL_NAME': {
if (!isCompositeField) { if (!isSubFieldFilter) {
const fullNameFilters = generateILikeFiltersForCompositeFields( const fullNameFilters = generateILikeFiltersForCompositeFields(
'', '',
correspondingField.name, correspondingField.name,
@ -123,7 +187,7 @@ export const getEmptyRecordGqlOperationFilter = ({
break; break;
} }
case 'LINKS': { case 'LINKS': {
if (!isCompositeField) { if (!isSubFieldFilter) {
const linksFilters = generateILikeFiltersForCompositeFields( const linksFilters = generateILikeFiltersForCompositeFields(
'', '',
correspondingField.name, correspondingField.name,
@ -153,7 +217,7 @@ export const getEmptyRecordGqlOperationFilter = ({
break; break;
} }
case 'ADDRESS': case 'ADDRESS':
if (!isCompositeField) { if (!isSubFieldFilter) {
emptyRecordFilter = { emptyRecordFilter = {
and: [ and: [
{ {

View File

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

View File

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