feat(sdk): error on incompatible view filter operand at sync time (#20763)

view filters with mismatched operand + field type now error at sync --
was silently failing before
This commit is contained in:
nitin
2026-05-22 14:31:40 +05:30
committed by GitHub
parent 9b05e7fff4
commit 068d365731
29 changed files with 1000 additions and 273 deletions
@@ -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
@@ -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.
@@ -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 = {
@@ -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<CompositeFieldFilterOperandMap>;
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,
});
};
@@ -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';
@@ -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,
@@ -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
? {
@@ -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 = <T extends FieldMetadataType>({
universalIdentifier,
type,
name = 'field',
label = 'Field',
}: {
universalIdentifier: string;
type: T;
name?: string;
label?: string;
}): FieldManifest<T> =>
({
universalIdentifier,
objectUniversalIdentifier: 'object-uid',
name,
label,
type,
}) as FieldManifest<T>;
const buildRelationField = ({
universalIdentifier,
relationTargetFieldMetadataUniversalIdentifier,
name = 'relation',
label = 'Relation',
}: {
universalIdentifier: string;
relationTargetFieldMetadataUniversalIdentifier: string;
name?: string;
label?: string;
}): FieldManifest<FieldMetadataType.RELATION> =>
({
universalIdentifier,
objectUniversalIdentifier: 'object-uid',
name,
label,
type: FieldMetadataType.RELATION,
relationTargetFieldMetadataUniversalIdentifier,
relationTargetObjectMetadataUniversalIdentifier: 'target-object-uid',
universalSettings: {},
}) as FieldManifest<FieldMetadataType.RELATION>;
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([]);
});
});
@@ -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;
};
@@ -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);
});
});
@@ -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:
@@ -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];
};
@@ -27,7 +27,7 @@ export class CreateViewFilterInput {
@IsOptional()
@IsEnum(ViewFilterOperand)
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
@Field({ nullable: true })
operand?: ViewFilterOperand;
@IsDefined()
@@ -27,7 +27,7 @@ export class UpsertViewWidgetViewFilterInput {
@IsOptional()
@IsEnum(ViewFilterOperand)
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
@Field({ nullable: true })
operand?: ViewFilterOperand;
@IsDefined()
@@ -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,
@@ -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`,
};
}
}
@@ -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<String>,
},
"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`] = `
{
@@ -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', () => {
@@ -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": [
@@ -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,
@@ -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,
@@ -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,
@@ -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,
});
@@ -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';
@@ -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,
]);
});
});
@@ -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<CompositeFieldFilterOperandMap>;
@@ -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;
@@ -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];
};
@@ -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';