From badebc513f166b96078d27082a753a45932d648f Mon Sep 17 00:00:00 2001 From: ad-elias Date: Sun, 17 Nov 2024 15:27:38 +0100 Subject: [PATCH] Fix multi select filtering (#5358) (#8452) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Allow filtering by multi-select fields. Screenshot 2024-11-11 at 11 54 45 --------- Co-authored-by: FĂ©lix Malfait --- ...ColumnDefinitionsFromFieldMetadata.test.ts | 2 +- ...atFieldMetadataItemsAsFilterDefinitions.ts | 1 + .../graphql/types/RecordGqlOperationFilter.ts | 27 ++- .../ObjectFilterDropdownFilterInput.tsx | 4 +- .../utils/getOperandsForFilterType.ts | 8 +- .../__tests__/isMatchingStringFilter.test.ts | 56 ----- .../computeViewRecordGqlOperationFilter.ts | 209 ++++++++++++------ .../utils/getEmptyRecordGqlOperationFilter.ts | 25 ++- .../utils/isMatchingArrayFilter.ts | 22 +- .../utils/isMatchingMultiSelectFilter.ts | 35 +++ .../utils/isMatchingRatingFilter.ts | 30 +++ .../utils/isMatchingSelectFilter.ts | 27 +++ .../utils/isMatchingStringFilter.ts | 12 - .../utils/isRecordMatchingFilter.ts | 22 +- .../views/components/ViewBarFilterEffect.tsx | 5 +- .../utils/resolveFilterValue.ts | 8 +- .../utils/resolveSelectViewFilterValue.ts | 19 ++ .../graphql-query-filter-field.parser.ts | 2 +- .../utils/compute-where-condition-parts.ts | 20 +- .../factories/input-type.factory.ts | 9 +- .../input/array-filter.input-type.ts | 6 +- .../input/multi-select-filter.input-type.ts | 17 ++ .../input/select-filter.input-type.ts | 11 + .../services/type-mapper.service.ts | 4 + .../utils/generate-fields.utils.ts | 4 +- 25 files changed, 408 insertions(+), 177 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts create mode 100644 packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts create mode 100644 packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts 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 2205cd82af..bbe5122efc 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 @@ -61,7 +61,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => { result.current; expect(columnDefinitions.length).toBe(21); - expect(filterDefinitions.length).toBe(14); + expect(filterDefinitions.length).toBe(15); expect(sortDefinitions.length).toBe(14); }); }); diff --git a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts index 42734cb92f..94a1b27469 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -35,6 +35,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ FieldMetadataType.Address, FieldMetadataType.Relation, FieldMetadataType.Select, + FieldMetadataType.MultiSelect, FieldMetadataType.Currency, FieldMetadataType.Rating, FieldMetadataType.Actor, 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 9393d98b84..df189bd62a 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 @@ -21,11 +21,7 @@ export type BooleanFilter = { export type StringFilter = { eq?: string; - gt?: string; - gte?: string; in?: string[]; - lt?: string; - lte?: string; neq?: string; startsWith?: string; like?: string; @@ -35,6 +31,12 @@ export type StringFilter = { is?: IsFilter; }; +export type RatingFilter = { + eq?: string; + in?: string[]; + is?: IsFilter; +}; + export type FloatFilter = { eq?: number; gt?: number; @@ -104,10 +106,21 @@ export type PhonesFilter = { primaryPhoneCountryCode?: StringFilter; }; -export type ArrayFilter = { - contains?: string[]; - not_contains?: string[]; +export type SelectFilter = { is?: IsFilter; + in?: string[]; +}; + +export type MultiSelectFilter = { + is?: IsFilter; + isEmptyArray?: boolean; + containsAny?: string[]; +}; + +export type ArrayFilter = { + is?: IsFilter; + isEmptyArray?: boolean; + containsIlike?: string; }; export type RawJsonFilter = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index 050fb90218..7d5bcf561c 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -93,7 +93,9 @@ export const ObjectFilterDropdownFilterInput = ({ )} - {filterDefinitionUsedInDropdown.type === 'SELECT' && ( + {['SELECT', 'MULTI_SELECT'].includes( + filterDefinitionUsedInDropdown.type, + ) && ( <> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 993c109165..897139af94 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -58,8 +58,14 @@ export const getOperandsForFilterDefinition = ( ]; case 'RELATION': return [...relationOperands, ...emptyOperands]; + case 'MULTI_SELECT': + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; case 'SELECT': - return [...relationOperands]; + return [ViewFilterOperand.Is, ViewFilterOperand.IsNot, ...emptyOperands]; case 'ACTOR': { if (isActorSourceCompositeFilter(filterDefinition)) { return [ diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts index b6949ac986..ef396ebfc3 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/isMatchingStringFilter.test.ts @@ -158,62 +158,6 @@ describe('isMatchingStringFilter', () => { }); }); - describe('gt', () => { - it('value is greater than gt filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { gt: 'a' }, value: 'b' }), - ).toBe(true); - }); - - it('value is not greater than gt filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { gt: 'b' }, value: 'a' }), - ).toBe(false); - }); - }); - - describe('gte', () => { - it('value is greater than or equal to gte filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { gte: 'a' }, value: 'a' }), - ).toBe(true); - }); - - it('value is not greater than or equal to gte filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { gte: 'b' }, value: 'a' }), - ).toBe(false); - }); - }); - - describe('lt', () => { - it('value is less than lt filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { lt: 'b' }, value: 'a' }), - ).toBe(true); - }); - - it('value is not less than lt filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { lt: 'a' }, value: 'b' }), - ).toBe(false); - }); - }); - - describe('lte', () => { - it('value is less than or equal to lte filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'a' }), - ).toBe(true); - }); - - it('value is not less than or equal to lte filter', () => { - expect( - isMatchingStringFilter({ stringFilter: { lte: 'a' }, value: 'b' }), - ).toBe(false); - }); - }); - describe('startsWith', () => { it('value starts with the startsWith filter', () => { expect( 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 c57bce3b4f..10abe10295 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 @@ -3,15 +3,18 @@ import { isNonEmptyString } from '@sniptt/guards'; import { ActorFilter, AddressFilter, + ArrayFilter, CurrencyFilter, DateFilter, EmailsFilter, FloatFilter, + MultiSelectFilter, + RatingFilter, RawJsonFilter, RecordGqlOperationFilter, RelationFilter, + SelectFilter, StringFilter, - UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { Field } from '~/generated/graphql'; @@ -241,7 +244,7 @@ const computeFilterRecordGqlOperationFilter = ( return { [correspondingField.name]: { eq: convertRatingToRatingValue(parseFloat(filter.value)), - } as StringFilter, + } as RatingFilter, }; case ViewFilterOperand.GreaterThan: return { @@ -249,7 +252,7 @@ const computeFilterRecordGqlOperationFilter = ( in: convertGreaterThanRatingToArrayOfRatingValues( parseFloat(filter.value), ), - } as StringFilter, + } as RatingFilter, }; case ViewFilterOperand.LessThan: return { @@ -257,7 +260,7 @@ const computeFilterRecordGqlOperationFilter = ( in: convertLessThanRatingToArrayOfRatingValues( parseFloat(filter.value), ), - } as StringFilter, + } as RatingFilter, }; case ViewFilterOperand.IsEmpty: case ViewFilterOperand.IsNotEmpty: @@ -309,30 +312,28 @@ const computeFilterRecordGqlOperationFilter = ( const parsedRecordIds = JSON.parse(filter.value) as string[]; - if (parsedRecordIds.length > 0) { - switch (filter.operand) { - case ViewFilterOperand.Is: - return { + if (parsedRecordIds.length === 0) return; + switch (filter.operand) { + case ViewFilterOperand.Is: + return { + [correspondingField.name + 'Id']: { + in: parsedRecordIds, + } as RelationFilter, + }; + case ViewFilterOperand.IsNot: { + if (parsedRecordIds.length === 0) return; + return { + not: { [correspondingField.name + 'Id']: { in: parsedRecordIds, } as RelationFilter, - }; - case ViewFilterOperand.IsNot: - if (parsedRecordIds.length > 0) { - return { - not: { - [correspondingField.name + 'Id']: { - in: parsedRecordIds, - } as RelationFilter, - }, - }; - } - break; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, - ); + }, + }; } + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); } } else { switch (filter.operand) { @@ -349,7 +350,6 @@ const computeFilterRecordGqlOperationFilter = ( ); } } - break; } case 'CURRENCY': switch (filter.operand) { @@ -601,6 +601,56 @@ const computeFilterRecordGqlOperationFilter = ( `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, ); } + case 'MULTI_SELECT': { + if (isEmptyOperand) { + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + } + + const options = resolveFilterValue( + filter as Filter & { definition: { type: 'MULTI_SELECT' } }, + ); + + if (options.length === 0) return; + + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + [correspondingField.name]: { + containsAny: options, + } as MultiSelectFilter, + }; + case ViewFilterOperand.DoesNotContain: + return { + or: [ + { + not: { + [correspondingField.name]: { + containsAny: options, + } as MultiSelectFilter, + }, + }, + { + [correspondingField.name]: { + isEmptyArray: true, + } as MultiSelectFilter, + }, + { + [correspondingField.name]: { + is: 'NULL', + } as MultiSelectFilter, + }, + ], + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } case 'SELECT': { if (isEmptyOperand) { return getEmptyRecordGqlOperationFilter( @@ -609,44 +659,61 @@ const computeFilterRecordGqlOperationFilter = ( filter.definition, ); } - const stringifiedSelectValues = filter.value; - let parsedOptionValues: string[] = []; + const options = resolveFilterValue( + filter as Filter & { definition: { type: 'SELECT' } }, + ); - if (!isNonEmptyString(stringifiedSelectValues)) { - break; - } + if (options.length === 0) return; - try { - parsedOptionValues = JSON.parse(stringifiedSelectValues); - } catch (e) { - throw new Error( - `Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`, - ); - } - - if (parsedOptionValues.length > 0) { - switch (filter.operand) { - case ViewFilterOperand.Is: - return { + switch (filter.operand) { + case ViewFilterOperand.Is: + return { + [correspondingField.name]: { + in: options, + } as SelectFilter, + }; + case ViewFilterOperand.IsNot: + return { + not: { [correspondingField.name]: { - in: parsedOptionValues, - } as UUIDFilter, - }; - case ViewFilterOperand.IsNot: - return { - not: { - [correspondingField.name]: { - in: parsedOptionValues, - } as UUIDFilter, - }, - }; - default: - throw new Error( - `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, - ); - } + in: options, + } as SelectFilter, + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + case 'ARRAY': { + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + [correspondingField.name]: { + containsIlike: `%${filter.value}%`, + } as ArrayFilter, + }; + case ViewFilterOperand.DoesNotContain: + return { + not: { + [correspondingField.name]: { + containsIlike: `%${filter.value}%`, + } as ArrayFilter, + }, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); } - break; } // TODO: fix this with a new composite field in ViewFilter entity case 'ACTOR': { @@ -665,18 +732,17 @@ const computeFilterRecordGqlOperationFilter = ( case ViewFilterOperand.IsNot: { const parsedRecordIds = JSON.parse(filter.value) as string[]; - if (parsedRecordIds.length > 0) { - return { - not: { - [correspondingField.name]: { - source: { - in: parsedRecordIds, - } as RelationFilter, - }, + if (parsedRecordIds.length === 0) return; + + return { + not: { + [correspondingField.name]: { + source: { + in: parsedRecordIds, + } as RelationFilter, }, - }; - } - break; + }, + }; } case ViewFilterOperand.Contains: return { @@ -716,7 +782,6 @@ const computeFilterRecordGqlOperationFilter = ( `Unknown operand ${filter.operand} for ${filter.definition.label} filter`, ); } - break; } case 'EMAILS': switch (filter.operand) { @@ -806,7 +871,7 @@ const computeViewFilterGroupRecordGqlOperationFilter = ( ); if (!currentViewFilterGroup) { - return undefined; + return; } const groupFilters = filters.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 f670e46e0f..b2cce9468b 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 @@ -6,12 +6,14 @@ import { DateFilter, EmailsFilter, FloatFilter, + MultiSelectFilter, + RatingFilter, RawJsonFilter, RecordGqlOperationFilter, RelationFilter, + SelectFilter, StringFilter, URLFilter, - UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; @@ -255,7 +257,7 @@ export const getEmptyRecordGqlOperationFilter = ( break; case 'RATING': emptyRecordFilter = { - [correspondingField.name]: { is: 'NULL' } as StringFilter, + [correspondingField.name]: { is: 'NULL' } as RatingFilter, }; break; case 'DATE': @@ -266,7 +268,21 @@ export const getEmptyRecordGqlOperationFilter = ( break; case 'SELECT': emptyRecordFilter = { - [correspondingField.name]: { is: 'NULL' } as UUIDFilter, + [correspondingField.name]: { is: 'NULL' } as SelectFilter, + }; + break; + case 'MULTI_SELECT': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { is: 'NULL' } as MultiSelectFilter, + }, + { + [correspondingField.name]: { + isEmptyArray: true, + } as MultiSelectFilter, + }, + ], }; break; case 'RELATION': @@ -296,6 +312,9 @@ export const getEmptyRecordGqlOperationFilter = ( { [correspondingField.name]: { is: 'NULL' } as ArrayFilter, }, + { + [correspondingField.name]: { isEmptyArray: true } as ArrayFilter, + }, ], }; break; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts index 7578a04aac..8cfd18a353 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts @@ -5,19 +5,9 @@ export const isMatchingArrayFilter = ({ value, }: { arrayFilter: ArrayFilter; - value: string[]; + value: string[] | null; }) => { - if (value === null || !Array.isArray(value)) { - return false; - } - switch (true) { - case arrayFilter.contains !== undefined: { - return arrayFilter.contains.every((item) => value.includes(item)); - } - case arrayFilter.not_contains !== undefined: { - return !arrayFilter.not_contains.some((item) => value.includes(item)); - } case arrayFilter.is !== undefined: { if (arrayFilter.is === 'NULL') { return value === null; @@ -25,6 +15,16 @@ export const isMatchingArrayFilter = ({ return value !== null; } } + case arrayFilter.isEmptyArray !== undefined: { + return Array.isArray(value) && value.length === 0; + } + case arrayFilter.containsIlike !== undefined: { + const searchTerm = arrayFilter.containsIlike.toLowerCase(); + return ( + Array.isArray(value) && + value.some((item) => item.toLowerCase().includes(searchTerm)) + ); + } default: { throw new Error( `Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts new file mode 100644 index 0000000000..c26572e5e1 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingMultiSelectFilter.ts @@ -0,0 +1,35 @@ +import { MultiSelectFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingMultiSelectFilter = ({ + multiSelectFilter, + value, +}: { + multiSelectFilter: MultiSelectFilter; + value: string[] | null; +}) => { + switch (true) { + case multiSelectFilter.containsAny !== undefined: { + return ( + Array.isArray(value) && + multiSelectFilter.containsAny.every((item) => value.includes(item)) + ); + } + case multiSelectFilter.isEmptyArray !== undefined: { + return Array.isArray(value) && value.length === 0; + } + case multiSelectFilter.is !== undefined: { + if (multiSelectFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for multi-select filter: ${JSON.stringify( + multiSelectFilter, + )}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts new file mode 100644 index 0000000000..6f22b5e62d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRatingFilter.ts @@ -0,0 +1,30 @@ +import { RatingFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingRatingFilter = ({ + ratingFilter, + value, +}: { + ratingFilter: RatingFilter; + value: string | null; +}) => { + switch (true) { + case ratingFilter.eq !== undefined: { + return value === ratingFilter.eq; + } + case ratingFilter.in !== undefined: { + return value !== null && ratingFilter.in.includes(value); + } + case ratingFilter.is !== undefined: { + if (ratingFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for rating filter : ${JSON.stringify(ratingFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts new file mode 100644 index 0000000000..c564f63a14 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingSelectFilter.ts @@ -0,0 +1,27 @@ +import { SelectFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingSelectFilter = ({ + selectFilter, + value, +}: { + selectFilter: SelectFilter; + value: string; +}) => { + switch (true) { + case selectFilter.in !== undefined: { + return selectFilter.in.includes(value); + } + case selectFilter.is !== undefined: { + if (selectFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for select filter : ${JSON.stringify(selectFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts index 053be8cdec..5c80c36fe9 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingStringFilter.ts @@ -48,18 +48,6 @@ export const isMatchingStringFilter = ({ return regexCaseInsensitive.test(value); } - case stringFilter.gt !== undefined: { - return value > stringFilter.gt; - } - case stringFilter.gte !== undefined: { - return value >= stringFilter.gte; - } - case stringFilter.lt !== undefined: { - return value < stringFilter.lt; - } - case stringFilter.lte !== undefined: { - return value <= stringFilter.lte; - } case stringFilter.startsWith !== undefined: { return value.startsWith(stringFilter.startsWith); } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index c2a5da47fd..2b01d2405f 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -14,11 +14,14 @@ import { FullNameFilter, LeafObjectRecordFilter, LinksFilter, + MultiSelectFilter, NotObjectRecordFilter, OrObjectRecordFilter, PhonesFilter, + RatingFilter, RawJsonFilter, RecordGqlOperationFilter, + SelectFilter, StringFilter, UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; @@ -27,7 +30,10 @@ import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isM import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter'; import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter'; import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter'; +import { isMatchingMultiSelectFilter } from '@/object-record/record-filter/utils/isMatchingMultiSelectFilter'; +import { isMatchingRatingFilter } from '@/object-record/record-filter/utils/isMatchingRatingFilter'; import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter'; +import { isMatchingSelectFilter } from '@/object-record/record-filter/utils/isMatchingSelectFilter'; import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -160,15 +166,27 @@ export const isRecordMatchingFilter = ({ } switch (objectMetadataField.type) { - case FieldMetadataType.Select: case FieldMetadataType.Rating: - case FieldMetadataType.MultiSelect: + return isMatchingRatingFilter({ + ratingFilter: filterValue as RatingFilter, + value: record[filterKey], + }); case FieldMetadataType.Text: { return isMatchingStringFilter({ stringFilter: filterValue as StringFilter, value: record[filterKey], }); } + case FieldMetadataType.Select: + return isMatchingSelectFilter({ + selectFilter: filterValue as SelectFilter, + value: record[filterKey], + }); + case FieldMetadataType.MultiSelect: + return isMatchingMultiSelectFilter({ + multiSelectFilter: filterValue as MultiSelectFilter, + value: record[filterKey], + }); case FieldMetadataType.Array: { return isMatchingArrayFilter({ arrayFilter: filterValue as ArrayFilter, diff --git a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx index ed632475f0..4e3836f6ef 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarFilterEffect.tsx @@ -75,7 +75,10 @@ export const ViewBarFilterEffect = ({ ? JSON.parse(viewFilterUsedInDropdown.value) : []; setObjectFilterDropdownSelectedRecordIds(viewFilterSelectedRecords); - } else if (filterDefinitionUsedInDropdown?.type === 'SELECT') { + } else if ( + isDefined(filterDefinitionUsedInDropdown) && + ['SELECT', 'MULTI_SELECT'].includes(filterDefinitionUsedInDropdown.type) + ) { const viewFilterUsedInDropdown = currentViewWithCombinedFiltersAndSorts?.viewFilters.find( (filter) => diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts index c5c4e06450..47a160cbe8 100644 --- a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts @@ -2,6 +2,7 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { FilterableFieldType } from '@/object-record/object-filter-dropdown/types/FilterableFieldType'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { resolveNumberViewFilterValue } from '@/views/view-filter-value/utils/resolveNumberViewFilterValue'; +import { resolveSelectViewFilterValue } from '@/views/view-filter-value/utils/resolveSelectViewFilterValue'; import { resolveDateViewFilterValue, ResolvedDateViewFilterValue, @@ -14,7 +15,9 @@ type ResolvedFilterValue< ? ResolvedDateViewFilterValue : T extends 'NUMBER' ? ReturnType - : string; + : T extends 'SELECT' | 'MULTI_SELECT' + ? string[] + : string; type PartialFilter< T extends FilterableFieldType, @@ -36,6 +39,9 @@ export const resolveFilterValue = < return resolveDateViewFilterValue(filter) as ResolvedFilterValue; case 'NUMBER': return resolveNumberViewFilterValue(filter) as ResolvedFilterValue; + case 'SELECT': + case 'MULTI_SELECT': + return resolveSelectViewFilterValue(filter) as ResolvedFilterValue; default: return filter.value as ResolvedFilterValue; } diff --git a/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts new file mode 100644 index 0000000000..c5253fb2e1 --- /dev/null +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveSelectViewFilterValue.ts @@ -0,0 +1,19 @@ +import { ViewFilter } from '@/views/types/ViewFilter'; +import { z } from 'zod'; + +const selectViewFilterValueSchema = z + .string() + .transform((val) => (val === '' ? [] : JSON.parse(val))) + .refine( + (parsed) => + Array.isArray(parsed) && parsed.every((item) => typeof item === 'string'), + { + message: 'Expected an array of strings', + }, + ); + +export const resolveSelectViewFilterValue = ( + viewFilter: Pick, +) => { + return selectViewFilterValueSchema.parse(viewFilter.value); +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts index 95fb650826..3cfc53b4a7 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser.ts @@ -13,7 +13,7 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metada import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspace-migration/factories/composite-column-action.factory'; import { capitalize } from 'src/utils/capitalize'; -const ARRAY_OPERATORS = ['in', 'contains', 'not_contains']; +const ARRAY_OPERATORS = ['in', 'contains', 'notContains']; export class GraphqlQueryFilterFieldParser { private fieldMetadataMapByName: FieldMetadataMap; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts index aae3f9b001..0985a2eb58 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/utils/compute-where-condition-parts.ts @@ -19,6 +19,11 @@ export const computeWhereConditionParts = ( const uuid = Math.random().toString(36).slice(2, 7); switch (operator) { + case 'isEmptyArray': + return { + sql: `"${objectNameSingular}"."${key}" = '{}'`, + params: {}, + }; case 'eq': return { sql: `"${objectNameSingular}"."${key}" = :${key}${uuid}`, @@ -84,10 +89,19 @@ export const computeWhereConditionParts = ( sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`, params: { [`${key}${uuid}`]: value }, }; - - case 'not_contains': + case 'notContains': return { - sql: `NOT ("${objectNameSingular}"."${key}" && ARRAY[:...${key}${uuid}])`, + sql: `NOT ("${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[])`, + params: { [`${key}${uuid}`]: value }, + }; + case 'containsAny': + return { + sql: `"${objectNameSingular}"."${key}"::text[] && ARRAY[:...${key}${uuid}]::text[]`, + params: { [`${key}${uuid}`]: value }, + }; + case 'containsIlike': + return { + sql: `EXISTS (SELECT 1 FROM unnest("${objectNameSingular}"."${key}") AS elem WHERE elem ILIKE :${key}${uuid})`, params: { [`${key}${uuid}`]: value }, }; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts index 5f2d696a0a..97eb3c6b19 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type.factory.ts @@ -1,6 +1,11 @@ import { Injectable, Logger } from '@nestjs/common'; -import { GraphQLInputObjectType, GraphQLInputType, GraphQLList } from 'graphql'; +import { + GraphQLBoolean, + GraphQLInputObjectType, + GraphQLInputType, + GraphQLList, +} from 'graphql'; import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-optionts.interface'; @@ -103,7 +108,9 @@ export class InputTypeFactory { eq: { type: enumType }, neq: { type: enumType }, in: { type: new GraphQLList(enumType) }, + containsAny: { type: new GraphQLList(enumType) }, is: { type: FilterIs }, + isEmptyArray: { type: GraphQLBoolean }, }), }); } diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts index 3cd24cbbaa..118eb898fc 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/array-filter.input-type.ts @@ -1,12 +1,12 @@ -import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql'; +import { GraphQLBoolean, GraphQLInputObjectType, GraphQLString } from 'graphql'; import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type'; export const ArrayFilterType = new GraphQLInputObjectType({ name: 'ArrayFilter', fields: { - contains: { type: new GraphQLList(GraphQLString) }, - not_contains: { type: new GraphQLList(GraphQLString) }, + containsIlike: { type: GraphQLString }, is: { type: FilterIs }, + isEmptyArray: { type: GraphQLBoolean }, }, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts new file mode 100644 index 0000000000..21798eaed9 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type.ts @@ -0,0 +1,17 @@ +import { + GraphQLBoolean, + GraphQLInputObjectType, + GraphQLList, + GraphQLString, +} from 'graphql'; + +import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type'; + +export const MultiSelectFilterType = new GraphQLInputObjectType({ + name: 'MultiSelectFilter', + fields: { + containsAny: { type: new GraphQLList(GraphQLString) }, + is: { type: FilterIs }, + isEmptyArray: { type: GraphQLBoolean }, + }, +}); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts new file mode 100644 index 0000000000..7f21ae426c --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type.ts @@ -0,0 +1,11 @@ +import { GraphQLInputObjectType, GraphQLList, GraphQLString } from 'graphql'; + +import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type'; + +export const SelectFilterType = new GraphQLInputObjectType({ + name: 'SelectFilter', + fields: { + in: { type: new GraphQLList(GraphQLString) }, + is: { type: FilterIs }, + }, +}); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts index 429cd195fa..aa3259aea9 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service.ts @@ -27,6 +27,8 @@ import { StringFilterType, } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input'; import { IDFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/id-filter.input-type'; +import { MultiSelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/multi-select-filter.input-type'; +import { SelectFilterType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/select-filter.input-type'; import { BigFloatScalarType, UUIDScalarType, @@ -115,6 +117,8 @@ export class TypeMapperService { [FieldMetadataType.RAW_JSON, RawJsonFilterType], [FieldMetadataType.RICH_TEXT, StringFilterType], [FieldMetadataType.ARRAY, ArrayFilterType], + [FieldMetadataType.MULTI_SELECT, MultiSelectFilterType], + [FieldMetadataType.SELECT, SelectFilterType], [FieldMetadataType.TS_VECTOR, StringFilterType], // TODO: Add TSVectorFilterType ]); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts index 88e8091a04..4797b9dbba 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts @@ -58,7 +58,9 @@ export const generateFields = < ? { nullable: fieldMetadata.isNullable, defaultValue: fieldMetadata.defaultValue, - isArray: fieldMetadata.type === FieldMetadataType.MULTI_SELECT, + isArray: + kind !== InputTypeDefinitionKind.Filter && + fieldMetadata.type === FieldMetadataType.MULTI_SELECT, settings: fieldMetadata.settings, isIdField: fieldMetadata.name === 'id', }