From 7b10bfa7d240e6afa81a33de92646480ae50e87f Mon Sep 17 00:00:00 2001 From: "gitstart-app[bot]" <57568882+gitstart-app[bot]@users.noreply.github.com> Date: Mon, 21 Oct 2024 18:11:02 +0200 Subject: [PATCH] Add filter on array and jsonb field types (#7839) This PR was created by [GitStart](https://gitstart.com/) to address the requirements from this ticket: [TWNTY-6784](https://clients.gitstart.com/twenty/5449/tickets/TWNTY-6784). This ticket was imported from: [TWNTY-6784](https://github.com/twentyhq/twenty/issues/6784) --- ### Description - Add filter on array and jsonb field types - We did not implement the contains any filter for arrays on the frontend because we would need to change the UI design since this should be an array of values, and now we have only one input ### Demo Fixes #6784 --------- Co-authored-by: gitstart-twenty Co-authored-by: gitstart-twenty <140154534+gitstart-twenty@users.noreply.github.com> Co-authored-by: Weiko --- ...ColumnDefinitionsFromFieldMetadata.test.ts | 20 ++++++ .../useColumnDefinitionsFromFieldMetadata.ts | 6 ++ ...atFieldMetadataItemsAsFilterDefinitions.ts | 12 +++- .../graphql/types/RecordGqlOperationFilter.ts | 13 ++++ .../ObjectFilterDropdownFilterInput.tsx | 1 + .../types/FilterableFieldType.ts | 1 + .../utils/getOperandsForFilterType.ts | 13 +++- .../record-filter/utils/applyEmptyFilters.ts | 20 ++++++ .../utils/isMatchingArrayFilter.ts | 34 +++++++++ .../utils/isMatchingRawJsonFilter.ts | 32 +++++++++ .../utils/isRecordMatchingFilter.ts | 17 +++++ .../utils/turnFiltersIntoQueryFilter.ts | 69 +++++++++++++++++++ ...blesFromActiveFieldsOfViewOrDefaultView.ts | 6 ++ .../views/utils/getQueryVariablesFromView.ts | 3 + .../modules/workspace/types/FeatureFlagKey.ts | 3 +- .../graphql-query-filter-field.parser.ts | 17 +++-- .../utils/compute-where-condition-parts.ts | 20 ++++-- .../input/array-filter.input-type.ts | 4 +- .../input/raw-json-filter.input-type.ts | 3 +- 19 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts create mode 100644 packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.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 05c87497d..62846c8fb 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 @@ -1,16 +1,35 @@ import { renderHook } from '@testing-library/react'; import { Nullable } from 'twenty-ui'; +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { WorkspaceActivationStatus } from '~/generated/graphql'; +import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; +const Wrapper = getJestMetadataAndApolloMocksWrapper({ + apolloMocks: [], + onInitializeRecoilSnapshot: ({ set }) => { + set(currentWorkspaceState, { + id: '1', + featureFlags: [], + allowImpersonation: false, + activationStatus: WorkspaceActivationStatus.Active, + metadataVersion: 1, + }); + }, +}); + describe('useColumnDefinitionsFromFieldMetadata', () => { it('should return empty definitions if no object is passed', () => { const { result } = renderHook( (objectMetadataItem?: Nullable) => { return useColumnDefinitionsFromFieldMetadata(objectMetadataItem); }, + { + wrapper: Wrapper, + }, ); expect(Array.isArray(result.current.columnDefinitions)).toBe(true); @@ -32,6 +51,7 @@ describe('useColumnDefinitionsFromFieldMetadata', () => { }, { initialProps: companyObjectMetadata, + wrapper: Wrapper, }, ); diff --git a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts index b7764673e..d7155f3d7 100644 --- a/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts +++ b/packages/twenty-front/src/modules/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata.ts @@ -6,6 +6,7 @@ import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata' import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { filterAvailableTableColumns } from '@/object-record/utils/filterAvailableTableColumns'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import { formatFieldMetadataItemAsColumnDefinition } from '../utils/formatFieldMetadataItemAsColumnDefinition'; import { formatFieldMetadataItemsAsFilterDefinitions } from '../utils/formatFieldMetadataItemsAsFilterDefinitions'; import { formatFieldMetadataItemsAsSortDefinitions } from '../utils/formatFieldMetadataItemsAsSortDefinitions'; @@ -23,8 +24,13 @@ export const useColumnDefinitionsFromFieldMetadata = ( [objectMetadataItem], ); + const isArrayAndJsonFilterEnabled = useIsFeatureEnabled( + 'IS_ARRAY_AND_JSON_FILTER_ENABLED', + ); + const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ fields: activeFieldMetadataItems, + isArrayAndJsonFilterEnabled, }); const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ 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 a110acdce..42734cb92 100644 --- a/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts +++ b/packages/twenty-front/src/modules/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions.ts @@ -8,10 +8,12 @@ import { ObjectMetadataItem } from '../types/ObjectMetadataItem'; export const formatFieldMetadataItemsAsFilterDefinitions = ({ fields, + isArrayAndJsonFilterEnabled, }: { fields: Array; -}): FilterDefinition[] => - fields.reduce((acc, field) => { + isArrayAndJsonFilterEnabled: boolean; +}): FilterDefinition[] => { + return fields.reduce((acc, field) => { if ( field.type === FieldMetadataType.Relation && field.relationDefinition?.direction !== @@ -37,6 +39,9 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ FieldMetadataType.Rating, FieldMetadataType.Actor, FieldMetadataType.Phones, + ...(isArrayAndJsonFilterEnabled + ? [FieldMetadataType.Array, FieldMetadataType.RawJson] + : []), ].includes(field.type) ) { return acc; @@ -44,6 +49,7 @@ export const formatFieldMetadataItemsAsFilterDefinitions = ({ return [...acc, formatFieldMetadataItemAsFilterDefinition({ field })]; }, [] as FilterDefinition[]); +}; export const formatFieldMetadataItemAsFilterDefinition = ({ field, @@ -92,6 +98,8 @@ export const getFilterTypeFromFieldType = (fieldType: FieldMetadataType) => { return 'ACTOR'; case FieldMetadataType.Array: return 'ARRAY'; + case FieldMetadataType.RawJson: + return 'RAW_JSON'; default: return 'TEXT'; } 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 6c1615b17..9393d98b8 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,17 @@ export type PhonesFilter = { primaryPhoneCountryCode?: StringFilter; }; +export type ArrayFilter = { + contains?: string[]; + not_contains?: string[]; + is?: IsFilter; +}; + +export type RawJsonFilter = { + like?: string; + is?: IsFilter; +}; + export type LeafFilter = | UUIDFilter | StringFilter @@ -117,6 +128,8 @@ export type LeafFilter = | LinksFilter | ActorFilter | PhonesFilter + | ArrayFilter + | RawJsonFilter | undefined; export type AndObjectRecordFilter = { 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 a630286ff..35f20b4a5 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,6 +93,7 @@ export const ObjectFilterDropdownFilterInput = ({ 'ADDRESS', 'ACTOR', 'ARRAY', + 'RAW_JSON', 'PHONES', ].includes(filterDefinitionUsedInDropdown.type) && !isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && ( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts index 0624fe937..b2bf87102 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts @@ -19,4 +19,5 @@ export type FilterableFieldType = PickLiteral< | 'MULTI_SELECT' | 'ACTOR' | 'ARRAY' + | 'RAW_JSON' >; 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 688aa02b6..3634a7bae 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 @@ -18,7 +18,6 @@ export const getOperandsForFilterDefinition = ( case 'FULL_NAME': case 'ADDRESS': case 'LINKS': - case 'ARRAY': case 'PHONES': return [ ViewFilterOperand.Contains, @@ -32,6 +31,12 @@ export const getOperandsForFilterDefinition = ( ViewFilterOperand.LessThan, ...emptyOperands, ]; + case 'RAW_JSON': + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; case 'DATE_TIME': case 'DATE': return [ @@ -70,6 +75,12 @@ export const getOperandsForFilterDefinition = ( ...emptyOperands, ]; } + case 'ARRAY': + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; default: return []; } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts index e004288ce..03ea135ce 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts @@ -1,10 +1,12 @@ import { ActorFilter, AddressFilter, + ArrayFilter, CurrencyFilter, DateFilter, EmailsFilter, FloatFilter, + RawJsonFilter, RecordGqlOperationFilter, RelationFilter, StringFilter, @@ -290,6 +292,24 @@ export const applyEmptyFilters = ( ], }; break; + case 'ARRAY': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { is: 'NULL' } as ArrayFilter, + }, + ], + }; + break; + case 'RAW_JSON': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { is: 'NULL' } as RawJsonFilter, + }, + ], + }; + break; case 'EMAILS': emptyRecordFilter = { or: [ 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 new file mode 100644 index 000000000..7578a04aa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts @@ -0,0 +1,34 @@ +import { ArrayFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingArrayFilter = ({ + arrayFilter, + value, +}: { + arrayFilter: ArrayFilter; + value: string[]; +}) => { + 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; + } else { + return value !== null; + } + } + 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/isMatchingRawJsonFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts new file mode 100644 index 000000000..8251bca72 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts @@ -0,0 +1,32 @@ +import { RawJsonFilter } from '../../graphql/types/RecordGqlOperationFilter'; + +export const isMatchingRawJsonFilter = ({ + rawJsonFilter, + value, +}: { + rawJsonFilter: RawJsonFilter; + value: string; +}) => { + switch (true) { + case rawJsonFilter.like !== undefined: { + const regexPattern = rawJsonFilter.like.replace(/%/g, '.*'); + const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i'); + + const stringValue = JSON.stringify(value); + + return regexCaseInsensitive.test(stringValue); + } + case rawJsonFilter.is !== undefined: { + if (rawJsonFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(rawJsonFilter)}`, + ); + } + } +}; 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 929956d77..c2a5da47f 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 @@ -5,6 +5,7 @@ import { ActorFilter, AddressFilter, AndObjectRecordFilter, + ArrayFilter, BooleanFilter, CurrencyFilter, DateFilter, @@ -16,14 +17,17 @@ import { NotObjectRecordFilter, OrObjectRecordFilter, PhonesFilter, + RawJsonFilter, RecordGqlOperationFilter, StringFilter, UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; +import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter'; import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter'; 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 { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter'; import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -165,6 +169,18 @@ export const isRecordMatchingFilter = ({ value: record[filterKey], }); } + case FieldMetadataType.Array: { + return isMatchingArrayFilter({ + arrayFilter: filterValue as ArrayFilter, + value: record[filterKey], + }); + } + case FieldMetadataType.RawJson: { + return isMatchingRawJsonFilter({ + rawJsonFilter: filterValue as RawJsonFilter, + value: record[filterKey], + }); + } case FieldMetadataType.FullName: { const fullNameFilter = filterValue as FullNameFilter; @@ -302,6 +318,7 @@ export const isRecordMatchingFilter = ({ `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, ); } + default: { throw new Error( `Not implemented yet for field type "${objectMetadataField.type}"`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts index 0e3c69d7c..9829d957e 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnFiltersIntoQueryFilter.ts @@ -3,10 +3,12 @@ import { isNonEmptyString } from '@sniptt/guards'; import { ActorFilter, AddressFilter, + ArrayFilter, CurrencyFilter, DateFilter, EmailsFilter, FloatFilter, + RawJsonFilter, RecordGqlOperationFilter, RelationFilter, StringFilter, @@ -98,6 +100,39 @@ export const turnFiltersIntoQueryFilter = ( ); } break; + case 'RAW_JSON': + switch (rawUIFilter.operand) { + case ViewFilterOperand.Contains: + objectRecordFilters.push({ + [correspondingField.name]: { + like: `%${rawUIFilter.value}%`, + } as RawJsonFilter, + }); + break; + case ViewFilterOperand.DoesNotContain: + objectRecordFilters.push({ + not: { + [correspondingField.name]: { + like: `%${rawUIFilter.value}%`, + } as RawJsonFilter, + }, + }); + break; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition, + ); + break; + default: + throw new Error( + `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, + ); + } + break; case 'DATE': case 'DATE_TIME': { const resolvedFilterValue = resolveFilterValue(rawUIFilter); @@ -835,6 +870,40 @@ export const turnFiltersIntoQueryFilter = ( } break; } + case 'ARRAY': { + switch (rawUIFilter.operand) { + case ViewFilterOperand.Contains: { + objectRecordFilters.push({ + [correspondingField.name]: { + contains: [`${rawUIFilter.value}`], + } as ArrayFilter, + }); + break; + } + case ViewFilterOperand.DoesNotContain: { + objectRecordFilters.push({ + [correspondingField.name]: { + not_contains: [`${rawUIFilter.value}`], + } as ArrayFilter, + }); + break; + } + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + applyEmptyFilters( + rawUIFilter.operand, + correspondingField, + objectRecordFilters, + rawUIFilter.definition, + ); + break; + default: + throw new Error( + `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.label} filter`, + ); + } + break; + } default: throw new Error('Unknown filter type'); } diff --git a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts index f5431bc45..d40afd106 100644 --- a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts @@ -2,6 +2,7 @@ import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFi import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews'; import { getQueryVariablesFromView } from '@/views/utils/getQueryVariablesFromView'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItem, @@ -19,10 +20,15 @@ export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItem, }); + const isArrayAndJsonFilterEnabled = useIsFeatureEnabled( + 'IS_ARRAY_AND_JSON_FILTER_ENABLED', + ); + const { filter, orderBy } = getQueryVariablesFromView({ fieldMetadataItems: activeFieldMetadataItems, objectMetadataItem, view, + isArrayAndJsonFilterEnabled, }); return { diff --git a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts index 8206f52b3..fc685af7e 100644 --- a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts +++ b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts @@ -13,10 +13,12 @@ export const getQueryVariablesFromView = ({ view, fieldMetadataItems, objectMetadataItem, + isArrayAndJsonFilterEnabled, }: { view: View | null | undefined; fieldMetadataItems: FieldMetadataItem[]; objectMetadataItem: ObjectMetadataItem; + isArrayAndJsonFilterEnabled: boolean; }) => { if (!isDefined(view)) { return { @@ -29,6 +31,7 @@ export const getQueryVariablesFromView = ({ const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ fields: fieldMetadataItems, + isArrayAndJsonFilterEnabled, }); const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ diff --git a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts index 5471c5d4d..f0346d505 100644 --- a/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts +++ b/packages/twenty-front/src/modules/workspace/types/FeatureFlagKey.ts @@ -13,4 +13,5 @@ export type FeatureFlagKey = | 'IS_QUERY_RUNNER_TWENTY_ORM_ENABLED' | 'IS_GMAIL_SEND_EMAIL_SCOPE_ENABLED' | 'IS_ANALYTICS_V2_ENABLED' - | 'IS_UNIQUE_INDEXES_ENABLED'; + | 'IS_UNIQUE_INDEXES_ENABLED' + | 'IS_ARRAY_AND_JSON_FILTER_ENABLED'; 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 9b1d1020b..5d35ebf5e 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,6 +13,8 @@ import { FieldMetadataMap } from 'src/engine/metadata-modules/utils/generate-obj 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']; + export class GraphqlQueryFilterFieldParser { private fieldMetadataMap: FieldMetadataMap; @@ -44,13 +46,14 @@ export class GraphqlQueryFilterFieldParser { } const [[operator, value]] = Object.entries(filterValue); - if (operator === 'in') { - if (!Array.isArray(value) || value.length === 0) { - throw new GraphqlQueryRunnerException( - `Invalid filter value for field ${key}. Expected non-empty array`, - GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, - ); - } + if ( + ARRAY_OPERATORS.includes(operator) && + (!Array.isArray(value) || value.length === 0) + ) { + throw new GraphqlQueryRunnerException( + `Invalid filter value for field ${key}. Expected non-empty array`, + GraphqlQueryRunnerExceptionCode.INVALID_QUERY_INPUT, + ); } const { sql, params } = computeWhereConditionParts( 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 ef8d4680e..aae3f9b00 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 @@ -61,24 +61,36 @@ export const computeWhereConditionParts = ( }; case 'like': return { - sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`, params: { [`${key}${uuid}`]: `${value}` }, }; case 'ilike': return { - sql: `"${objectNameSingular}"."${key}" ILIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text ILIKE :${key}${uuid}`, params: { [`${key}${uuid}`]: `${value}` }, }; case 'startsWith': return { - sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`, params: { [`${key}${uuid}`]: `${value}` }, }; case 'endsWith': return { - sql: `"${objectNameSingular}"."${key}" LIKE :${key}${uuid}`, + sql: `"${objectNameSingular}"."${key}"::text LIKE :${key}${uuid}`, params: { [`${key}${uuid}`]: `${value}` }, }; + case 'contains': + return { + sql: `"${objectNameSingular}"."${key}" @> ARRAY[:...${key}${uuid}]`, + params: { [`${key}${uuid}`]: value }, + }; + + case 'not_contains': + return { + sql: `NOT ("${objectNameSingular}"."${key}" && ARRAY[:...${key}${uuid}])`, + params: { [`${key}${uuid}`]: value }, + }; + default: throw new GraphqlQueryRunnerException( `Operator "${operator}" is not supported`, 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 37b3ba829..3cd24cbba 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,10 +1,12 @@ 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 ArrayFilterType = new GraphQLInputObjectType({ name: 'ArrayFilter', fields: { contains: { type: new GraphQLList(GraphQLString) }, - contains_any: { type: new GraphQLList(GraphQLString) }, not_contains: { type: new GraphQLList(GraphQLString) }, + is: { type: FilterIs }, }, }); diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts index 5b06437dd..75f40b7e5 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/graphql-types/input/raw-json-filter.input-type.ts @@ -1,4 +1,4 @@ -import { GraphQLInputObjectType } from 'graphql'; +import { GraphQLInputObjectType, GraphQLString } from 'graphql'; import { FilterIs } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/input/filter-is.input-type'; @@ -6,5 +6,6 @@ export const RawJsonFilterType = new GraphQLInputObjectType({ name: 'RawJsonFilter', fields: { is: { type: FilterIs }, + like: { type: GraphQLString }, }, });