Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/containment-auto-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Add containment support integrated with auto-layout.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -39,39 +39,80 @@ export type Size = {
export type WayPoints = Point[];

export const ROOT_LAYOUT_OPTIONS: LayoutOptions = {
"elk.algorithm": "org.eclipse.elk.layered",
"elk.hierarchyHandling": "INCLUDE_CHILDREN",
"elk.direction": "DOWN",
"org.eclipse.elk.layered.layering.strategy": "INTERACTIVE",
"org.eclipse.elk.edgeRouting": "ORTHOGONAL",
"elk.layered.unnecessaryBendpoints": "true",
"org.eclipse.elk.algorithm": "org.eclipse.elk.layered",
"org.eclipse.elk.direction": "DOWN",
"org.eclipse.elk.layered.nodePlacement.strategy": "BRANDES_KOEPF",
"org.eclipse.elk.layered.nodePlacement.bk.fixedAlignment": "BALANCED",
"org.eclipse.elk.layered.nodePlacement.bk.edgeStraightening": "IMPROVE_STRAIGHTNESS",
"org.eclipse.elk.layered.cycleBreaking.strategy": "DEPTH_FIRST",
"org.eclipse.elk.insideSelfLoops.activate": "true",
"elk.separateConnectedComponents": "false",
"org.eclipse.elk.layered.nodePlacement.favorStraightEdges": "true",
"org.eclipse.elk.layered.considerModelOrder.strategy": "EDGES",
"org.eclipse.elk.layered.priority.straightness": "10",
"org.eclipse.elk.hierarchyHandling": "INCLUDE_CHILDREN",
"org.eclipse.elk.layered.crossingMinimization.strategy": "LAYER_SWEEP",
"org.eclipse.elk.edgeRouting": "ORTHOGONAL",
"org.eclipse.elk.layered.unnecessaryBendpoints": "true",
"org.eclipse.elk.layered.cycleBreaking.strategy": "GREEDY_MODEL_ORDER",
"org.eclipse.elk.layered.considerModelOrder.crossingCounterNodeInfluence": "0.001",
"elk.layered.crossingMinimization.strategy": "INTERACTIVE",
spacing: "75",
"spacing.componentComponent": "70",
"spacing.nodeNodeBetweenLayers": "80",
"elk.layered.spacing.edgeNodeBetweenLayers": "40",
"org.eclipse.elk.spacing.edgeNode": "24",
"org.eclipse.elk.layered.spacing.edgeNode": "24",
"org.eclipse.elk.layered.spacing.edgeNodeBetweenLayers": "40",
"org.eclipse.elk.layered.spacing.nodeNode": "24",
"org.eclipse.elk.layered.spacing.nodeNodeBetweenLayers": "80",
"org.eclipse.elk.layered.spacing.componentComponent": "70",
"org.eclipse.elk.layered.mergeEdges": "true",
};

export const PARENT_LAYOUT_OPTIONS: LayoutOptions = {
...ROOT_LAYOUT_OPTIONS,
"org.eclipse.elk.padding": "[top=60,left=20,bottom=20,right=20]",
};

// Helper function to clean up empty edges arrays from nodes
function cleanupEmptyEdges(node: ElkNode): void {
if (node.edges && node.edges.length === 0) {
delete node.edges;
}
if (node.children) {
node.children.forEach(cleanupEmptyEdges);
}
}

// Helper function to find the common ancestor of two nodes
function findCommonAncestor(
sourceId: string,
targetId: string,
reactFlowNodeMap: Map<string, { id: string; parentId?: string | undefined }>,
): string {
// Build path from source to root
const sourcePath = new Set<string>();
let currentId: string | undefined = sourceId;
while (currentId) {
sourcePath.add(currentId);
const node = reactFlowNodeMap.get(currentId);
currentId = node?.parentId;
}

// Traverse from target to root and find first common node
currentId = targetId;
while (currentId) {
if (sourcePath.has(currentId)) {
return currentId;
}
const node = reactFlowNodeMap.get(currentId);
currentId = node?.parentId;
}

// If no common ancestor found, return "root"
return "root";
}

export function buildElkGraphFromReactFlowGraph(reactFlowGraph: ReactFlowGraph): ElkNode {
// Create a map for easy lookup
const nodeMap = new Map(
// Create a map for easy lookup (without width/height initially)
const nodeMap = new Map<string, ElkNode>(
reactFlowGraph.nodes.map((node) => [
node.id,
{
id: node.id,
width: node.measured?.width ?? DEFAULT_NODE_SIZE.width,
height: node.measured?.height ?? DEFAULT_NODE_SIZE.height,
children: [] as ElkNode[],
edges: [] as ElkExtendedEdge[],
},
]),
);
Expand All @@ -81,24 +122,64 @@ export function buildElkGraphFromReactFlowGraph(reactFlowGraph: ReactFlowGraph):
reactFlowGraph.nodes.forEach((node) => {
const elkNode = nodeMap.get(node.id)!;
if (node.parentId && nodeMap.has(node.parentId)) {
nodeMap.get(node.parentId)!.children.push(elkNode);
const parentNode = nodeMap.get(node.parentId)!;
if (!parentNode.children) {
parentNode.children = [];
}
parentNode.children.push(elkNode);
} else {
rootChildren.push(elkNode);
}
});

// edges
const elkEdges: ElkExtendedEdge[] = reactFlowGraph.edges.map((edge) => ({
id: edge.id,
sources: [edge.source],
targets: [edge.target],
}));
// Apply layout options and dimensions based on whether node has children
reactFlowGraph.nodes.forEach((node) => {
const elkNode = nodeMap.get(node.id)!;
if (elkNode.children && elkNode.children.length > 0) {
// Nodes with children get layout options but no fixed dimensions
elkNode.layoutOptions = { ...PARENT_LAYOUT_OPTIONS };
} else {
// Leaf nodes get fixed dimensions
elkNode.width = node.measured?.width ?? DEFAULT_NODE_SIZE.width;
elkNode.height = node.measured?.height ?? DEFAULT_NODE_SIZE.height;
}
});

const reactFlowNodeMap = new Map(reactFlowGraph.nodes.map((node) => [node.id, node]));

// Nest edges in the appropriate hierarchy level
const rootEdges: ElkExtendedEdge[] = [];
reactFlowGraph.edges.forEach((edge) => {
const elkEdge: ElkExtendedEdge = {
id: edge.id,
sources: [edge.source],
targets: [edge.target],
};

// Find the lowest common ancestor that contains both source and target
const commonAncestor = findCommonAncestor(edge.source, edge.target, reactFlowNodeMap);

if (commonAncestor === "root") {
rootEdges.push(elkEdge);
} else {
const ancestorNode = nodeMap.get(commonAncestor);
if (ancestorNode) {
if (!ancestorNode.edges) {
ancestorNode.edges = [];
}
ancestorNode.edges.push(elkEdge);
}
}
});

// Clean up empty edges arrays from nodes that don't need them
rootChildren.forEach(cleanupEmptyEdges);

return {
id: "root",
layoutOptions: ROOT_LAYOUT_OPTIONS,
children: rootChildren,
edges: elkEdges,
edges: rootEdges,
};
}

Expand All @@ -116,14 +197,48 @@ function buildElkNodeMap(
return map;
}

// Helper function to recursively collect all edges from ELK graph
function buildElkEdgeMap(
elkNode: ElkNode,
map: Map<string, ElkExtendedEdge> = new Map(),
): Map<string, ElkExtendedEdge> {
if (elkNode.edges) {
for (const edge of elkNode.edges) {
map.set(edge.id, edge);
}
}
if (elkNode.children) {
for (const child of elkNode.children) {
buildElkEdgeMap(child, map);
}
}
return map;
}

// Helper function to check if an edge is inside a parent node
function isEdgeInsideParent(
edge: { source: string; target: string },
nodeMap: Map<string, { id: string; parentId: string | undefined }>,
): boolean {
// Edge is inside a parent if the lowest common ancestor is not the root
// This matches the logic used in findCommonAncestor when building the ELK graph
const commonAncestor = findCommonAncestor(edge.source, edge.target, nodeMap);
return commonAncestor !== "root";
}
Comment thread
handreyrc marked this conversation as resolved.

// set
export function matchReactFlowGraphWithElkLayoutedGraph(
graph: ReactFlowGraph,
layoutedGraph: ElkNode,
): ReactFlowGraph {
// Build flat maps for O(1) lookups
const elkNodeMap = buildElkNodeMap(layoutedGraph);
const elkEdgeMap = new Map(layoutedGraph.edges?.map((e) => [e.id, e]) || []);
const elkEdgeMap = buildElkEdgeMap(layoutedGraph);

// Build node map for O(1) lookups in isEdgeInsideParent
const reactFlowNodeMap = new Map(
graph.nodes.map((node) => [node.id, { id: node.id, parentId: node.parentId }]),
);

// Map node positions
const layoutedNodes = graph.nodes.map((node) => {
Expand All @@ -143,15 +258,27 @@ export function matchReactFlowGraphWithElkLayoutedGraph(
const layoutedEdges = graph.edges.map((edge) => {
const elkEdge = elkEdgeMap.get(edge.id);
if (elkEdge) {
// Reconstruct data without old wayPoints to avoid stale routing whenever ELK produced this edge.
// Reconstruct data without old wayPoints to avoid stale routing
const { wayPoints: _oldWayPoints, ...restData } = edge.data || {};
const bendPoints = elkEdge.sections?.flatMap((section) => section.bendPoints || []) || [];

// Always create new data object, only add wayPoints if there are bend points
const newData = { ...restData };
if (bendPoints.length > 0) {
// Drop ELK-provided way points for edges nested inside a parent to avoid React Flow rendering distortion
const isInsideParent = isEdgeInsideParent(edge, reactFlowNodeMap);
if (isInsideParent) {
// There is an incompatibility with the react flow library, the wayPoints are calculated correctly by ELK
// but the way react flow render edges inside parent nodes cause path distortions.
newData.wayPoints = undefined;
} else {
newData.wayPoints = bendPoints;
}
}

return {
...edge,
data: {
...restData,
...(bendPoints.length > 0 && { wayPoints: bendPoints }),
},
data: newData,
};
}
return edge;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ function buildReactFlowNode(
height: DEFAULT_NODE_SIZE.height,
width: DEFAULT_NODE_SIZE.width,
position: { x: 0, y: 0 },
...(graphNode.parentId !== "root" && { parentId: graphNode.parentId, extent: "parent" }),
};
}

Expand Down Expand Up @@ -121,12 +122,9 @@ export function buildDiagramElements(model: sdk.Specification.Workflow | null):
const graph = buildFlatGraph(model);
const catchContainerIds = getCatchContainerNodeIds(graph);

graph.nodes.forEach((graphNode) => {
// TODO: only nodes on root level are supported for now
if (graphNode.parentId === "root") {
nodes.push(buildReactFlowNode(graphNode, catchContainerIds));
}
});
graph.nodes.forEach((graphNode) =>
nodes.push(buildReactFlowNode(graphNode, catchContainerIds)),
);

// Precompute node ID set for O(1) membership checks
const nodeIdSet = new Set(nodes.map((node) => node.id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export const CATCH_CONTAINER_NODE_TYPE = "catch-container";
export const ReactFlowNodeTypes: RF.NodeTypes = {
[GraphNodeType.Start]: StartNode,
[GraphNodeType.End]: EndNode,
[GraphNodeType.Entry]: EntryNode,
[GraphNodeType.Exit]: ExitNode,
[GraphNodeType.Call]: CallNode,
[GraphNodeType.Do]: DoNode,
[GraphNodeType.Emit]: EmitNode,
Expand Down Expand Up @@ -158,6 +160,20 @@ export function EndNode({ id, data, selected, type }: RF.NodeProps<EndNodeType>)
return <PlaceholderContent id={id} data={data} selected={selected} type={type} />;
}

/* entry node */
export type EntryNodeType = RF.Node<BaseNodeData, typeof GraphNodeType.Entry>;
export function EntryNode({ id, data, selected, type }: RF.NodeProps<EntryNodeType>) {
// TODO: This component is just a placeholder
return <PlaceholderContent id={id} data={data} selected={selected} type={type} />;
}

/* exit node */
export type ExitNodeType = RF.Node<BaseNodeData, typeof GraphNodeType.Exit>;
export function ExitNode({ id, data, selected, type }: RF.NodeProps<ExitNodeType>) {
// TODO: This component is just a placeholder
return <PlaceholderContent id={id} data={data} selected={selected} type={type} />;
}

/* call leaf node */
export type CallNodeType = RF.Node<BaseNodeData<Specification.CallTask>, typeof GraphNodeType.Call>;
export function CallNode({ id, data, selected, type }: RF.NodeProps<CallNodeType>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ test("diagram editor renders correctly", async ({ page }) => {

// Check total nodes
const nodes = page.locator('[data-testid^="rf__node-"]');
await expect(nodes).toHaveCount(6);
await expect(nodes).toHaveCount(9);

// Check total edge
const edges = page.locator('[data-testid^="rf__edge-"]');
await expect(edges).toHaveCount(5);
await expect(edges).toHaveCount(7);
});
Loading
Loading