From 6e79339e6458338ef6de69c5958c38f680407d55 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Wed, 9 Jul 2025 13:52:07 +0200 Subject: [PATCH] Add first filter step version (#13093) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I rebuilt the advanced filters used in views and workflow search for a specific filter step. Components structure remains the same, using `stepFilterGroups` and `stepFilters`. But those filters are directly sent to backend. Also re-using the same kind of states we use for advanced filters to share the current filters used. And a context to share what's coming from workflow props (function to update step settings and readonly) ⚠️ this PR only focusses on the content of the step. There is still a lot to do on the filter icon behavior in the workflow https://github.com/user-attachments/assets/8a6a76f0-11fa-444a-82b9-71fc96b18af4 --- .../utils/__tests__/findStepPosition.test.ts | 152 ++++++++ .../getStepDefinitionOrThrow.test.ts | 140 ++++++++ .../getStepOutputSchemaFamilyStateKey.test.ts | 50 +++ ...kflowVisualizerComponentInstanceId.test.ts | 35 ++ .../splitWorkflowTriggerEventName.test.ts | 102 ++++++ .../validation-schemas/workflowSchema.ts | 3 +- .../WorkflowDiagramEdgeV2Content.tsx | 30 +- .../utils/__tests__/addEdgeOptions.test.ts | 136 +++++++ .../__tests__/getWorkflowNodeIconKey.test.ts | 136 +++++++ .../utils/__tests__/isCreateStepNode.test.ts | 132 +++++++ .../utils/__tests__/isStepNode.test.ts | 84 +++++ .../components/WorkflowStepDetail.tsx | 9 +- .../getWorkflowPreviousStepId.test.ts | 239 ++++++++++++ .../components/WorkflowEditActionFilter.tsx | 87 +++++ .../WorkflowEditActionFilterBody.tsx | 117 ++++++ .../WorkflowEditActionFilterBodyEffect.tsx | 74 ++++ .../WorkflowStepFilterAddFilterRuleSelect.tsx | 144 ++++++++ ...kflowStepFilterAddRootStepFilterButton.tsx | 25 ++ .../components/WorkflowStepFilterColumn.tsx | 48 +++ .../WorkflowStepFilterFieldSelect.tsx | 79 ++++ .../WorkflowStepFilterGroupChildren.tsx | 61 ++++ .../WorkflowStepFilterGroupColumn.tsx | 46 +++ ...WorkflowStepFilterGroupOptionsDropdown.tsx | 49 +++ .../WorkflowStepFilterLogicalOperatorCell.tsx | 93 +++++ .../WorkflowStepFilterOperandSelect.tsx | 54 +++ .../WorkflowStepFilterOptionsDropdown.tsx | 50 +++ .../WorkflowStepFilterValueInput.tsx | 39 ++ .../WorkflowEditActionFilter.stories.tsx | 108 ++++++ .../WorkflowEditActionFilterBody.stories.tsx | 71 ++++ ...wStepFilterAddFilterRuleSelect.stories.tsx | 48 +++ ...pFilterAddRootStepFilterButton.stories.tsx | 39 ++ .../WorkflowStepFilterColumn.stories.tsx | 58 +++ .../WorkflowStepFilterFieldSelect.stories.tsx | 46 +++ ...wStepFilterLogicalOperatorCell.stories.tsx | 67 ++++ ...orkflowStepFilterOperandSelect.stories.tsx | 99 +++++ .../WorkflowStepFilterValueInput.stories.tsx | 76 ++++ .../WorkflowStepFilterDecorator.tsx | 23 ++ .../hooks/useAddRootStepFilter.ts | 84 +++++ ...hildStepFiltersAndChildStepFilterGroups.ts | 66 ++++ .../hooks/useRemoveStepFilter.ts | 106 ++++++ .../hooks/useRemoveStepFilterGroup.ts | 78 ++++ .../hooks/useUpsertStepFilterSettings.ts | 83 +++++ ...tepFilterGroupsComponentInstanceContext.ts | 4 + .../StepFiltersComponentInstanceContext.ts | 4 + .../context/WorkflowStepFilterContext.ts | 13 + .../currentStepFilterGroupsComponentState.ts | 11 + .../currentStepFiltersComponentState.ts | 11 + ...entStepFilterGroupsComponentFamilyState.ts | 9 + ...dCurrentStepFiltersComponentFamilyState.ts | 9 + ...otLevelStepFilterGroupComponentSelector.ts | 24 ++ .../isStepFilterGroupChildAStepFilterGroup.ts | 10 + .../getActionIconColorOrThrow.test.ts | 340 +++++++++++++----- .../utils/getActionHeaderTypeOrThrow.ts | 4 +- .../workflow-actions/utils/getActionIcon.ts | 2 + .../utils/getActionIconColorOrThrow.ts | 6 +- .../components/WorkflowVariablesDropdown.tsx | 16 +- ...workflow-version-step.workspace-service.ts | 4 +- .../types/workflow-action-output.type.ts | 1 + .../filter/filter.workflow-action.ts | 18 +- .../workflow-filter-action-settings.type.ts | 42 +-- .../evaluate-filter-conditions.util.spec.ts | 328 ++++++++--------- .../utils/evaluate-filter-conditions.util.ts | 24 +- .../utils/get-previous-step-output.util.ts | 50 --- .../workflow-executor.workspace-service.ts | 11 +- .../types/ObjectRecordsPermissionsByRoleId.ts | 2 +- .../twenty-shared/src/types/StepFilters.ts | 36 ++ packages/twenty-shared/src/types/index.ts | 2 + 67 files changed, 3851 insertions(+), 396 deletions(-) create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/findStepPosition.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/getStepDefinitionOrThrow.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/getStepOutputSchemaFamilyStateKey.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVisualizerComponentInstanceId.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/utils/__tests__/splitWorkflowTriggerEventName.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addEdgeOptions.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousStepId.test.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterColumn.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterFieldSelect.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupChildren.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupColumn.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupOptionsDropdown.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterLogicalOperatorCell.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOperandSelect.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterOptionsDropdown.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterValueInput.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilter.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowEditActionFilterBody.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterAddFilterRuleSelect.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterAddRootStepFilterButton.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterColumn.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterFieldSelect.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterLogicalOperatorCell.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterOperandSelect.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/__stories__/WorkflowStepFilterValueInput.stories.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/decorators/WorkflowStepFilterDecorator.tsx create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/hooks/useAddRootStepFilter.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/hooks/useRemoveStepFilter.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/hooks/useRemoveStepFilterGroup.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFiltersComponentFamilyState.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/states/rootLevelStepFilterGroupComponentSelector.ts create mode 100644 packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/utils/isStepFilterGroupChildAStepFilterGroup.ts delete mode 100644 packages/twenty-server/src/modules/workflow/workflow-executor/workflow-actions/filter/utils/get-previous-step-output.util.ts create mode 100644 packages/twenty-shared/src/types/StepFilters.ts diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/findStepPosition.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/findStepPosition.test.ts new file mode 100644 index 000000000..ade6e0c8e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/findStepPosition.test.ts @@ -0,0 +1,152 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; +import { findStepPosition } from '../findStepPosition'; + +describe('findStepPosition', () => { + const mockSteps: WorkflowStep[] = [ + { + id: 'step-1', + name: 'First Step', + type: 'CREATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-2', + name: 'Second Step', + type: 'UPDATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + objectRecordId: 'test-id', + fieldsToUpdate: ['name'], + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-3', + name: 'Third Step', + type: 'DELETE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecordId: 'test-id', + }, + outputSchema: {}, + }, + } as WorkflowStep, + ]; + + it('should return index 0 when stepId is undefined', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: undefined, + }); + + expect(result).toEqual({ + steps: mockSteps, + index: 0, + }); + }); + + it('should return index 0 when stepId is TRIGGER_STEP_ID', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: TRIGGER_STEP_ID, + }); + + expect(result).toEqual({ + steps: mockSteps, + index: 0, + }); + }); + + it('should find the correct position for an existing step', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: 'step-2', + }); + + expect(result).toEqual({ + steps: mockSteps, + index: 1, + }); + }); + + it('should find the first step position', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: 'step-1', + }); + + expect(result).toEqual({ + steps: mockSteps, + index: 0, + }); + }); + + it('should find the last step position', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: 'step-3', + }); + + expect(result).toEqual({ + steps: mockSteps, + index: 2, + }); + }); + + it('should return undefined for non-existent stepId', () => { + const result = findStepPosition({ + steps: mockSteps, + stepId: 'non-existent-step', + }); + + expect(result).toBeUndefined(); + }); + + it('should work with empty steps array', () => { + const result = findStepPosition({ + steps: [], + stepId: 'step-1', + }); + + expect(result).toBeUndefined(); + }); + + it('should work with single step array', () => { + const singleStep = [mockSteps[0]]; + const result = findStepPosition({ + steps: singleStep, + stepId: 'step-1', + }); + + expect(result).toEqual({ + steps: singleStep, + index: 0, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepDefinitionOrThrow.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepDefinitionOrThrow.test.ts new file mode 100644 index 000000000..3448f2dff --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepDefinitionOrThrow.test.ts @@ -0,0 +1,140 @@ +import { WorkflowAction, WorkflowTrigger } from '@/workflow/types/Workflow'; +import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; +import { getStepDefinitionOrThrow } from '../getStepDefinitionOrThrow'; + +describe('getStepDefinitionOrThrow', () => { + const mockTrigger: WorkflowTrigger = { + type: 'DATABASE_EVENT', + settings: { + eventName: 'company.created', + outputSchema: {}, + }, + }; + + const mockSteps: WorkflowAction[] = [ + { + id: 'step-1', + name: 'Create Record', + type: 'CREATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + }, + outputSchema: {}, + }, + } as WorkflowAction, + { + id: 'step-2', + name: 'Update Record', + type: 'UPDATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + objectRecordId: 'test-id', + fieldsToUpdate: ['name'], + }, + outputSchema: {}, + }, + } as WorkflowAction, + ]; + + describe('when stepId is TRIGGER_STEP_ID', () => { + it('should return trigger definition when trigger is provided', () => { + const result = getStepDefinitionOrThrow({ + stepId: TRIGGER_STEP_ID, + trigger: mockTrigger, + steps: mockSteps, + }); + + expect(result).toEqual({ + type: 'trigger', + definition: mockTrigger, + }); + }); + + it('should return undefined trigger definition when trigger is null', () => { + const result = getStepDefinitionOrThrow({ + stepId: TRIGGER_STEP_ID, + trigger: null, + steps: mockSteps, + }); + + expect(result).toEqual({ + type: 'trigger', + definition: undefined, + }); + }); + }); + + describe('when stepId is not TRIGGER_STEP_ID', () => { + it('should throw error when steps is null', () => { + expect(() => { + getStepDefinitionOrThrow({ + stepId: 'step-1', + trigger: mockTrigger, + steps: null, + }); + }).toThrow( + 'Malformed workflow version: missing steps information; be sure to create at least one step before trying to edit one', + ); + }); + + it('should return action definition for existing step', () => { + const result = getStepDefinitionOrThrow({ + stepId: 'step-1', + trigger: mockTrigger, + steps: mockSteps, + }); + + expect(result).toEqual({ + type: 'action', + definition: mockSteps[0], + }); + }); + + it('should return action definition for second step', () => { + const result = getStepDefinitionOrThrow({ + stepId: 'step-2', + trigger: mockTrigger, + steps: mockSteps, + }); + + expect(result).toEqual({ + type: 'action', + definition: mockSteps[1], + }); + }); + + it('should return undefined for non-existent step', () => { + const result = getStepDefinitionOrThrow({ + stepId: 'non-existent-step', + trigger: mockTrigger, + steps: mockSteps, + }); + + expect(result).toBeUndefined(); + }); + + it('should work with empty steps array', () => { + const result = getStepDefinitionOrThrow({ + stepId: 'step-1', + trigger: mockTrigger, + steps: [], + }); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepOutputSchemaFamilyStateKey.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepOutputSchemaFamilyStateKey.test.ts new file mode 100644 index 000000000..fd13a0526 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/getStepOutputSchemaFamilyStateKey.test.ts @@ -0,0 +1,50 @@ +import { getStepOutputSchemaFamilyStateKey } from '../getStepOutputSchemaFamilyStateKey'; + +describe('getStepOutputSchemaFamilyStateKey', () => { + it('should concatenate workflowVersionId and stepId with a dash', () => { + const workflowVersionId = 'workflow-version-123'; + const stepId = 'step-456'; + + const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId); + + expect(result).toBe('workflow-version-123-step-456'); + }); + + it('should handle empty strings', () => { + const workflowVersionId = ''; + const stepId = ''; + + const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId); + + expect(result).toBe('-'); + }); + + it('should handle UUID format IDs', () => { + const workflowVersionId = '550e8400-e29b-41d4-a716-446655440000'; + const stepId = '6ba7b810-9dad-11d1-80b4-00c04fd430c8'; + + const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId); + + expect(result).toBe( + '550e8400-e29b-41d4-a716-446655440000-6ba7b810-9dad-11d1-80b4-00c04fd430c8', + ); + }); + + it('should handle special characters in IDs', () => { + const workflowVersionId = 'workflow_version.123'; + const stepId = 'step@456'; + + const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId); + + expect(result).toBe('workflow_version.123-step@456'); + }); + + it('should handle numeric IDs', () => { + const workflowVersionId = '123'; + const stepId = '456'; + + const result = getStepOutputSchemaFamilyStateKey(workflowVersionId, stepId); + + expect(result).toBe('123-456'); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVisualizerComponentInstanceId.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVisualizerComponentInstanceId.test.ts new file mode 100644 index 000000000..cbf4a1c53 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/getWorkflowVisualizerComponentInstanceId.test.ts @@ -0,0 +1,35 @@ +import { getWorkflowVisualizerComponentInstanceId } from '../getWorkflowVisualizerComponentInstanceId'; + +describe('getWorkflowVisualizerComponentInstanceId', () => { + it('should return the same recordId that was passed in', () => { + const recordId = 'test-record-id-123'; + + const result = getWorkflowVisualizerComponentInstanceId({ recordId }); + + expect(result).toBe(recordId); + }); + + it('should return empty string when recordId is empty', () => { + const recordId = ''; + + const result = getWorkflowVisualizerComponentInstanceId({ recordId }); + + expect(result).toBe(''); + }); + + it('should handle UUID format recordIds', () => { + const recordId = '550e8400-e29b-41d4-a716-446655440000'; + + const result = getWorkflowVisualizerComponentInstanceId({ recordId }); + + expect(result).toBe(recordId); + }); + + it('should handle special characters in recordId', () => { + const recordId = 'test-record-id_with.special@chars'; + + const result = getWorkflowVisualizerComponentInstanceId({ recordId }); + + expect(result).toBe(recordId); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/utils/__tests__/splitWorkflowTriggerEventName.test.ts b/packages/twenty-front/src/modules/workflow/utils/__tests__/splitWorkflowTriggerEventName.test.ts new file mode 100644 index 000000000..64bfd4324 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/utils/__tests__/splitWorkflowTriggerEventName.test.ts @@ -0,0 +1,102 @@ +import { splitWorkflowTriggerEventName } from '../splitWorkflowTriggerEventName'; + +describe('splitWorkflowTriggerEventName', () => { + it('should split a basic event name into objectType and event', () => { + const eventName = 'company.created'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'company', + event: 'created', + }); + }); + + it('should split event name with updated event', () => { + const eventName = 'person.updated'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'person', + event: 'updated', + }); + }); + + it('should split event name with deleted event', () => { + const eventName = 'opportunity.deleted'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'opportunity', + event: 'deleted', + }); + }); + + it('should handle camelCase object types', () => { + const eventName = 'activityTarget.created'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'activityTarget', + event: 'created', + }); + }); + + it('should handle event names with underscores', () => { + const eventName = 'custom_object.field_updated'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'custom_object', + event: 'field_updated', + }); + }); + + it('should handle event names without dots', () => { + const eventName = 'invalidEventName'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'invalidEventName', + event: undefined, + }); + }); + + it('should handle empty string', () => { + const eventName = ''; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: '', + event: undefined, + }); + }); + + it('should handle event name starting with dot', () => { + const eventName = '.created'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: '', + event: 'created', + }); + }); + + it('should handle event name ending with dot', () => { + const eventName = 'company.'; + + const result = splitWorkflowTriggerEventName(eventName); + + expect(result).toEqual({ + objectType: 'company', + event: '', + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts index 078e21421..a7df039c5 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -141,7 +141,8 @@ export const workflowAiAgentActionSettingsSchema = export const workflowFilterActionSettingsSchema = baseWorkflowActionSettingsSchema.extend({ input: z.object({ - filter: z.record(z.any()), + stepFilterGroups: z.array(z.any()), + stepFilters: z.array(z.any()), }), }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content.tsx index 1bd52ddad..00c7ce9a6 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content.tsx @@ -1,3 +1,4 @@ +import { useWorkflowCommandMenu } from '@/command-menu/hooks/useWorkflowCommandMenu'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -7,8 +8,10 @@ import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { workflowVisualizerWorkflowIdComponentState } from '@/workflow/states/workflowVisualizerWorkflowIdComponentState'; import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState'; +import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { css } from '@emotion/react'; import styled from '@emotion/styled'; @@ -99,6 +102,12 @@ export const WorkflowDiagramEdgeV2Content = ({ dropdownId, ); + const workflowVisualizerWorkflowId = useRecoilComponentValueV2( + workflowVisualizerWorkflowIdComponentState, + ); + + const { openWorkflowEditStepInCommandMenu } = useWorkflowCommandMenu(); + const handleCreateFilter = async () => { await onCreateFilter(); @@ -106,6 +115,23 @@ export const WorkflowDiagramEdgeV2Content = ({ setHovered(false); }; + const setWorkflowSelectedNode = useSetRecoilComponentStateV2( + workflowSelectedNodeComponentState, + ); + + const handleFilterButtonClick = () => { + setWorkflowSelectedNode(stepId); + if (isDefined(filter) && isDefined(workflowVisualizerWorkflowId)) { + openWorkflowEditStepInCommandMenu( + workflowVisualizerWorkflowId, + 'Filter', + IconFilter, + ); + } else { + handleCreateFilter(); + } + }; + return ( { - handleCreateFilter(); - }, + onClick: handleFilterButtonClick, }, { Icon: IconDotsVertical, diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addEdgeOptions.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addEdgeOptions.test.ts new file mode 100644 index 000000000..efe2dc1bc --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/addEdgeOptions.test.ts @@ -0,0 +1,136 @@ +import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { addEdgeOptions } from '../addEdgeOptions'; + +describe('addEdgeOptions', () => { + it('should add shouldDisplayEdgeOptions to all edges', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Company Created', + icon: 'IconPlus', + }, + }, + { + id: 'action-1', + position: { x: 0, y: 100 }, + data: { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Create Company', + }, + }, + ], + edges: [ + { + id: 'edge-1', + source: 'trigger', + target: 'action-1', + data: { + shouldDisplayEdgeOptions: true, + }, + }, + { + id: 'edge-2', + source: 'action-1', + target: 'action-2', + data: {}, + }, + ], + }; + + const result = addEdgeOptions(diagram); + + expect(result.nodes).toEqual(diagram.nodes); + expect(result.edges).toHaveLength(2); + + expect(result.edges[0]).toEqual({ + id: 'edge-1', + source: 'trigger', + target: 'action-1', + data: { + shouldDisplayEdgeOptions: true, + }, + }); + + expect(result.edges[1]).toEqual({ + id: 'edge-2', + source: 'action-1', + target: 'action-2', + data: { + shouldDisplayEdgeOptions: true, + }, + }); + }); + + it('should handle empty edges array', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Company Created', + icon: 'IconPlus', + }, + }, + ], + edges: [], + }; + + const result = addEdgeOptions(diagram); + + expect(result.nodes).toEqual(diagram.nodes); + expect(result.edges).toEqual([]); + }); + + it('should handle edges without existing data property', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'trigger', + triggerType: 'MANUAL', + name: 'Manual Trigger', + icon: 'IconClick', + }, + }, + { + id: 'action-1', + position: { x: 0, y: 100 }, + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Send Email', + }, + }, + ], + edges: [ + { + id: 'edge-1', + source: 'trigger', + target: 'action-1', + } as any, + ], + }; + + const result = addEdgeOptions(diagram); + + expect(result.edges[0]).toEqual({ + id: 'edge-1', + source: 'trigger', + target: 'action-1', + data: { + shouldDisplayEdgeOptions: true, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts new file mode 100644 index 000000000..777597d99 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/getWorkflowNodeIconKey.test.ts @@ -0,0 +1,136 @@ +import { WorkflowDiagramStepNodeData } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { getWorkflowNodeIconKey } from '../getWorkflowNodeIconKey'; + +// Mock the getActionIcon function +jest.mock( + '@/workflow/workflow-steps/workflow-actions/utils/getActionIcon', + () => ({ + getActionIcon: jest.fn((actionType) => { + const mockIcons = { + CREATE_RECORD: 'IconPlus', + UPDATE_RECORD: 'IconEdit', + DELETE_RECORD: 'IconTrash', + SEND_EMAIL: 'IconMail', + FILTER: 'IconFilter', + }; + return mockIcons[actionType as keyof typeof mockIcons] || 'IconQuestion'; + }), + }), +); + +describe('getWorkflowNodeIconKey', () => { + describe('trigger nodes', () => { + it('should return the icon from trigger node data', () => { + const triggerNodeData: WorkflowDiagramStepNodeData = { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Company Created', + icon: 'IconDatabase', + }; + + const result = getWorkflowNodeIconKey(triggerNodeData); + + expect(result).toBe('IconDatabase'); + }); + + it('should return icon for manual trigger', () => { + const manualTriggerData: WorkflowDiagramStepNodeData = { + nodeType: 'trigger', + triggerType: 'MANUAL', + name: 'Manual Trigger', + icon: 'IconClick', + }; + + const result = getWorkflowNodeIconKey(manualTriggerData); + + expect(result).toBe('IconClick'); + }); + + it('should return icon for webhook trigger', () => { + const webhookTriggerData: WorkflowDiagramStepNodeData = { + nodeType: 'trigger', + triggerType: 'WEBHOOK', + name: 'Webhook Trigger', + icon: 'IconWebhook', + }; + + const result = getWorkflowNodeIconKey(webhookTriggerData); + + expect(result).toBe('IconWebhook'); + }); + }); + + describe('action nodes', () => { + it('should return icon for CREATE_RECORD action', () => { + const createActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Create Company', + }; + + const result = getWorkflowNodeIconKey(createActionData); + + expect(result).toBe('IconPlus'); + }); + + it('should return icon for UPDATE_RECORD action', () => { + const updateActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'UPDATE_RECORD', + name: 'Update Company', + }; + + const result = getWorkflowNodeIconKey(updateActionData); + + expect(result).toBe('IconEdit'); + }); + + it('should return icon for DELETE_RECORD action', () => { + const deleteActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'DELETE_RECORD', + name: 'Delete Company', + }; + + const result = getWorkflowNodeIconKey(deleteActionData); + + expect(result).toBe('IconTrash'); + }); + + it('should return icon for SEND_EMAIL action', () => { + const emailActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Send Email', + }; + + const result = getWorkflowNodeIconKey(emailActionData); + + expect(result).toBe('IconMail'); + }); + + it('should return icon for FILTER action', () => { + const filterActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter Records', + }; + + const result = getWorkflowNodeIconKey(filterActionData); + + expect(result).toBe('IconFilter'); + }); + + it('should handle unknown action types', () => { + const unknownActionData: WorkflowDiagramStepNodeData = { + nodeType: 'action', + actionType: 'UNKNOWN_ACTION' as any, + name: 'Unknown Action', + }; + + const result = getWorkflowNodeIconKey(unknownActionData); + + expect(result).toBe('IconQuestion'); + }); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts new file mode 100644 index 000000000..4925ecd40 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isCreateStepNode.test.ts @@ -0,0 +1,132 @@ +import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { isCreateStepNode } from '../isCreateStepNode'; + +describe('isCreateStepNode', () => { + it('should return true for create-step node with correct type and nodeType', () => { + const createStepNode: WorkflowDiagramNode = { + id: 'create-step-1', + type: 'create-step', + position: { x: 0, y: 200 }, + data: { + nodeType: 'create-step', + parentNodeId: 'action-1', + }, + }; + + const result = isCreateStepNode(createStepNode); + + expect(result).toBe(true); + }); + + it('should return false for node with create-step type but wrong nodeType', () => { + const node: WorkflowDiagramNode = { + id: 'fake-create-step', + type: 'create-step', + position: { x: 0, y: 200 }, + data: { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Create Company', + } as any, + }; + + const result = isCreateStepNode(node); + + expect(result).toBe(false); + }); + + it('should return false for node with correct nodeType but wrong type', () => { + const node: WorkflowDiagramNode = { + id: 'fake-create-step', + type: 'action', + position: { x: 0, y: 200 }, + data: { + nodeType: 'create-step', + parentNodeId: 'action-1', + } as any, + }; + + const result = isCreateStepNode(node); + + expect(result).toBe(false); + }); + + it('should return false for action node', () => { + const actionNode: WorkflowDiagramNode = { + id: 'action-1', + position: { x: 0, y: 100 }, + data: { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Create Company', + }, + }; + + const result = isCreateStepNode(actionNode); + + expect(result).toBe(false); + }); + + it('should return false for trigger node', () => { + const triggerNode: WorkflowDiagramNode = { + id: 'trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Company Created', + icon: 'IconPlus', + }, + }; + + const result = isCreateStepNode(triggerNode); + + expect(result).toBe(false); + }); + + it('should return false for empty-trigger node', () => { + const emptyTriggerNode: WorkflowDiagramNode = { + id: 'empty-trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'empty-trigger', + }, + }; + + const result = isCreateStepNode(emptyTriggerNode); + + expect(result).toBe(false); + }); + + it('should handle create-step node with additional properties', () => { + const createStepNodeWithExtras: WorkflowDiagramNode = { + id: 'create-step-with-extras', + type: 'create-step', + position: { x: 50, y: 250 }, + selected: true, + data: { + nodeType: 'create-step', + parentNodeId: 'action-2', + }, + }; + + const result = isCreateStepNode(createStepNodeWithExtras); + + expect(result).toBe(true); + }); + + it('should return false for node without type property', () => { + const nodeWithoutType: WorkflowDiagramNode = { + id: 'node-without-type', + position: { x: 0, y: 200 }, + data: { + nodeType: 'create-step', + parentNodeId: 'action-1', + } as any, + }; + + const result = isCreateStepNode(nodeWithoutType); + + expect(result).toBe(false); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts new file mode 100644 index 000000000..c1e96ed0d --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/isStepNode.test.ts @@ -0,0 +1,84 @@ +import { WorkflowDiagramNode } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { isStepNode } from '../isStepNode'; + +describe('isStepNode', () => { + it('should return true for trigger node', () => { + const triggerNode: WorkflowDiagramNode = { + id: 'trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Company Created', + icon: 'IconPlus', + }, + }; + + const result = isStepNode(triggerNode); + + expect(result).toBe(true); + }); + + it('should return true for action node', () => { + const actionNode: WorkflowDiagramNode = { + id: 'action-1', + position: { x: 0, y: 100 }, + data: { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Create Company', + }, + }; + + const result = isStepNode(actionNode); + + expect(result).toBe(true); + }); + + it('should return false for create-step node', () => { + const createStepNode: WorkflowDiagramNode = { + id: 'create-step-1', + position: { x: 0, y: 200 }, + data: { + nodeType: 'create-step', + parentNodeId: 'action-1', + }, + }; + + const result = isStepNode(createStepNode); + + expect(result).toBe(false); + }); + + it('should return false for empty-trigger node', () => { + const emptyTriggerNode: WorkflowDiagramNode = { + id: 'empty-trigger', + position: { x: 0, y: 0 }, + data: { + nodeType: 'empty-trigger', + }, + }; + + const result = isStepNode(emptyTriggerNode); + + expect(result).toBe(false); + }); + + it('should handle nodes with additional properties', () => { + const nodeWithExtra: WorkflowDiagramNode = { + id: 'trigger-with-extra', + position: { x: 50, y: 50 }, + selected: true, + data: { + nodeType: 'trigger', + triggerType: 'MANUAL', + name: 'Manual Trigger', + icon: 'IconClick', + }, + }; + + const result = isStepNode(nodeWithExtra); + + expect(result).toBe(true); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx index 05b20e820..8d0285b50 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowStepDetail.tsx @@ -7,6 +7,7 @@ import { WorkflowEditActionCreateRecord } from '@/workflow/workflow-steps/workfl import { WorkflowEditActionDeleteRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionDeleteRecord'; import { WorkflowEditActionSendEmail } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionSendEmail'; import { WorkflowEditActionUpdateRecord } from '@/workflow/workflow-steps/workflow-actions/components/WorkflowEditActionUpdateRecord'; +import { WorkflowEditActionFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter'; import { WorkflowEditActionFindRecords } from '@/workflow/workflow-steps/workflow-actions/find-records-action/components/WorkflowEditActionFindRecords'; import { WorkflowEditActionFormBuilder } from '@/workflow/workflow-steps/workflow-actions/form-action/components/WorkflowEditActionFormBuilder'; import { WorkflowEditActionHttpRequest } from '@/workflow/workflow-steps/workflow-actions/http-request-action/components/WorkflowEditActionHttpRequest'; @@ -185,8 +186,12 @@ export const WorkflowStepDetail = ({ ); } case 'FILTER': { - throw new Error( - "The Filter action isn't meant to be displayed as a node.", + return ( + ); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousStepId.test.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousStepId.test.ts new file mode 100644 index 000000000..f2d14e1f8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/utils/__tests__/getWorkflowPreviousStepId.test.ts @@ -0,0 +1,239 @@ +import { WorkflowStep } from '@/workflow/types/Workflow'; +import { TRIGGER_STEP_ID } from '@/workflow/workflow-trigger/constants/TriggerStepId'; +import { getWorkflowPreviousStepId } from '../getWorkflowPreviousStepId'; + +describe('getWorkflowPreviousStepId', () => { + const mockSteps: WorkflowStep[] = [ + { + id: 'step-1', + name: 'First Step', + type: 'CREATE_RECORD', + valid: true, + nextStepIds: ['step-2'], + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-2', + name: 'Second Step', + type: 'UPDATE_RECORD', + valid: true, + nextStepIds: ['step-3'], + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + objectRecordId: 'test-id', + fieldsToUpdate: ['name'], + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-3', + name: 'Third Step', + type: 'SEND_EMAIL', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + connectedAccountId: 'account-id', + email: 'test@example.com', + }, + outputSchema: {}, + }, + } as WorkflowStep, + ]; + + it('should return undefined for TRIGGER_STEP_ID', () => { + const result = getWorkflowPreviousStepId({ + stepId: TRIGGER_STEP_ID, + steps: mockSteps, + }); + + expect(result).toBeUndefined(); + }); + + it('should return TRIGGER_STEP_ID for first step', () => { + const result = getWorkflowPreviousStepId({ + stepId: 'step-1', + steps: mockSteps, + }); + + expect(result).toBe(TRIGGER_STEP_ID); + }); + + it('should return previous step ID for middle step', () => { + const result = getWorkflowPreviousStepId({ + stepId: 'step-2', + steps: mockSteps, + }); + + expect(result).toBe('step-1'); + }); + + it('should return previous step ID for last step', () => { + const result = getWorkflowPreviousStepId({ + stepId: 'step-3', + steps: mockSteps, + }); + + expect(result).toBe('step-2'); + }); + + it('should return undefined for non-existent step', () => { + const result = getWorkflowPreviousStepId({ + stepId: 'non-existent-step', + steps: mockSteps, + }); + + expect(result).toBeUndefined(); + }); + + it('should work with single step', () => { + const singleStep = [mockSteps[0]]; + + const result = getWorkflowPreviousStepId({ + stepId: 'step-1', + steps: singleStep, + }); + + expect(result).toBe(TRIGGER_STEP_ID); + }); + + it('should handle branching workflow with multiple next steps', () => { + const branchingSteps: WorkflowStep[] = [ + { + id: 'step-1', + name: 'First Step', + type: 'CREATE_RECORD', + valid: true, + nextStepIds: ['step-2a', 'step-2b'], + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-2a', + name: 'Second Step A', + type: 'UPDATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + objectRecordId: 'test-id', + fieldsToUpdate: ['name'], + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-2b', + name: 'Second Step B', + type: 'SEND_EMAIL', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + connectedAccountId: 'account-id', + email: 'test@example.com', + }, + outputSchema: {}, + }, + } as WorkflowStep, + ]; + + const resultA = getWorkflowPreviousStepId({ + stepId: 'step-2a', + steps: branchingSteps, + }); + + const resultB = getWorkflowPreviousStepId({ + stepId: 'step-2b', + steps: branchingSteps, + }); + + expect(resultA).toBe('step-1'); + expect(resultB).toBe('step-1'); + }); + + it('should handle workflow where step is not connected to previous steps', () => { + const disconnectedSteps: WorkflowStep[] = [ + { + id: 'step-1', + name: 'First Step', + type: 'CREATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + }, + outputSchema: {}, + }, + } as WorkflowStep, + { + id: 'step-2', + name: 'Disconnected Step', + type: 'UPDATE_RECORD', + valid: true, + settings: { + errorHandlingOptions: { + continueOnFailure: { value: false }, + retryOnFailure: { value: false }, + }, + input: { + objectName: 'Company', + objectRecord: {}, + objectRecordId: 'test-id', + fieldsToUpdate: ['name'], + }, + outputSchema: {}, + }, + } as WorkflowStep, + ]; + + const result = getWorkflowPreviousStepId({ + stepId: 'step-2', + steps: disconnectedSteps, + }); + + expect(result).toBeUndefined(); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter.tsx new file mode 100644 index 000000000..8c6bd551c --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter.tsx @@ -0,0 +1,87 @@ +import { WorkflowFilterAction } from '@/workflow/types/Workflow'; +import { WorkflowStepHeader } from '@/workflow/workflow-steps/components/WorkflowStepHeader'; +import { WorkflowEditActionFilterBody } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody'; +import { WorkflowEditActionFilterBodyEffect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect'; +import { StepFilterGroupsComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFilterGroupsComponentInstanceContext'; +import { StepFiltersComponentInstanceContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/StepFiltersComponentInstanceContext'; +import { useWorkflowActionHeader } from '@/workflow/workflow-steps/workflow-actions/hooks/useWorkflowActionHeader'; +import { + StepFilter, + StepFilterGroup, +} from 'twenty-shared/src/types/StepFilters'; +import { useIcons } from 'twenty-ui/display'; + +type WorkflowEditActionFilterProps = { + action: WorkflowFilterAction; + actionOptions: + | { + readonly: true; + } + | { + readonly?: false; + onActionUpdate: (action: WorkflowFilterAction) => void; + }; +}; + +export type FilterSettings = { + stepFilterGroups?: StepFilterGroup[]; + stepFilters?: StepFilter[]; +}; + +export const WorkflowEditActionFilter = ({ + action, + actionOptions, +}: WorkflowEditActionFilterProps) => { + const { headerTitle, headerIcon, headerIconColor, headerType } = + useWorkflowActionHeader({ + action, + defaultTitle: 'Filter', + }); + + const { getIcon } = useIcons(); + + return ( + <> + { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + name: newName, + }); + }} + Icon={getIcon(headerIcon)} + iconColor={headerIconColor} + initialTitle={headerTitle} + headerType={headerType} + disabled={actionOptions.readonly} + /> + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody.tsx new file mode 100644 index 000000000..b035683c9 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBody.tsx @@ -0,0 +1,117 @@ +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { WorkflowFilterAction } from '@/workflow/types/Workflow'; +import { WorkflowStepBody } from '@/workflow/workflow-steps/components/WorkflowStepBody'; +import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter'; +import { WorkflowStepFilterAddFilterRuleSelect } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect'; +import { WorkflowStepFilterAddRootStepFilterButton } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton'; +import { WorkflowStepFilterColumn } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterColumn'; +import { WorkflowStepFilterGroupColumn } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterGroupColumn'; +import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups'; +import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext'; +import { rootLevelStepFilterGroupComponentSelector } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/rootLevelStepFilterGroupComponentSelector'; +import { isStepFilterGroupChildAStepFilterGroup } from '@/workflow/workflow-steps/workflow-actions/filter-action/utils/isStepFilterGroupChildAStepFilterGroup'; +import styled from '@emotion/styled'; +import { isDefined } from 'twenty-shared/utils'; + +const StyledContainer = styled.div` + align-items: start; + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledChildContainer = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(6)}; + width: 100%; +`; + +type WorkflowEditActionFilterBodyProps = { + action: WorkflowFilterAction; + actionOptions: + | { + readonly: true; + } + | { + readonly?: false; + onActionUpdate: (action: WorkflowFilterAction) => void; + }; +}; + +export const WorkflowEditActionFilterBody = ({ + action, + actionOptions, +}: WorkflowEditActionFilterBodyProps) => { + const rootStepFilterGroup = useRecoilComponentValueV2( + rootLevelStepFilterGroupComponentSelector, + ); + + const { childStepFiltersAndChildStepFilterGroups } = + useChildStepFiltersAndChildStepFilterGroups({ + stepFilterGroupId: rootStepFilterGroup?.id ?? '', + }); + + const onFilterSettingsUpdate = (newFilterSettings: FilterSettings) => { + if (actionOptions.readonly === true) { + return; + } + + actionOptions.onActionUpdate({ + ...action, + settings: { + ...action.settings, + input: { + stepFilterGroups: newFilterSettings.stepFilterGroups ?? [], + stepFilters: newFilterSettings.stepFilters ?? [], + }, + }, + }); + }; + + return ( + + + {isDefined(rootStepFilterGroup) ? ( + + + {childStepFiltersAndChildStepFilterGroups.map( + (stepFilterGroupChild, stepFilterGroupChildIndex) => + isStepFilterGroupChildAStepFilterGroup( + stepFilterGroupChild, + ) ? ( + + ) : ( + + ), + )} + + {!actionOptions.readonly && ( + + )} + + ) : ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect.tsx new file mode 100644 index 000000000..7a9560fe8 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilterBodyEffect.tsx @@ -0,0 +1,74 @@ +import { useRecoilComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyStateV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { FilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowEditActionFilter'; +import { currentStepFilterGroupsComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFilterGroupsComponentState'; +import { currentStepFiltersComponentState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/currentStepFiltersComponentState'; +import { hasInitializedCurrentStepFilterGroupsComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFilterGroupsComponentFamilyState'; +import { hasInitializedCurrentStepFiltersComponentFamilyState } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/hasInitializedCurrentStepFiltersComponentFamilyState'; +import { useEffect } from 'react'; +import { isDefined } from 'twenty-shared/utils'; + +export const WorkflowEditActionFilterBodyEffect = ({ + stepId, + defaultValue, +}: { + stepId: string; + defaultValue?: FilterSettings; +}) => { + const [ + hasInitializedCurrentStepFilters, + setHasInitializedCurrentStepFilters, + ] = useRecoilComponentFamilyStateV2( + hasInitializedCurrentStepFiltersComponentFamilyState, + { stepId }, + ); + + const [ + hasInitializedCurrentStepFilterGroups, + setHasInitializedCurrentStepFilterGroups, + ] = useRecoilComponentFamilyStateV2( + hasInitializedCurrentStepFilterGroupsComponentFamilyState, + { stepId }, + ); + + const setCurrentStepFilters = useSetRecoilComponentStateV2( + currentStepFiltersComponentState, + ); + + const setCurrentStepFilterGroups = useSetRecoilComponentStateV2( + currentStepFilterGroupsComponentState, + ); + + useEffect(() => { + if ( + !hasInitializedCurrentStepFilters && + isDefined(defaultValue?.stepFilters) + ) { + setCurrentStepFilters(defaultValue.stepFilters ?? []); + setHasInitializedCurrentStepFilters(true); + } + }, [ + setCurrentStepFilters, + hasInitializedCurrentStepFilters, + setHasInitializedCurrentStepFilters, + defaultValue?.stepFilters, + ]); + + useEffect(() => { + if ( + !hasInitializedCurrentStepFilterGroups && + isDefined(defaultValue?.stepFilterGroups) && + defaultValue.stepFilterGroups.length > 0 + ) { + setCurrentStepFilterGroups(defaultValue.stepFilterGroups ?? []); + setHasInitializedCurrentStepFilterGroups(true); + } + }, [ + setCurrentStepFilterGroups, + hasInitializedCurrentStepFilterGroups, + setHasInitializedCurrentStepFilterGroups, + defaultValue?.stepFilterGroups, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect.tsx new file mode 100644 index 000000000..6c64b618e --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddFilterRuleSelect.tsx @@ -0,0 +1,144 @@ +import { ActionButton } from '@/action-menu/actions/display/components/ActionButton'; +import { getAdvancedFilterAddFilterRuleSelectDropdownId } from '@/object-record/advanced-filter/utils/getAdvancedFilterAddFilterRuleSelectDropdownId'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useChildStepFiltersAndChildStepFilterGroups } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useChildStepFiltersAndChildStepFilterGroups'; +import { useUpsertStepFilterSettings } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useUpsertStepFilterSettings'; +import { + StepFilter, + StepFilterGroup, + StepLogicalOperator, + StepOperand, +} from 'twenty-shared/src/types'; +import { isDefined } from 'twenty-shared/utils'; +import { IconLibraryPlus, IconPlus } from 'twenty-ui/display'; +import { MenuItem } from 'twenty-ui/navigation'; +import { v4 } from 'uuid'; + +type WorkflowStepFilterAddFilterRuleSelectProps = { + stepFilterGroup: StepFilterGroup; +}; + +export const WorkflowStepFilterAddFilterRuleSelect = ({ + stepFilterGroup, +}: WorkflowStepFilterAddFilterRuleSelectProps) => { + const { upsertStepFilterSettings } = useUpsertStepFilterSettings(); + + const dropdownId = getAdvancedFilterAddFilterRuleSelectDropdownId( + stepFilterGroup.id, + ); + + const { lastChildPosition } = useChildStepFiltersAndChildStepFilterGroups({ + stepFilterGroupId: stepFilterGroup.id, + }); + + const newPositionInStepFilterGroup = lastChildPosition + 1; + + const { closeDropdown } = useCloseDropdown(); + + const handleAddFilter = () => { + closeDropdown(dropdownId); + + const newStepFilter = { + id: v4(), + type: 'text', + label: 'New Filter', + value: '', + operand: StepOperand.EQ, + displayValue: '', + stepFilterGroupId: stepFilterGroup.id, + stepOutputKey: '', + positionInStepFilterGroup: newPositionInStepFilterGroup, + }; + + upsertStepFilterSettings({ + stepFilterToUpsert: newStepFilter, + }); + }; + + const handleAddFilterGroup = () => { + closeDropdown(dropdownId); + + const newStepFilterGroupId = v4(); + + const newStepFilterGroup: StepFilterGroup = { + id: newStepFilterGroupId, + logicalOperator: StepLogicalOperator.AND, + parentStepFilterGroupId: stepFilterGroup.id, + positionInStepFilterGroup: newPositionInStepFilterGroup, + }; + + const newStepFilter: StepFilter = { + id: v4(), + type: 'text', + operand: StepOperand.EQ, + value: '', + displayValue: '', + stepFilterGroupId: newStepFilterGroupId, + positionInStepFilterGroup: 1, + label: 'New Filter', + stepOutputKey: '', + }; + + upsertStepFilterSettings({ + stepFilterToUpsert: newStepFilter, + stepFilterGroupToUpsert: newStepFilterGroup, + }); + }; + + const isFilterRuleGroupOptionVisible = !isDefined( + stepFilterGroup.parentStepFilterGroupId, + ); + + if (!isFilterRuleGroupOptionVisible) { + return ( + + ); + } + + return ( + + } + dropdownComponents={ + + + + {isFilterRuleGroupOptionVisible && ( + + )} + + + } + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + /> + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton.tsx new file mode 100644 index 000000000..38680a8d4 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/filter-action/components/WorkflowStepFilterAddRootStepFilterButton.tsx @@ -0,0 +1,25 @@ +import { useAddRootStepFilter } from '@/workflow/workflow-steps/workflow-actions/filter-action/hooks/useAddRootStepFilter'; +import { WorkflowStepFilterContext } from '@/workflow/workflow-steps/workflow-actions/filter-action/states/context/WorkflowStepFilterContext'; +import { useLingui } from '@lingui/react/macro'; +import { useContext } from 'react'; +import { IconFilter } from 'twenty-ui/display'; +import { Button } from 'twenty-ui/input'; + +export const WorkflowStepFilterAddRootStepFilterButton = () => { + const { t } = useLingui(); + const { readonly } = useContext(WorkflowStepFilterContext); + const { addRootStepFilter } = useAddRootStepFilter(); + + return ( +