From 0df07a766a188178a06f3ee44b59585b9942f883 Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Thu, 3 Apr 2025 10:41:50 +0200 Subject: [PATCH] Handle no value options in filters (#11351) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #11323 - see description to reproduce The issue was that the filter was malformed for no value options: Capture d’écran 2025-04-02 à 14 56 25 causing Capture d’écran 2025-04-02 à 14 56 43 after fix: Capture d’écran 2025-04-02 à 14 39 56 --- .../favorites/hooks/__mocks__/useFavorites.ts | 2 + ...ColumnDefinitionsFromFieldMetadata.test.ts | 2 +- .../hooks/__mocks__/personFragments.ts | 1 + .../__tests__/useToggleEditOnlyInput.test.tsx | 1 + ...omputeViewRecordGqlOperationFilter.test.ts | 152 ++++++++++++++++++ .../computeViewRecordGqlOperationFilter.ts | 91 ++++++++--- ...jectRecordsSpreadsheetImportDialog.test.ts | 1 + .../generated/mock-metadata-query-result.ts | 36 +++++ 8 files changed, 266 insertions(+), 20 deletions(-) diff --git a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts index 99c18e06b..f5e09ae32 100644 --- a/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts +++ b/packages/twenty-front/src/modules/favorites/hooks/__mocks__/useFavorites.ts @@ -141,6 +141,7 @@ mutation UpdateOneFavorite( employees id idealCustomerProfile + internalCompetitions introVideo { primaryLinkUrl primaryLinkLabel @@ -510,6 +511,7 @@ export const mocks = [ employees id idealCustomerProfile + internalCompetitions introVideo { primaryLinkUrl primaryLinkLabel diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts index 1edaf1d60..ea7148b4b 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/__tests__/useColumnDefinitionsFromFieldMetadata.test.ts @@ -67,6 +67,6 @@ describe('useColumnDefinitionsFromFieldMetadata', () => { const { columnDefinitions } = result.current; - expect(columnDefinitions.length).toBe(21); + expect(columnDefinitions.length).toBe(22); }); }); diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts index c392b1d3b..7d57d7b94 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts @@ -127,6 +127,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` employees id idealCustomerProfile + internalCompetitions introVideo { primaryLinkUrl primaryLinkLabel diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx index 1f8be4cad..824618748 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx @@ -124,6 +124,7 @@ const mocks: MockedResponse[] = [ } id idealCustomerProfile + internalCompetitions introVideo { primaryLinkUrl primaryLinkLabel diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts index 484e078d5..41ac1a722 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts @@ -933,4 +933,156 @@ describe('should work as expected for the different field types', () => { ], }); }); + + it('select field type with empty options', () => { + const selectFieldMetadata = companyMockObjectMetadataItem.fields.find( + (field) => field.type === FieldMetadataType.SELECT, + ); + + if (!selectFieldMetadata) { + throw new Error( + `Select field metadata not found ${companyMockObjectMetadataItem.fields.map((field) => [field.name, field.type])}`, + ); + } + + const selectFilterIs: RecordFilter = { + id: 'company-select-filter-is', + value: '["option1",""]', + fieldMetadataId: selectFieldMetadata?.id, + displayValue: '["option1",""]', + operand: ViewFilterOperand.Is, + label: 'Select', + type: FieldMetadataType.SELECT, + }; + + const selectFilterIsNot: RecordFilter = { + id: 'company-select-filter-is-not', + value: '["option1",""]', + fieldMetadataId: selectFieldMetadata.id, + displayValue: '["option1",""]', + operand: ViewFilterOperand.IsNot, + label: 'Select', + type: FieldMetadataType.SELECT, + }; + + const result = computeRecordGqlOperationFilter({ + filterValueDependencies: mockFilterValueDependencies, + recordFilters: [selectFilterIs, selectFilterIsNot], + recordFilterGroups: [], + fields: companyMockObjectMetadataItem.fields, + }); + + expect(result).toEqual({ + and: [ + { + or: [ + { + [selectFieldMetadata.name]: { + in: ['option1'], + }, + }, + { + [selectFieldMetadata.name]: { + is: 'NULL', + }, + }, + ], + }, + { + and: [ + { + not: { + [selectFieldMetadata.name]: { + in: ['option1'], + }, + }, + }, + { + not: { + [selectFieldMetadata.name]: { + is: 'NULL', + }, + }, + }, + ], + }, + ], + }); + }); + + it('multi-select field type with empty options', () => { + const multiSelectFieldMetadata = companyMockObjectMetadataItem.fields.find( + (field) => field.type === FieldMetadataType.MULTI_SELECT, + )!; + + const multiSelectFilterContains: RecordFilter = { + id: 'company-multi-select-filter-contains', + value: '["option1",""]', + fieldMetadataId: multiSelectFieldMetadata.id, + displayValue: '["option1",""]', + operand: ViewFilterOperand.Contains, + label: 'MultiSelect', + type: FieldMetadataType.MULTI_SELECT, + }; + + const multiSelectFilterDoesNotContain: RecordFilter = { + id: 'company-multi-select-filter-does-not-contain', + value: '["option1",""]', + fieldMetadataId: multiSelectFieldMetadata.id, + displayValue: '["option1",""]', + operand: ViewFilterOperand.DoesNotContain, + label: 'MultiSelect', + type: FieldMetadataType.MULTI_SELECT, + }; + + const result = computeRecordGqlOperationFilter({ + filterValueDependencies: mockFilterValueDependencies, + recordFilters: [ + multiSelectFilterContains, + multiSelectFilterDoesNotContain, + ], + recordFilterGroups: [], + fields: companyMockObjectMetadataItem.fields, + }); + + expect(result).toEqual({ + and: [ + { + or: [ + { + [multiSelectFieldMetadata.name]: { + containsAny: ['option1'], + }, + }, + { + [multiSelectFieldMetadata.name]: { + isEmptyArray: true, + }, + }, + ], + }, + { + or: [ + { + not: { + [multiSelectFieldMetadata.name]: { + containsAny: ['option1'], + }, + }, + }, + { + [multiSelectFieldMetadata.name]: { + isEmptyArray: true, + }, + }, + { + [multiSelectFieldMetadata.name]: { + is: 'NULL', + }, + }, + ], + }, + ], + }); + }); }); 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 c7e28c0a8..af8a06b59 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 @@ -584,20 +584,38 @@ export const computeFilterRecordGqlOperationFilter = ({ if (options.length === 0) return; + const emptyOptions = options.filter((option: string) => option === ''); + const nonEmptyOptions = options.filter((option: string) => option !== ''); + switch (filter.operand) { - case RecordFilterOperand.Contains: - return { - [correspondingField.name]: { - containsAny: options, - } as MultiSelectFilter, - }; + case RecordFilterOperand.Contains: { + const conditions = []; + + if (nonEmptyOptions.length > 0) { + conditions.push({ + [correspondingField.name]: { + containsAny: nonEmptyOptions, + } as MultiSelectFilter, + }); + } + + if (emptyOptions.length > 0) { + conditions.push({ + [correspondingField.name]: { + isEmptyArray: true, + } as MultiSelectFilter, + }); + } + + return conditions.length === 1 ? conditions[0] : { or: conditions }; + } case RecordFilterOperand.DoesNotContain: return { or: [ { not: { [correspondingField.name]: { - containsAny: options, + containsAny: nonEmptyOptions, } as MultiSelectFilter, }, }, @@ -624,21 +642,56 @@ export const computeFilterRecordGqlOperationFilter = ({ if (options.length === 0) return; + const emptyOptions = options.filter((option: string) => option === ''); + const nonEmptyOptions = options.filter((option: string) => option !== ''); + switch (filter.operand) { - case RecordFilterOperand.Is: - return { - [correspondingField.name]: { - in: options, - } as SelectFilter, - }; - case RecordFilterOperand.IsNot: - return { - not: { + case RecordFilterOperand.Is: { + const conditions = []; + + if (nonEmptyOptions.length > 0) { + conditions.push({ [correspondingField.name]: { - in: options, + in: nonEmptyOptions, } as SelectFilter, - }, - }; + }); + } + + if (emptyOptions.length > 0) { + conditions.push({ + [correspondingField.name]: { + is: 'NULL', + } as SelectFilter, + }); + } + + return conditions.length === 1 ? conditions[0] : { or: conditions }; + } + case RecordFilterOperand.IsNot: { + const conditions = []; + + if (nonEmptyOptions.length > 0) { + conditions.push({ + not: { + [correspondingField.name]: { + in: nonEmptyOptions, + } as SelectFilter, + }, + }); + } + + if (emptyOptions.length > 0) { + conditions.push({ + not: { + [correspondingField.name]: { + is: 'NULL', + } as SelectFilter, + }, + }); + } + + return conditions.length === 1 ? conditions[0] : { and: conditions }; + } default: throw new Error( `Unknown operand ${filter.operand} for ${filterType} filter`, diff --git a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts index dbdad7c09..c53074a51 100644 --- a/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts +++ b/packages/twenty-front/src/modules/object-record/spreadsheet-import/hooks/__tests__/useOpenObjectRecordsSpreadsheetImportDialog.test.ts @@ -124,6 +124,7 @@ const companyMocks = [ } id idealCustomerProfile + internalCompetitions introVideo { primaryLinkUrl primaryLinkLabel diff --git a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts index 00a9abbac..fe2659209 100644 --- a/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts +++ b/packages/twenty-front/src/testing/mock-data/generated/mock-metadata-query-result.ts @@ -3876,6 +3876,42 @@ export const mockedStandardObjectMetadataQueryResult: ObjectMetadataItemsQuery = label: "Work Policy", description: "Company's Work Policy" }, + { + __typename: "Field", + id: "c6dfcc2d-dc84-4553-94df-3b75cccee53c", + type: "SELECT", + name: "internalCompetitions", + icon: "IconHome", + isCustom: true, + isActive: true, + isSystem: false, + isNullable: true, + isUnique: false, + createdAt: "2025-02-11T09:14:38.892Z", + updatedAt: "2025-02-11T09:14:38.892Z", + defaultValue: null, + options: [ + { + id: "ee1b741b-0359-4ffd-b866-506e7b9c0cd9", + color: "green", + label: "Best employy", + value: "BEST_EMPLOYEE", + position: 0 + }, + { + id: "3b2ed882-ec07-43fd-96e6-0fca8669c1f5", + color: "turquoise", + label: "Ultimate debugger", + value: "ULTIMATE_DEBUGGER", + position: 1 + }, + ], + settings: null, + isLabelSyncedWithName: false, + relationDefinition: null, + label: "Internal competitions", + description: "Internal competitions" + }, { __typename: "Field", id: "8b1b88c0-a802-4c5d-8632-a4e343f3c8f1",