fix(workflow): handle IS/IS_NOT operand in text and array filters (#22640)
## 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 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22640?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+94
@@ -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', () => {
|
||||
|
||||
+5
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user