diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx index 064bf22a28..461d5411c0 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx @@ -5,6 +5,7 @@ import { useWorkflowStepContextOrThrow } from '@/workflow/states/context/Workflo import { stepsOutputSchemaFamilySelector } from '@/workflow/states/selectors/stepsOutputSchemaFamilySelector'; import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext'; +import { getViewFilterOperands } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands'; import { WorkflowVariablesDropdown } from '@/workflow/workflow-variables/components/WorkflowVariablesDropdown'; import { useAvailableVariablesInWorkflowStep } from '@/workflow/workflow-variables/hooks/useAvailableVariablesInWorkflowStep'; import { extractRawVariableNamePart } from '@/workflow/workflow-variables/utils/extractRawVariableNamePart'; @@ -89,6 +90,10 @@ export const WorkflowStepFilterFieldSelect = ({ value: '', fieldMetadataId, compositeFieldSubFieldName, + operand: getViewFilterOperands({ + filterType, + subFieldName: compositeFieldSubFieldName, + })?.[0], }, }); }, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx index 483b8bf218..c35025f23b 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx @@ -18,7 +18,10 @@ export const WorkflowStepFilterOperandSelect = ({ const { readonly } = useContext(WorkflowStepFilterContext); const { upsertStepFilterSettings } = useUpsertStepFilterSettings(); - const operands = getViewFilterOperands({ filterType: stepFilter.type }); + const operands = getViewFilterOperands({ + filterType: stepFilter.type, + subFieldName: stepFilter.compositeFieldSubFieldName, + }); const options = operands.map((operand) => ({ value: operand, diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts index 67a85113aa..c48fe26bb3 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/getStepFilterOperands.ts @@ -76,14 +76,45 @@ export const FILTER_OPERANDS_MAP = { ], }; +export const COMPOSITE_FIELD_FILTER_OPERANDS_MAP = { + CURRENCY: { + currencyCode: [ + ViewFilterOperand.Is, + ViewFilterOperand.IsNot, + ...emptyOperands, + ], + amountMicros: [ + ViewFilterOperand.GreaterThanOrEqual, + ViewFilterOperand.LessThanOrEqual, + ViewFilterOperand.Is, + ViewFilterOperand.IsNot, + ...emptyOperands, + ], + }, +}; + export const getViewFilterOperands = ({ filterType, + subFieldName, }: { - filterType: string; + filterType: string | undefined; + subFieldName: string | undefined; }): readonly ViewFilterOperand[] => { switch (filterType) { case 'TEXT': + case 'EMAILS': + case 'FULL_NAME': + case 'ADDRESS': + case 'LINKS': + case 'PHONES': return FILTER_OPERANDS_MAP.TEXT; + case 'CURRENCY': { + if (subFieldName === 'currencyCode') { + 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': 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 487dacceb5..201c631eb0 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 @@ -17,9 +17,10 @@ describe('evaluateFilterConditions', () => { operand: ViewFilterOperand, leftOperand: unknown, rightOperand: unknown, + type = 'TEXT', ): ResolvedFilter => ({ id: 'filter1', - type: 'text', + type: type, label: 'Test Filter', rightOperand, operand, @@ -46,46 +47,83 @@ describe('evaluateFilterConditions', () => { }); describe('single filter operands', () => { - describe('Is operand', () => { - it('should return true when values are equal', () => { - const filter = createFilter(ViewFilterOperand.Is, 'John', 'John'); + describe('Relation/UUID filter operands', () => { + it('should return true when values are equal (RELATION)', () => { + const filter = createFilter( + ViewFilterOperand.Is, + 'John', + 'John', + 'RELATION', + ); const result = evaluateFilterConditions({ filters: [filter] }); expect(result).toBe(true); }); - it('should return true when values are loosely equal', () => { - const filter = createFilter(ViewFilterOperand.Is, '123', 123); - const result = evaluateFilterConditions({ filters: [filter] }); - - expect(result).toBe(true); - }); - - it('should return false when values are not equal', () => { - const filter = createFilter(ViewFilterOperand.Is, 'John', 'Jane'); + it('should return false when values are not equal (RELATION)', () => { + const filter = createFilter( + ViewFilterOperand.Is, + 'John', + 'Jane', + 'RELATION', + ); const result = evaluateFilterConditions({ filters: [filter] }); expect(result).toBe(false); }); - it('should return true when values are equal but different types', () => { - const filter = createFilter(ViewFilterOperand.Is, '123', 123); + it('should return false when values are equal (IsNot RELATION)', () => { + const filter = createFilter( + ViewFilterOperand.IsNot, + 'John', + 'John', + 'RELATION', + ); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(false); + }); + + it('should return true when values are not equal (IsNot RELATION)', () => { + const filter = createFilter( + ViewFilterOperand.IsNot, + 'John', + 'Jane', + 'RELATION', + ); const result = evaluateFilterConditions({ filters: [filter] }); expect(result).toBe(true); }); }); - describe('IsNot operand', () => { - it('should return false when values are equal', () => { - const filter = createFilter(ViewFilterOperand.IsNot, 'John', 'John'); + describe('Boolean filter operands', () => { + it('should return true when boolean values are equal', () => { + const filter = createFilter( + ViewFilterOperand.Is, + true, + true, + 'BOOLEAN', + ); + const result = evaluateFilterConditions({ filters: [filter] }); + + expect(result).toBe(true); + }); + + it('should return false when boolean values are not equal', () => { + const filter = createFilter( + ViewFilterOperand.Is, + true, + false, + 'BOOLEAN', + ); const result = evaluateFilterConditions({ filters: [filter] }); expect(result).toBe(false); }); - it('should return true when values are not equal', () => { - const filter = createFilter(ViewFilterOperand.IsNot, 'John', 'Jane'); + it('should handle truthy/falsy conversion', () => { + const filter = createFilter(ViewFilterOperand.Is, 1, true, 'BOOLEAN'); const result = evaluateFilterConditions({ filters: [filter] }); expect(result).toBe(true); @@ -98,16 +136,19 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.GreaterThanOrEqual, 25, 25, + 'NUMBER', ); const filter2 = createFilter( ViewFilterOperand.GreaterThanOrEqual, 30, 25, + 'NUMBER', ); const filter3 = createFilter( ViewFilterOperand.GreaterThanOrEqual, 20, 25, + 'NUMBER', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); @@ -116,9 +157,24 @@ describe('evaluateFilterConditions', () => { }); it('should handle LessThanOrEqual operand correctly', () => { - const filter1 = createFilter(ViewFilterOperand.LessThanOrEqual, 25, 25); - const filter2 = createFilter(ViewFilterOperand.LessThanOrEqual, 20, 25); - const filter3 = createFilter(ViewFilterOperand.LessThanOrEqual, 30, 25); + const filter1 = createFilter( + ViewFilterOperand.LessThanOrEqual, + 25, + 25, + 'NUMBER', + ); + const filter2 = createFilter( + ViewFilterOperand.LessThanOrEqual, + 20, + 25, + 'NUMBER', + ); + const filter3 = createFilter( + ViewFilterOperand.LessThanOrEqual, + 30, + 25, + 'NUMBER', + ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); @@ -132,11 +188,13 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.Contains, 'Hello World', 'World', + 'TEXT', ); const filter2 = createFilter( ViewFilterOperand.Contains, 'Hello', 'World', + 'TEXT', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); @@ -148,11 +206,13 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.DoesNotContain, 'Hello World', 'World', + 'TEXT', ); const filter2 = createFilter( ViewFilterOperand.DoesNotContain, 'Hello', 'World', + 'TEXT', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false); @@ -164,11 +224,13 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.Contains, ['apple', 'banana', 'cherry'], ['apple'], + 'ARRAY', ); const filter2 = createFilter( ViewFilterOperand.Contains, ['apple', 'banana', 'cherry'], ['grape'], + 'ARRAY', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); @@ -180,11 +242,13 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.DoesNotContain, ['apple', 'banana', 'cherry'], ['apple'], + 'ARRAY', ); const filter2 = createFilter( ViewFilterOperand.DoesNotContain, ['apple', 'banana', 'cherry'], ['grape'], + 'ARRAY', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(false); @@ -194,14 +258,30 @@ describe('evaluateFilterConditions', () => { describe('empty operands', () => { it('should handle IsEmpty operand correctly', () => { - const filter1 = createFilter(ViewFilterOperand.IsEmpty, null, ''); - const filter2 = createFilter(ViewFilterOperand.IsEmpty, undefined, ''); - const filter3 = createFilter(ViewFilterOperand.IsEmpty, '', ''); - const filter4 = createFilter(ViewFilterOperand.IsEmpty, [], ''); + const filter1 = createFilter( + ViewFilterOperand.IsEmpty, + null, + '', + 'TEXT', + ); + const filter2 = createFilter( + ViewFilterOperand.IsEmpty, + undefined, + '', + 'TEXT', + ); + const filter3 = createFilter(ViewFilterOperand.IsEmpty, '', '', 'TEXT'); + const filter4 = createFilter( + ViewFilterOperand.IsEmpty, + [], + '', + 'ARRAY', + ); const filter5 = createFilter( ViewFilterOperand.IsEmpty, 'not empty', '', + 'TEXT', ); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); @@ -216,15 +296,32 @@ describe('evaluateFilterConditions', () => { ViewFilterOperand.IsNotEmpty, 'not empty', '', + 'TEXT', ); const filter2 = createFilter( ViewFilterOperand.IsNotEmpty, ['item'], '', + 'ARRAY', + ); + const filter3 = createFilter( + ViewFilterOperand.IsNotEmpty, + null, + '', + 'TEXT', + ); + const filter4 = createFilter( + ViewFilterOperand.IsNotEmpty, + '', + '', + 'TEXT', + ); + const filter5 = createFilter( + ViewFilterOperand.IsNotEmpty, + [], + '', + 'ARRAY', ); - const filter3 = createFilter(ViewFilterOperand.IsNotEmpty, null, ''); - const filter4 = createFilter(ViewFilterOperand.IsNotEmpty, '', ''); - const filter5 = createFilter(ViewFilterOperand.IsNotEmpty, [], ''); expect(evaluateFilterConditions({ filters: [filter1] })).toBe(true); expect(evaluateFilterConditions({ filters: [filter2] })).toBe(true); @@ -235,21 +332,193 @@ describe('evaluateFilterConditions', () => { }); describe('date operands', () => { - it('should handle date operands (returning false as placeholder)', () => { - const dateOperands = [ - ViewFilterOperand.IsRelative, + const now = new Date(); + const pastDate = new Date(now.getTime() - 24 * 60 * 60 * 1000); // 1 day ago + const futureDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 1 day from now + const today = new Date(); + + it('should handle IsInPast operand correctly', () => { + const filter = createFilter( ViewFilterOperand.IsInPast, + pastDate, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + + const futureFilter = createFilter( + ViewFilterOperand.IsInPast, + futureDate, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [futureFilter] })).toBe( + false, + ); + }); + + it('should handle IsInFuture operand correctly', () => { + const filter = createFilter( ViewFilterOperand.IsInFuture, + futureDate, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + + const pastFilter = createFilter( + ViewFilterOperand.IsInFuture, + pastDate, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [pastFilter] })).toBe(false); + }); + + it('should handle IsToday operand correctly', () => { + const filter = createFilter( ViewFilterOperand.IsToday, + today, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + + const pastFilter = createFilter( + ViewFilterOperand.IsToday, + pastDate, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [pastFilter] })).toBe(false); + }); + + it('should handle IsBefore operand correctly', () => { + const filter = createFilter( ViewFilterOperand.IsBefore, + pastDate, + now, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + + const futureFilter = createFilter( + ViewFilterOperand.IsBefore, + futureDate, + now, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [futureFilter] })).toBe( + false, + ); + }); + + it('should handle IsAfter operand correctly', () => { + const filter = createFilter( ViewFilterOperand.IsAfter, - ]; + futureDate, + now, + 'DATE', + ); - dateOperands.forEach((operand) => { - const filter = createFilter(operand, new Date(), new Date()); + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); - expect(evaluateFilterConditions({ filters: [filter] })).toBe(false); - }); + const pastFilter = createFilter( + ViewFilterOperand.IsAfter, + pastDate, + now, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [pastFilter] })).toBe(false); + }); + + it('should handle Is operand for dates correctly', () => { + const sameDate1 = new Date('2023-01-15'); + const sameDate2 = new Date('2023-01-15'); + const filter = createFilter( + ViewFilterOperand.Is, + sameDate1, + sameDate2, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + + const differentFilter = createFilter( + ViewFilterOperand.Is, + sameDate1, + now, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [differentFilter] })).toBe( + false, + ); + }); + + it('should handle date IsEmpty and IsNotEmpty operands', () => { + const emptyFilter = createFilter( + ViewFilterOperand.IsEmpty, + null, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [emptyFilter] })).toBe(true); + + const notEmptyFilter = createFilter( + ViewFilterOperand.IsNotEmpty, + now, + null, + 'DATE', + ); + + expect(evaluateFilterConditions({ filters: [notEmptyFilter] })).toBe( + true, + ); + }); + }); + + describe('currency operands', () => { + it('should handle currency code operands', () => { + const filter: ResolvedFilter = { + id: 'filter1', + type: 'CURRENCY', + label: 'Currency Filter', + rightOperand: 'USD', + operand: ViewFilterOperand.Is, + displayValue: 'USD', + stepFilterGroupId: 'group1', + leftOperand: 'USD', + compositeFieldSubFieldName: 'currencyCode', + }; + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); + }); + + it('should handle currency amount operands', () => { + const filter: ResolvedFilter = { + id: 'filter1', + type: 'CURRENCY', + label: 'Currency Filter', + rightOperand: 100, + operand: ViewFilterOperand.GreaterThanOrEqual, + displayValue: '100', + stepFilterGroupId: 'group1', + leftOperand: 150, + compositeFieldSubFieldName: 'amountMicros', + }; + + expect(evaluateFilterConditions({ filters: [filter] })).toBe(true); }); }); @@ -259,10 +528,24 @@ describe('evaluateFilterConditions', () => { 'unknown' as ViewFilterOperand, 'value', 'value', + 'TEXT', ); expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow(); }); + + it('should throw error for unsupported filter type', () => { + const filter = createFilter( + ViewFilterOperand.Contains, + 'value', + 'value', + 'UNSUPPORTED_TYPE', + ); + + expect(() => evaluateFilterConditions({ filters: [filter] })).toThrow( + 'Filter type UNSUPPORTED_TYPE not supported', + ); + }); }); }); @@ -271,7 +554,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -281,7 +564,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -300,7 +583,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -310,7 +593,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -339,7 +622,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -349,7 +632,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -375,7 +658,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -385,7 +668,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -413,7 +696,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -423,7 +706,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -449,7 +732,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -459,7 +742,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -491,7 +774,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -501,7 +784,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'number', + type: 'NUMBER', label: 'Age Filter', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -541,7 +824,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Filter 1', rightOperand: 'John', operand: ViewFilterOperand.Is, @@ -551,7 +834,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter2', - type: 'text', + type: 'RELATION', label: 'Filter 2', rightOperand: 'Smith', operand: ViewFilterOperand.Is, @@ -561,7 +844,7 @@ describe('evaluateFilterConditions', () => { }, { id: 'filter3', - type: 'number', + type: 'NUMBER', label: 'Filter 3', rightOperand: 25, operand: ViewFilterOperand.GreaterThanOrEqual, @@ -604,7 +887,7 @@ describe('evaluateFilterConditions', () => { const filters: ResolvedFilter[] = [ { id: 'filter1', - type: 'text', + type: 'RELATION', label: 'Name Filter', rightOperand: 'John', operand: ViewFilterOperand.Is, 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 771c6fd01d..9a0c25ffa9 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 @@ -1,9 +1,9 @@ +import { isString } from '@sniptt/guards'; import { StepFilter, StepFilterGroup, ViewFilterOperand, } from 'twenty-shared/types'; -import { assertUnreachable } from 'twenty-shared/utils'; type ResolvedFilter = Omit & { rightOperand: unknown; @@ -11,129 +11,33 @@ type ResolvedFilter = Omit & { }; function evaluateFilter(filter: ResolvedFilter): boolean { - const leftValue = filter.leftOperand; - const rightValue = filter.rightOperand; - - switch (filter.operand) { - case ViewFilterOperand.Is: - switch (typeof leftValue) { - case 'string': - return ( - String(leftValue).toLowerCase() === String(rightValue).toLowerCase() - ); - case 'boolean': - return Boolean(leftValue) === Boolean(rightValue); - default: - return leftValue === rightValue; - } - - case ViewFilterOperand.IsNot: - return String(leftValue) !== String(rightValue); - - case ViewFilterOperand.GreaterThanOrEqual: - return Number(leftValue) >= Number(rightValue); - - case ViewFilterOperand.LessThanOrEqual: - return Number(leftValue) <= Number(rightValue); - - case ViewFilterOperand.Contains: - if (Array.isArray(leftValue)) { - try { - const parsedRightValue = Array.isArray(rightValue) - ? rightValue - : JSON.parse(rightValue as string); - - if (Array.isArray(parsedRightValue)) { - return parsedRightValue.every((item) => leftValue.includes(item)); - } else { - return leftValue.includes(parsedRightValue); - } - } catch (error) { - return leftValue.includes(rightValue); - } - } - - return String(leftValue).includes(String(rightValue)); - - case ViewFilterOperand.DoesNotContain: - if (Array.isArray(leftValue)) { - try { - const parsedRightValue = Array.isArray(rightValue) - ? rightValue - : JSON.parse(rightValue as string); - - if (Array.isArray(parsedRightValue)) { - return !parsedRightValue.every((item) => leftValue.includes(item)); - } else { - return !leftValue.includes(parsedRightValue); - } - } catch (error) { - return !leftValue.includes(rightValue); - } - } - - return !String(leftValue).includes(String(rightValue)); - - case ViewFilterOperand.IsEmpty: - return ( - leftValue === null || - leftValue === undefined || - leftValue === '' || - (Array.isArray(leftValue) && leftValue.length === 0) - ); - - case ViewFilterOperand.IsNotEmpty: - return ( - leftValue !== null && - leftValue !== undefined && - leftValue !== '' && - (!Array.isArray(leftValue) || leftValue.length > 0) - ); - - case ViewFilterOperand.IsNotNull: - return leftValue !== null && leftValue !== undefined; - - case ViewFilterOperand.IsInPast: - if (typeof leftValue === 'string') { - return Date.now() - new Date(leftValue).getTime() > 0; - } - - return false; - - case ViewFilterOperand.IsInFuture: - if (typeof leftValue === 'string') { - return Date.now() - new Date(leftValue).getTime() < 0; - } - - return false; - - case ViewFilterOperand.IsToday: - if (typeof leftValue === 'string') { - return new Date(leftValue).toDateString() === new Date().toDateString(); - } - - return false; - - case ViewFilterOperand.IsBefore: - if (typeof leftValue === 'string' && typeof rightValue === 'string') { - return new Date(leftValue).getTime() < new Date(rightValue).getTime(); - } - - return false; - - case ViewFilterOperand.IsAfter: - if (typeof leftValue === 'string' && typeof rightValue === 'string') { - return new Date(leftValue).getTime() > new Date(rightValue).getTime(); - } - - return false; - - case ViewFilterOperand.VectorSearch: - case ViewFilterOperand.IsRelative: - return false; - + switch (filter.type) { + case 'NUMBER': + case 'NUMERIC': + return evaluateNumberFilter(filter); + case 'DATE': + case 'DATE_TIME': + return evaluateDateFilter(filter); + case 'TEXT': + case 'SELECT': + case 'MULTI_SELECT': + case 'FULL_NAME': + case 'EMAILS': + case 'PHONES': + case 'ADDRESS': + case 'LINKS': + case 'ARRAY': + case 'RAW_JSON': + return evaluateTextAndArrayFilter(filter); + case 'BOOLEAN': + return evaluateBooleanFilter(filter); + case 'UUID': + case 'RELATION': + return evaluateRelationFilter(filter); + case 'CURRENCY': + return evaluateCurrencyFilter(filter); default: - assertUnreachable(filter.operand); + throw new Error(`Filter type ${filter.type} not supported`); } } @@ -181,6 +85,180 @@ function evaluateFilterGroup( } } +function contains(leftValue: unknown, rightValue: unknown): boolean { + // if two arrays, check if any item is in the other array + if (Array.isArray(leftValue) && Array.isArray(rightValue)) { + return leftValue.some((item) => rightValue.includes(item)); + } + + if ( + (Array.isArray(leftValue) || isString(leftValue)) && + isString(rightValue) + ) { + try { + const parsedRightValue = JSON.parse(rightValue as string); + + if (Array.isArray(parsedRightValue)) { + return parsedRightValue.some((item) => leftValue.includes(item)); + } else { + return leftValue.includes(parsedRightValue); + } + } catch (error) { + return leftValue.includes(rightValue); + } + } + + return String(leftValue).includes(String(rightValue)); +} + +function evaluateTextAndArrayFilter(filter: ResolvedFilter): boolean { + switch (filter.operand) { + case ViewFilterOperand.Contains: + return contains(filter.leftOperand, filter.rightOperand); + case ViewFilterOperand.DoesNotContain: + return !contains(filter.leftOperand, filter.rightOperand); + case ViewFilterOperand.IsEmpty: + return ( + filter.leftOperand === null || + filter.leftOperand === undefined || + filter.leftOperand === '' || + (Array.isArray(filter.leftOperand) && filter.leftOperand.length === 0) + ); + + case ViewFilterOperand.IsNotEmpty: + return ( + filter.leftOperand !== null && + filter.leftOperand !== undefined && + filter.leftOperand !== '' && + (!Array.isArray(filter.leftOperand) || filter.leftOperand.length > 0) + ); + default: + throw new Error( + `Operand ${filter.operand} not supported for this filter type`, + ); + } +} + +function evaluateBooleanFilter(filter: ResolvedFilter): boolean { + switch (filter.operand) { + case ViewFilterOperand.Is: + return Boolean(filter.leftOperand) === Boolean(filter.rightOperand); + default: + throw new Error( + `Operand ${filter.operand} not supported for boolean filter`, + ); + } +} + +function evaluateDateFilter(filter: ResolvedFilter): boolean { + const dateLeftValue = new Date(String(filter.leftOperand)); + const dateRightValue = new Date(String(filter.rightOperand)); + + switch (filter.operand) { + case ViewFilterOperand.Is: + return dateLeftValue.getDate() === dateRightValue.getDate(); + case ViewFilterOperand.IsInPast: + return dateLeftValue.getTime() < Date.now(); + + case ViewFilterOperand.IsInFuture: + return dateLeftValue.getTime() > Date.now(); + + case ViewFilterOperand.IsToday: + return dateLeftValue.toDateString() === new Date().toDateString(); + + case ViewFilterOperand.IsBefore: + return dateLeftValue.getTime() < dateRightValue.getTime(); + + case ViewFilterOperand.IsAfter: + return dateLeftValue.getTime() > dateRightValue.getTime(); + + case ViewFilterOperand.IsEmpty: + return ( + filter.leftOperand === null || + filter.leftOperand === undefined || + filter.leftOperand === '' + ); + + case ViewFilterOperand.IsNotEmpty: + return ( + filter.leftOperand !== null && + filter.leftOperand !== undefined && + filter.leftOperand !== '' + ); + + default: + throw new Error( + `Operand ${filter.operand} not supported for date filter`, + ); + } +} + +function evaluateRelationFilter(filter: ResolvedFilter): boolean { + switch (filter.operand) { + case ViewFilterOperand.Is: + return filter.leftOperand === filter.rightOperand; + case ViewFilterOperand.IsNot: + return filter.leftOperand !== filter.rightOperand; + default: + throw new Error( + `Operand ${filter.operand} not supported for relation filter`, + ); + } +} + +function evaluateCurrencyFilter(filter: ResolvedFilter): boolean { + if (filter.compositeFieldSubFieldName === 'currencyCode') { + switch (filter.operand) { + case ViewFilterOperand.Is: + return filter.leftOperand === filter.rightOperand; + case ViewFilterOperand.IsNot: + return filter.leftOperand !== filter.rightOperand; + case ViewFilterOperand.IsEmpty: + return ( + filter.leftOperand === null || + filter.leftOperand === undefined || + filter.leftOperand === '' + ); + case ViewFilterOperand.IsNotEmpty: + return ( + filter.leftOperand !== null && + filter.leftOperand !== undefined && + filter.leftOperand !== '' + ); + default: + throw new Error( + `Operand ${filter.operand} not supported for currency filter`, + ); + } + } else { + return evaluateNumberFilter(filter); + } +} + +function evaluateNumberFilter(filter: ResolvedFilter): boolean { + const leftValue = filter.leftOperand; + const rightValue = filter.rightOperand; + + switch (filter.operand) { + case ViewFilterOperand.GreaterThanOrEqual: + return Number(leftValue) >= Number(rightValue); + + case ViewFilterOperand.LessThanOrEqual: + return Number(leftValue) <= Number(rightValue); + + case ViewFilterOperand.IsEmpty: + return leftValue === null || leftValue === undefined || leftValue === ''; + + case ViewFilterOperand.IsNotEmpty: + return leftValue !== null && leftValue !== undefined && leftValue !== ''; + + default: + throw new Error( + `Operand ${filter.operand} not supported for number filter`, + ); + } +} + export function evaluateFilterConditions({ filterGroups = [], filters = [],