diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 7f68021dbc..bd06ccc5f0 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3392,7 +3392,7 @@ input UpdateViewFilterGroupInput { input CreateViewFilterInput { id: UUID fieldMetadataId: UUID! - operand: ViewFilterOperand = CONTAINS + operand: ViewFilterOperand value: JSON! viewFilterGroupId: UUID positionInViewFilterGroup: Float @@ -3500,7 +3500,7 @@ input UpsertViewWidgetViewFieldInput { input UpsertViewWidgetViewFilterInput { id: UUID fieldMetadataId: UUID! - operand: ViewFilterOperand = CONTAINS + operand: ViewFilterOperand value: JSON! viewFilterGroupId: UUID positionInViewFilterGroup: Float diff --git a/packages/twenty-docs/developers/extend/apps/layout/views.mdx b/packages/twenty-docs/developers/extend/apps/layout/views.mdx index 12144fa371..ea950f3043 100644 --- a/packages/twenty-docs/developers/extend/apps/layout/views.mdx +++ b/packages/twenty-docs/developers/extend/apps/layout/views.mdx @@ -1,7 +1,7 @@ --- title: Views description: Ship pre-configured saved views — column order, filters, groups — for objects in your app. -icon: "list" +icon: 'list' --- A **view** is a saved configuration for how records of an object are displayed: which fields appear, their order, whether they're visible, and any filters or groups applied. Use `defineView()` to ship pre-configured views with your app — typically a default index view for each custom object you create. @@ -38,6 +38,60 @@ export default defineView({ - You can also declare `filters`, `filterGroups`, `groups`, and `fieldGroups` for advanced configurations. - `position` controls ordering when multiple views exist for the same object. +## Filters + +A view can ship with pre-applied filters. Each filter has three coordinates: the **field** being filtered, the **operand** (how to compare), and the **value** (what to compare against). All three must line up — using an operand that doesn't apply to a field type will be rejected at sync time. + +```ts +import { ViewFilterOperand } from 'twenty-shared/types'; + +filters: [ + { + universalIdentifier: '...', + fieldMetadataUniversalIdentifier: STATUS_FIELD_UNIVERSAL_IDENTIFIER, + operand: ViewFilterOperand.IS, + value: ['ACTIVE'], + }, +], +``` + +### Supported operands per field type + +| Field type | Supported operands | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `TEXT`, `EMAILS`, `FULL_NAME`, `ADDRESS`, `LINKS`, `PHONES`, `RAW_JSON`, `FILES`, `ACTOR`, `ARRAY` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `ACTOR.source`, `ACTOR.workspaceMemberId` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `SELECT` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `MULTI_SELECT` | `CONTAINS`, `DOES_NOT_CONTAIN`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `RELATION` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `NUMBER` | `IS`, `IS_NOT`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `RATING` | `IS`, `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `CURRENCY`, `CURRENCY.amountMicros` | `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL`, `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `CURRENCY.currencyCode` | `IS`, `IS_NOT`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `DATE`, `DATE_TIME` | `IS`, `IS_RELATIVE`, `IS_IN_PAST`, `IS_IN_FUTURE`, `IS_TODAY`, `IS_BEFORE`, `IS_AFTER`, `IS_EMPTY`, `IS_NOT_EMPTY` | +| `BOOLEAN` | `IS` | +| `UUID` | `IS` | +| `TS_VECTOR` | `VECTOR_SEARCH` | + +> Field types with similar names can use entirely different operands — `SELECT` and `MULTI_SELECT` being a common case. + +### Value shape per operand + +The `value` field is always a JSON-serializable value, but its expected shape depends on the operand: + +| Operand family | Value shape | Example | +| ----------------------------------------------------- | ------------------------------ | ------------------------ | +| `IS`, `IS_NOT` on `SELECT` | array of option keys (strings) | `['ACTIVE', 'PENDING']` | +| `CONTAINS`, `DOES_NOT_CONTAIN` on `MULTI_SELECT` | array of option keys (strings) | `['TAG_A']` | +| `IS`, `IS_NOT` on `RELATION` | array of record IDs (uuids) | `['c5a1...']` | +| `CONTAINS`, `DOES_NOT_CONTAIN` on text-like fields | string | `'acme'` | +| `IS`, `IS_NOT` on `NUMBER` | string (the value) | `'5'` | +| `IS` on `RATING` / `UUID` | string (the value) | `'5'` | +| `GREATER_THAN_OR_EQUAL`, `LESS_THAN_OR_EQUAL` | string (the bound) | `'10'` | +| `IS`, `IS_BEFORE`, `IS_AFTER` on `DATE` / `DATE_TIME` | ISO 8601 string | `'2025-01-01T00:00:00Z'` | +| `IS_EMPTY`, `IS_NOT_EMPTY` | empty string | `''` | +| `IS` on `BOOLEAN` | `'true'` or `'false'` | `'true'` | + ## How views show up in the UI A view by itself isn't reachable from the sidebar. To make it appear there, pair it with a [navigation menu item](/developers/extend/apps/layout/navigation-menu-items) of type `VIEW` that points at the view's `universalIdentifier`. That's the canonical pattern: every custom object typically ships a default view + a sidebar entry that opens it. diff --git a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts index bd0fbb7701..78b93b14a8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/types/RecordFilter.ts @@ -1,9 +1,9 @@ -import { type FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; import { type FilterableAndTSVectorFieldType, type ViewFilterOperand, } from 'twenty-shared/types'; +import { type FILTER_OPERANDS_MAP } from 'twenty-shared/utils'; // RLS-specific: references a workspace member field for dynamic "Me" comparisons export type RLSDynamicValue = { diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts index 8c3fc4d711..80e51ef5e8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts @@ -1,230 +1,18 @@ -import { isFilterOnActorSourceSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorSourceSubField'; -import { isFilterOnActorWorkspaceMemberSubField } from '@/object-record/object-filter-dropdown/utils/isFilterOnActorWorkspaceMemberSubField'; -import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; import { - FieldMetadataType, - ViewFilterOperand as RecordFilterOperand, type FilterableAndTSVectorFieldType, - type FilterableFieldType, + type ViewFilterOperand as RecordFilterOperand, } from 'twenty-shared/types'; -import { assertUnreachable, isExpectedSubFieldName } from 'twenty-shared/utils'; - -export type GetRecordFilterOperandsParams = { - filterType: FilterableAndTSVectorFieldType; - subFieldName?: string | null | undefined; -}; - -const emptyOperands = [ - RecordFilterOperand.IS_EMPTY, - RecordFilterOperand.IS_NOT_EMPTY, -] as const; - -const relationOperands = [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, -] as const; - -type FilterOperandMap = { - [K in FilterableAndTSVectorFieldType]: readonly RecordFilterOperand[]; -}; - -// TODO: we would need to refactor the typing of SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS first -// with types like FieldCurrencyValue being derived from a central constant value and not being created like that -// in order to narrow down the possible subfield names for each field type -type CompositeFieldFilterOperandMap = { - [K in FilterableFieldType]: Partial<{ - [S in CompositeFieldSubFieldName]: readonly RecordFilterOperand[]; - }>; -}; - -export const FILTER_OPERANDS_MAP = { - TEXT: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - EMAILS: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - FULL_NAME: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - ADDRESS: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - LINKS: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - PHONES: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - CURRENCY: [ - RecordFilterOperand.GREATER_THAN_OR_EQUAL, - RecordFilterOperand.LESS_THAN_OR_EQUAL, - ...emptyOperands, - ], - NUMBER: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - RecordFilterOperand.GREATER_THAN_OR_EQUAL, - RecordFilterOperand.LESS_THAN_OR_EQUAL, - ...emptyOperands, - ], - RAW_JSON: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - FILES: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - DATE_TIME: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_RELATIVE, - RecordFilterOperand.IS_IN_PAST, - RecordFilterOperand.IS_IN_FUTURE, - RecordFilterOperand.IS_TODAY, - RecordFilterOperand.IS_BEFORE, - RecordFilterOperand.IS_AFTER, - ...emptyOperands, - ], - DATE: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_RELATIVE, - RecordFilterOperand.IS_IN_PAST, - RecordFilterOperand.IS_IN_FUTURE, - RecordFilterOperand.IS_TODAY, - RecordFilterOperand.IS_BEFORE, - RecordFilterOperand.IS_AFTER, - ...emptyOperands, - ], - RATING: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - RecordFilterOperand.GREATER_THAN_OR_EQUAL, - RecordFilterOperand.LESS_THAN_OR_EQUAL, - ...emptyOperands, - ], - RELATION: [...relationOperands, ...emptyOperands], - MULTI_SELECT: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - SELECT: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - ...emptyOperands, - ], - ACTOR: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - ARRAY: [ - RecordFilterOperand.CONTAINS, - RecordFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - BOOLEAN: [RecordFilterOperand.IS], - TS_VECTOR: [RecordFilterOperand.VECTOR_SEARCH], - UUID: [RecordFilterOperand.IS, RecordFilterOperand.IS_NOT, ...emptyOperands], -} as const satisfies FilterOperandMap; - -export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = { - CURRENCY: { - currencyCode: [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - ...emptyOperands, - ], - amountMicros: [ - RecordFilterOperand.GREATER_THAN_OR_EQUAL, - RecordFilterOperand.LESS_THAN_OR_EQUAL, - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - ...emptyOperands, - ], - }, -} as const satisfies Partial; +import { getFilterOperandsForFilterableFieldType } from 'twenty-shared/utils'; export const getRecordFilterOperands = ({ filterType, subFieldName, -}: GetRecordFilterOperandsParams): readonly RecordFilterOperand[] => { - switch (filterType) { - case 'TEXT': - case 'EMAILS': - case 'FULL_NAME': - case 'ADDRESS': - case 'LINKS': - case 'PHONES': - return FILTER_OPERANDS_MAP.TEXT; - case 'CURRENCY': { - if ( - isExpectedSubFieldName( - FieldMetadataType.CURRENCY, - 'currencyCode', - subFieldName, - ) === true - ) { - return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.currencyCode; - } else { - return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.amountMicros; - } - } - case 'NUMBER': - return FILTER_OPERANDS_MAP.NUMBER; - case 'RAW_JSON': - return FILTER_OPERANDS_MAP.RAW_JSON; - case 'FILES': - return FILTER_OPERANDS_MAP.FILES; - case 'DATE_TIME': - case 'DATE': - return FILTER_OPERANDS_MAP.DATE_TIME; - case 'RATING': - return FILTER_OPERANDS_MAP.RATING; - case 'RELATION': - return FILTER_OPERANDS_MAP.RELATION; - case 'MULTI_SELECT': - return FILTER_OPERANDS_MAP.MULTI_SELECT; - case 'SELECT': - return FILTER_OPERANDS_MAP.SELECT; - case 'ACTOR': { - if ( - isFilterOnActorSourceSubField(subFieldName) || - isFilterOnActorWorkspaceMemberSubField(subFieldName) - ) { - return [ - RecordFilterOperand.IS, - RecordFilterOperand.IS_NOT, - ...emptyOperands, - ]; - } - - return FILTER_OPERANDS_MAP.ACTOR; - } - case 'ARRAY': - return FILTER_OPERANDS_MAP.ARRAY; - case 'BOOLEAN': - return FILTER_OPERANDS_MAP.BOOLEAN; - case 'TS_VECTOR': - return FILTER_OPERANDS_MAP.TS_VECTOR; - case 'UUID': - return FILTER_OPERANDS_MAP.UUID; - default: - assertUnreachable(filterType, `Unknown filter type ${filterType}`); - } +}: { + filterType: FilterableAndTSVectorFieldType; + subFieldName?: string | null | undefined; +}): readonly RecordFilterOperand[] => { + return getFilterOperandsForFilterableFieldType({ + filterType, + subFieldName, + }); }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts index 5bd43858b3..23888cca3c 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts @@ -5,10 +5,7 @@ import { type RecordFilter, type RecordFilterToRecordInputOperand, } from '@/object-record/record-filter/types/RecordFilter'; -import { - FILTER_OPERANDS_MAP, - getRecordFilterOperands, -} from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from 'twenty-shared/constants'; import { compositeTypeDefinitions, @@ -16,7 +13,11 @@ import { ViewFilterOperand, type FieldMetadataOptions, } from 'twenty-shared/types'; -import { assertUnreachable, parseJson } from 'twenty-shared/utils'; +import { + assertUnreachable, + FILTER_OPERANDS_MAP, + parseJson, +} from 'twenty-shared/utils'; import { RelationType } from '~/generated-metadata/graphql'; import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts index 0af07694ea..938941fc88 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts @@ -1,8 +1,8 @@ +import { ViewFilterOperand } from 'twenty-shared/types'; import { COMPOSITE_FIELD_FILTER_OPERANDS_MAP, FILTER_OPERANDS_MAP, -} from '@/object-record/record-filter/utils/getRecordFilterOperands'; -import { ViewFilterOperand } from 'twenty-shared/types'; +} from 'twenty-shared/utils'; const defaultOperands = [ ViewFilterOperand.IS, diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts index bb9d6a930d..87f6385ae4 100644 --- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-build.ts @@ -9,6 +9,7 @@ import { extractManifestFromFile } from '@/cli/utilities/build/manifest/manifest import { addMissingFieldOptionIds } from '@/cli/utilities/build/manifest/utils/add-missing-field-option-ids'; import { fromRoleConfigToRoleManifest } from '@/cli/utilities/build/manifest/utils/from-role-config-to-role-manifest'; import { getDefaultFieldsInObjectFields } from '@/cli/utilities/build/manifest/utils/get-default-fields-in-object-fields'; +import { validateViewFilterOperands } from '@/cli/utilities/build/manifest/utils/validate-view-filter-operands'; import { type ApplicationConfig, type LogicFunctionConfig } from '@/sdk/define'; import { type CommandMenuItemConfig } from '@/sdk/define/command-menu-items/command-menu-item-config'; import { type FrontComponentConfig } from '@/sdk/define/front-component/front-component-config'; @@ -488,6 +489,14 @@ export const buildManifest = async ( ); } + errors.push( + ...validateViewFilterOperands({ + views, + objects, + fields, + }), + ); + const application: ApplicationManifest | undefined = applicationConfig && resolvedDefaultRoleUniversalIdentifier ? { diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/validate-view-filter-operands.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/validate-view-filter-operands.spec.ts new file mode 100644 index 0000000000..21344e7011 --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/__tests__/validate-view-filter-operands.spec.ts @@ -0,0 +1,265 @@ +import { validateViewFilterOperands } from '@/cli/utilities/build/manifest/utils/validate-view-filter-operands'; +import { + type FieldManifest, + type ObjectFieldManifest, + type ObjectManifest, + type ViewManifest, +} from 'twenty-shared/application'; +import { + FieldMetadataType, + ViewFilterOperand, + ViewType, +} from 'twenty-shared/types'; + +const buildField = ({ + universalIdentifier, + type, + name = 'field', + label = 'Field', +}: { + universalIdentifier: string; + type: T; + name?: string; + label?: string; +}): FieldManifest => + ({ + universalIdentifier, + objectUniversalIdentifier: 'object-uid', + name, + label, + type, + }) as FieldManifest; + +const buildRelationField = ({ + universalIdentifier, + relationTargetFieldMetadataUniversalIdentifier, + name = 'relation', + label = 'Relation', +}: { + universalIdentifier: string; + relationTargetFieldMetadataUniversalIdentifier: string; + name?: string; + label?: string; +}): FieldManifest => + ({ + universalIdentifier, + objectUniversalIdentifier: 'object-uid', + name, + label, + type: FieldMetadataType.RELATION, + relationTargetFieldMetadataUniversalIdentifier, + relationTargetObjectMetadataUniversalIdentifier: 'target-object-uid', + universalSettings: {}, + }) as FieldManifest; + +const buildView = (filters: ViewManifest['filters']): ViewManifest => ({ + universalIdentifier: 'view-uid', + name: 'View', + objectUniversalIdentifier: 'object-uid', + type: ViewType.TABLE, + filters, +}); + +const buildObject = (fields: ObjectFieldManifest[]): ObjectManifest => + ({ + universalIdentifier: 'object-uid', + nameSingular: 'company', + namePlural: 'companies', + labelSingular: 'Company', + labelPlural: 'Companies', + labelIdentifierFieldMetadataUniversalIdentifier: + fields[0]?.universalIdentifier ?? '', + fields, + }) as ObjectManifest; + +describe('validateViewFilterOperands', () => { + it('should return no errors when operand is valid for the field type', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'field-uid', + operand: ViewFilterOperand.CONTAINS, + value: 'acme', + }, + ]), + ], + objects: [], + fields: [ + buildField({ + universalIdentifier: 'field-uid', + type: FieldMetadataType.TEXT, + name: 'company', + }), + ], + }); + + expect(errors).toEqual([]); + }); + + it('should return an error when operand is invalid for the field type', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'field-uid', + operand: ViewFilterOperand.CONTAINS, + value: '5', + }, + ]), + ], + objects: [], + fields: [ + buildField({ + universalIdentifier: 'field-uid', + type: FieldMetadataType.NUMBER, + name: 'rating', + }), + ], + }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('CONTAINS'); + expect(errors[0]).toContain('rating'); + expect(errors[0]).toContain('NUMBER'); + }); + + it('should resolve relation fields against their target field type', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'relation-field-uid', + operand: ViewFilterOperand.IS, + value: ['target-record-uid'], + }, + ]), + ], + objects: [], + fields: [ + buildRelationField({ + universalIdentifier: 'relation-field-uid', + relationTargetFieldMetadataUniversalIdentifier: 'target-field-uid', + name: 'company', + }), + buildField({ + universalIdentifier: 'target-field-uid', + type: FieldMetadataType.UUID, + name: 'id', + }), + ], + }); + + expect(errors).toEqual([]); + }); + + it('should fallback to RELATION operands when relation target field is missing', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'relation-field-uid', + operand: ViewFilterOperand.IS, + value: ['target-record-uid'], + }, + ]), + ], + objects: [], + fields: [ + buildRelationField({ + universalIdentifier: 'relation-field-uid', + relationTargetFieldMetadataUniversalIdentifier: 'missing-target-uid', + name: 'company', + }), + ], + }); + + expect(errors).toEqual([]); + }); + + it('should look up fields nested under objects', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'field-uid', + operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + value: '5', + }, + ]), + ], + objects: [ + buildObject([ + buildField({ + universalIdentifier: 'field-uid', + type: FieldMetadataType.TEXT, + name: 'company', + }), + ]), + ], + fields: [], + }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('GREATER_THAN_OR_EQUAL'); + expect(errors[0]).toContain('TEXT'); + }); + + it('should skip filters that reference unknown fields', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'unknown-field-uid', + operand: ViewFilterOperand.CONTAINS, + value: 'acme', + }, + ]), + ], + objects: [], + fields: [], + }); + + expect(errors).toEqual([]); + }); + + it('should respect CURRENCY subFieldName when checking operands', () => { + const errors = validateViewFilterOperands({ + views: [ + buildView([ + { + universalIdentifier: 'filter-uid', + fieldMetadataUniversalIdentifier: 'amount-uid', + operand: ViewFilterOperand.CONTAINS, + value: 'USD', + subFieldName: 'currencyCode', + }, + ]), + ], + objects: [], + fields: [ + buildField({ + universalIdentifier: 'amount-uid', + type: FieldMetadataType.CURRENCY, + name: 'amount', + }), + ], + }); + + expect(errors).toHaveLength(1); + expect(errors[0]).toContain('CONTAINS'); + expect(errors[0]).toContain('CURRENCY'); + }); + + it('should return an empty array when no views are defined', () => { + expect( + validateViewFilterOperands({ views: [], objects: [], fields: [] }), + ).toEqual([]); + }); +}); diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/validate-view-filter-operands.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/validate-view-filter-operands.ts new file mode 100644 index 0000000000..b33836ab1e --- /dev/null +++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/utils/validate-view-filter-operands.ts @@ -0,0 +1,78 @@ +import { + type FieldManifest, + type ObjectFieldManifest, + type ObjectManifest, + type ViewManifest, +} from 'twenty-shared/application'; +import { + FieldMetadataType, + type FilterableAndTSVectorFieldType, +} from 'twenty-shared/types'; +import { + FILTER_OPERANDS_MAP, + getFilterOperandsForFilterableFieldType, + isDefined, +} from 'twenty-shared/utils'; + +export const validateViewFilterOperands = ({ + views, + objects, + fields, +}: { + views: ViewManifest[]; + objects: ObjectManifest[]; + fields: FieldManifest[]; +}): string[] => { + const errors: string[] = []; + + const fieldByUniversalIdentifier = new Map< + string, + FieldManifest | ObjectFieldManifest + >(); + for (const field of fields) { + fieldByUniversalIdentifier.set(field.universalIdentifier, field); + } + for (const object of objects) { + for (const objectField of object.fields) { + fieldByUniversalIdentifier.set( + objectField.universalIdentifier, + objectField, + ); + } + } + + for (const view of views) { + for (const filter of view.filters ?? []) { + const referencedField = fieldByUniversalIdentifier.get( + filter.fieldMetadataUniversalIdentifier, + ); + if (!isDefined(referencedField)) { + continue; + } + + const effectiveFieldType: FieldMetadataType = + referencedField.type === FieldMetadataType.RELATION + ? (fieldByUniversalIdentifier.get( + referencedField.relationTargetFieldMetadataUniversalIdentifier, + )?.type ?? referencedField.type) + : referencedField.type; + + if (!(effectiveFieldType in FILTER_OPERANDS_MAP)) { + continue; + } + + const allowedOperands = getFilterOperandsForFilterableFieldType({ + filterType: effectiveFieldType as FilterableAndTSVectorFieldType, + subFieldName: filter.subFieldName, + }); + + if (!allowedOperands.includes(filter.operand)) { + errors.push( + `Operand "${filter.operand}" is not supported on field "${referencedField.name}" (type "${effectiveFieldType}"). Supported operands: ${allowedOperands.join(', ')}.`, + ); + } + } + } + + return errors; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/__tests__/get-default-view-filter-operand.spec.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/__tests__/get-default-view-filter-operand.spec.ts new file mode 100644 index 0000000000..e6ec49038a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/__tests__/get-default-view-filter-operand.spec.ts @@ -0,0 +1,30 @@ +import { FieldMetadataType, ViewFilterOperand } from 'twenty-shared/types'; + +import { getDefaultViewFilterOperand } from 'src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util'; + +describe('getDefaultViewFilterOperand', () => { + it('should default select filters to IS', () => { + expect( + getDefaultViewFilterOperand({ + fieldType: FieldMetadataType.SELECT, + }), + ).toBe(ViewFilterOperand.IS); + }); + + it('should default relation filters without a target field to IS', () => { + expect( + getDefaultViewFilterOperand({ + fieldType: FieldMetadataType.RELATION, + }), + ).toBe(ViewFilterOperand.IS); + }); + + it('should default relation traversal filters from the target field type', () => { + expect( + getDefaultViewFilterOperand({ + fieldType: FieldMetadataType.RELATION, + relationTargetFieldType: FieldMetadataType.TEXT, + }), + ).toBe(ViewFilterOperand.CONTAINS); + }); +}); diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util.ts index 2246dfc55d..1c50296451 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/from-create-view-filter-input-to-flat-view-filter-to-create.util.ts @@ -1,10 +1,15 @@ import { ViewFilterOperand } from 'twenty-shared/types'; -import { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from 'twenty-shared/utils'; +import { + isDefined, + trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties, +} from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { getDefaultViewFilterOperand } from 'src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util'; import { type CreateViewFilterInput } from 'src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input'; import { type UniversalFlatViewFilter } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-view-filter.type'; @@ -59,6 +64,29 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({ }, }); + const referencedFieldMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: fieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); + const relationTargetFieldMetadata = isDefined( + relationTargetFieldMetadataUniversalIdentifier, + ) + ? findFlatEntityByUniversalIdentifier({ + universalIdentifier: relationTargetFieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }) + : undefined; + const operand = + createViewFilterInput.operand ?? + (isDefined(referencedFieldMetadata) + ? getDefaultViewFilterOperand({ + fieldType: referencedFieldMetadata.type, + subFieldName: createViewFilterInput.subFieldName, + relationTargetFieldType: relationTargetFieldMetadata?.type, + }) + : undefined) ?? + ViewFilterOperand.CONTAINS; + return { id: viewFilterId, fieldMetadataUniversalIdentifier, @@ -67,7 +95,7 @@ export const fromCreateViewFilterInputToFlatViewFilterToCreate = ({ updatedAt: createdAt, deletedAt: null, universalIdentifier: createViewFilterInput.universalIdentifier ?? v4(), - operand: createViewFilterInput.operand ?? ViewFilterOperand.CONTAINS, + operand, value, viewFilterGroupUniversalIdentifier, positionInViewFilterGroup: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util.ts new file mode 100644 index 0000000000..52d11d7816 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util.ts @@ -0,0 +1,35 @@ +import { + FieldMetadataType, + type FilterableAndTSVectorFieldType, + type ViewFilterOperand, +} from 'twenty-shared/types'; +import { + FILTER_OPERANDS_MAP, + getFilterOperandsForFilterableFieldType, + isDefined, +} from 'twenty-shared/utils'; + +export const getDefaultViewFilterOperand = ({ + fieldType, + subFieldName, + relationTargetFieldType, +}: { + fieldType: FieldMetadataType; + subFieldName?: string | null; + relationTargetFieldType?: FieldMetadataType; +}): ViewFilterOperand | undefined => { + const effectiveFieldType = + fieldType === FieldMetadataType.RELATION && + isDefined(relationTargetFieldType) + ? relationTargetFieldType + : fieldType; + + if (!(effectiveFieldType in FILTER_OPERANDS_MAP)) { + return undefined; + } + + return getFilterOperandsForFilterableFieldType({ + filterType: effectiveFieldType as FilterableAndTSVectorFieldType, + subFieldName, + })[0]; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input.ts b/packages/twenty-server/src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input.ts index e31c115638..afa2417045 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view-filter/dtos/inputs/create-view-filter.input.ts @@ -27,7 +27,7 @@ export class CreateViewFilterInput { @IsOptional() @IsEnum(ViewFilterOperand) - @Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS }) + @Field({ nullable: true }) operand?: ViewFilterOperand; @IsDefined() diff --git a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input.ts b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input.ts index 28dd9a92c2..7fa8f68a40 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/dtos/inputs/upsert-view-widget-view-filter.input.ts @@ -27,7 +27,7 @@ export class UpsertViewWidgetViewFilterInput { @IsOptional() @IsEnum(ViewFilterOperand) - @Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS }) + @Field({ nullable: true }) operand?: ViewFilterOperand; @IsDefined() diff --git a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts index 8a6f9a4ba3..8d7e26d55d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/view/services/view-widget-upsert.service.ts @@ -26,6 +26,7 @@ import { fromViewFieldOverridesToUniversalOverrides } from 'src/engine/metadata- import { type FlatViewFilterGroupMaps } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group-maps.type'; import { type FlatViewFilterGroup } from 'src/engine/metadata-modules/flat-view-filter-group/types/flat-view-filter-group.type'; import { type FlatViewFilter } from 'src/engine/metadata-modules/flat-view-filter/types/flat-view-filter.type'; +import { getDefaultViewFilterOperand } from 'src/engine/metadata-modules/flat-view-filter/utils/get-default-view-filter-operand.util'; import { type FlatViewSort } from 'src/engine/metadata-modules/flat-view-sort/types/flat-view-sort.type'; import { type FlatViewMaps } from 'src/engine/metadata-modules/flat-view/types/flat-view-maps.type'; import { WidgetConfigurationType } from 'src/engine/metadata-modules/page-layout-widget/enums/widget-configuration-type.type'; @@ -691,6 +692,28 @@ export class ViewWidgetUpsertService { flatViewFilterGroupMaps, }, }); + const referencedFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: inputFilter.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); + const relationTargetFieldMetadata = isDefined( + inputFilter.relationTargetFieldMetadataId, + ) + ? findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: inputFilter.relationTargetFieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }) + : undefined; + const operand = + inputFilter.operand ?? + (isDefined(referencedFieldMetadata) + ? getDefaultViewFilterOperand({ + fieldType: referencedFieldMetadata.type, + subFieldName: inputFilter.subFieldName, + relationTargetFieldType: relationTargetFieldMetadata?.type, + }) + : undefined) ?? + ViewFilterOperand.CONTAINS; filtersToCreate.push({ id: filterId, @@ -702,7 +725,7 @@ export class ViewWidgetUpsertService { fieldMetadataUniversalIdentifier, viewId, viewUniversalIdentifier, - operand: inputFilter.operand ?? ViewFilterOperand.CONTAINS, + operand, value: inputFilter.value, viewFilterGroupId: inputFilter.viewFilterGroupId ?? null, viewFilterGroupUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts index e491a13cb7..9c209f3187 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-view-filter-validator.service.ts @@ -2,7 +2,16 @@ import { Injectable } from '@nestjs/common'; import { msg, t } from '@lingui/core/macro'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; -import { isDefined } from 'twenty-shared/utils'; +import { + FieldMetadataType, + type FilterableAndTSVectorFieldType, + type ViewFilterOperand, +} from 'twenty-shared/types'; +import { + FILTER_OPERANDS_MAP, + getFilterOperandsForFilterableFieldType, + isDefined, +} from 'twenty-shared/utils'; import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; import { ViewFilterExceptionCode } from 'src/engine/metadata-modules/view-filter/exceptions/view-filter.exception'; @@ -72,6 +81,35 @@ export class FlatViewFilterValidatorService { message: t`Field metadata not found`, userFriendlyMessage: msg`Field metadata not found`, }); + } else { + let relationTargetFieldType: FieldMetadataType | undefined; + + if ( + isDefined( + flatViewFilterToValidate.relationTargetFieldMetadataUniversalIdentifier, + ) + ) { + const relationTargetFieldMetadata = findFlatEntityByUniversalIdentifier( + { + universalIdentifier: + flatViewFilterToValidate.relationTargetFieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }, + ); + + relationTargetFieldType = relationTargetFieldMetadata?.type; + } + + const incompatibleOperandError = this.getIncompatibleOperandError({ + operand: flatViewFilterToValidate.operand, + fieldType: referencedFieldMetadata.type, + subFieldName: flatViewFilterToValidate.subFieldName, + relationTargetFieldType, + }); + + if (isDefined(incompatibleOperandError)) { + validationResult.errors.push(incompatibleOperandError); + } } if ( @@ -180,6 +218,35 @@ export class FlatViewFilterValidatorService { message: t`Field metadata not found`, userFriendlyMessage: msg`Field metadata not found`, }); + } else { + let relationTargetFieldType: FieldMetadataType | undefined; + + if ( + isDefined( + updatedFlatViewFilter.relationTargetFieldMetadataUniversalIdentifier, + ) + ) { + const relationTargetFieldMetadata = findFlatEntityByUniversalIdentifier( + { + universalIdentifier: + updatedFlatViewFilter.relationTargetFieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }, + ); + + relationTargetFieldType = relationTargetFieldMetadata?.type; + } + + const incompatibleOperandError = this.getIncompatibleOperandError({ + operand: updatedFlatViewFilter.operand, + fieldType: referencedFieldMetadata.type, + subFieldName: updatedFlatViewFilter.subFieldName, + relationTargetFieldType, + }); + + if (isDefined(incompatibleOperandError)) { + validationResult.errors.push(incompatibleOperandError); + } } if (isDefined(updatedFlatViewFilter.viewFilterGroupUniversalIdentifier)) { @@ -200,4 +267,41 @@ export class FlatViewFilterValidatorService { return validationResult; } + + private getIncompatibleOperandError({ + operand, + fieldType, + subFieldName, + relationTargetFieldType, + }: { + operand: ViewFilterOperand; + fieldType: FieldMetadataType; + subFieldName: string | null | undefined; + relationTargetFieldType: FieldMetadataType | undefined; + }) { + const effectiveFieldType = + fieldType === FieldMetadataType.RELATION && + isDefined(relationTargetFieldType) + ? relationTargetFieldType + : fieldType; + + if (!(effectiveFieldType in FILTER_OPERANDS_MAP)) { + return undefined; + } + + const allowedOperands = getFilterOperandsForFilterableFieldType({ + filterType: effectiveFieldType as FilterableAndTSVectorFieldType, + subFieldName, + }); + + if (allowedOperands.includes(operand)) { + return undefined; + } + + return { + code: ViewFilterExceptionCode.INVALID_VIEW_FILTER_DATA, + message: t`Operand "${operand}" is not supported on field type "${effectiveFieldType}". Supported operands: ${allowedOperands.join(', ')}.`, + userFriendlyMessage: msg`Filter operand is not supported for this field type`, + }; + } } diff --git a/packages/twenty-server/test/integration/graphql/suites/view/__snapshots__/view-filter-resolver.integration-spec.ts.snap b/packages/twenty-server/test/integration/graphql/suites/view/__snapshots__/view-filter-resolver.integration-spec.ts.snap index 7d1e113513..88723dc61f 100644 --- a/packages/twenty-server/test/integration/graphql/suites/view/__snapshots__/view-filter-resolver.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/graphql/suites/view/__snapshots__/view-filter-resolver.integration-spec.ts.snap @@ -1,4 +1,39 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`View Filter Resolver createViewFilter should reject a view filter with an operand incompatible with the field type 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "viewFilter": [ + { + "errors": [ + { + "code": "INVALID_VIEW_FILTER_DATA", + "message": "Operand "IS" is not supported on field type "TEXT". Supported operands: CONTAINS, DOES_NOT_CONTAIN, IS_EMPTY, IS_NOT_EMPTY.", + "userFriendlyMessage": "Filter operand is not supported for this field type", + }, + ], + "flatEntityMinimalInformation": { + "universalIdentifier": Any, + }, + "metadataName": "viewFilter", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 viewFilter", + "summary": { + "totalErrors": 1, + "viewFilter": 1, + }, + "userFriendlyMessage": "Filter operand is not supported for this field type", + }, + "message": "Multiple validation errors occurred while creating view filter", + "name": "GraphQLError", +} +`; exports[`View Filter Resolver deleteViewFilter should throw an error when deleting non-existent view filter 1`] = ` { diff --git a/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts index 998b09df0a..c783278176 100644 --- a/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/view/view-filter-resolver.integration-spec.ts @@ -131,7 +131,7 @@ describe('View Filter Resolver', () => { input: { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test value', }, expectToFail: false, @@ -140,18 +140,18 @@ describe('View Filter Resolver', () => { expect(errors).toBeUndefined(); expect(data.createViewFilter).toMatchObject({ fieldMetadataId: testFieldMetadataId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test value', viewId: testViewId, }); }); - it('should create a view filter with numeric value', async () => { + it('should create a view filter with a numeric value payload', async () => { const { data, errors } = await createOneViewFilter({ input: { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + operand: ViewFilterOperand.CONTAINS, value: 100, }, expectToFail: false, @@ -160,18 +160,18 @@ describe('View Filter Resolver', () => { expect(errors).toBeUndefined(); expect(data.createViewFilter).toMatchObject({ fieldMetadataId: testFieldMetadataId, - operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + operand: ViewFilterOperand.CONTAINS, value: 100, viewId: testViewId, }); }); - it('should create a view filter with boolean value', async () => { + it('should create a view filter with a boolean value payload', async () => { const { data, errors } = await createOneViewFilter({ input: { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: true, }, expectToFail: false, @@ -180,11 +180,25 @@ describe('View Filter Resolver', () => { expect(errors).toBeUndefined(); expect(data.createViewFilter).toMatchObject({ fieldMetadataId: testFieldMetadataId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: true, viewId: testViewId, }); }); + + it('should reject a view filter with an operand incompatible with the field type', async () => { + const { errors } = await createOneViewFilter({ + input: { + fieldMetadataId: testFieldMetadataId, + viewId: testViewId, + operand: ViewFilterOperand.IS, + value: 'test', + }, + expectToFail: true, + }); + + expectOneNotInternalServerErrorSnapshot({ errors }); + }); }); describe('updateViewFilter', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap index 1c57663b26..49f31a36e1 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/__snapshots__/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts.snap @@ -2,7 +2,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle adding new options while maintaining existing view filter 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ @@ -15,7 +15,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle no changes update of options while maintaining existing view filter values 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ @@ -36,7 +36,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle partial deletion of selected options in view filter 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ @@ -53,7 +53,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should handle reordering of options while maintaining view filter values 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ @@ -79,7 +79,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related multi selected options view filter 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ @@ -100,7 +100,7 @@ exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT shou exports[`update-one-field-metadata-view-filters-side-effect-v2 MULTI_SELECT should update related solo selected option view filter 1`] = ` { - "operand": "IS", + "operand": "CONTAINS", "positionInViewFilterGroup": null, "subFieldName": null, "value": [ diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts index 2427812f31..3889180f61 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/update-one-field-metadata-view-filters-side-effect-v2.integration-spec.ts @@ -148,6 +148,11 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { }); describe.each(testFieldMetadataType)('%s', (fieldType) => { + const operandForFieldType = + fieldType === FieldMetadataType.MULTI_SELECT + ? ViewFilterOperand.CONTAINS + : ViewFilterOperand.IS; + const testCases: TestCase[] = [ { title: @@ -239,7 +244,7 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { input: { viewId: createdView.id, fieldMetadataId: createOneField.id, - operand: ViewFilterOperand.IS, + operand: operandForFieldType, value: createViewFilter.value, }, expectToFail: false, @@ -336,7 +341,7 @@ describe('update-one-field-metadata-view-filters-side-effect-v2', () => { id: viewFilterId, viewId: createdView.id, fieldMetadataId: createOneField.id, - operand: ViewFilterOperand.IS, + operand: operandForFieldType, value: createViewFilterValue as unknown as ViewFilterValue, }, expectToFail: false, diff --git a/packages/twenty-server/test/integration/metadata/suites/view-filter/successful-view-filter-relation-traversal.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view-filter/successful-view-filter-relation-traversal.integration-spec.ts index 9bf1992946..b938119953 100644 --- a/packages/twenty-server/test/integration/metadata/suites/view-filter/successful-view-filter-relation-traversal.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/view-filter/successful-view-filter-relation-traversal.integration-spec.ts @@ -143,7 +143,7 @@ describe('View Filter relation-traversal round-trip should succeed', () => { input: { viewId: createdViewId, fieldMetadataId: personCompanyRelationFieldId, - operand: ViewFilterOperand.CONTAINS, + operand: ViewFilterOperand.IS, value: 'Acme', }, gqlFields: RELATION_TRAVERSAL_GQL_FIELDS, diff --git a/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts index c0e9c08158..58e73b75fd 100644 --- a/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/view/upsert-view-widget.integration-spec.ts @@ -570,7 +570,7 @@ describe('upsertViewWidget', () => { { id: filterId, fieldMetadataId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.DOES_NOT_CONTAIN, value: 'updated', }, ], @@ -588,7 +588,7 @@ describe('upsertViewWidget', () => { ); expect(updatedFilter).toBeDefined(); - expect(updatedFilter!.operand).toBe(ViewFilterOperand.IS); + expect(updatedFilter!.operand).toBe(ViewFilterOperand.DOES_NOT_CONTAIN); expect(updatedFilter!.value).toBe('updated'); }); @@ -837,7 +837,7 @@ describe('upsertViewWidget', () => { const filterGroupId = uuidv4(); const filterId = uuidv4(); const sortId = uuidv4(); - const fieldMetadataId = testSetup.fieldMetadataIds[2]; + const fieldMetadataId = testSetup.fieldMetadataIds[0]; const targetField = testSetup.viewFields.find( (field) => field.fieldMetadataId !== testSetup.labelIdentifierFieldMetadataId, diff --git a/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts index 06d14e718a..50e024dcf6 100644 --- a/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/view-filter.integration-spec.ts @@ -162,7 +162,7 @@ describe('View Filter REST API', () => { it('should create a new view filter with string value', async () => { const viewFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test value', fieldMetadataId: testFieldMetadataId, }); @@ -172,7 +172,7 @@ describe('View Filter REST API', () => { assertViewFilterStructure(viewFilter, { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test value', }); @@ -182,7 +182,7 @@ describe('View Filter REST API', () => { it('should create a view filter with numeric value', async () => { const numericFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + operand: ViewFilterOperand.CONTAINS, value: '100', fieldMetadataId: testFieldMetadataId, }); @@ -192,7 +192,7 @@ describe('View Filter REST API', () => { assertViewFilterStructure(numericFilter, { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.GREATER_THAN_OR_EQUAL, + operand: ViewFilterOperand.CONTAINS, value: '100', }); }); @@ -200,7 +200,7 @@ describe('View Filter REST API', () => { it('should create a view filter with boolean value', async () => { const booleanFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'true', fieldMetadataId: testFieldMetadataId, }); @@ -210,7 +210,7 @@ describe('View Filter REST API', () => { assertViewFilterStructure(booleanFilter, { fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'true', }); }); @@ -220,7 +220,7 @@ describe('View Filter REST API', () => { it('should return a view filter by id', async () => { const viewFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test', fieldMetadataId: testFieldMetadataId, }); @@ -238,7 +238,7 @@ describe('View Filter REST API', () => { id: viewFilter.id, fieldMetadataId: testFieldMetadataId, viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'test', }); @@ -260,7 +260,7 @@ describe('View Filter REST API', () => { it('should update an existing view filter', async () => { const viewFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'original', fieldMetadataId: testFieldMetadataId, }); @@ -268,7 +268,7 @@ describe('View Filter REST API', () => { testViewFilterId = viewFilter.id; const updateData = { - operand: ViewFilterOperand.IS_NOT, + operand: ViewFilterOperand.DOES_NOT_CONTAIN, value: 'updated', }; @@ -282,7 +282,7 @@ describe('View Filter REST API', () => { assertRestApiSuccessfulResponse(response); assertViewFilterStructure(response.body, { id: viewFilter.id, - operand: ViewFilterOperand.IS_NOT, + operand: ViewFilterOperand.DOES_NOT_CONTAIN, value: 'updated', fieldMetadataId: testFieldMetadataId, viewId: testViewId, @@ -293,7 +293,7 @@ describe('View Filter REST API', () => { it('should return 404 error when updating non-existent view filter', async () => { const updateData = { - operand: ViewFilterOperand.IS_NOT, + operand: ViewFilterOperand.DOES_NOT_CONTAIN, value: 'updated', }; @@ -312,7 +312,7 @@ describe('View Filter REST API', () => { it('should delete an existing view filter', async () => { const viewFilter = await createTestViewFilterWithRestApi({ viewId: testViewId, - operand: ViewFilterOperand.IS, + operand: ViewFilterOperand.CONTAINS, value: 'to delete', fieldMetadataId: testFieldMetadataId, }); diff --git a/packages/twenty-shared/src/utils/filter/index.ts b/packages/twenty-shared/src/utils/filter/index.ts index 05ce5692b1..b94e495830 100644 --- a/packages/twenty-shared/src/utils/filter/index.ts +++ b/packages/twenty-shared/src/utils/filter/index.ts @@ -15,6 +15,9 @@ export * from './utils/combineFilters'; export * from './utils/fieldRatingConvertors'; export * from './utils/generateILikeFiltersForCompositeFields'; export * from './utils/getEmptyRecordGqlOperationFilter'; +export * from './utils/compositeFieldFilterOperandsMap'; +export * from './utils/filterOperandsMap'; +export * from './utils/getFilterOperandsForFilterableFieldType'; export * from './utils/getFilterTypeFromFieldType'; export * from './utils/isExpectedSubFieldName'; export * from './utils/isMatchingArrayFilter'; diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/getFilterOperandsForFilterableFieldType.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/getFilterOperandsForFilterableFieldType.test.ts new file mode 100644 index 0000000000..21dd0ad8b7 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/getFilterOperandsForFilterableFieldType.test.ts @@ -0,0 +1,57 @@ +import { ViewFilterOperand } from '@/types'; +import { getFilterOperandsForFilterableFieldType } from '@/utils/filter/utils/getFilterOperandsForFilterableFieldType'; + +describe('getFilterOperandsForFilterableFieldType', () => { + const emptyOperands = [ + ViewFilterOperand.IS_EMPTY, + ViewFilterOperand.IS_NOT_EMPTY, + ]; + + it('should return select operands', () => { + expect( + getFilterOperandsForFilterableFieldType({ filterType: 'SELECT' }), + ).toEqual([ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ]); + }); + + it('should preserve actor source subfield operands', () => { + expect( + getFilterOperandsForFilterableFieldType({ + filterType: 'ACTOR', + subFieldName: 'source', + }), + ).toEqual([ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ]); + }); + + it('should preserve actor workspace member subfield operands', () => { + expect( + getFilterOperandsForFilterableFieldType({ + filterType: 'ACTOR', + subFieldName: 'workspaceMemberId', + }), + ).toEqual([ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ]); + }); + + it('should default currency to amount operands', () => { + expect( + getFilterOperandsForFilterableFieldType({ filterType: 'CURRENCY' }), + ).toEqual([ + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + ViewFilterOperand.LESS_THAN_OR_EQUAL, + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ]); + }); +}); diff --git a/packages/twenty-shared/src/utils/filter/utils/compositeFieldFilterOperandsMap.ts b/packages/twenty-shared/src/utils/filter/utils/compositeFieldFilterOperandsMap.ts new file mode 100644 index 0000000000..d2ab48fe19 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/compositeFieldFilterOperandsMap.ts @@ -0,0 +1,36 @@ +import { + type CompositeFieldSubFieldName, + type FilterableFieldType, + ViewFilterOperand, +} from '@/types'; + +const emptyOperands = [ + ViewFilterOperand.IS_EMPTY, + ViewFilterOperand.IS_NOT_EMPTY, +] as const; + +// TODO: we would need to refactor the typing of SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS first +// with types like FieldCurrencyValue being derived from a central constant value and not being created like that +// in order to narrow down the possible subfield names for each field type +type CompositeFieldFilterOperandMap = { + [K in FilterableFieldType]: Partial<{ + [S in CompositeFieldSubFieldName]: readonly ViewFilterOperand[]; + }>; +}; + +export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = { + CURRENCY: { + currencyCode: [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ], + amountMicros: [ + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + ViewFilterOperand.LESS_THAN_OR_EQUAL, + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ...emptyOperands, + ], + }, +} as const satisfies Partial; diff --git a/packages/twenty-shared/src/utils/filter/utils/filterOperandsMap.ts b/packages/twenty-shared/src/utils/filter/utils/filterOperandsMap.ts new file mode 100644 index 0000000000..6368a93bda --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/filterOperandsMap.ts @@ -0,0 +1,120 @@ +import { + type FilterableAndTSVectorFieldType, + ViewFilterOperand, +} from '@/types'; + +const emptyOperands = [ + ViewFilterOperand.IS_EMPTY, + ViewFilterOperand.IS_NOT_EMPTY, +] as const; + +const relationOperands = [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, +] as const; + +type FilterOperandMap = { + [K in FilterableAndTSVectorFieldType]: readonly ViewFilterOperand[]; +}; + +export const FILTER_OPERANDS_MAP = { + TEXT: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + EMAILS: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + FULL_NAME: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + ADDRESS: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + LINKS: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + PHONES: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + CURRENCY: [ + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + ViewFilterOperand.LESS_THAN_OR_EQUAL, + ...emptyOperands, + ], + NUMBER: [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + ViewFilterOperand.LESS_THAN_OR_EQUAL, + ...emptyOperands, + ], + RAW_JSON: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + FILES: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + DATE_TIME: [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_RELATIVE, + ViewFilterOperand.IS_IN_PAST, + ViewFilterOperand.IS_IN_FUTURE, + ViewFilterOperand.IS_TODAY, + ViewFilterOperand.IS_BEFORE, + ViewFilterOperand.IS_AFTER, + ...emptyOperands, + ], + DATE: [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_RELATIVE, + ViewFilterOperand.IS_IN_PAST, + ViewFilterOperand.IS_IN_FUTURE, + ViewFilterOperand.IS_TODAY, + ViewFilterOperand.IS_BEFORE, + ViewFilterOperand.IS_AFTER, + ...emptyOperands, + ], + RATING: [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + ViewFilterOperand.LESS_THAN_OR_EQUAL, + ...emptyOperands, + ], + RELATION: [...relationOperands, ...emptyOperands], + MULTI_SELECT: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + SELECT: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands], + ACTOR: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + ARRAY: [ + ViewFilterOperand.CONTAINS, + ViewFilterOperand.DOES_NOT_CONTAIN, + ...emptyOperands, + ], + BOOLEAN: [ViewFilterOperand.IS], + TS_VECTOR: [ViewFilterOperand.VECTOR_SEARCH], + UUID: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands], +} as const satisfies FilterOperandMap; diff --git a/packages/twenty-shared/src/utils/filter/utils/getFilterOperandsForFilterableFieldType.ts b/packages/twenty-shared/src/utils/filter/utils/getFilterOperandsForFilterableFieldType.ts new file mode 100644 index 0000000000..968f0c5375 --- /dev/null +++ b/packages/twenty-shared/src/utils/filter/utils/getFilterOperandsForFilterableFieldType.ts @@ -0,0 +1,39 @@ +import { + type FilterableAndTSVectorFieldType, + ViewFilterOperand, +} from '@/types'; + +import { COMPOSITE_FIELD_FILTER_OPERANDS_MAP } from './compositeFieldFilterOperandsMap'; +import { FILTER_OPERANDS_MAP } from './filterOperandsMap'; + +const actorSubFieldOperands = [ + ViewFilterOperand.IS, + ViewFilterOperand.IS_NOT, + ViewFilterOperand.IS_EMPTY, + ViewFilterOperand.IS_NOT_EMPTY, +] as const; + +export const getFilterOperandsForFilterableFieldType = ({ + filterType, + subFieldName, +}: { + filterType: FilterableAndTSVectorFieldType; + subFieldName?: string | null | undefined; +}): readonly ViewFilterOperand[] => { + if (filterType === 'CURRENCY') { + if (subFieldName === 'currencyCode') { + return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.currencyCode; + } + + return COMPOSITE_FIELD_FILTER_OPERANDS_MAP.CURRENCY.amountMicros; + } + + if ( + filterType === 'ACTOR' && + (subFieldName === 'source' || subFieldName === 'workspaceMemberId') + ) { + return actorSubFieldOperands; + } + + return FILTER_OPERANDS_MAP[filterType]; +}; diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index f7da84dae3..04cbb9668c 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -107,6 +107,7 @@ export { turnRecordFilterGroupsIntoGqlOperationFilter } from './filter/turnRecor export type { FieldShared } from './filter/turnRecordFilterIntoGqlOperationFilter'; export { turnRecordFilterIntoRecordGqlOperationFilter } from './filter/turnRecordFilterIntoGqlOperationFilter'; export { combineFilters } from './filter/utils/combineFilters'; +export { COMPOSITE_FIELD_FILTER_OPERANDS_MAP } from './filter/utils/compositeFieldFilterOperandsMap'; export { convertViewFilterOperandToCoreOperand } from './filter/utils/convert-view-filter-operand-to-core-operand.util'; export { convertViewFilterValueToString } from './filter/utils/convertViewFilterValueToString'; export { createAnyFieldRecordFilterBaseProperties } from './filter/utils/createAnyFieldRecordFilterBaseProperties'; @@ -115,9 +116,11 @@ export { convertLessThanOrEqualRatingToArrayOfRatingValues, convertRatingToRatingValue, } from './filter/utils/fieldRatingConvertors'; +export { FILTER_OPERANDS_MAP } from './filter/utils/filterOperandsMap'; export { filterSelectOptionsOfFieldMetadataItem } from './filter/utils/filterSelectOptionsOfFieldMetadataItem'; export { generateILikeFiltersForCompositeFields } from './filter/utils/generateILikeFiltersForCompositeFields'; export { getEmptyRecordGqlOperationFilter } from './filter/utils/getEmptyRecordGqlOperationFilter'; +export { getFilterOperandsForFilterableFieldType } from './filter/utils/getFilterOperandsForFilterableFieldType'; export { getFilterTypeFromFieldType } from './filter/utils/getFilterTypeFromFieldType'; export { isExpectedSubFieldName } from './filter/utils/isExpectedSubFieldName'; export { isMatchingArrayFilter } from './filter/utils/isMatchingArrayFilter';