diff --git a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx index 1aafec1f9..670494287 100644 --- a/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx +++ b/packages/twenty-front/src/modules/command-menu/pages/workflow/action/components/CommandMenuWorkflowSelectActionContent.tsx @@ -1,9 +1,15 @@ -import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { + WorkflowActionType, + WorkflowWithCurrentVersion, +} from '@/workflow/types/Workflow'; import { RightDrawerStepListContainer } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepContainer'; import { RightDrawerWorkflowSelectStepTitle } from '@/workflow/workflow-steps/components/RightDrawerWorkflowSelectStepTitle'; import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { RECORD_ACTIONS } from '@/workflow/workflow-steps/workflow-actions/constants/RecordActions'; import { useFilteredOtherActions } from '@/workflow/workflow-steps/workflow-actions/hooks/useFilteredOtherActions'; +import { isDefined } from 'twenty-shared/utils'; import { useIcons } from 'twenty-ui/display'; import { MenuItemCommand } from 'twenty-ui/navigation'; @@ -13,11 +19,36 @@ export const CommandMenuWorkflowSelectActionContent = ({ workflow: WorkflowWithCurrentVersion; }) => { const { getIcon } = useIcons(); + const { createStep } = useCreateStep({ workflow, }); const filteredOtherActions = useFilteredOtherActions(); + const [workflowInsertStepIds, setWorkflowInsertStepIds] = + useRecoilComponentStateV2(workflowInsertStepIdsComponentState); + + const handleCreateStep = async (actionType: WorkflowActionType) => { + const { parentStepId, nextStepId } = workflowInsertStepIds; + + if (!isDefined(parentStepId)) { + throw new Error( + 'No parentStepId. Please select a parent step to create from.', + ); + } + + await createStep({ + newStepType: actionType, + parentStepId, + nextStepId, + }); + + setWorkflowInsertStepIds({ + parentStepId: undefined, + nextStepId: undefined, + }); + }; + return ( @@ -28,7 +59,7 @@ export const CommandMenuWorkflowSelectActionContent = ({ key={action.type} LeftIcon={getIcon(action.icon)} text={action.label} - onClick={() => createStep(action.type)} + onClick={() => handleCreateStep(action.type)} /> ))} @@ -39,7 +70,7 @@ export const CommandMenuWorkflowSelectActionContent = ({ key={action.type} LeftIcon={getIcon(action.icon)} text={action.label} - onClick={() => createStep(action.type)} + onClick={() => handleCreateStep(action.type)} /> ))} diff --git a/packages/twenty-front/src/modules/workflow/types/Workflow.ts b/packages/twenty-front/src/modules/workflow/types/Workflow.ts index 762afb843..4d9e1d287 100644 --- a/packages/twenty-front/src/modules/workflow/types/Workflow.ts +++ b/packages/twenty-front/src/modules/workflow/types/Workflow.ts @@ -10,6 +10,8 @@ import { workflowDeleteRecordActionSchema, workflowDeleteRecordActionSettingsSchema, workflowExecutorOutputSchema, + workflowFilterActionSchema, + workflowFilterActionSettingsSchema, workflowFindRecordsActionSchema, workflowFindRecordsActionSettingsSchema, workflowFormActionSchema, @@ -48,6 +50,9 @@ export type WorkflowDeleteRecordActionSettings = z.infer< export type WorkflowFindRecordsActionSettings = z.infer< typeof workflowFindRecordsActionSettingsSchema >; +export type WorkflowFilterActionSettings = z.infer< + typeof workflowFilterActionSettingsSchema +>; export type WorkflowFormActionSettings = z.infer< typeof workflowFormActionSettingsSchema >; @@ -68,6 +73,7 @@ export type WorkflowDeleteRecordAction = z.infer< export type WorkflowFindRecordsAction = z.infer< typeof workflowFindRecordsActionSchema >; +export type WorkflowFilterAction = z.infer; export type WorkflowFormAction = z.infer; export type WorkflowHttpRequestAction = z.infer< typeof workflowHttpRequestActionSchema @@ -86,6 +92,7 @@ export type WorkflowAction = | WorkflowUpdateRecordAction | WorkflowDeleteRecordAction | WorkflowFindRecordsAction + | WorkflowFilterAction | WorkflowFormAction | WorkflowHttpRequestAction | WorkflowAiAgentAction; 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 5ba7dc7e2..078e21421 100644 --- a/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts +++ b/packages/twenty-front/src/modules/workflow/validation-schemas/workflowSchema.ts @@ -138,6 +138,13 @@ export const workflowAiAgentActionSettingsSchema = }), }); +export const workflowFilterActionSettingsSchema = + baseWorkflowActionSettingsSchema.extend({ + input: z.object({ + filter: z.record(z.any()), + }), + }); + // Action schemas export const workflowCodeActionSchema = baseWorkflowActionSchema.extend({ type: z.literal('CODE'), @@ -190,6 +197,11 @@ export const workflowAiAgentActionSchema = baseWorkflowActionSchema.extend({ settings: workflowAiAgentActionSettingsSchema, }); +export const workflowFilterActionSchema = baseWorkflowActionSchema.extend({ + type: z.literal('FILTER'), + settings: workflowFilterActionSettingsSchema, +}); + // Combined action schema export const workflowActionSchema = z.discriminatedUnion('type', [ workflowCodeActionSchema, @@ -201,6 +213,7 @@ export const workflowActionSchema = z.discriminatedUnion('type', [ workflowFormActionSchema, workflowHttpRequestActionSchema, workflowAiAgentActionSchema, + workflowFilterActionSchema, ]); // Trigger schemas diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx index 8d5f87d3a..b279c1ec5 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramDefaultEdge.tsx @@ -50,8 +50,10 @@ export const WorkflowDiagramDefaultEdge = ({ ) : ( ` - padding: ${({ theme }) => theme.spacing(1)}; - pointer-events: all; - ${({ labelX, labelY }) => css` - transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px); - `} - position: absolute; -`; - -const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>` - opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)}; - position: relative; -`; +import { useCreateStep } from '@/workflow/workflow-steps/hooks/useCreateStep'; +import { useDeleteStep } from '@/workflow/workflow-steps/hooks/useDeleteStep'; +import { isDefined } from 'twenty-shared/utils'; type WorkflowDiagramEdgeV2Props = { labelX: number; labelY: number; + stepId: string | undefined; parentStepId: string; nextStepId: string; + filter: Record | undefined; }; export const WorkflowDiagramEdgeV2 = ({ labelX, labelY, + stepId, parentStepId, nextStepId, + filter, }: WorkflowDiagramEdgeV2Props) => { - const { openDropdown } = useOpenDropdown(); - const { closeDropdown } = useCloseDropdown(); + const workflowVisualizerWorkflowId = useRecoilComponentValueV2( + workflowVisualizerWorkflowIdComponentState, + ); + const workflow = useWorkflowWithCurrentVersion(workflowVisualizerWorkflowId); + assertWorkflowWithCurrentVersionIsDefined(workflow); + + const { createStep } = useCreateStep({ workflow }); + const { deleteStep } = useDeleteStep({ workflow }); const { startNodeCreation } = useStartNodeCreation(); - const [hovered, setHovered] = useState(false); - - const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2( - workflowDiagramPanOnDragComponentState, - ); - - const workflowInsertStepIds = useRecoilComponentValueV2( - workflowInsertStepIdsComponentState, - ); - - const isSelected = - workflowInsertStepIds.parentStepId === parentStepId && - workflowInsertStepIds.nextStepId === nextStepId; - - const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`; - - const isDropdownOpen = useRecoilComponentValueV2( - isDropdownOpenComponentStateV2, - dropdownId, - ); - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - > - - {}, - }, - { - Icon: IconDotsVertical, - onClick: () => { - openDropdown({ - dropdownComponentInstanceIdFromProps: dropdownId, - }); - }, - }, - ]} - /> + stepId={stepId} + parentStepId={parentStepId} + nextStepId={nextStepId} + filter={filter} + onCreateFilter={() => { + return createStep({ + newStepType: 'FILTER', + parentStepId, + nextStepId, + }); + }} + onDeleteFilter={() => { + if (!isDefined(stepId)) { + throw new Error( + 'Step ID must be configured for the edge when rendering a filter', + ); + } - } - data-select-disable - dropdownPlacement="bottom-start" - dropdownStrategy="absolute" - dropdownOffset={{ - x: 0, - y: 4, - }} - onOpen={() => { - setWorkflowDiagramPanOnDrag(false); - }} - onClose={() => { - setWorkflowDiagramPanOnDrag(true); - }} - dropdownComponents={ - - - {}} - /> - {}} - /> - { - closeDropdown(dropdownId); - setHovered(false); - - startNodeCreation({ parentStepId, nextStepId }); - }} - /> - {}} - /> - - - } - /> - - + return deleteStep(stepId); + }} + onCreateNode={() => { + if (isDefined(filter)) { + startNodeCreation({ parentStepId: stepId, nextStepId }); + } else { + startNodeCreation({ parentStepId, nextStepId }); + } + }} + /> ); }; 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 new file mode 100644 index 000000000..208d4490b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/WorkflowDiagramEdgeV2Content.tsx @@ -0,0 +1,209 @@ +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 { GenericDropdownContentWidth } from '@/ui/layout/dropdown/constants/GenericDropdownContentWidth'; +import { useCloseDropdown } from '@/ui/layout/dropdown/hooks/useCloseDropdown'; +import { useOpenDropdown } from '@/ui/layout/dropdown/hooks/useOpenDropdown'; +import { isDropdownOpenComponentStateV2 } from '@/ui/layout/dropdown/states/isDropdownOpenComponentStateV2'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID } from '@/workflow/workflow-diagram/constants/WorkflowDiagramEdgeOptionsClickOutsideId'; +import { workflowDiagramPanOnDragComponentState } from '@/workflow/workflow-diagram/states/workflowDiagramPanOnDragComponentState'; +import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; +import { css } from '@emotion/react'; +import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; +import { useState } from 'react'; +import { isDefined } from 'twenty-shared/utils'; +import { + IconDotsVertical, + IconFilter, + IconFilterPlus, + IconFilterX, + IconGitBranchDeleted, + IconPlus, +} from 'twenty-ui/display'; +import { IconButtonGroup } from 'twenty-ui/input'; +import { MenuItem } from 'twenty-ui/navigation'; + +const StyledIconButtonGroup = styled(IconButtonGroup)` + pointer-events: all; +`; + +const StyledRoundedIconButtonGroup = styled(IconButtonGroup)` + border-radius: 50px; + overflow: hidden; + pointer-events: all; +`; + +const StyledContainer = styled.div<{ labelX: number; labelY: number }>` + padding: ${({ theme }) => theme.spacing(1)}; + pointer-events: all; + ${({ labelX, labelY }) => css` + transform: translate(-50%, -50%) translate(${labelX}px, ${labelY}px); + `} + position: absolute; +`; + +const StyledOpacityOverlay = styled.div<{ shouldDisplay: boolean }>` + opacity: ${({ shouldDisplay }) => (shouldDisplay ? 1 : 0)}; + position: relative; +`; + +type WorkflowDiagramEdgeV2ContentProps = { + labelX: number; + labelY: number; + stepId: string | undefined; + parentStepId: string; + nextStepId: string; + filter: Record | undefined; + onCreateFilter: () => Promise; + onDeleteFilter: () => Promise; + onCreateNode: () => void; +}; + +export const WorkflowDiagramEdgeV2Content = ({ + labelX, + labelY, + stepId, + parentStepId, + nextStepId, + filter, + onCreateFilter, + onDeleteFilter, + onCreateNode, +}: WorkflowDiagramEdgeV2ContentProps) => { + const { openDropdown } = useOpenDropdown(); + const { closeDropdown } = useCloseDropdown(); + + const [hovered, setHovered] = useState(false); + + const setWorkflowDiagramPanOnDrag = useSetRecoilComponentStateV2( + workflowDiagramPanOnDragComponentState, + ); + + const workflowInsertStepIds = useRecoilComponentValueV2( + workflowInsertStepIdsComponentState, + ); + + const isSelected = + workflowInsertStepIds.nextStepId === nextStepId && + (workflowInsertStepIds.parentStepId === parentStepId || + (isNonEmptyString(stepId) && + workflowInsertStepIds.parentStepId === stepId)); + + const dropdownId = `${WORKFLOW_DIAGRAM_EDGE_OPTIONS_CLICK_OUTSIDE_ID}-${parentStepId}-${nextStepId}`; + + const isDropdownOpen = useRecoilComponentValueV2( + isDropdownOpenComponentStateV2, + dropdownId, + ); + + const handleCreateFilter = async () => { + await onCreateFilter(); + + closeDropdown(dropdownId); + setHovered(false); + }; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + + {isDefined(filter) && !hovered && !isDropdownOpen && !isSelected ? ( + + ) : ( + { + handleCreateFilter(); + }, + }, + { + Icon: IconDotsVertical, + onClick: () => { + openDropdown({ + dropdownComponentInstanceIdFromProps: dropdownId, + }); + }, + }, + ]} + /> + )} + + } + data-select-disable + dropdownPlacement="bottom-start" + dropdownStrategy="absolute" + dropdownOffset={{ + x: 0, + y: 4, + }} + onOpen={() => { + setWorkflowDiagramPanOnDrag(false); + }} + onClose={() => { + setWorkflowDiagramPanOnDrag(true); + }} + dropdownComponents={ + + + {}} + /> + { + closeDropdown(dropdownId); + setHovered(false); + + onDeleteFilter(); + }} + /> + { + closeDropdown(dropdownId); + setHovered(false); + + onCreateNode(); + }} + /> + {}} + /> + + + } + /> + + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2Content.stories.tsx similarity index 81% rename from packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx rename to packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2Content.stories.tsx index 5bd3f2380..d52dc3ce4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2.stories.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/components/__stories__/WorkflowDiagramEdgeV2Content.stories.tsx @@ -1,17 +1,17 @@ import { WorkflowVisualizerComponentInstanceContext } from '@/workflow/workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; import { Meta, StoryObj } from '@storybook/react'; -import { expect, userEvent, waitFor, within } from '@storybook/test'; +import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; import '@xyflow/react/dist/style.css'; import { ComponentDecorator, getCanvasElementForDropdownTesting, } from 'twenty-ui/testing'; import { ReactflowDecorator } from '~/testing/decorators/ReactflowDecorator'; -import { WorkflowDiagramEdgeV2 } from '../WorkflowDiagramEdgeV2'; +import { WorkflowDiagramEdgeV2Content } from '../WorkflowDiagramEdgeV2Content'; -const meta: Meta = { - title: 'Modules/Workflow/WorkflowDiagramEdgeV2', - component: WorkflowDiagramEdgeV2, +const meta: Meta = { + title: 'Modules/Workflow/WorkflowDiagramEdgeV2Content', + component: WorkflowDiagramEdgeV2Content, decorators: [ ComponentDecorator, ReactflowDecorator, @@ -35,11 +35,14 @@ const meta: Meta = { labelY: 0, parentStepId: 'parent-step-id', nextStepId: 'next-step-id', + onCreateFilter: fn(), + onDeleteFilter: fn(), + onCreateNode: fn(), }, }; export default meta; -type Story = StoryObj; +type Story = StoryObj; export const ButtonsAppearOnHover: Story = { play: async ({ canvasElement }) => { @@ -57,7 +60,7 @@ export const ButtonsAppearOnHover: Story = { }; export const CreateFilter: Story = { - play: async ({ canvasElement }) => { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const buttons = await canvas.findAllByRole('button'); @@ -71,12 +74,14 @@ export const CreateFilter: Story = { userEvent.click(filterButton); - // TODO: Assert we created a filter + await waitFor(() => { + expect(args.onCreateFilter).toHaveBeenCalledTimes(1); + }); }, }; export const AddNodeAction: Story = { - play: async ({ canvasElement }) => { + play: async ({ canvasElement, args }) => { const canvas = within(canvasElement); const buttons = await canvas.findAllByRole('button'); @@ -99,6 +104,10 @@ export const AddNodeAction: Story = { await waitFor(() => { expect(canvas.queryByText('Add Node')).not.toBeInTheDocument(); }); + + await waitFor(() => { + expect(args.onCreateNode).toHaveBeenCalledTimes(1); + }); }, }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts index a86c26e88..131a64147 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/types/WorkflowDiagram.ts @@ -69,6 +69,8 @@ export type WorkflowRunDiagramNodeData = Exclude< > & { runStatus: WorkflowDiagramRunStatus }; export type EdgeData = { + stepId?: string; + filter?: Record; shouldDisplayEdgeOptions?: boolean; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts new file mode 100644 index 000000000..d28b26a7b --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/__tests__/transformFilterNodesAsEdges.test.ts @@ -0,0 +1,333 @@ +import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { transformFilterNodesAsEdges } from '../transformFilterNodesAsEdges'; + +describe('transformFilterNodesAsEdges', () => { + it('should return the original diagram when there are no filter nodes', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + { + id: 'C', + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + }, + position: { x: 0, y: 300 }, + }, + ], + edges: [ + { + id: 'A-C', + source: 'A', + target: 'C', + data: { stepId: 'A', shouldDisplayEdgeOptions: true }, + }, + ], + }; + + const result = transformFilterNodesAsEdges(diagram); + + expect(result.nodes).toEqual(diagram.nodes); + expect(result.edges).toEqual(diagram.edges); + }); + + it('should transform A->B->C where B is a FILTER step', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + { + id: 'B', + data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + position: { x: 0, y: 150 }, + }, + { + id: 'C', + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + }, + position: { x: 0, y: 300 }, + }, + ], + edges: [ + { + id: 'A-B', + source: 'A', + target: 'B', + data: { stepId: 'A', shouldDisplayEdgeOptions: true }, + }, + { + id: 'B-C', + source: 'B', + target: 'C', + data: { stepId: 'B', shouldDisplayEdgeOptions: true }, + }, + ], + }; + + const result = transformFilterNodesAsEdges(diagram); + + // Should only have nodes A and C + expect(result.nodes).toEqual([ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + { + id: 'C', + data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' }, + position: { x: 0, y: 300 }, + }, + ]); + + // Should have one edge with filter data + expect(result.edges).toHaveLength(1); + expect(result.edges[0]).toEqual({ + id: 'A-C-filter-B', + source: 'A', + target: 'C', + data: { + shouldDisplayEdgeOptions: true, + stepId: 'B', + filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + }, + }); + }); + + it('should handle multiple filter nodes', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + { + id: 'B1', + data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' }, + position: { x: 0, y: 150 }, + }, + { + id: 'C', + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + }, + position: { x: 0, y: 300 }, + }, + { + id: 'B2', + data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' }, + position: { x: 0, y: 450 }, + }, + { + id: 'D', + data: { + nodeType: 'action', + actionType: 'CREATE_RECORD', + name: 'Step D', + }, + position: { x: 0, y: 600 }, + }, + ], + edges: [ + { + id: 'A-B1', + source: 'A', + target: 'B1', + data: { stepId: 'A', shouldDisplayEdgeOptions: true }, + }, + { + id: 'B1-C', + source: 'B1', + target: 'C', + data: { stepId: 'B1', shouldDisplayEdgeOptions: true }, + }, + { + id: 'C-B2', + source: 'C', + target: 'B2', + data: { stepId: 'C', shouldDisplayEdgeOptions: true }, + }, + { + id: 'B2-D', + source: 'B2', + target: 'D', + data: { stepId: 'B2', shouldDisplayEdgeOptions: true }, + }, + ], + }; + + const result = transformFilterNodesAsEdges(diagram); + + // Should only have nodes A, C, and D + expect(result.nodes).toHaveLength(3); + expect(result.nodes.map((n) => n.id)).toEqual( + expect.arrayContaining(['A', 'C', 'D']), + ); + + // Should have two edges with filter data + expect(result.edges).toHaveLength(2); + + const edgeAC = result.edges.find( + (e) => e.source === 'A' && e.target === 'C', + ); + expect(edgeAC).toEqual({ + id: 'A-C-filter-B1', + source: 'A', + target: 'C', + data: { + stepId: 'B1', + shouldDisplayEdgeOptions: true, + filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B1' }, + }, + }); + + const edgeCD = result.edges.find( + (e) => e.source === 'C' && e.target === 'D', + ); + expect(edgeCD).toEqual({ + id: 'C-D-filter-B2', + source: 'C', + target: 'D', + data: { + stepId: 'B2', + shouldDisplayEdgeOptions: true, + filter: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B2' }, + }, + }); + }); + + it('should handle filter nodes that are not part of a chain', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + { + id: 'B', + data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + position: { x: 0, y: 150 }, + }, + ], + edges: [ + { + id: 'A-B', + source: 'A', + target: 'B', + data: { stepId: 'A', shouldDisplayEdgeOptions: true }, + }, + ], + }; + + const result = transformFilterNodesAsEdges(diagram); + + // Should only have node A (filter node B is removed) + expect(result.nodes).toEqual([ + { + id: 'A', + data: { nodeType: 'action', actionType: 'CODE', name: 'Step A' }, + position: { x: 0, y: 0 }, + }, + ]); + + // Should have no edges (original edge A-B is removed, no new edges created) + expect(result.edges).toEqual([]); + }); + + it('should preserve trigger nodes', () => { + const diagram: WorkflowDiagram = { + nodes: [ + { + id: 'trigger', + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Trigger', + }, + position: { x: 0, y: 0 }, + }, + { + id: 'B', + data: { nodeType: 'action', actionType: 'FILTER', name: 'Filter B' }, + position: { x: 0, y: 150 }, + }, + { + id: 'C', + data: { + nodeType: 'action', + actionType: 'SEND_EMAIL', + name: 'Step C', + }, + position: { x: 0, y: 300 }, + }, + ], + edges: [ + { + id: 'trigger-B', + source: 'trigger', + target: 'B', + data: { stepId: 'trigger', shouldDisplayEdgeOptions: true }, + }, + { + id: 'B-C', + source: 'B', + target: 'C', + data: { stepId: 'B', shouldDisplayEdgeOptions: true }, + }, + ], + }; + + const result = transformFilterNodesAsEdges(diagram); + + // Should have trigger and C nodes + expect(result.nodes).toEqual([ + { + id: 'trigger', + data: { + nodeType: 'trigger', + triggerType: 'DATABASE_EVENT', + name: 'Trigger', + }, + position: { x: 0, y: 0 }, + }, + { + id: 'C', + data: { nodeType: 'action', actionType: 'SEND_EMAIL', name: 'Step C' }, + position: { x: 0, y: 300 }, + }, + ]); + + // Should have one edge with filter data + expect(result.edges).toEqual([ + { + id: 'trigger-C-filter-B', + source: 'trigger', + target: 'C', + data: { + stepId: 'B', + shouldDisplayEdgeOptions: true, + filter: { + nodeType: 'action', + actionType: 'FILTER', + name: 'Filter B', + }, + }, + }, + ]); + }); +}); diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts index ac2cd3e27..f37e00111 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/addEdgeOptions.ts @@ -6,9 +6,14 @@ export const addEdgeOptions = ({ }: WorkflowDiagram): WorkflowDiagram => { return { nodes, - edges: edges.map((edge) => ({ - ...edge, - data: { shouldDisplayEdgeOptions: true }, - })), + edges: edges.map((edge) => { + return { + ...edge, + data: { + ...edge.data, + shouldDisplayEdgeOptions: true, + }, + }; + }), }; }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts index 34b92df7c..b6b98b2a4 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/generateWorkflowRunDiagram.ts @@ -10,9 +10,10 @@ import { WorkflowRunDiagramNode, WorkflowRunDiagramStepNodeData, } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import { isDefined } from 'twenty-shared/utils'; import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; import { isStepNode } from '@/workflow/workflow-diagram/utils/isStepNode'; +import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges'; +import { isDefined } from 'twenty-shared/utils'; export const generateWorkflowRunDiagram = ({ trigger, @@ -38,7 +39,9 @@ export const generateWorkflowRunDiagram = ({ } | undefined = undefined; - const workflowDiagram = generateWorkflowDiagram({ trigger, steps }); + const workflowDiagram = transformFilterNodesAsEdges( + generateWorkflowDiagram({ trigger, steps }), + ); let skippedExecution = false; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts index 9ac6a65a7..e50ff47e1 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getWorkflowVersionDiagram.ts @@ -1,6 +1,7 @@ import { WorkflowVersion } from '@/workflow/types/Workflow'; import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; import { generateWorkflowDiagram } from '@/workflow/workflow-diagram/utils/generateWorkflowDiagram'; +import { transformFilterNodesAsEdges } from '@/workflow/workflow-diagram/utils/transformFilterNodesAsEdges'; import { isDefined } from 'twenty-shared/utils'; const EMPTY_DIAGRAM: WorkflowDiagram = { @@ -15,8 +16,10 @@ export const getWorkflowVersionDiagram = ( return EMPTY_DIAGRAM; } - return generateWorkflowDiagram({ - trigger: workflowVersion.trigger ?? undefined, - steps: workflowVersion.steps ?? [], - }); + return transformFilterNodesAsEdges( + generateWorkflowDiagram({ + trigger: workflowVersion.trigger ?? undefined, + steps: workflowVersion.steps ?? [], + }), + ); }; diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/transformFilterNodesAsEdges.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/transformFilterNodesAsEdges.ts new file mode 100644 index 000000000..67584b881 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/transformFilterNodesAsEdges.ts @@ -0,0 +1,66 @@ +import { WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; +import { isDefined } from 'twenty-shared/utils'; + +export const transformFilterNodesAsEdges = ({ + nodes, + edges, +}: WorkflowDiagram): WorkflowDiagram => { + const filterNodes = nodes.filter( + (node) => + node.data.nodeType === 'action' && + 'actionType' in node.data && + node.data.actionType === 'FILTER', + ); + + if (filterNodes.length === 0) { + return { nodes, edges }; + } + + const resultNodes = nodes.filter( + (node) => !filterNodes.some((filterNode) => filterNode.id === node.id), + ); + + const resultEdges = [...edges]; + const edgesToRemove = new Set(); + const edgesToAdd: typeof edges = []; + + for (const filterNode of filterNodes) { + const incomingEdge = edges.find((edge) => edge.target === filterNode.id); + const outgoingEdge = edges.find((edge) => edge.source === filterNode.id); + + if (isDefined(incomingEdge) && isDefined(outgoingEdge)) { + const newEdge = { + ...incomingEdge, + id: `${incomingEdge.source}-${outgoingEdge.target}-filter-${filterNode.id}`, + target: outgoingEdge.target, + data: { + ...incomingEdge.data, + stepId: filterNode.id, + filter: filterNode.data, + }, + }; + + edgesToAdd.push(newEdge); + edgesToRemove.add(incomingEdge.id); + edgesToRemove.add(outgoingEdge.id); + } else { + if (isDefined(incomingEdge)) { + edgesToRemove.add(incomingEdge.id); + } + + if (isDefined(outgoingEdge)) { + edgesToRemove.add(outgoingEdge.id); + } + } + } + + const finalEdges = [ + ...resultEdges.filter((edge) => !edgesToRemove.has(edge.id)), + ...edgesToAdd, + ]; + + return { + nodes: resultNodes, + edges: finalEdges, + }; +}; diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx index 9929c7711..862423663 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/components/WorkflowRunStepNodeDetail.tsx @@ -200,6 +200,11 @@ export const WorkflowRunStepNodeDetail = ({ /> ); } + case 'FILTER': { + throw new Error( + "The Filter action isn't meant to be displayed as a node.", + ); + } } } } 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 e2e316a13..05b20e820 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 @@ -184,6 +184,11 @@ export const WorkflowStepDetail = ({ /> ); } + case 'FILTER': { + throw new Error( + "The Filter action isn't meant to be displayed as a node.", + ); + } default: return assertUnreachable( diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx index 07a9c60b6..b633921da 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/__tests__/useCreateStep.test.tsx @@ -1,5 +1,4 @@ import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; -import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; import { renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { WorkflowVisualizerComponentInstanceContext } from '../../../workflow-diagram/states/contexts/WorkflowVisualizerComponentInstanceContext'; @@ -30,16 +29,7 @@ const wrapper = ({ children }: { children: React.ReactNode }) => { 'workflow-visualizer-instance-id'; return ( - { - set( - workflowInsertStepIdsComponentState.atomFamily({ - instanceId: workflowVisualizerComponentInstanceId, - }), - { parentStepId: 'parent-step-id', nextStepId: undefined }, - ); - }} - > + { wrapper, }, ); - await result.current.createStep('CODE'); + await result.current.createStep({ + newStepType: 'CODE', + parentStepId: 'parent-step-id', + nextStepId: undefined, + }); expect(mockCreateWorkflowVersionStep).toHaveBeenCalled(); }); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts index 2236b97e4..43019717d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/hooks/useCreateStep.ts @@ -7,10 +7,7 @@ import { } from '@/workflow/types/Workflow'; import { workflowSelectedNodeComponentState } from '@/workflow/workflow-diagram/states/workflowSelectedNodeComponentState'; import { useCreateWorkflowVersionStep } from '@/workflow/workflow-steps/hooks/useCreateWorkflowVersionStep'; -import { workflowInsertStepIdsComponentState } from '@/workflow/workflow-steps/states/workflowInsertStepIdsComponentState'; -import { isDefined } from 'twenty-shared/utils'; import { useState } from 'react'; -import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; export const useCreateStep = ({ workflow, @@ -26,12 +23,17 @@ export const useCreateStep = ({ workflowLastCreatedStepIdComponentState, ); - const [workflowInsertStepIds, setWorkflowInsertStepIds] = - useRecoilComponentStateV2(workflowInsertStepIdsComponentState); - const { getUpdatableWorkflowVersion } = useGetUpdatableWorkflowVersion(); - const createStep = async (newStepType: WorkflowStepType) => { + const createStep = async ({ + newStepType, + parentStepId, + nextStepId, + }: { + newStepType: WorkflowStepType; + parentStepId: string; + nextStepId: string | undefined; + }) => { if (isLoading === true) { return; } @@ -39,20 +41,14 @@ export const useCreateStep = ({ setIsLoading(true); try { - if (!isDefined(workflowInsertStepIds.parentStepId)) { - throw new Error( - 'No parentStepId. Please select a parent step to create from.', - ); - } - const workflowVersionId = await getUpdatableWorkflowVersion(workflow); const createdStep = ( await createWorkflowVersionStep({ workflowVersionId, stepType: newStepType, - parentStepId: workflowInsertStepIds.parentStepId, - nextStepId: workflowInsertStepIds.nextStepId, + parentStepId, + nextStepId, }) )?.data?.createWorkflowVersionStep; @@ -62,10 +58,6 @@ export const useCreateStep = ({ setWorkflowSelectedNode(createdStep.id); setWorkflowLastCreatedStepId(createdStep.id); - setWorkflowInsertStepIds({ - parentStepId: undefined, - nextStepId: undefined, - }); } finally { setIsLoading(false); } diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx index 8626b3118..d7e402184 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/hooks/__tests__/useWorkflowActionHeader.test.tsx @@ -1,5 +1,9 @@ import { - workflowActionSchema, + WorkflowFormAction, + WorkflowHttpRequestAction, + WorkflowSendEmailAction, +} from '@/workflow/types/Workflow'; +import { workflowFormActionSettingsSchema, workflowHttpRequestActionSettingsSchema, workflowSendEmailActionSettingsSchema, @@ -34,7 +38,7 @@ describe('useWorkflowActionHeader', () => { describe('when action name is not defined', () => { it('should return default title', () => { - const action = workflowActionSchema.parse({ + const action = { id: '1', name: '', type: 'HTTP_REQUEST', @@ -52,7 +56,7 @@ describe('useWorkflowActionHeader', () => { }, }), valid: true, - }); + } satisfies WorkflowHttpRequestAction; const { result } = renderHook(() => useWorkflowActionHeader({ @@ -71,7 +75,7 @@ describe('useWorkflowActionHeader', () => { describe('when action name is defined', () => { it('should return the action name', () => { - const action = workflowActionSchema.parse({ + const action = { id: '1', name: 'Test Action', type: 'HTTP_REQUEST', @@ -89,7 +93,7 @@ describe('useWorkflowActionHeader', () => { }, }), valid: true, - }); + } satisfies WorkflowHttpRequestAction; const { result } = renderHook(() => useWorkflowActionHeader({ @@ -108,7 +112,7 @@ describe('useWorkflowActionHeader', () => { describe('when action type is defined', () => { it('should return default title for HTTP request action', () => { - const action = workflowActionSchema.parse({ + const action = { id: '1', name: '', type: 'HTTP_REQUEST', @@ -126,7 +130,7 @@ describe('useWorkflowActionHeader', () => { }, }), valid: true, - }); + } satisfies WorkflowHttpRequestAction; const { result } = renderHook(() => useWorkflowActionHeader({ @@ -143,7 +147,7 @@ describe('useWorkflowActionHeader', () => { }); it('should return default title for form action', () => { - const action = workflowActionSchema.parse({ + const action = { id: '1', name: '', type: 'FORM', @@ -165,7 +169,7 @@ describe('useWorkflowActionHeader', () => { }, }), valid: true, - }); + } satisfies WorkflowFormAction; const { result } = renderHook(() => useWorkflowActionHeader({ @@ -182,7 +186,7 @@ describe('useWorkflowActionHeader', () => { }); it('should return default title for email action', () => { - const action = workflowActionSchema.parse({ + const action = { id: '1', name: '', type: 'SEND_EMAIL', @@ -200,7 +204,7 @@ describe('useWorkflowActionHeader', () => { }, }), valid: true, - }); + } satisfies WorkflowSendEmailAction; const { result } = renderHook(() => useWorkflowActionHeader({ diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts index f86b119e1..34e42800d 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionHeaderTypeOrThrow.ts @@ -17,6 +17,11 @@ export const getActionHeaderTypeOrThrow = (actionType: WorkflowActionType) => { return msg`HTTP Request`; case 'AI_AGENT': return msg`AI Agent`; + case 'FILTER': { + throw new Error( + "The Filter action isn't meant to be displayed as a node.", + ); + } default: assertUnreachable(actionType, `Unsupported action type: ${actionType}`); diff --git a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts index 7ae50765f..cb8d14b64 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-steps/workflow-actions/utils/getActionIconColorOrThrow.ts @@ -23,6 +23,11 @@ export const getActionIconColorOrThrow = ({ return theme.color.blue; case 'AI_AGENT': return theme.color.pink; + case 'FILTER': { + throw new Error( + "The Filter action isn't meant to be displayed as a node.", + ); + } default: assertUnreachable(actionType, `Unsupported action type: ${actionType}`); } diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts index e0067f024..2067b585e 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-step/workflow-version-step.workspace-service.ts @@ -576,6 +576,20 @@ export class WorkflowVersionStepWorkspaceService { }, }; } + case WorkflowActionType.FILTER: { + return { + id: newStepId, + name: 'Filter', + type: WorkflowActionType.FILTER, + valid: false, + settings: { + ...BASE_STEP_DEFINITION, + input: { + filter: {}, + }, + }, + }; + } case WorkflowActionType.HTTP_REQUEST: { return { id: newStepId,