diff --git a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts index f534ce747e..3f52cd7ba2 100644 --- a/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts +++ b/packages/twenty-front/src/modules/workflow/workflow-diagram/utils/getOrganizedDiagram.ts @@ -1,36 +1,30 @@ import { type WorkflowDiagram } from '@/workflow/workflow-diagram/types/WorkflowDiagram'; -import Dagre from '@dagrejs/dagre'; +import { computeWorkflowLayout } from 'twenty-shared/workflow'; export const getOrganizedDiagram = ( diagram: WorkflowDiagram, ): WorkflowDiagram => { - const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); - graph.setGraph({ - ranksep: 80, // Vertical distance between 2 nodes - nodesep: 200, // Horizontal distance between 2 nodes - rankdir: 'TB', - }); - - diagram.edges.forEach((edge) => graph.setEdge(edge.source, edge.target)); - diagram.nodes.forEach((node) => - graph.setNode(node.id, { + const positions = computeWorkflowLayout({ + nodes: diagram.nodes.map((node) => ({ + id: node.id, width: node.measured?.width ?? 0, height: node.measured?.height ?? 0, - }), - ); + })), + edges: diagram.edges.map((edge) => ({ + source: edge.source, + target: edge.target, + })), + }); - Dagre.layout(graph); + const positionByNodeId = new Map( + positions.map((position) => [position.id, position.position]), + ); return { nodes: diagram.nodes.map((node) => { - const position = graph.node(node.id); + const position = positionByNodeId.get(node.id); - // We are shifting the dagre node position (anchor=center center) to the top left - // so it matches the React Flow node anchor point (top left). - const x = position.x - position.width / 2; - const y = position.y - position.height / 2; - - return { ...node, position: { x, y } }; + return position ? { ...node, position } : node; }), edges: diagram.edges, }; diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.module.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.module.ts index 76a35bbb5e..18f184fef0 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.module.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.module.ts @@ -6,6 +6,7 @@ import { CacheLockModule } from 'src/engine/core-modules/cache-lock/cache-lock.m import { RecordPositionModule } from 'src/engine/core-modules/record-position/record-position.module'; import { LogicFunctionModule } from 'src/engine/metadata-modules/logic-function/logic-function.module'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-common.module'; import { WorkflowSchemaModule } from 'src/modules/workflow/workflow-builder/workflow-schema/workflow-schema.module'; import { WorkflowVersionStepModule } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.module'; import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service'; @@ -15,6 +16,7 @@ import { WorkflowVersionWorkspaceService } from 'src/modules/workflow/workflow-b WorkflowSchemaModule, LogicFunctionModule, WorkflowVersionStepModule, + WorkflowCommonModule, NestjsQueryTypeOrmModule.forFeature([ObjectMetadataEntity]), RecordPositionModule, CacheLockModule, diff --git a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts index 6a9806fa27..903cb67f64 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-builder/workflow-version/workflow-version.workspace-service.ts @@ -1,7 +1,13 @@ import { Injectable } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; -import { TRIGGER_STEP_ID, WorkflowActionType } from 'twenty-shared/workflow'; +import { + buildWorkflowGraph, + computeWorkflowLayout, + TRIGGER_STEP_ID, + WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS, + WorkflowActionType, +} from 'twenty-shared/workflow'; import { WithLock } from 'src/engine/core-modules/cache-lock/with-lock.decorator'; import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service'; @@ -23,6 +29,7 @@ import { import { assertWorkflowVersionHasSteps } from 'src/modules/workflow/common/utils/assert-workflow-version-has-steps'; import { assertWorkflowVersionIsDraft } from 'src/modules/workflow/common/utils/assert-workflow-version-is-draft.util'; import { assertWorkflowVersionTriggerIsDefined } from 'src/modules/workflow/common/utils/assert-workflow-version-trigger-is-defined.util'; +import { WorkflowCommonWorkspaceService } from 'src/modules/workflow/common/workspace-services/workflow-common.workspace-service'; import { WorkflowVersionStepOperationsWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step-operations.workspace-service'; import { WorkflowVersionStepWorkspaceService } from 'src/modules/workflow/workflow-builder/workflow-version-step/workflow-version-step.workspace-service'; import { type WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; @@ -34,6 +41,7 @@ export class WorkflowVersionWorkspaceService { private readonly workflowVersionStepWorkspaceService: WorkflowVersionStepWorkspaceService, private readonly workflowVersionStepOperationsWorkspaceService: WorkflowVersionStepOperationsWorkspaceService, private readonly recordPositionService: RecordPositionService, + private readonly workflowCommonWorkspaceService: WorkflowCommonWorkspaceService, ) {} @WithLock('workflowId') @@ -382,4 +390,50 @@ export class WorkflowVersionWorkspaceService { await workflowVersionRepository.update(workflowVersionId, updatePayload); }, authContext); } + + async autoLayoutWorkflowVersion({ + workflowVersionId, + workspaceId, + }: { + workflowVersionId: string; + workspaceId: string; + }) { + const workflowVersion = + await this.workflowCommonWorkspaceService.getWorkflowVersionOrFail({ + workspaceId, + workflowVersionId, + }); + + assertWorkflowVersionIsDraft(workflowVersion); + + const steps = workflowVersion.steps ?? []; + + const { childrenByStepId } = buildWorkflowGraph({ + trigger: workflowVersion.trigger, + steps, + }); + + const nodes = [ + { + id: TRIGGER_STEP_ID, + ...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS, + }, + ...steps.map((step) => ({ + id: step.id, + ...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS, + })), + ]; + + const edges = [...childrenByStepId.entries()].flatMap(([source, targets]) => + targets.map((target) => ({ source, target })), + ); + + const positions = computeWorkflowLayout({ nodes, edges }); + + await this.updateWorkflowVersionPositions({ + workflowVersionId, + positions, + workspaceId, + }); + } } diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts index 7f650e9e77..e12a23a99f 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-complete-workflow.tool.ts @@ -32,20 +32,6 @@ const createCompleteWorkflowSchema = z.object({ steps: z .array(workflowActionSchema) .describe('Array of workflow action steps'), - stepPositions: z - .array( - z.object({ - stepId: z - .string() - .describe('The ID of the step (use "trigger" for trigger step)'), - position: z.object({ - x: z.number().describe('X coordinate for the step position'), - y: z.number().describe('Y coordinate for the step position'), - }), - }), - ) - .optional() - .describe('Optional array of step positions for layout'), edges: z .array( z.object({ @@ -92,7 +78,8 @@ CRITICAL SCHEMA REQUIREMENTS: - Each step MUST include: id (must be a valid UUID), name, type, valid, settings - CREATE_RECORD actions MUST have objectName and objectRecord in settings.input - objectRecord must contain actual field values, not just field names -- Use "trigger" as stepId for trigger step in stepPositions and edges +- Use "trigger" as the id for the trigger step in edges +- Step positions are computed automatically; do not provide coordinates Common mistakes to avoid: - Using "RECORD_CREATED" instead of "DATABASE_EVENT" @@ -118,10 +105,6 @@ The response includes a compact validation summary. For the full validation repo description?: string; trigger: WorkflowTrigger; steps: WorkflowAction[]; - stepPositions?: Array<{ - stepId: string; - position: { x: number; y: number }; - }>; edges?: Array<{ source: string; target: string }>; activate?: boolean; }) => { @@ -161,19 +144,6 @@ The response includes a compact validation summary. For the full validation repo steps: parameters.steps, }); - if (parameters.stepPositions && parameters.stepPositions.length > 0) { - const positions = parameters.stepPositions.map((pos) => ({ - id: pos.stepId === 'trigger' ? 'trigger' : pos.stepId, - position: pos.position, - })); - - await deps.workflowVersionService.updateWorkflowVersionPositions({ - workflowVersionId, - positions, - workspaceId: context.workspaceId, - }); - } - if (parameters.edges && parameters.edges.length > 0) { for (const edge of parameters.edges) { await deps.workflowVersionEdgeService.createWorkflowVersionEdge({ @@ -185,6 +155,11 @@ The response includes a compact validation summary. For the full validation repo } } + await deps.workflowVersionService.autoLayoutWorkflowVersion({ + workflowVersionId, + workspaceId: context.workspaceId, + }); + if (parameters.activate) { await deps.workflowTriggerService.activateWorkflowVersion( workflowVersionId, diff --git a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts index 169bfd1995..c5c94697ee 100644 --- a/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts +++ b/packages/twenty-server/src/modules/workflow/workflow-tools/tools/create-workflow-version-step.tool.ts @@ -31,13 +31,6 @@ const baseStepFields = { .string() .optional() .describe('Optional ID of the step this new step should connect to'), - position: z - .object({ - x: z.number(), - y: z.number(), - }) - .optional() - .describe('Optional position coordinates for the step'), }; const nonLogicFunctionStepTypes = Object.values(WorkflowActionType).filter( @@ -100,7 +93,9 @@ const enrichResultWithNextStep = ({ export const createCreateWorkflowVersionStepTool = ( deps: Pick< WorkflowToolDependencies, - 'workflowVersionStepService' | 'workflowVersionStepHelpersService' + | 'workflowVersionStepService' + | 'workflowVersionStepHelpersService' + | 'workflowVersionService' >, context: WorkflowToolContext, ) => ({ @@ -148,6 +143,11 @@ export const createCreateWorkflowVersionStepTool = ( }, }); + await deps.workflowVersionService.autoLayoutWorkflowVersion({ + workflowVersionId: parameters.workflowVersionId, + workspaceId: context.workspaceId, + }); + return enrichResultWithNextStep({ result, stepType: parameters.stepType, diff --git a/packages/twenty-shared/package.json b/packages/twenty-shared/package.json index 8f089b3613..a0b3941be8 100644 --- a/packages/twenty-shared/package.json +++ b/packages/twenty-shared/package.json @@ -43,6 +43,7 @@ "vite-tsconfig-paths": "^4.2.1" }, "dependencies": { + "@dagrejs/dagre": "^1.1.8", "@sniptt/guards": "^0.2.0", "ai": "6.0.97", "class-validator": "^0.14.0", diff --git a/packages/twenty-shared/src/workflow/index.ts b/packages/twenty-shared/src/workflow/index.ts index 22f1d18709..745381058c 100644 --- a/packages/twenty-shared/src/workflow/index.ts +++ b/packages/twenty-shared/src/workflow/index.ts @@ -18,6 +18,15 @@ export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_KEY } from './constants/W export { WORKFLOW_TRIGGER_METADATA_WORKSPACE_MEMBER_ID_LABEL } from './constants/WorkflowTriggerMetadataWorkspaceMemberIdLabel'; export { WORKFLOW_TRIGGER_PAYLOAD_KEY } from './constants/WorkflowTriggerPayloadKey'; export { WORKFLOW_TRIGGER_PAYLOAD_LABEL } from './constants/WorkflowTriggerPayloadLabel'; +export { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from './layout/constants/WorkflowDiagramDefaultNodeDimensions'; +export { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from './layout/constants/WorkflowLayoutDefaultOptions'; +export type { + WorkflowLayoutNode, + WorkflowLayoutEdge, + WorkflowLayoutPosition, + WorkflowLayoutOptions, +} from './layout/utils/compute-workflow-layout.util'; +export { computeWorkflowLayout } from './layout/utils/compute-workflow-layout.util'; export { workflowAiAgentActionSchema } from './schemas/ai-agent-action-schema'; export { workflowAiAgentActionSettingsSchema } from './schemas/ai-agent-action-settings-schema'; export { baseTriggerSchema } from './schemas/base-trigger-schema'; diff --git a/packages/twenty-shared/src/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions.ts b/packages/twenty-shared/src/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions.ts new file mode 100644 index 0000000000..592812fa5b --- /dev/null +++ b/packages/twenty-shared/src/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions.ts @@ -0,0 +1,4 @@ +export const WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS = { + width: 240, + height: 52, +} as const; diff --git a/packages/twenty-shared/src/workflow/layout/constants/WorkflowLayoutDefaultOptions.ts b/packages/twenty-shared/src/workflow/layout/constants/WorkflowLayoutDefaultOptions.ts new file mode 100644 index 0000000000..9b3700f940 --- /dev/null +++ b/packages/twenty-shared/src/workflow/layout/constants/WorkflowLayoutDefaultOptions.ts @@ -0,0 +1,5 @@ +export const WORKFLOW_LAYOUT_DEFAULT_OPTIONS = { + ranksep: 80, // Vertical distance between 2 nodes + nodesep: 200, // Horizontal distance between 2 nodes + rankdir: 'TB', +} as const; diff --git a/packages/twenty-shared/src/workflow/layout/utils/__tests__/compute-workflow-layout.util.test.ts b/packages/twenty-shared/src/workflow/layout/utils/__tests__/compute-workflow-layout.util.test.ts new file mode 100644 index 0000000000..5b1801cb58 --- /dev/null +++ b/packages/twenty-shared/src/workflow/layout/utils/__tests__/compute-workflow-layout.util.test.ts @@ -0,0 +1,99 @@ +import { WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS } from '@/workflow/layout/constants/WorkflowDiagramDefaultNodeDimensions'; +import { + computeWorkflowLayout, + type WorkflowLayoutEdge, + type WorkflowLayoutNode, +} from '@/workflow/layout/utils/compute-workflow-layout.util'; + +const buildNode = (id: string): WorkflowLayoutNode => ({ + id, + ...WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS, +}); + +const doNodesOverlap = ( + a: { x: number; y: number }, + b: { x: number; y: number }, +): boolean => { + const { width, height } = WORKFLOW_DIAGRAM_DEFAULT_NODE_DIMENSIONS; + + return Math.abs(a.x - b.x) < width && Math.abs(a.y - b.y) < height; +}; + +describe('computeWorkflowLayout', () => { + it('should return a position for every node', () => { + const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')]; + const edges: WorkflowLayoutEdge[] = [ + { source: 'trigger', target: 'a' }, + { source: 'a', target: 'b' }, + ]; + + const positions = computeWorkflowLayout({ nodes, edges }); + + expect(positions).toHaveLength(3); + expect(positions.map((position) => position.id).sort()).toEqual([ + 'a', + 'b', + 'trigger', + ]); + }); + + it('should stack a linear chain vertically without overlap (rankdir TB)', () => { + const nodes = [buildNode('trigger'), buildNode('a'), buildNode('b')]; + const edges: WorkflowLayoutEdge[] = [ + { source: 'trigger', target: 'a' }, + { source: 'a', target: 'b' }, + ]; + + const positionById = new Map( + computeWorkflowLayout({ nodes, edges }).map((position) => [ + position.id, + position.position, + ]), + ); + + const trigger = positionById.get('trigger')!; + const stepA = positionById.get('a')!; + const stepB = positionById.get('b')!; + + expect(stepA.y).toBeGreaterThan(trigger.y); + expect(stepB.y).toBeGreaterThan(stepA.y); + }); + + it('should spread if-else branches horizontally without overlap', () => { + const nodes = [ + buildNode('trigger'), + buildNode('ifElse'), + buildNode('branchA'), + buildNode('branchB'), + ]; + const edges: WorkflowLayoutEdge[] = [ + { source: 'trigger', target: 'ifElse' }, + { source: 'ifElse', target: 'branchA' }, + { source: 'ifElse', target: 'branchB' }, + ]; + + const positionById = new Map( + computeWorkflowLayout({ nodes, edges }).map((position) => [ + position.id, + position.position, + ]), + ); + + const branchA = positionById.get('branchA')!; + const branchB = positionById.get('branchB')!; + + expect(branchA.x).not.toEqual(branchB.x); + expect(doNodesOverlap(branchA, branchB)).toBe(false); + }); + + it('should ignore edges pointing to unknown nodes', () => { + const nodes = [buildNode('trigger'), buildNode('a')]; + const edges: WorkflowLayoutEdge[] = [ + { source: 'trigger', target: 'a' }, + { source: 'a', target: 'does-not-exist' }, + ]; + + expect(() => computeWorkflowLayout({ nodes, edges })).not.toThrow(); + expect(computeWorkflowLayout({ nodes, edges })).toHaveLength(2); + }); +}); diff --git a/packages/twenty-shared/src/workflow/layout/utils/compute-workflow-layout.util.ts b/packages/twenty-shared/src/workflow/layout/utils/compute-workflow-layout.util.ts new file mode 100644 index 0000000000..5e5a1708e4 --- /dev/null +++ b/packages/twenty-shared/src/workflow/layout/utils/compute-workflow-layout.util.ts @@ -0,0 +1,69 @@ +import Dagre from '@dagrejs/dagre'; + +import { WORKFLOW_LAYOUT_DEFAULT_OPTIONS } from '@/workflow/layout/constants/WorkflowLayoutDefaultOptions'; + +export type WorkflowLayoutNode = { + id: string; + width: number; + height: number; +}; + +export type WorkflowLayoutEdge = { + source: string; + target: string; +}; + +export type WorkflowLayoutPosition = { + id: string; + position: { x: number; y: number }; +}; + +export type WorkflowLayoutOptions = { + ranksep: number; + nodesep: number; + rankdir: string; +}; + +export const computeWorkflowLayout = ({ + nodes, + edges, + options = WORKFLOW_LAYOUT_DEFAULT_OPTIONS, +}: { + nodes: WorkflowLayoutNode[]; + edges: WorkflowLayoutEdge[]; + options?: WorkflowLayoutOptions; +}): WorkflowLayoutPosition[] => { + const graph = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})); + + graph.setGraph({ + ranksep: options.ranksep, + nodesep: options.nodesep, + rankdir: options.rankdir, + }); + + const nodeIds = new Set(nodes.map((node) => node.id)); + + nodes.forEach((node) => + graph.setNode(node.id, { + width: node.width, + height: node.height, + }), + ); + + edges.forEach((edge) => { + if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) { + graph.setEdge(edge.source, edge.target); + } + }); + + Dagre.layout(graph); + + return nodes.map((node) => { + const layoutedNode = graph.node(node.id); + + const x = layoutedNode.x - layoutedNode.width / 2; + const y = layoutedNode.y - layoutedNode.height / 2; + + return { id: node.id, position: { x, y } }; + }); +}; diff --git a/yarn.lock b/yarn.lock index 8ef1284879..cc7ad077b9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -54927,6 +54927,7 @@ __metadata: dependencies: "@babel/preset-env": "npm:^7.26.9" "@babel/preset-typescript": "npm:^7.24.6" + "@dagrejs/dagre": "npm:^1.1.8" "@lingui/core": "npm:^5.9.5" "@prettier/sync": "npm:^0.5.2" "@sniptt/guards": "npm:^0.2.0"