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:
@@ -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 = {
|
||||
|
||||
+10
-222
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
+6
-5
@@ -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';
|
||||
|
||||
|
||||
+2
-2
@@ -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
|
||||
? {
|
||||
|
||||
+265
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+78
@@ -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;
|
||||
};
|
||||
+30
@@ -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);
|
||||
});
|
||||
});
|
||||
+30
-2
@@ -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:
|
||||
|
||||
+35
@@ -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];
|
||||
};
|
||||
+1
-1
@@ -27,7 +27,7 @@ export class CreateViewFilterInput {
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ViewFilterOperand)
|
||||
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
|
||||
@Field({ nullable: true })
|
||||
operand?: ViewFilterOperand;
|
||||
|
||||
@IsDefined()
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ export class UpsertViewWidgetViewFilterInput {
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(ViewFilterOperand)
|
||||
@Field({ nullable: true, defaultValue: ViewFilterOperand.CONTAINS })
|
||||
@Field({ nullable: true })
|
||||
operand?: ViewFilterOperand;
|
||||
|
||||
@IsDefined()
|
||||
|
||||
+24
-1
@@ -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,
|
||||
|
||||
+105
-1
@@ -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`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+36
-1
@@ -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`] = `
|
||||
{
|
||||
|
||||
+22
-8
@@ -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', () => {
|
||||
|
||||
+6
-6
@@ -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": [
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+3
-3
@@ -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,
|
||||
|
||||
+13
-13
@@ -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';
|
||||
|
||||
+57
@@ -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;
|
||||
+39
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user