fix(workflow): improve node organization (#34276)

This commit is contained in:
Wu Tianwei
2026-03-30 21:07:20 +08:00
committed by GitHub
parent ae9a16a397
commit 51f6ca2bed
4 changed files with 123 additions and 188 deletions

View File

@@ -28,7 +28,7 @@ const mockHandleEdgeCancelRunningStatus = vi.hoisted(() => vi.fn())
const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn()) const mockHandleSyncWorkflowDraft = vi.hoisted(() => vi.fn())
const mockSaveStateToHistory = vi.hoisted(() => vi.fn()) const mockSaveStateToHistory = vi.hoisted(() => vi.fn())
const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn()) const mockGetLayoutForChildNodes = vi.hoisted(() => vi.fn())
const mockGetLayoutByDagre = vi.hoisted(() => vi.fn()) const mockGetLayoutByELK = vi.hoisted(() => vi.fn())
const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes)) const mockInitialNodes = vi.hoisted(() => vi.fn((nodes: unknown[], _edges: unknown[]) => nodes))
const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges)) const mockInitialEdges = vi.hoisted(() => vi.fn((edges: unknown[], _nodes: unknown[]) => edges))
@@ -112,7 +112,7 @@ vi.mock('../use-workflow-history', () => ({
vi.mock('../../utils', async importOriginal => ({ vi.mock('../../utils', async importOriginal => ({
...(await importOriginal<typeof import('../../utils')>()), ...(await importOriginal<typeof import('../../utils')>()),
getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args), getLayoutForChildNodes: (...args: unknown[]) => mockGetLayoutForChildNodes(...args),
getLayoutByDagre: (...args: unknown[]) => mockGetLayoutByDagre(...args), getLayoutByELK: (...args: unknown[]) => mockGetLayoutByELK(...args),
initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges), initialNodes: (nodes: unknown[], edges: unknown[]) => mockInitialNodes(nodes, edges),
initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes), initialEdges: (edges: unknown[], nodes: unknown[]) => mockInitialEdges(edges, nodes),
})) }))
@@ -203,7 +203,7 @@ describe('use-workflow-interactions exports', () => {
['loop-child', { x: 40, y: 60, width: 100, height: 60 }], ['loop-child', { x: 40, y: 60, width: 100, height: 60 }],
]), ]),
}) })
mockGetLayoutByDagre.mockResolvedValue({ mockGetLayoutByELK.mockResolvedValue({
nodes: new Map([ nodes: new Map([
['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }], ['loop-node', { x: 10, y: 20, width: 360, height: 260, layer: 0 }],
['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }], ['top-node', { x: 500, y: 30, width: 240, height: 100, layer: 0 }],

View File

@@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { useReactFlow, useStoreApi } from 'reactflow' import { useReactFlow, useStoreApi } from 'reactflow'
import { useWorkflowStore } from '../store' import { useWorkflowStore } from '../store'
import { import {
getLayoutByDagre, getLayoutByELK,
getLayoutForChildNodes, getLayoutForChildNodes,
} from '../utils' } from '../utils'
import { useNodesSyncDraft } from './use-nodes-sync-draft' import { useNodesSyncDraft } from './use-nodes-sync-draft'
@@ -49,7 +49,7 @@ export const useWorkflowOrganize = () => {
nodes, nodes,
getContainerSizeChanges(parentNodes, childLayoutsMap), getContainerSizeChanges(parentNodes, childLayoutsMap),
) )
const layout = await getLayoutByDagre(nodesWithUpdatedSizes, edges) const layout = await getLayoutByELK(nodesWithUpdatedSizes, edges)
const nextNodes = applyLayoutToNodes({ const nextNodes = applyLayoutToNodes({
nodes: nodesWithUpdatedSizes, nodes: nodesWithUpdatedSizes,
layout, layout,

View File

@@ -5,7 +5,7 @@ import { CUSTOM_ITERATION_START_NODE } from '../../nodes/iteration-start/constan
import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants' import { CUSTOM_LOOP_START_NODE } from '../../nodes/loop-start/constants'
import { BlockEnum } from '../../types' import { BlockEnum } from '../../types'
type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string }>, layoutOptions?: Record<string, string> } type ElkChild = Record<string, unknown> & { id: string, width?: number, height?: number, x?: number, y?: number, children?: ElkChild[], ports?: Array<{ id: string, layoutOptions?: Record<string, string> }>, layoutOptions?: Record<string, string> }
type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> } type ElkGraph = Record<string, unknown> & { id: string, children?: ElkChild[], edges?: Array<Record<string, unknown>> }
let layoutCallArgs: ElkGraph | null = null let layoutCallArgs: ElkGraph | null = null
@@ -32,7 +32,7 @@ vi.mock('elkjs/lib/elk.bundled.js', () => {
} }
}) })
const { getLayoutByDagre, getLayoutForChildNodes } = await import('../elk-layout') const { getLayoutByELK, getLayoutForChildNodes } = await import('../elk-layout')
function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node { function makeWorkflowNode(overrides: Omit<Partial<Node>, 'data'> & { data?: Partial<CommonNodeType> & Record<string, unknown> } = {}): Node {
return createNode({ return createNode({
@@ -51,7 +51,7 @@ beforeEach(() => {
mockReturnOverride = null mockReturnOverride = null
}) })
describe('getLayoutByDagre', () => { describe('getLayoutByELK', () => {
it('should return layout for simple linear graph', async () => { it('should return layout for simple linear graph', async () => {
const nodes = [ const nodes = [
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
@@ -59,7 +59,7 @@ describe('getLayoutByDagre', () => {
] ]
const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })] const edges = [makeWorkflowEdge({ source: 'a', target: 'b' })]
const result = await getLayoutByDagre(nodes, edges) const result = await getLayoutByELK(nodes, edges)
expect(result.nodes.size).toBe(2) expect(result.nodes.size).toBe(2)
expect(result.nodes.has('a')).toBe(true) expect(result.nodes.has('a')).toBe(true)
@@ -74,7 +74,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }), makeWorkflowNode({ id: 'child', data: { type: BlockEnum.Code, title: '', desc: '' }, parentId: 'a' }),
] ]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(1) expect(result.nodes.size).toBe(1)
expect(result.nodes.has('child')).toBe(false) expect(result.nodes.has('child')).toBe(false)
}) })
@@ -85,7 +85,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }), makeWorkflowNode({ id: 'iter-start', type: CUSTOM_ITERATION_START_NODE, data: { type: BlockEnum.IterationStart, title: '', desc: '' } }),
] ]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(1) expect(result.nodes.size).toBe(1)
}) })
@@ -98,7 +98,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }), makeWorkflowEdge({ source: 'a', target: 'b', data: { isInIteration: true, iteration_id: 'iter-1' } }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0) expect(layoutCallArgs!.edges).toHaveLength(0)
}) })
@@ -107,7 +107,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(node, 'width') Reflect.deleteProperty(node, 'width')
Reflect.deleteProperty(node, 'height') Reflect.deleteProperty(node, 'height')
const result = await getLayoutByDagre([node], []) const result = await getLayoutByELK([node], [])
expect(result.nodes.size).toBe(1) expect(result.nodes.size).toBe(1)
const info = result.nodes.get('a')! const info = result.nodes.get('a')!
expect(info.width).toBe(244) expect(info.width).toBe(244)
@@ -133,13 +133,13 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toHaveLength(2) expect(ifElkNode.ports).toHaveLength(2)
expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER') expect(ifElkNode.layoutOptions!['elk.portConstraints']).toBe('FIXED_ORDER')
}) })
it('should use normal node for IfElse with single branch', async () => { it('should build ports for IfElse even with single branch', async () => {
const nodes = [ const nodes = [
makeWorkflowNode({ makeWorkflowNode({
id: 'if-1', id: 'if-1',
@@ -149,9 +149,10 @@ describe('getLayoutByDagre', () => {
] ]
const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })] const edges = [makeWorkflowEdge({ source: 'if-1', target: 'b', sourceHandle: 'case-1' })]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifElkNode.ports).toBeUndefined() expect(ifElkNode.ports).toHaveLength(1)
expect(ifElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST')
}) })
it('should build ports for HumanInput nodes with multiple branches', async () => { it('should build ports for HumanInput nodes with multiple branches', async () => {
@@ -168,12 +169,12 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }), makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toHaveLength(2) expect(hiElkNode.ports).toHaveLength(2)
}) })
it('should use normal node for HumanInput with single branch', async () => { it('should build ports for HumanInput even with single branch', async () => {
const nodes = [ const nodes = [
makeWorkflowNode({ makeWorkflowNode({
id: 'hi-1', id: 'hi-1',
@@ -183,20 +184,21 @@ describe('getLayoutByDagre', () => {
] ]
const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })] const edges = [makeWorkflowEdge({ source: 'hi-1', target: 'b', sourceHandle: 'action-1' })]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiElkNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiElkNode.ports).toBeUndefined() expect(hiElkNode.ports).toHaveLength(1)
expect(hiElkNode.ports![0].layoutOptions!['elk.port.side']).toBe('EAST')
}) })
it('should normalise bounds so minX and minY start at 0', async () => { it('should normalise bounds so minX and minY start at 0', async () => {
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.bounds.minX).toBe(0) expect(result.bounds.minX).toBe(0)
expect(result.bounds.minY).toBe(0) expect(result.bounds.minY).toBe(0)
}) })
it('should return empty layout when no nodes match filter', async () => { it('should return empty layout when no nodes match filter', async () => {
const result = await getLayoutByDagre([], []) const result = await getLayoutByELK([], [])
expect(result.nodes.size).toBe(0) expect(result.nodes.size).toBe(0)
expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 }) expect(result.bounds).toEqual({ minX: 0, minY: 0, maxX: 0, maxY: 0 })
}) })
@@ -225,7 +227,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }), makeWorkflowEdge({ id: 'e-b', source: 'if-1', target: 'y', sourceHandle: 'case-b' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
const portIds = ifNode.ports!.map((p: { id: string }) => p.id) const portIds = ifNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('false') expect(portIds[portIds.length - 1]).toContain('false')
@@ -247,7 +249,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }), makeWorkflowEdge({ id: 'e-a2', source: 'hi-1', target: 'y', sourceHandle: 'a2' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
const portIds = hiNode.ports!.map((p: { id: string }) => p.id) const portIds = hiNode.ports!.map((p: { id: string }) => p.id)
expect(portIds[portIds.length - 1]).toContain('__timeout') expect(portIds[portIds.length - 1]).toContain('__timeout')
@@ -267,7 +269,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const portEdges = layoutCallArgs!.edges!.filter((e: Record<string, unknown>) => e.sourcePort) const portEdges = layoutCallArgs!.edges!.filter((e: Record<string, unknown>) => e.sourcePort)
expect(portEdges.length).toBeGreaterThan(0) expect(portEdges.length).toBeGreaterThan(0)
}) })
@@ -286,7 +288,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle')
const result = await getLayoutByDagre(nodes, [e1, e2]) const result = await getLayoutByELK(nodes, [e1, e2])
expect(result.nodes.size).toBeGreaterThan(0) expect(result.nodes.size).toBeGreaterThan(0)
}) })
@@ -299,7 +301,7 @@ describe('getLayoutByDagre', () => {
}) })
const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })] const nodes = [makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } })]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
const info = result.nodes.get('a')! const info = result.nodes.get('a')!
expect(info.x).toBe(0) expect(info.x).toBe(0)
expect(info.y).toBe(0) expect(info.y).toBe(0)
@@ -326,7 +328,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }), makeWorkflowNode({ id: 'a', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'b', data: { type: BlockEnum.Code, title: '', desc: '' } }),
] ]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.nodes.get('a')!.layer).toBe(0) expect(result.nodes.get('a')!.layer).toBe(0)
expect(result.nodes.get('b')!.layer).toBe(1) expect(result.nodes.get('b')!.layer).toBe(1)
}) })
@@ -354,7 +356,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'nested-1', data: { type: BlockEnum.Code, title: '', desc: '' } }),
makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'nested-2', data: { type: BlockEnum.Code, title: '', desc: '' } }),
] ]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.nodes.has('nested-1')).toBe(true) expect(result.nodes.has('nested-1')).toBe(true)
expect(result.nodes.has('nested-2')).toBe(true) expect(result.nodes.has('nested-2')).toBe(true)
}) })
@@ -372,7 +374,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }), makeWorkflowNode({ id: 'visible', data: { type: BlockEnum.Start, title: '', desc: '' } }),
makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }), makeWorkflowNode({ id: 'also-visible', data: { type: BlockEnum.Code, title: '', desc: '' } }),
] ]
const result = await getLayoutByDagre(nodes, []) const result = await getLayoutByELK(nodes, [])
expect(result.nodes.size).toBe(2) expect(result.nodes.size).toBe(2)
}) })
@@ -390,7 +392,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }), makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'other-unknown' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2) expect(ifNode.ports).toHaveLength(2)
}) })
@@ -409,7 +411,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }), makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: 'another-unknown' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2) expect(hiNode.ports).toHaveLength(2)
}) })
@@ -428,7 +430,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2]) await getLayoutByELK(nodes, [e1, e2])
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2) expect(ifNode.ports).toHaveLength(2)
}) })
@@ -447,7 +449,7 @@ describe('getLayoutByDagre', () => {
Reflect.deleteProperty(e1, 'sourceHandle') Reflect.deleteProperty(e1, 'sourceHandle')
Reflect.deleteProperty(e2, 'sourceHandle') Reflect.deleteProperty(e2, 'sourceHandle')
await getLayoutByDagre(nodes, [e1, e2]) await getLayoutByELK(nodes, [e1, e2])
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2) expect(hiNode.ports).toHaveLength(2)
}) })
@@ -463,7 +465,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }), makeWorkflowEdge({ id: 'e2', source: 'if-1', target: 'c', sourceHandle: 'false' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')! const ifNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'if-1')!
expect(ifNode.ports).toHaveLength(2) expect(ifNode.ports).toHaveLength(2)
}) })
@@ -479,7 +481,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }), makeWorkflowEdge({ id: 'e2', source: 'hi-1', target: 'c', sourceHandle: '__timeout' }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')! const hiNode = layoutCallArgs!.children!.find((c: ElkChild) => c.id === 'hi-1')!
expect(hiNode.ports).toHaveLength(2) expect(hiNode.ports).toHaveLength(2)
}) })
@@ -492,7 +494,7 @@ describe('getLayoutByDagre', () => {
makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }), makeWorkflowEdge({ source: 'x', target: 'y', data: { isInLoop: true, loop_id: 'loop-1' } }),
] ]
await getLayoutByDagre(nodes, edges) await getLayoutByELK(nodes, edges)
expect(layoutCallArgs!.edges).toHaveLength(0) expect(layoutCallArgs!.edges).toHaveLength(0)
}) })
}) })

