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 ( +