diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/__snapshots__/getRecordFilterOperands.test.ts.snap b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/__snapshots__/getRecordFilterOperands.test.ts.snap new file mode 100644 index 0000000000..17fbc54c77 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/__snapshots__/getRecordFilterOperands.test.ts.snap @@ -0,0 +1,238 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getRecordFilterOperands ACTOR returns ACTOR (text-like) operands when subFieldName is undefined 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands ACTOR returns IS/IS_NOT operands when subFieldName is source (actor source subField) 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands ACTOR returns IS/IS_NOT operands when subFieldName is workspaceMemberId (actor wsMember subField) 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands CURRENCY returns amountMicros operands when subFieldName is amountMicros 1`] = ` +[ + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands CURRENCY returns amountMicros operands when subFieldName is undefined 1`] = ` +[ + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands CURRENCY returns currencyCode operands when subFieldName is currencyCode 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=ADDRESS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=ARRAY 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=BOOLEAN 1`] = ` +[ + "IS", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=DATE 1`] = ` +[ + "IS", + "IS_RELATIVE", + "IS_IN_PAST", + "IS_IN_FUTURE", + "IS_TODAY", + "IS_BEFORE", + "IS_AFTER", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=DATE_TIME 1`] = ` +[ + "IS", + "IS_RELATIVE", + "IS_IN_PAST", + "IS_IN_FUTURE", + "IS_TODAY", + "IS_BEFORE", + "IS_AFTER", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=EMAILS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=FILES 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=FULL_NAME 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=LINKS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=MULTI_SELECT 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=NUMBER 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=PHONES 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=RATING 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=RAW_JSON 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=RELATION 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=SELECT 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=TEXT 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=TS_VECTOR 1`] = ` +[ + "VECTOR_SEARCH", +] +`; + +exports[`getRecordFilterOperands returns the expected operands for filterType=UUID 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getRecordFilterOperands.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getRecordFilterOperands.test.ts new file mode 100644 index 0000000000..e6f0e5451b --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/getRecordFilterOperands.test.ts @@ -0,0 +1,96 @@ +import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { type FilterableAndTSVectorFieldType } from 'twenty-shared/types'; + +const filterTypesWithoutSubField: FilterableAndTSVectorFieldType[] = [ + 'TEXT', + 'EMAILS', + 'FULL_NAME', + 'ADDRESS', + 'LINKS', + 'PHONES', + 'NUMBER', + 'RAW_JSON', + 'FILES', + 'DATE_TIME', + 'DATE', + 'RATING', + 'RELATION', + 'MULTI_SELECT', + 'SELECT', + 'ARRAY', + 'BOOLEAN', + 'UUID', + 'TS_VECTOR', +]; + +describe('getRecordFilterOperands', () => { + it.each(filterTypesWithoutSubField)( + 'returns the expected operands for filterType=%s', + (filterType) => { + const operands = getRecordFilterOperands({ + filterType, + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }, + ); + + describe('CURRENCY', () => { + it('returns currencyCode operands when subFieldName is currencyCode', () => { + const operands = getRecordFilterOperands({ + filterType: 'CURRENCY', + subFieldName: 'currencyCode', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns amountMicros operands when subFieldName is amountMicros', () => { + const operands = getRecordFilterOperands({ + filterType: 'CURRENCY', + subFieldName: 'amountMicros', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns amountMicros operands when subFieldName is undefined', () => { + const operands = getRecordFilterOperands({ + filterType: 'CURRENCY', + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }); + }); + + describe('ACTOR', () => { + it('returns IS/IS_NOT operands when subFieldName is source (actor source subField)', () => { + const operands = getRecordFilterOperands({ + filterType: 'ACTOR', + subFieldName: 'source', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns IS/IS_NOT operands when subFieldName is workspaceMemberId (actor wsMember subField)', () => { + const operands = getRecordFilterOperands({ + filterType: 'ACTOR', + subFieldName: 'workspaceMemberId', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns ACTOR (text-like) operands when subFieldName is undefined', () => { + const operands = getRecordFilterOperands({ + filterType: 'ACTOR', + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts index e99cb36566..8c3fc4d711 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getRecordFilterOperands.ts @@ -112,6 +112,7 @@ export const FILTER_OPERANDS_MAP = { ], RATING: [ RecordFilterOperand.IS, + RecordFilterOperand.IS_NOT, RecordFilterOperand.GREATER_THAN_OR_EQUAL, RecordFilterOperand.LESS_THAN_OR_EQUAL, ...emptyOperands, @@ -139,7 +140,7 @@ export const FILTER_OPERANDS_MAP = { ], BOOLEAN: [RecordFilterOperand.IS], TS_VECTOR: [RecordFilterOperand.VECTOR_SEARCH], - UUID: [RecordFilterOperand.IS], + UUID: [RecordFilterOperand.IS, RecordFilterOperand.IS_NOT, ...emptyOperands], } as const satisfies FilterOperandMap; export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = { diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts index 5d1cd894ee..5bd43858b3 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts @@ -167,6 +167,7 @@ const computeValueFromFilterRating = ( )?.value; return minusOne ?? option.value; } + case ViewFilterOperand.IS_NOT: case ViewFilterOperand.IS_EMPTY: return undefined; default: @@ -275,6 +276,10 @@ const computeValueFromFilterUUID = ( switch (operand) { case ViewFilterOperand.IS: return value; + case ViewFilterOperand.IS_NOT: + case ViewFilterOperand.IS_EMPTY: + case ViewFilterOperand.IS_NOT_EMPTY: + return undefined; default: assertUnreachable(operand); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/__snapshots__/getStepFilterOperands.test.ts.snap b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/__snapshots__/getStepFilterOperands.test.ts.snap new file mode 100644 index 0000000000..694017b9ef --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/__snapshots__/getStepFilterOperands.test.ts.snap @@ -0,0 +1,295 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`getStepFilterOperands ACTOR returns RELATION operands when subFieldName is workspaceMemberId 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands ACTOR returns SELECT operands when subFieldName is source 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands ACTOR returns TEXT operands when subFieldName is undefined 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands ACTOR returns TEXT operands when subFieldName is unknown 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands CURRENCY returns amountMicros operands when subFieldName is amountMicros 1`] = ` +[ + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands CURRENCY returns amountMicros operands when subFieldName is undefined 1`] = ` +[ + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands CURRENCY returns currencyCode operands when subFieldName is currencyCode 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=ADDRESS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=ARRAY 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=BOOLEAN 1`] = ` +[ + "IS", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=DATE 1`] = ` +[ + "IS", + "IS_RELATIVE", + "IS_IN_PAST", + "IS_IN_FUTURE", + "IS_TODAY", + "IS_BEFORE", + "IS_AFTER", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=DATE_TIME 1`] = ` +[ + "IS", + "IS_RELATIVE", + "IS_IN_PAST", + "IS_IN_FUTURE", + "IS_TODAY", + "IS_BEFORE", + "IS_AFTER", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=EMAILS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=FULL_NAME 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=LINKS 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=MULTI_SELECT 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=NUMBER 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=NUMERIC 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=PHONES 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=RATING 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=RAW_JSON 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=RELATION 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=SELECT 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=TEXT 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=UNKNOWN_TYPE_FALLS_THROUGH 1`] = ` +[ + "IS", + "IS_NOT", + "CONTAINS", + "DOES_NOT_CONTAIN", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=UUID 1`] = ` +[ + "IS", + "IS_NOT", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=array 1`] = ` +[ + "CONTAINS", + "DOES_NOT_CONTAIN", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=boolean 1`] = ` +[ + "IS", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=number 1`] = ` +[ + "IS", + "IS_NOT", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; + +exports[`getStepFilterOperands returns the expected operands for filterType=undefined 1`] = ` +[ + "IS", + "IS_NOT", + "CONTAINS", + "DOES_NOT_CONTAIN", + "GREATER_THAN_OR_EQUAL", + "LESS_THAN_OR_EQUAL", + "IS_EMPTY", + "IS_NOT_EMPTY", +] +`; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/getStepFilterOperands.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/getStepFilterOperands.test.ts new file mode 100644 index 0000000000..8b9ad767dd --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/__tests__/getStepFilterOperands.test.ts @@ -0,0 +1,108 @@ +import { getStepFilterOperands } from '@/workflow/workflow-steps/filters/utils/getStepFilterOperands'; + +const filterTypesWithoutSubField = [ + 'TEXT', + 'EMAILS', + 'FULL_NAME', + 'ADDRESS', + 'LINKS', + 'PHONES', + 'NUMBER', + 'number', + 'RAW_JSON', + 'DATE_TIME', + 'DATE', + 'RATING', + 'RELATION', + 'MULTI_SELECT', + 'SELECT', + 'ARRAY', + 'array', + 'BOOLEAN', + 'boolean', + 'UUID', + 'NUMERIC', + 'UNKNOWN_TYPE_FALLS_THROUGH', + undefined, +] as const; + +describe('getStepFilterOperands', () => { + it.each(filterTypesWithoutSubField)( + 'returns the expected operands for filterType=%s', + (filterType) => { + const operands = getStepFilterOperands({ + filterType, + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }, + ); + + describe('CURRENCY', () => { + it('returns currencyCode operands when subFieldName is currencyCode', () => { + const operands = getStepFilterOperands({ + filterType: 'CURRENCY', + subFieldName: 'currencyCode', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns amountMicros operands when subFieldName is amountMicros', () => { + const operands = getStepFilterOperands({ + filterType: 'CURRENCY', + subFieldName: 'amountMicros', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns amountMicros operands when subFieldName is undefined', () => { + const operands = getStepFilterOperands({ + filterType: 'CURRENCY', + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }); + }); + + describe('ACTOR', () => { + it('returns SELECT operands when subFieldName is source', () => { + const operands = getStepFilterOperands({ + filterType: 'ACTOR', + subFieldName: 'source', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns RELATION operands when subFieldName is workspaceMemberId', () => { + const operands = getStepFilterOperands({ + filterType: 'ACTOR', + subFieldName: 'workspaceMemberId', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns TEXT operands when subFieldName is unknown', () => { + const operands = getStepFilterOperands({ + filterType: 'ACTOR', + subFieldName: 'someUnknownSubField', + }); + + expect(operands).toMatchSnapshot(); + }); + + it('returns TEXT operands when subFieldName is undefined', () => { + const operands = getStepFilterOperands({ + filterType: 'ACTOR', + subFieldName: undefined, + }); + + expect(operands).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts index b1ae3e7a7b..0af07694ea 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/filters/utils/getStepFilterOperands.ts @@ -1,15 +1,9 @@ +import { + COMPOSITE_FIELD_FILTER_OPERANDS_MAP, + FILTER_OPERANDS_MAP, +} from '@/object-record/record-filter/utils/getRecordFilterOperands'; import { ViewFilterOperand } from 'twenty-shared/types'; -const emptyOperands = [ - ViewFilterOperand.IS_EMPTY, - ViewFilterOperand.IS_NOT_EMPTY, -] as const; - -const relationOperands = [ - ViewFilterOperand.IS, - ViewFilterOperand.IS_NOT, -] as const; - const defaultOperands = [ ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, @@ -17,78 +11,10 @@ const defaultOperands = [ ViewFilterOperand.DOES_NOT_CONTAIN, ViewFilterOperand.GREATER_THAN_OR_EQUAL, ViewFilterOperand.LESS_THAN_OR_EQUAL, - ...emptyOperands, + ViewFilterOperand.IS_EMPTY, + ViewFilterOperand.IS_NOT_EMPTY, ] as const; -export const FILTER_OPERANDS_MAP = { - TEXT: [ - ViewFilterOperand.CONTAINS, - ViewFilterOperand.DOES_NOT_CONTAIN, - ...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, - ], - DATE_TIME: [ - ViewFilterOperand.IS, - ViewFilterOperand.IS_IN_PAST, - ViewFilterOperand.IS_IN_FUTURE, - ViewFilterOperand.IS_TODAY, - ViewFilterOperand.IS_BEFORE, - ViewFilterOperand.IS_AFTER, - ViewFilterOperand.IS_RELATIVE, - ...emptyOperands, - ], - RATING: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands], - RELATION: [...relationOperands, ...emptyOperands], - MULTI_SELECT: [ - ViewFilterOperand.CONTAINS, - ViewFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - SELECT: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT, ...emptyOperands], - ARRAY: [ - ViewFilterOperand.CONTAINS, - ViewFilterOperand.DOES_NOT_CONTAIN, - ...emptyOperands, - ], - BOOLEAN: [ViewFilterOperand.IS], - UUID: [ViewFilterOperand.IS, ViewFilterOperand.IS_NOT], - NUMERIC: [ - ViewFilterOperand.IS, - ViewFilterOperand.IS_NOT, - ViewFilterOperand.GREATER_THAN_OR_EQUAL, - ViewFilterOperand.LESS_THAN_OR_EQUAL, - ...emptyOperands, - ], -}; - -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, - ], - }, -}; - export const getStepFilterOperands = ({ filterType, subFieldName, @@ -136,7 +62,7 @@ export const getStepFilterOperands = ({ case 'UUID': return FILTER_OPERANDS_MAP.UUID; case 'NUMERIC': - return FILTER_OPERANDS_MAP.NUMERIC; + return FILTER_OPERANDS_MAP.NUMBER; case 'ACTOR': { if (subFieldName === 'source') { return FILTER_OPERANDS_MAP.SELECT; diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts index 7fbb7efced..e5ce98f4a9 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/__tests__/evaluate-filter-conditions.util.spec.ts @@ -310,6 +310,171 @@ describe('evaluateFilterConditions', () => { 'Operand CONTAINS not supported for uuid filter', ); }); + + it('should return true for IS_EMPTY when UUID is null/undefined/empty', () => { + const cases = [null, undefined, '']; + + for (const leftOperand of cases) { + const filter = createFilter( + ViewFilterOperand.IS_EMPTY, + leftOperand, + null, + 'UUID', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + } + }); + + it('should return false for IS_EMPTY when UUID is a non-empty string', () => { + const filter = createFilter( + ViewFilterOperand.IS_EMPTY, + uuid1, + null, + 'UUID', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(false); + }); + + it('should return false for IS_NOT_EMPTY when UUID is null/undefined/empty', () => { + const cases = [null, undefined, '']; + + for (const leftOperand of cases) { + const filter = createFilter( + ViewFilterOperand.IS_NOT_EMPTY, + leftOperand, + null, + 'UUID', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(false); + } + }); + + it('should return true for IS_NOT_EMPTY when UUID is a non-empty string', () => { + const filter = createFilter( + ViewFilterOperand.IS_NOT_EMPTY, + uuid1, + null, + 'UUID', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + }); + }); + + describe('Rating filter operands', () => { + it('should compare RATING_n strings by numeric rank for IS', () => { + const filterMatch = createFilter( + ViewFilterOperand.IS, + 'RATING_3', + '3', + 'RATING', + ); + const filterNoMatch = createFilter( + ViewFilterOperand.IS, + 'RATING_3', + '4', + 'RATING', + ); + + expect(evaluateFilterConditions({ filters: [filterMatch] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filterNoMatch] })).toBe( + false, + ); + }); + + it('should compare with GREATER_THAN_OR_EQUAL on rank', () => { + const filterGte = createFilter( + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + 'RATING_4', + '3', + 'RATING', + ); + const filterLt = createFilter( + ViewFilterOperand.GREATER_THAN_OR_EQUAL, + 'RATING_2', + '3', + 'RATING', + ); + + expect(evaluateFilterConditions({ filters: [filterGte] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filterLt] })).toBe(false); + }); + + it('should compare with LESS_THAN_OR_EQUAL on rank', () => { + const filterLte = createFilter( + ViewFilterOperand.LESS_THAN_OR_EQUAL, + 'RATING_2', + '3', + 'RATING', + ); + const filterGt = createFilter( + ViewFilterOperand.LESS_THAN_OR_EQUAL, + 'RATING_4', + '3', + 'RATING', + ); + + expect(evaluateFilterConditions({ filters: [filterLte] })).toBe(true); + expect(evaluateFilterConditions({ filters: [filterGt] })).toBe(false); + }); + + it('should handle IS_NOT, IS_EMPTY, IS_NOT_EMPTY', () => { + const filterIsNotMatch = createFilter( + ViewFilterOperand.IS_NOT, + 'RATING_3', + '4', + 'RATING', + ); + const filterIsEmpty = createFilter( + ViewFilterOperand.IS_EMPTY, + null, + null, + 'RATING', + ); + const filterIsNotEmpty = createFilter( + ViewFilterOperand.IS_NOT_EMPTY, + 'RATING_3', + null, + 'RATING', + ); + + expect(evaluateFilterConditions({ filters: [filterIsNotMatch] })).toBe( + true, + ); + expect(evaluateFilterConditions({ filters: [filterIsEmpty] })).toBe( + true, + ); + expect(evaluateFilterConditions({ filters: [filterIsNotEmpty] })).toBe( + true, + ); + }); + + it('should treat unparseable rating string as empty', () => { + const filter = createFilter( + ViewFilterOperand.IS_EMPTY, + 'not-a-rating', + null, + 'RATING', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + }); + + it('should throw on unsupported rating operand', () => { + const filter = createFilter( + ViewFilterOperand.CONTAINS, + 'RATING_3', + '3', + 'RATING', + ); + + expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow( + 'Operand CONTAINS not supported for rating filter', + ); + }); }); describe('Select filter operands', () => { diff --git a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts index 1ba0308573..05ded6949f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/evaluate-filter-conditions.util.ts @@ -75,6 +75,8 @@ function evaluateFilter( return evaluateBooleanFilter(filterWithConvertedOperand); case 'UUID': return evaluateUuidFilter(filterWithConvertedOperand); + case 'RATING': + return evaluateRatingFilter(filterWithConvertedOperand); case 'RELATION': return evaluateRelationFilter(filterWithConvertedOperand); case 'CURRENCY': @@ -268,6 +270,10 @@ function evaluateUuidFilter(filter: ResolvedFilter): boolean { return filter.leftOperand === filter.rightOperand; case ViewFilterOperand.IS_NOT: return filter.leftOperand !== filter.rightOperand; + case ViewFilterOperand.IS_EMPTY: + return !isNonEmptyString(filter.leftOperand); + case ViewFilterOperand.IS_NOT_EMPTY: + return isNonEmptyString(filter.leftOperand); default: throw new Error( `Operand ${filter.operand} not supported for uuid filter`, @@ -275,6 +281,59 @@ function evaluateUuidFilter(filter: ResolvedFilter): boolean { } } +// Rating values are stored as enum strings 'RATING_1'..'RATING_5'. Comparisons +// must operate on the numeric rank (1..5), not lexicographic order or Number() +// on the enum string (which would be NaN). +function parseRatingRank(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.length > 0) { + const ratingPrefixMatch = value.match(/^RATING_(\d+)$/); + + if (ratingPrefixMatch !== null) { + return Number(ratingPrefixMatch[1]); + } + + const numericValue = Number(value); + + if (Number.isFinite(numericValue)) { + return numericValue; + } + } + + return undefined; +} + +function evaluateRatingFilter(filter: ResolvedFilter): boolean { + const leftRank = parseRatingRank(filter.leftOperand); + const rightRank = parseRatingRank(filter.rightOperand); + + switch (filter.operand) { + case ViewFilterOperand.IS: + return isDefined(leftRank) && leftRank === rightRank; + case ViewFilterOperand.IS_NOT: + return !isDefined(leftRank) || leftRank !== rightRank; + case ViewFilterOperand.GREATER_THAN_OR_EQUAL: + return ( + isDefined(leftRank) && isDefined(rightRank) && leftRank >= rightRank + ); + case ViewFilterOperand.LESS_THAN_OR_EQUAL: + return ( + isDefined(leftRank) && isDefined(rightRank) && leftRank <= rightRank + ); + case ViewFilterOperand.IS_EMPTY: + return !isDefined(leftRank); + case ViewFilterOperand.IS_NOT_EMPTY: + return isDefined(leftRank); + default: + throw new Error( + `Operand ${filter.operand} not supported for rating filter`, + ); + } +} + function evaluateRelationFilter(filter: ResolvedFilter): boolean { // compare only the ids. If the left operand is the relation object, get the id const leftValue = diff --git a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts index 9d7ec5e896..85bc8db4e7 100644 --- a/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/__tests__/turnRecordFilterIntoGqlOperationFilter.test.ts @@ -484,6 +484,18 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => { expect(result).toHaveProperty('rating.in'); }); + + it('should handle IS_NOT operand as wrapped eq', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter('f-rating', RecordFilterOperand.IS_NOT, '3'), + fieldMetadataItemById, + }); + + expect(result).toEqual({ + not: { rating: { eq: 'RATING_3' } }, + }); + }); }); describe('BOOLEAN filter', () => { @@ -920,6 +932,24 @@ describe('turnRecordFilterIntoRecordGqlOperationFilter', () => { expect(result).toHaveProperty('recordId.in'); }); + + it('should handle IS_NOT operand as wrapped in', () => { + const result = turnRecordFilterIntoRecordGqlOperationFilter({ + filterValueDependencies, + recordFilter: makeFilter( + 'f-uuid', + RecordFilterOperand.IS_NOT, + '["550e8400-e29b-41d4-a716-446655440000"]', + ), + fieldMetadataItemById, + }); + + expect(result).toEqual({ + not: { + recordId: { in: ['550e8400-e29b-41d4-a716-446655440000'] }, + }, + }); + }); }); describe('relation traversal', () => { diff --git a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts index dbaf96184d..338d6d7dc4 100644 --- a/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/turnRecordFilterIntoGqlOperationFilter.ts @@ -507,6 +507,14 @@ const buildDirectFieldGqlOperationFilter = ({ eq: convertRatingToRatingValue(parseFloat(recordFilter.value)), } as RatingFilter, }; + case RecordFilterOperand.IS_NOT: + return { + not: { + [fieldMetadataItem.name]: { + eq: convertRatingToRatingValue(parseFloat(recordFilter.value)), + } as RatingFilter, + }, + }; case RecordFilterOperand.GREATER_THAN_OR_EQUAL: return { [fieldMetadataItem.name]: { @@ -1525,9 +1533,16 @@ const buildDirectFieldGqlOperationFilter = ({ }; } case 'UUID': { - const recordIds = arrayOfUuidOrVariableSchema.parse(recordFilter.value); + const parsedRecordIds = arrayOfUuidOrVariableSchema.parse( + recordFilter.value, + ); - if (!isDefined(recordIds) || recordIds.length === 0) return; + // Fall back to a sentinel v4 UUID when the input isn't a valid UUID so the + // filter compiles to a guaranteed no-match instead of being silently dropped. + const recordIds = + isDefined(parsedRecordIds) && parsedRecordIds.length > 0 + ? parsedRecordIds + : ['00000000-0000-4000-8000-000000000000']; switch (recordFilter.operand) { case RecordFilterOperand.IS: @@ -1536,6 +1551,14 @@ const buildDirectFieldGqlOperationFilter = ({ in: recordIds, } as UUIDFilter, }; + case RecordFilterOperand.IS_NOT: + return { + not: { + [fieldMetadataItem.name]: { + in: recordIds, + } as UUIDFilter, + }, + }; default: throw new Error( `Unknown operand ${recordFilter.operand} for ${filterType} filter`, diff --git a/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts b/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts index 9ded6c3aed..a4936bedea 100644 --- a/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts +++ b/packages/twenty-shared/src/utils/filter/utils/__tests__/getEmptyRecordGqlOperationFilter.test.ts @@ -97,6 +97,16 @@ describe('getEmptyRecordGqlOperationFilter', () => { }); }); + it('should handle UUID type', () => { + const result = getEmptyRecordGqlOperationFilter( + makeParams(FieldMetadataType.UUID), + ); + + expect(result).toEqual({ + testField: { is: 'NULL' }, + }); + }); + it('should handle CURRENCY type', () => { const result = getEmptyRecordGqlOperationFilter( makeParams(FieldMetadataType.CURRENCY), diff --git a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts index 56d45e6c6c..aabf5639fe 100644 --- a/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts +++ b/packages/twenty-shared/src/utils/filter/utils/getEmptyRecordGqlOperationFilter.ts @@ -15,6 +15,7 @@ import { type RelationFilter, type SelectFilter, type StringFilter, + type UUIDFilter, } from '@/types'; import { CustomError } from '@/utils/errors'; import { computeEmptyGqlOperationFilterForEmails } from '@/utils/filter/computeEmptyGqlOperationFilterForEmails'; @@ -308,6 +309,11 @@ export const getEmptyRecordGqlOperationFilter = ({ [correspondingField.name]: { is: 'NULL' } as SelectFilter, }; break; + case 'UUID': + emptyRecordFilter = { + [correspondingField.name]: { is: 'NULL' } as UUIDFilter, + }; + break; case 'MULTI_SELECT': emptyRecordFilter = { or: [