View File

@@ -18,9 +18,6 @@ import {
BlockEnum, BlockEnum,
} from '@/app/components/workflow/types' } from '@/app/components/workflow/types'
// Although the file name refers to Dagre, the implementation now relies on ELK's layered algorithm.
// Keep the export signatures unchanged to minimise the blast radius while we migrate the layout stack.
const elk = new ELK() const elk = new ELK()
const DEFAULT_NODE_WIDTH = 244 const DEFAULT_NODE_WIDTH = 244
@@ -41,7 +38,6 @@ const ROOT_LAYOUT_OPTIONS = {
// === Port Configuration === // === Port Configuration ===
'elk.portConstraints': 'FIXED_ORDER', 'elk.portConstraints': 'FIXED_ORDER',
'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES', 'elk.layered.considerModelOrder.strategy': 'PREFER_EDGES',
'elk.port.side': 'SOUTH',
// === Node Placement - Best quality === // === Node Placement - Best quality ===
'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX', 'elk.layered.nodePlacement.strategy': 'NETWORK_SIMPLEX',
@@ -278,32 +274,16 @@ const collectLayout = (graph: ElkNode, predicate: (id: string) => boolean): Layo
} }
} }
/** const sortIfElseOutEdges = (ifElseNode: Node, outEdges: Edge[]): Edge[] => {
* Build If/Else node with ELK native Ports instead of dummy nodes return [...outEdges].sort((edgeA, edgeB) => {
* This is the recommended approach for handling multiple branches
*/
const buildIfElseWithPorts = (
ifElseNode: Node,
edges: Edge[],
): { node: ElkNodeShape, portMap: Map<string, string> } | null => {
const childEdges = edges.filter(edge => edge.source === ifElseNode.id)
if (childEdges.length <= 1)
return null
// Sort child edges according to case order
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle const handleB = edgeB.sourceHandle
if (handleA && handleB) { if (handleA && handleB) {
const cases = (ifElseNode.data as IfElseNodeType).cases || [] const cases = (ifElseNode.data as IfElseNodeType).cases || []
const isAElse = handleA === 'false' if (handleA === 'false')
const isBElse = handleB === 'false'
if (isAElse)
return 1 return 1
if (isBElse) if (handleB === 'false')
return -1 return -1
const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA) const indexA = cases.findIndex((c: CaseItem) => c.case_id === handleA)
@@ -315,67 +295,20 @@ const buildIfElseWithPorts = (
return 0 return 0
}) })
// Create ELK ports for each branch
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
id: `${ifElseNode.id}-port-${edge.sourceHandle || index}`,
layoutOptions: {
'port.side': 'EAST', // Ports on the right side (matching 'RIGHT' direction)
'port.index': String(index),
},
}))
// Build port mapping: sourceHandle -> portId
const portMap = new Map<string, string>()
sortedChildEdges.forEach((edge, index) => {
const portId = `${ifElseNode.id}-port-${edge.sourceHandle || index}`
portMap.set(edge.id, portId)
})
return {
node: {
id: ifElseNode.id,
width: ifElseNode.width ?? DEFAULT_NODE_WIDTH,
height: ifElseNode.height ?? DEFAULT_NODE_HEIGHT,
ports,
layoutOptions: {
'elk.portConstraints': 'FIXED_ORDER',
},
},
portMap,
}
} }
/** const sortHumanInputOutEdges = (humanInputNode: Node, outEdges: Edge[]): Edge[] => {
* Build Human Input node with ELK native Ports for multiple branches return [...outEdges].sort((edgeA, edgeB) => {
* Handles user actions as branches with __timeout as the last fixed branch
*/
const buildHumanInputWithPorts = (
humanInputNode: Node,
edges: Edge[],
): { node: ElkNodeShape, portMap: Map<string, string> } | null => {
const childEdges = edges.filter(edge => edge.source === humanInputNode.id)
if (childEdges.length <= 1)
return null
// Sort child edges: user actions first (by order), then __timeout last
const sortedChildEdges = [...childEdges].sort((edgeA, edgeB) => {
const handleA = edgeA.sourceHandle const handleA = edgeA.sourceHandle
const handleB = edgeB.sourceHandle const handleB = edgeB.sourceHandle
if (handleA && handleB) { if (handleA && handleB) {
const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || [] const userActions = (humanInputNode.data as HumanInputNodeType).user_actions || []
const isATimeout = handleA === '__timeout' if (handleA === '__timeout')
const isBTimeout = handleB === '__timeout'
// __timeout should always be last
if (isATimeout)
return 1 return 1
if (isBTimeout) if (handleB === '__timeout')
return -1 return -1
// Sort by user_actions order
const indexA = userActions.findIndex(action => action.id === handleA) const indexA = userActions.findIndex(action => action.id === handleA)
const indexB = userActions.findIndex(action => action.id === handleB) const indexB = userActions.findIndex(action => action.id === handleB)
@@ -385,35 +318,6 @@ const buildHumanInputWithPorts = (
return 0 return 0
}) })
// Create ELK ports for each branch
const ports: ElkPortShape[] = sortedChildEdges.map((edge, index) => ({
id: `${humanInputNode.id}-port-${edge.sourceHandle || index}`,
layoutOptions: {
'port.side': 'EAST',
'port.index': String(index),
},
}))
// Build port mapping: edge.id -> portId
const portMap = new Map<string, string>()
sortedChildEdges.forEach((edge, index) => {
const portId = `${humanInputNode.id}-port-${edge.sourceHandle || index}`
portMap.set(edge.id, portId)
})
return {
node: {
id: humanInputNode.id,
width: humanInputNode.width ?? DEFAULT_NODE_WIDTH,
height: humanInputNode.height ?? DEFAULT_NODE_HEIGHT,
ports,
layoutOptions: {
'elk.portConstraints': 'FIXED_ORDER',
},
},
portMap,
}
} }
const normaliseBounds = (layout: LayoutResult): LayoutResult => { const normaliseBounds = (layout: LayoutResult): LayoutResult => {
@@ -448,58 +352,87 @@ const normaliseBounds = (layout: LayoutResult): LayoutResult => {
} }
} }
export const getLayoutByDagre = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => { export const getLayoutByELK = async (originNodes: Node[], originEdges: Edge[]): Promise<LayoutResult> => {
edgeCounter = 0 edgeCounter = 0
const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE) const nodes = cloneDeep(originNodes).filter(node => !node.parentId && node.type === CUSTOM_NODE)
const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop)) const edges = cloneDeep(originEdges).filter(edge => (!edge.data?.isInIteration && !edge.data?.isInLoop))
const elkNodes: ElkNodeShape[] = [] const outEdgesByNode = new Map<string, Edge[]>()
const elkEdges: ElkEdgeShape[] = [] const inEdgesByNode = new Map<string, Edge[]>()
edges.forEach((edge) => {
// Track which edges have been processed for If/Else nodes with ports if (!outEdgesByNode.has(edge.source))
const edgeToPortMap = new Map<string, string>() outEdgesByNode.set(edge.source, [])
outEdgesByNode.get(edge.source)!.push(edge)
// Build nodes with ports for If/Else and Human Input nodes if (!inEdgesByNode.has(edge.target))
nodes.forEach((node) => { inEdgesByNode.set(edge.target, [])
if (node.data.type === BlockEnum.IfElse) { inEdgesByNode.get(edge.target)!.push(edge)
const portsResult = buildIfElseWithPorts(node, edges)
if (portsResult) {
// Use node with ports
elkNodes.push(portsResult.node)
// Store port mappings for edges
portsResult.portMap.forEach((portId, edgeId) => {
edgeToPortMap.set(edgeId, portId)
})
}
else {
// No multiple branches, use normal node
elkNodes.push(toElkNode(node))
}
}
else if (node.data.type === BlockEnum.HumanInput) {
const portsResult = buildHumanInputWithPorts(node, edges)
if (portsResult) {
// Use node with ports
elkNodes.push(portsResult.node)
// Store port mappings for edges
portsResult.portMap.forEach((portId, edgeId) => {
edgeToPortMap.set(edgeId, portId)
})
}
else {
// No multiple branches, use normal node
elkNodes.push(toElkNode(node))
}
}
else {
elkNodes.push(toElkNode(node))
}
}) })
// Build edges with port connections const elkNodes: ElkNodeShape[] = []
edges.forEach((edge) => { const elkEdges: ElkEdgeShape[] = []
const sourcePort = edgeToPortMap.get(edge.id) const sourcePortMap = new Map<string, string>()
elkEdges.push(createEdge(edge.source, edge.target, sourcePort)) const targetPortMap = new Map<string, string>()
const sortedOutEdgesByNode = new Map<string, Edge[]>()
nodes.forEach((node) => {
const inEdges = inEdgesByNode.get(node.id) || []
let outEdges = outEdgesByNode.get(node.id) || []
if (node.data.type === BlockEnum.IfElse)
outEdges = sortIfElseOutEdges(node, outEdges)
else if (node.data.type === BlockEnum.HumanInput)
outEdges = sortHumanInputOutEdges(node, outEdges)
sortedOutEdgesByNode.set(node.id, outEdges)
const ports: ElkPortShape[] = []
inEdges.forEach((edge, index) => {
const portId = `${node.id}-in-${index}`
ports.push({
id: portId,
layoutOptions: {
'elk.port.side': 'WEST',
'elk.port.index': String(index),
},
})
targetPortMap.set(edge.id, portId)
})
outEdges.forEach((edge, index) => {
const portId = `${node.id}-out-${edge.sourceHandle || index}`
ports.push({
id: portId,
layoutOptions: {
'elk.port.side': 'EAST',
'elk.port.index': String(index),
},
})
sourcePortMap.set(edge.id, portId)
})
elkNodes.push({
id: node.id,
width: node.width ?? DEFAULT_NODE_WIDTH,
height: node.height ?? DEFAULT_NODE_HEIGHT,
...(ports.length > 0 && {
ports,
layoutOptions: { 'elk.portConstraints': 'FIXED_ORDER' },
}),
})
})
// Build edges in sorted per-node order so PREFER_EDGES aligns with port order
nodes.forEach((node) => {
const outEdges = sortedOutEdgesByNode.get(node.id) || []
outEdges.forEach((edge) => {
elkEdges.push(createEdge(
edge.source,
edge.target,
sourcePortMap.get(edge.id),
targetPortMap.get(edge.id),
))
})
}) })
const graph = { const graph = {