From bfeaaa56a3f40f3a5fff1b423b9520da3b0b5dd4 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 8 Jul 2026 13:12:08 +0200 Subject: [PATCH] fix(workflow): handle IS/IS_NOT operand in text and array filters (#22640) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Sentry `TWENTY-SERVER-G4F` — `Error: Operand IS not supported for this filter type` (30k+ occurrences, 15 workspaces, ongoing). A workflow **Filter** step throws when a step filter carries an `IS`/`IS_NOT` operand on a text/array field type (`TEXT`, `MULTI_SELECT`, `EMAILS`, `PHONES`, `ADDRESS`, `LINKS`, `FULL_NAME`, `ARRAY`, `RAW_JSON`). `evaluateTextAndArrayFilter` only handled `CONTAINS`/`DOES_NOT_CONTAIN`/`IS_EMPTY`/`IS_NOT_EMPTY` and hit `default:` → `throw`. The throw propagates out of `FilterWorkflowAction` and **fails the entire workflow run**. The current frontend no longer offers `IS`/`IS_NOT` for these types, so these are **legacy persisted step filters** in older (immutable) workflow versions that keep executing. ## Fix Handle `IS`/`IS_NOT` in `evaluateTextAndArrayFilter` as `contains`/`!contains`, consistent with `evaluateSelectFilter` (chosen over strict equality because the routed types include arrays/composites where `==` would silently never match). No existing operand behavior changes. ## Tests Added coverage for legacy `IS`/`IS_NOT` on `TEXT` and `MULTI_SELECT`. Note: the pre-existing `date operands` test failures are timezone-dependent and unrelated to this change (they fail on `main` too). Fixes TWENTY-SERVER-G4F Review in cubic --- .../evaluate-filter-conditions.util.spec.ts | 94 +++++++++++++++++++ .../utils/evaluate-filter-conditions.util.ts | 5 + 2 files changed, 99 insertions(+) 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 bafe9cceb2..d29fbb4d75 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 @@ -804,6 +804,100 @@ describe('evaluateFilterConditions', () => { expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); expect(evaluateFilterConditions({ filters: [filter3] })).toBe(true); }); + + it('should match legacy Is operand on text by equality', () => { + const matching = createFilter( + ViewFilterOperand.IS, + 'World', + 'World', + 'TEXT', + ); + const substring = createFilter( + ViewFilterOperand.IS, + 'Hello World', + 'World', + 'TEXT', + ); + const notMatching = createFilter( + ViewFilterOperand.IS, + 'Hello', + 'World', + 'TEXT', + ); + + expect(evaluateFilterConditions({ filters: [matching] })).toBe(true); + expect(evaluateFilterConditions({ filters: [substring] })).toBe(false); + expect(evaluateFilterConditions({ filters: [notMatching] })).toBe( + false, + ); + }); + + it('should match legacy IsNot operand on text by inequality', () => { + const matching = createFilter( + ViewFilterOperand.IS_NOT, + 'Hello', + 'World', + 'TEXT', + ); + const notMatching = createFilter( + ViewFilterOperand.IS_NOT, + 'World', + 'World', + 'TEXT', + ); + + expect(evaluateFilterConditions({ filters: [matching] })).toBe(true); + expect(evaluateFilterConditions({ filters: [notMatching] })).toBe( + false, + ); + }); + + it('should match legacy Is operand on arrays by equality', () => { + const matching = createFilter( + ViewFilterOperand.IS, + ['apple', 'banana'], + ['apple', 'banana'], + 'MULTI_SELECT', + ); + const subset = createFilter( + ViewFilterOperand.IS, + ['apple', 'banana'], + ['apple'], + 'MULTI_SELECT', + ); + const notMatching = createFilter( + ViewFilterOperand.IS, + ['apple', 'banana'], + ['grape'], + 'MULTI_SELECT', + ); + + expect(evaluateFilterConditions({ filters: [matching] })).toBe(true); + expect(evaluateFilterConditions({ filters: [subset] })).toBe(false); + expect(evaluateFilterConditions({ filters: [notMatching] })).toBe( + false, + ); + }); + + it('should match legacy IsNot operand on arrays by set inequality', () => { + const matching = createFilter( + ViewFilterOperand.IS_NOT, + ['apple', 'banana'], + ['grape'], + 'MULTI_SELECT', + ); + const notMatching = createFilter( + ViewFilterOperand.IS_NOT, + ['apple', 'banana'], + ['apple', 'banana'], + 'MULTI_SELECT', + ); + + expect(evaluateFilterConditions({ filters: [matching] })).toBe(true); + expect(evaluateFilterConditions({ filters: [notMatching] })).toBe( + false, + ); + }); }); describe('empty 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 b7db327389..f139bb7de2 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 @@ -4,6 +4,7 @@ import { isObject, isString, } from '@sniptt/guards'; +import isEqual from 'lodash.isequal'; import { Temporal } from 'temporal-polyfill'; import { type StepFilter, @@ -186,6 +187,10 @@ function evaluateTextAndArrayFilter( (isDefined(nullEquivalentRightValue) && isNotEmptyTextOrArray(filter.leftOperand)) ); + case ViewFilterOperand.IS: + return isEqual(filter.leftOperand, filter.rightOperand); + case ViewFilterOperand.IS_NOT: + return !isEqual(filter.leftOperand, filter.rightOperand); case ViewFilterOperand.IS_EMPTY: return !isNotEmptyTextOrArray(filter.leftOperand);