From 8e07160c843c968e3c314b664d71e2701232f88c Mon Sep 17 00:00:00 2001 From: Lucas Bordeau Date: Fri, 9 May 2025 14:32:57 +0200 Subject: [PATCH] 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. --- .../AdvancedFilterDropdownFilterInput.tsx | 2 +- .../graphql/types/RecordGqlOperationFilter.ts | 2 + .../ObjectFilterDropdownCurrencySelect.tsx | 10 +- ...omputeViewRecordGqlOperationFilter.test.ts | 115 +++++++++-- .../areCompositeTypeSubFieldsFilterable.ts | 1 + .../computeViewRecordGqlOperationFilter.ts | 185 ++++++++++++++++-- .../utils/getEmptyRecordGqlOperationFilter.ts | 114 ++++++++--- .../SettingsCompositeFieldTypeConfigs.ts | 7 +- .../ViewBarFilterDropdownFieldSelectMenu.tsx | 2 +- 9 files changed, 373 insertions(+), 65 deletions(-) diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx index 1cf20a2c2..f3b6dc5e8 100644 --- a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterDropdownFilterInput.tsx @@ -83,7 +83,7 @@ export const AdvancedFilterDropdownFilterInput = ({ recordFilter.subFieldName, ) ? ( <> - + ) : ( <> diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 3eac11ace..f1c04c298 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -104,6 +104,8 @@ export type EmailsFilter = { export type PhonesFilter = { primaryPhoneNumber?: StringFilter; + primaryPhoneCallingCode?: StringFilter; + additionalPhones?: RawJsonFilter; }; export type SelectFilter = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect.tsx index dd79785a8..329330c92 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownCurrencySelect.tsx @@ -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 = ({ }} /> - + {filteredSelectedItems?.map((item) => { return ( { }, }, }, + { + 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: '[]' }, }, }, ], diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable.ts index 79af110c0..5205c3736 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/areCompositeTypeSubFieldsFilterable.ts @@ -5,6 +5,7 @@ const COMPOSITE_TYPES_FILTERABLE = [ 'FULL_NAME', 'CURRENCY', 'ADDRESS', + 'PHONES', ] satisfies FieldType[]; type FilterableCompositeFieldType = (typeof COMPOSITE_TYPES_FILTERABLE)[number]; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts index ac34a8061..9d1e9758f 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts @@ -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`, ); } } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts index f5f3d2cb3..51fb37acc 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts @@ -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: [ { diff --git a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts index ebf2a0803..0f700d998 100644 --- a/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts +++ b/packages/twenty-front/src/modules/settings/data-model/constants/SettingsCompositeFieldTypeConfigs.ts @@ -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', diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx index f969c0e31..1eb0dd3bd 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterDropdownFieldSelectMenu.tsx @@ -79,7 +79,7 @@ export const ViewBarFilterDropdownFieldSelectMenu = () => { selectableItemIdArray={selectableFieldMetadataItemIds} selectableListInstanceId={FILTER_FIELD_LIST_ID} > - + {selectableVisibleFieldMetadataItems.map( (visibleFieldMetadataItem) => (