diff --git a/packages/serverless-workflow-diagram-editor/package.json b/packages/serverless-workflow-diagram-editor/package.json index 50ccf70..184a523 100644 --- a/packages/serverless-workflow-diagram-editor/package.json +++ b/packages/serverless-workflow-diagram-editor/package.json @@ -46,6 +46,7 @@ "@xyflow/react": "catalog:", "class-variance-authority": "catalog:", "clsx": "catalog:", + "elkjs": "catalog:", "js-yaml": "catalog:", "radix-ui": "catalog:" }, diff --git a/packages/serverless-workflow-diagram-editor/src/core/elkjs.ts b/packages/serverless-workflow-diagram-editor/src/core/elkjs.ts new file mode 100644 index 0000000..4a9982f --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/core/elkjs.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ELK, { ElkNode } from "elkjs/lib/elk.bundled.js"; + +const elk = new ELK(); + +export async function processElkLayout( + graph: ElkNode, + signal?: AbortSignal, +): Promise { + try { + // Check if already aborted before starting + if (signal?.aborted) { + throw new DOMException("Layout operation aborted", "AbortError"); + } + + // Create a promise that rejects when the signal is aborted + const abortPromise = new Promise((_, reject) => { + if (signal) { + signal.addEventListener("abort", () => { + reject(new DOMException("Layout operation aborted", "AbortError")); + }); + } + }); + + // Race between layout calculation and abort signal + const layoutPromise = elk.layout(graph); + + // If signal is provided, race the promises; otherwise just await layout + const result = signal ? await Promise.race([layoutPromise, abortPromise]) : await layoutPromise; + + return result; + } catch (error: unknown) { + // Re-throw abort errors so they can be handled appropriately + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + + // Type-safe error handling for other errors + if (error instanceof Error) { + console.error("ELK Layout failed:", error.message); + } else { + console.error("An unexpected error occurred:", String(error)); + } + // Return a fallback, null, or rethrow the error as needed + return null; + } +} diff --git a/packages/serverless-workflow-diagram-editor/src/core/index.ts b/packages/serverless-workflow-diagram-editor/src/core/index.ts index e4b9e03..db02185 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/index.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/index.ts @@ -17,3 +17,4 @@ export * from "./workflowSdk"; export * from "./graph"; export * from "./taskSubType"; +export * from "./elkjs"; diff --git a/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx b/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx index cda8b85..b1f5e46 100644 --- a/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx +++ b/packages/serverless-workflow-diagram-editor/src/diagram-editor/DiagramEditor.tsx @@ -15,6 +15,7 @@ */ import * as React from "react"; +import { ReactFlowProvider } from "@xyflow/react"; import { Diagram, DiagramRef } from "../react-flow/diagram/Diagram"; import { DiagramEditorContextProvider } from "../store/DiagramEditorContextProvider"; import { I18nProvider, detectLocale, useI18n } from "@serverlessworkflow/i18n"; @@ -105,22 +106,24 @@ export const DiagramEditor = (props: DiagramEditorProps) => { }; return ( - - -
- -
- -
-
+ + + +
+ +
+ +
+
+
); }} diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx index 7678152..6cb41c3 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.tsx @@ -24,6 +24,7 @@ import { ReactFlowEdgeTypes } from "../edges/Edges"; import { useDiagramEditorContext } from "../../store/DiagramEditorContext"; import { buildDiagramElements } from "./diagramBuilder"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import { applyAutoLayout } from "./autoLayout"; const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, @@ -45,6 +46,7 @@ export type DiagramProps = { }; export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { + const reactFlowInstance: RF.ReactFlowInstance = RF.useReactFlow(); const { model, nodes, edges, setNodes, setEdges } = useDiagramEditorContext(); const [minimapVisible, setMinimapVisible] = React.useState(false); @@ -68,12 +70,60 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { [setEdges], ); - // Rebuild nodes and edges as model changes + // Rebuild nodes and edges as model changes with debouncing React.useEffect(() => { - const { nodes, edges } = buildDiagramElements(model); - setNodes(nodes); - setEdges(edges); - }, [model, setNodes, setEdges]); + let isActive = true; + let debounceTimeoutId: ReturnType | null = null; + let fitViewTimeoutId: ReturnType | null = null; + let abortController: AbortController | null = null; + + // Debounce layout calculation to avoid excessive CPU usage on rapid changes + debounceTimeoutId = setTimeout(() => { + // Create abort controller for this layout operation + abortController = new AbortController(); + + const graph = buildDiagramElements(model); + applyAutoLayout(graph, abortController.signal) + .then(({ nodes, edges }) => { + // Only update if this effect is still active (not cancelled by cleanup) + if (isActive && !abortController?.signal.aborted) { + setNodes(nodes); + setEdges(edges); + + // Queue fitView to run after React updates the DOM + fitViewTimeoutId = setTimeout(() => reactFlowInstance.fitView(), 0); + } + }) + .catch((error) => { + // Ignore abort errors as they are expected when cancelling + if (error.name === "AbortError") { + return; + } + // Handle other auto-layout errors to prevent unhandled promise rejections + console.error("Failed to apply auto-layout:", error); + }); + }, 100); // 150ms debounce delay + + // Cleanup function to cancel stale updates and clear timeouts + return () => { + isActive = false; + + // Cancel debounce timer + if (debounceTimeoutId !== null) { + clearTimeout(debounceTimeoutId); + } + + // Cancel fitView timer + if (fitViewTimeoutId !== null) { + clearTimeout(fitViewTimeoutId); + } + + // Abort in-flight layout calculation + if (abortController) { + abortController.abort(); + } + }; + }, [model, reactFlowInstance, setNodes, setEdges]); return (
diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts index 7af17a3..ec01f12 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/autoLayout.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import type { ElkNode, LayoutOptions, ElkExtendedEdge } from "elkjs/lib/elk.bundled.js"; +import { processElkLayout } from "@/core"; import { ReactFlowGraph } from "./diagramBuilder"; // Defaults @@ -36,19 +38,139 @@ export type Size = { export type WayPoints = Point[]; -export function applyAutoLayout(graph: ReactFlowGraph): ReactFlowGraph { - const graphClone = structuredClone(graph); +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.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.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.mergeEdges": "true", +}; - // TODO: This is just a temporary implementation until the actual auto-layout engine is integrated - let position: Position = { x: 0, y: 0 }; +export function buildElkGraphFromReactFlowGraph(reactFlowGraph: ReactFlowGraph): ElkNode { + // Create a map for easy lookup + const nodeMap = new Map( + 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[], + }, + ]), + ); - // TODO: Containment is not supported for now. - graphClone.nodes.forEach((node) => { - node.height = DEFAULT_NODE_SIZE.height; - node.width = DEFAULT_NODE_SIZE.width; - node.position = { ...position }; - position.y = position.y + 100; + const rootChildren: ElkNode[] = []; + // Nest children based on parentId + reactFlowGraph.nodes.forEach((node) => { + const elkNode = nodeMap.get(node.id)!; + if (node.parentId && nodeMap.has(node.parentId)) { + nodeMap.get(node.parentId)!.children.push(elkNode); + } else { + rootChildren.push(elkNode); + } }); - return graphClone; + // edges + const elkEdges: ElkExtendedEdge[] = reactFlowGraph.edges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })); + + return { + id: "root", + layoutOptions: ROOT_LAYOUT_OPTIONS, + children: rootChildren, + edges: elkEdges, + }; +} + +// Helper function to recursively build a flat map of all ELK nodes +function buildElkNodeMap( + elkNode: ElkNode, + map: Map = new Map(), +): Map { + map.set(elkNode.id, elkNode); + if (elkNode.children) { + for (const child of elkNode.children) { + buildElkNodeMap(child, map); + } + } + return map; +} + +// 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]) || []); + + // Map node positions + const layoutedNodes = graph.nodes.map((node) => { + const elkNode = elkNodeMap.get(node.id); + if (elkNode && elkNode.x !== undefined && elkNode.y !== undefined) { + return { + ...node, + position: { x: elkNode.x, y: elkNode.y }, + ...(elkNode.height !== undefined && { height: elkNode.height }), + ...(elkNode.width !== undefined && { width: elkNode.width }), + }; + } + return node; + }); + + // Map edge waypoints (bend points) + 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. + const { wayPoints: _oldWayPoints, ...restData } = edge.data || {}; + const bendPoints = elkEdge.sections?.flatMap((section) => section.bendPoints || []) || []; + return { + ...edge, + data: { + ...restData, + ...(bendPoints.length > 0 && { wayPoints: bendPoints }), + }, + }; + } + return edge; + }); + + return { nodes: layoutedNodes, edges: layoutedEdges }; +} + +export async function applyAutoLayout( + graph: ReactFlowGraph, + signal?: AbortSignal, +): Promise { + const elkGraph = buildElkGraphFromReactFlowGraph(graph); + const layoutedGraph = await processElkLayout(elkGraph, signal); + + // it is not possible to calculate auto-layout + if (!layoutedGraph) { + return graph; + } + + return matchReactFlowGraphWithElkLayoutedGraph(graph, layoutedGraph); } diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts index d75e473..2847135 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -19,7 +19,7 @@ import { buildFlatGraph } from "../../core"; import { BaseNodeData, CATCH_CONTAINER_NODE_TYPE, ReactFlowNodeTypes } from "../nodes/Nodes"; import { BaseEdgeData, EdgeTypes } from "../edges/Edges"; import * as sdk from "@serverlessworkflow/sdk"; -import { applyAutoLayout, DEFAULT_NODE_SIZE } from "./autoLayout"; +import { DEFAULT_NODE_SIZE } from "./autoLayout"; export type ReactFlowGraph = { nodes: RF.Node[]; @@ -141,5 +141,5 @@ export function buildDiagramElements(model: sdk.Specification.Workflow | null): }); } - return applyAutoLayout({ nodes, edges }); + return { nodes, edges }; } diff --git a/packages/serverless-workflow-diagram-editor/tests/core/elkjs.test.ts b/packages/serverless-workflow-diagram-editor/tests/core/elkjs.test.ts new file mode 100644 index 0000000..11c32c0 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/core/elkjs.test.ts @@ -0,0 +1,256 @@ +/* + * Copyright 2021-Present The Serverless Workflow Specification Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { ElkNode } from "elkjs/lib/elk.bundled.js"; + +// Mock the ELK module with a shared layout mock +vi.mock("elkjs/lib/elk.bundled.js", () => { + const mockLayoutFn = vi.fn(); + return { + default: vi.fn(function (this: unknown) { + return { + layout: mockLayoutFn, + }; + }), + // Export the mock so we can access it in tests + __mockLayout: mockLayoutFn, + }; +}); + +// Helper function to setup ELK mock with success or error response +function setupElkMock( + elkMockLayout: ReturnType, + returnValue: ElkNode | Error | string | null | undefined | object, +) { + if ( + returnValue instanceof Error || + typeof returnValue === "string" || + returnValue === null || + returnValue === undefined || + (typeof returnValue === "object" && !("id" in returnValue)) + ) { + elkMockLayout.mockRejectedValue(returnValue); + } else { + elkMockLayout.mockResolvedValue(returnValue); + } + + return elkMockLayout; +} + +// Test data factory for simple graphs +function createSimpleGraph(nodeCount: number = 2): ElkNode { + return { + id: "root", + children: Array.from({ length: nodeCount }, (_, i) => ({ + id: `node${i + 1}`, + width: 100, + height: 50, + })), + edges: nodeCount > 1 ? [{ id: "edge1", sources: ["node1"], targets: ["node2"] }] : [], + }; +} + +describe("elkjs", () => { + describe("processElkLayout", () => { + let consoleErrorSpy: ReturnType; + let elkMockLayout: ReturnType; + let processElkLayout: (graph: ElkNode) => Promise; + + beforeEach(async () => { + // Dynamically import processElkLayout after the mock is declared + const elkjsModule = await import("../../src/core/elkjs"); + processElkLayout = elkjsModule.processElkLayout; + + // Get the mock layout function from the mocked module + const ELK = await import("elkjs/lib/elk.bundled.js"); + // Access the mock instance's layout method + const elkInstance = new (ELK.default as unknown as new () => { + layout: ReturnType; + })(); + elkMockLayout = elkInstance.layout; + + // Spy on console.error to verify error logging + consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + vi.clearAllMocks(); + }); + + it("processes valid graph and returns positioned nodes", async () => { + const inputGraph = createSimpleGraph(2); + + const expectedOutput: ElkNode = { + id: "root", + children: [ + { id: "node1", width: 100, height: 50, x: 0, y: 0 }, + { id: "node2", width: 100, height: 50, x: 150, y: 0 }, + ], + edges: [{ id: "edge1", sources: ["node1"], targets: ["node2"] }], + x: 0, + y: 0, + width: 250, + height: 50, + }; + + await setupElkMock(elkMockLayout, expectedOutput); + + const result = await processElkLayout(inputGraph); + + expect(result).toEqual(expectedOutput); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("handles empty graph with no children", async () => { + const inputGraph: ElkNode = { + id: "root", + children: [], + edges: [], + }; + + const expectedOutput: ElkNode = { + id: "root", + children: [], + edges: [], + x: 0, + y: 0, + width: 0, + height: 0, + }; + + await setupElkMock(elkMockLayout, expectedOutput); + + const result = await processElkLayout(inputGraph); + + expect(result).toEqual(expectedOutput); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("handles graph with single node", async () => { + const inputGraph = createSimpleGraph(1); + + const expectedOutput: ElkNode = { + id: "root", + children: [{ id: "node1", width: 100, height: 50, x: 0, y: 0 }], + edges: [], + x: 0, + y: 0, + width: 100, + height: 50, + }; + + await setupElkMock(elkMockLayout, expectedOutput); + + const result = await processElkLayout(inputGraph); + + expect(result).toEqual(expectedOutput); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + it("handles complex graph with nested children", async () => { + const inputGraph: ElkNode = { + id: "root", + children: [ + { + id: "parent1", + width: 200, + height: 150, + children: [ + { id: "child1", width: 80, height: 40 }, + { id: "child2", width: 80, height: 40 }, + ], + }, + { id: "node2", width: 100, height: 50 }, + ], + edges: [{ id: "edge1", sources: ["parent1"], targets: ["node2"] }], + }; + + const expectedOutput: ElkNode = { + id: "root", + children: [ + { + id: "parent1", + width: 200, + height: 150, + x: 0, + y: 0, + children: [ + { id: "child1", width: 80, height: 40, x: 10, y: 10 }, + { id: "child2", width: 80, height: 40, x: 10, y: 60 }, + ], + }, + { id: "node2", width: 100, height: 50, x: 250, y: 50 }, + ], + edges: [{ id: "edge1", sources: ["parent1"], targets: ["node2"] }], + x: 0, + y: 0, + width: 350, + height: 150, + }; + + await setupElkMock(elkMockLayout, expectedOutput); + + const result = await processElkLayout(inputGraph); + + expect(result).toEqual(expectedOutput); + expect(consoleErrorSpy).not.toHaveBeenCalled(); + }); + + // Parameterized error handling tests + it.each([ + { + description: "Error instance", + error: new Error("Invalid graph structure"), + expectedLog: ["ELK Layout failed:", "Invalid graph structure"], + }, + { + description: "string value", + error: "String error message", + expectedLog: ["An unexpected error occurred:", "String error message"], + }, + { + description: "undefined", + error: undefined, + expectedLog: ["An unexpected error occurred:", "undefined"], + }, + { + description: "null", + error: null, + expectedLog: ["An unexpected error occurred:", "null"], + }, + { + description: "plain object", + error: { code: 500, message: "Internal error" }, + expectedLog: ["An unexpected error occurred:", "[object Object]"], + }, + ])( + "returns null and logs error when ELK layout throws $description", + async ({ error, expectedLog }) => { + const inputGraph = createSimpleGraph(1); + + await setupElkMock(elkMockLayout, error); + + const result = await processElkLayout(inputGraph); + + expect(result).toBeNull(); + expect(consoleErrorSpy).toHaveBeenCalledWith(...expectedLog); + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + }, + ); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx index 69af289..bf1db18 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/Diagram.test.tsx @@ -14,28 +14,42 @@ * limitations under the License. */ -import { render, screen } from "@testing-library/react"; -import { vi, it, expect, afterEach, describe } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { vi, it, expect, afterEach, describe, beforeEach } from "vitest"; import { Diagram } from "../../../src/react-flow/diagram/Diagram"; import { DiagramEditorContextProvider } from "../../../src/store/DiagramEditorContextProvider"; import { SidebarProvider } from "../../../src/components/ui/sidebar"; import { I18nProvider } from "@serverlessworkflow/i18n"; import { en } from "../../../src/i18n/locales/en"; +import { ReactFlowProvider } from "@xyflow/react"; +import * as autoLayoutModule from "../../../src/react-flow/diagram/autoLayout"; describe("Diagram Component", () => { + let applyAutoLayoutSpy: ReturnType; + + beforeEach(() => { + // Mock applyAutoLayout to return a resolved promise with empty nodes and edges + applyAutoLayoutSpy = vi.spyOn(autoLayoutModule, "applyAutoLayout").mockResolvedValue({ + nodes: [], + edges: [], + }); + }); + afterEach(() => { vi.restoreAllMocks(); }); - it("render Diagram component and canvas", () => { + it("render Diagram component and canvas", async () => { render( - - - - - - - , + + + + + + + + + , ); const diagram = screen.getByTestId("diagram-container"); @@ -43,5 +57,10 @@ describe("Diagram Component", () => { expect(diagram).toBeInTheDocument(); expect(canvas).toBeInTheDocument(); + + // Verify that applyAutoLayout was called + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts index 3791461..a00b4db 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts @@ -14,27 +14,808 @@ * limitations under the License. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import type { ElkNode } from "elkjs/lib/elk.bundled.js"; +import type { Node, Edge } from "@xyflow/react"; +import { + buildElkGraphFromReactFlowGraph, + matchReactFlowGraphWithElkLayoutedGraph, + applyAutoLayout, + DEFAULT_NODE_SIZE, + ROOT_LAYOUT_OPTIONS, +} from "../../../src/react-flow/diagram/autoLayout"; +import type { ReactFlowGraph } from "../../../src/react-flow/diagram/diagramBuilder"; +import * as core from "../../../src/core"; -import { BASIC_VALID_WORKFLOW_JSON_TASKS } from "../../fixtures/workflows"; -import { applyAutoLayout } from "../../../src/react-flow/diagram/autoLayout"; -import { parseWorkflow } from "../../../src/core"; -import { buildDiagramElements } from "../../../src/react-flow/diagram/diagramBuilder"; +// Mock the processElkLayout function +vi.mock("../../../src/core", () => ({ + processElkLayout: vi.fn(), +})); -describe("applyAutoLayout", () => { - it("apply auto-layout calculated layout to graph elements", () => { - const result = parseWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); +describe("autoLayout", () => { + describe("buildElkGraphFromReactFlowGraph", () => { + it("converts simple ReactFlow graph to ELK graph", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "node1", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 150, height: 50 }, + }, + { + id: "node2", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 200, height: 60 }, + }, + ] as Node[], + edges: [{ id: "edge1", source: "node1", target: "node2", data: {} }] as Edge[], + }; - const reacflowGraph = applyAutoLayout(buildDiagramElements(result.model)); + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); - expect(reacflowGraph.nodes).toHaveLength(7); - expect(reacflowGraph.edges).toHaveLength(6); + expect(elkGraph.id).toBe("root"); + expect(elkGraph.layoutOptions).toEqual(ROOT_LAYOUT_OPTIONS); + expect(elkGraph.children).toHaveLength(2); + expect(elkGraph.children?.[0]).toEqual({ + id: "node1", + width: 150, + height: 50, + children: [], + }); + expect(elkGraph.children?.[1]).toEqual({ + id: "node2", + width: 200, + height: 60, + children: [], + }); + expect(elkGraph.edges).toHaveLength(1); + expect(elkGraph.edges?.[0]).toEqual({ + id: "edge1", + sources: ["node1"], + targets: ["node2"], + }); + }); + + it("uses default node size when measured dimensions are not available", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 0, y: 0 }, data: {} }] as Node[], + edges: [], + }; + + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); + + expect(elkGraph.children?.[0]).toEqual({ + id: "node1", + width: DEFAULT_NODE_SIZE.width, + height: DEFAULT_NODE_SIZE.height, + children: [], + }); + }); + + it("handles nested nodes with parentId", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "parent", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 300, height: 200 }, + }, + { + id: "child1", + position: { x: 10, y: 10 }, + data: {}, + parentId: "parent", + measured: { width: 100, height: 50 }, + }, + { + id: "child2", + position: { x: 10, y: 70 }, + data: {}, + parentId: "parent", + measured: { width: 100, height: 50 }, + }, + ] as Node[], + edges: [], + }; + + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); + + expect(elkGraph.children).toHaveLength(1); + expect(elkGraph.children?.[0].id).toBe("parent"); + expect(elkGraph.children?.[0].children).toHaveLength(2); + expect(elkGraph.children?.[0].children?.[0].id).toBe("child1"); + expect(elkGraph.children?.[0].children?.[1].id).toBe("child2"); + }); + + it("handles empty graph", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [], + }; + + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); + + expect(elkGraph.id).toBe("root"); + expect(elkGraph.children).toHaveLength(0); + expect(elkGraph.edges).toHaveLength(0); + }); + + it("handles multiple edges between nodes", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "node1", position: { x: 0, y: 0 }, data: {} }, + { id: "node2", position: { x: 0, y: 0 }, data: {} }, + ] as Node[], + edges: [ + { id: "edge1", source: "node1", target: "node2", data: {} }, + { id: "edge2", source: "node2", target: "node1", data: {} }, + ] as Edge[], + }; + + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); + + expect(elkGraph.edges).toHaveLength(2); + expect(elkGraph.edges?.[0]).toEqual({ + id: "edge1", + sources: ["node1"], + targets: ["node2"], + }); + expect(elkGraph.edges?.[1]).toEqual({ + id: "edge2", + sources: ["node2"], + targets: ["node1"], + }); + }); + + it("treat as a root-level if parentId is non-existent", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "node1", + position: { x: 0, y: 0 }, + data: {}, + parentId: "nonexistent", + }, + ] as Node[], + edges: [], + }; + + const elkGraph = buildElkGraphFromReactFlowGraph(reactFlowGraph); + + expect(elkGraph.children).toHaveLength(1); + expect(elkGraph.children?.[0].id).toBe("node1"); + }); + }); + + describe("matchReactFlowGraphWithElkLayoutedGraph", () => { + it("updates node positions from ELK layout", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "node1", position: { x: 0, y: 0 }, data: {} }, + { id: "node2", position: { x: 0, y: 0 }, data: {} }, + ] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { id: "node1", x: 50, y: 100, width: 200, height: 60 }, + { id: "node2", x: 50, y: 200, width: 200, height: 60 }, + ], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.nodes[0].position).toEqual({ x: 50, y: 100 }); + expect(result.nodes[0].width).toBe(200); + expect(result.nodes[0].height).toBe(60); + expect(result.nodes[1].position).toEqual({ x: 50, y: 200 }); + }); + + it("preserves original node data when no ELK node found", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 10, y: 20 }, data: { label: "Test" } }] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.nodes[0].position).toEqual({ x: 10, y: 20 }); + expect(result.nodes[0].data).toEqual({ label: "Test" }); + }); + + it("adds waypoints to edges from ELK bend points", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "node1", position: { x: 0, y: 0 }, data: {} }, + { id: "node2", position: { x: 0, y: 0 }, data: {} }, + ] as Node[], + edges: [{ id: "edge1", source: "node1", target: "node2", data: {} }] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { id: "node1", x: 0, y: 0 }, + { id: "node2", x: 200, y: 100 }, + ], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + sections: [ + { + id: "section1", + startPoint: { x: 0, y: 0 }, + endPoint: { x: 200, y: 100 }, + bendPoints: [ + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ], + }, + ], + }, + ], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data?.wayPoints).toEqual([ + { x: 100, y: 0 }, + { x: 100, y: 100 }, + ]); + }); + + it("handles edges without bend points", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "node1", position: { x: 0, y: 0 }, data: {} }, + { id: "node2", position: { x: 0, y: 0 }, data: {} }, + ] as Node[], + edges: [{ id: "edge1", source: "node1", target: "node2", data: {} }] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { id: "node1", x: 0, y: 0 }, + { id: "node2", x: 200, y: 0 }, + ], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + sections: [ + { + id: "section1", + startPoint: { x: 0, y: 0 }, + endPoint: { x: 200, y: 0 }, + }, + ], + }, + ], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data?.wayPoints).toBeUndefined(); + }); + + it("clears stale wayPoints when ELK edge has no sections", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [ + { + id: "edge1", + source: "node1", + target: "node2", + data: { label: "Test Edge", wayPoints: [{ x: 10, y: 20 }] }, + }, + ] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + }, + ], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data).toEqual({ label: "Test Edge" }); + expect(result.edges[0].data?.wayPoints).toBeUndefined(); + }); + + it("clears stale wayPoints when ELK edge sections have no bend points", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [ + { + id: "edge1", + source: "node1", + target: "node2", + data: { label: "Test Edge", wayPoints: [{ x: 10, y: 20 }] }, + }, + ] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + sections: [ + { + id: "section1", + startPoint: { x: 0, y: 0 }, + endPoint: { x: 200, y: 0 }, + }, + ], + }, + ], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data).toEqual({ label: "Test Edge" }); + expect(result.edges[0].data?.wayPoints).toBeUndefined(); + }); + + it("preserves edge data when no ELK edge found", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [ + { + id: "edge1", + source: "node1", + target: "node2", + data: { label: "Test Edge" }, + }, + ] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data).toEqual({ label: "Test Edge" }); + }); + + it("does not mutate original graph", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 0, y: 0 }, data: {} }] as Node[], + edges: [], + }; + + const originalPosition = { ...reactFlowGraph.nodes[0].position }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [{ id: "node1", x: 100, y: 200 }], + edges: [], + }; + + matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(reactFlowGraph.nodes[0].position).toEqual(originalPosition); + }); + + it("handles multiple sections with bend points", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [{ id: "edge1", source: "node1", target: "node2", data: {} }] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + sections: [ + { + id: "section1", + startPoint: { x: 0, y: 0 }, + endPoint: { x: 100, y: 50 }, + bendPoints: [{ x: 50, y: 0 }], + }, + { + id: "section2", + startPoint: { x: 100, y: 50 }, + endPoint: { x: 200, y: 100 }, + bendPoints: [{ x: 150, y: 100 }], + }, + ], + }, + ], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.edges[0].data?.wayPoints).toEqual([ + { x: 50, y: 0 }, + { x: 150, y: 100 }, + ]); + }); + }); + + describe("applyAutoLayout", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("applies auto layout successfully", async () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "node1", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 200, height: 60 }, + }, + { + id: "node2", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 200, height: 60 }, + }, + ] as Node[], + edges: [{ id: "edge1", source: "node1", target: "node2", data: {} }] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { id: "node1", x: 50, y: 100, width: 200, height: 60 }, + { id: "node2", x: 50, y: 200, width: 200, height: 60 }, + ], + edges: [ + { + id: "edge1", + sources: ["node1"], + targets: ["node2"], + }, + ], + }; + + vi.mocked(core.processElkLayout).mockResolvedValue(layoutedElkGraph); + + const result = await applyAutoLayout(reactFlowGraph); + + expect(core.processElkLayout).toHaveBeenCalledTimes(1); + expect(result.nodes[0].position).toEqual({ x: 50, y: 100 }); + expect(result.nodes[1].position).toEqual({ x: 50, y: 200 }); + }); + + it("returns original graph when ELK layout fails", async () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 10, y: 20 }, data: {} }] as Node[], + edges: [], + }; + + vi.mocked(core.processElkLayout).mockResolvedValue(null); + + const result = await applyAutoLayout(reactFlowGraph); + + expect(result).toEqual(reactFlowGraph); + expect(result.nodes[0].position).toEqual({ x: 10, y: 20 }); + }); + + it("handles empty graph", async () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [], + edges: [], + }; + + vi.mocked(core.processElkLayout).mockResolvedValue(layoutedElkGraph); + + const result = await applyAutoLayout(reactFlowGraph); + + expect(result.nodes).toHaveLength(0); + expect(result.edges).toHaveLength(0); + }); + + it("passes correct ELK graph structure to processElkLayout", async () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "node1", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 150, height: 50 }, + }, + ] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [{ id: "node1", x: 0, y: 0 }], + edges: [], + }; + + vi.mocked(core.processElkLayout).mockResolvedValue(layoutedElkGraph); + + await applyAutoLayout(reactFlowGraph); + + expect(core.processElkLayout).toHaveBeenCalledWith( + { + id: "root", + layoutOptions: ROOT_LAYOUT_OPTIONS, + children: [ + { + id: "node1", + width: 150, + height: 50, + children: [], + }, + ], + edges: [], + }, + undefined, + ); + }); + + it("handles complex graph with nested nodes and edges", async () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { + id: "parent", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 300, height: 200 }, + }, + { + id: "child1", + position: { x: 10, y: 10 }, + data: {}, + parentId: "parent", + measured: { width: 100, height: 50 }, + }, + { + id: "node2", + position: { x: 0, y: 0 }, + data: {}, + measured: { width: 200, height: 60 }, + }, + ] as Node[], + edges: [{ id: "edge1", source: "parent", target: "node2", data: {} }] as Edge[], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { + id: "parent", + x: 0, + y: 0, + width: 300, + height: 200, + children: [{ id: "child1", x: 10, y: 10, width: 100, height: 50 }], + }, + { id: "node2", x: 350, y: 50, width: 200, height: 60 }, + ], + edges: [ + { + id: "edge1", + sources: ["parent"], + targets: ["node2"], + }, + ], + }; + + vi.mocked(core.processElkLayout).mockResolvedValue(layoutedElkGraph); + + const result = await applyAutoLayout(reactFlowGraph); + + expect(result.nodes).toHaveLength(3); + expect(result.nodes[0].position).toEqual({ x: 0, y: 0 }); + expect(result.nodes[2].position).toEqual({ x: 350, y: 50 }); + }); + + describe("buildElkNodeMap helper", () => { + it("handles nested nodes correctly in matchReactFlowGraphWithElkLayoutedGraph", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "parent", position: { x: 0, y: 0 }, data: {} }, + { + id: "child1", + position: { x: 0, y: 0 }, + data: {}, + parentId: "parent", + }, + { + id: "child2", + position: { x: 0, y: 0 }, + data: {}, + parentId: "parent", + }, + ] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { + id: "parent", + x: 50, + y: 100, + width: 300, + height: 200, + children: [ + { id: "child1", x: 10, y: 10, width: 100, height: 50 }, + { id: "child2", x: 10, y: 70, width: 100, height: 50 }, + ], + }, + ], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + // Parent node should be positioned + expect(result.nodes[0].position).toEqual({ x: 50, y: 100 }); + expect(result.nodes[0].width).toBe(300); + expect(result.nodes[0].height).toBe(200); + + // Nested children should also be positioned (this was the bug) + expect(result.nodes[1].position).toEqual({ x: 10, y: 10 }); + expect(result.nodes[1].width).toBe(100); + expect(result.nodes[1].height).toBe(50); + + expect(result.nodes[2].position).toEqual({ x: 10, y: 70 }); + expect(result.nodes[2].width).toBe(100); + expect(result.nodes[2].height).toBe(50); + }); + + it("handles deeply nested nodes", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [ + { id: "level1", position: { x: 0, y: 0 }, data: {} }, + { + id: "level2", + position: { x: 0, y: 0 }, + data: {}, + parentId: "level1", + }, + { + id: "level3", + position: { x: 0, y: 0 }, + data: {}, + parentId: "level2", + }, + ] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [ + { + id: "level1", + x: 0, + y: 0, + children: [ + { + id: "level2", + x: 10, + y: 10, + children: [{ id: "level3", x: 20, y: 20 }], + }, + ], + }, + ], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + expect(result.nodes[0].position).toEqual({ x: 0, y: 0 }); + expect(result.nodes[1].position).toEqual({ x: 10, y: 10 }); + expect(result.nodes[2].position).toEqual({ x: 20, y: 20 }); + }); + }); + + describe("dimension handling with !== undefined", () => { + it("correctly handles width and height of 0", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 0, y: 0 }, data: {} }] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [{ id: "node1", x: 50, y: 100, width: 0, height: 0 }], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + // Should include width and height even though they are 0 + expect(result.nodes[0].width).toBe(0); + expect(result.nodes[0].height).toBe(0); + }); + + it("does not add width/height when undefined", () => { + const reactFlowGraph: ReactFlowGraph = { + nodes: [{ id: "node1", position: { x: 0, y: 0 }, data: {} }] as Node[], + edges: [], + }; + + const layoutedElkGraph: ElkNode = { + id: "root", + children: [{ id: "node1", x: 50, y: 100 }], + edges: [], + }; + + const result = matchReactFlowGraphWithElkLayoutedGraph(reactFlowGraph, layoutedElkGraph); + + // Should not have width/height properties + expect(result.nodes[0].width).toBeUndefined(); + expect(result.nodes[0].height).toBeUndefined(); + }); + }); + + describe("DEFAULT_NODE_SIZE", () => { + it("has correct default dimensions", () => { + expect(DEFAULT_NODE_SIZE).toEqual({ + height: 60, + width: 200, + }); + }); + }); + + describe("ROOT_LAYOUT_OPTIONS", () => { + it("contains required ELK layout options", () => { + expect(ROOT_LAYOUT_OPTIONS["elk.algorithm"]).toBe("org.eclipse.elk.layered"); + expect(ROOT_LAYOUT_OPTIONS["elk.direction"]).toBe("DOWN"); + expect(ROOT_LAYOUT_OPTIONS["elk.hierarchyHandling"]).toBe("INCLUDE_CHILDREN"); + }); - let y = 0; - reacflowGraph.nodes.forEach((node) => { - // TODO coordinates are fixed (y = y + 100) for now - expect(node.position!.y).toBe(y); - y += 100; + it("has proper spacing configuration", () => { + expect(ROOT_LAYOUT_OPTIONS["spacing"]).toBe("75"); + expect(ROOT_LAYOUT_OPTIONS["spacing.componentComponent"]).toBe("70"); + expect(ROOT_LAYOUT_OPTIONS["spacing.nodeNodeBetweenLayers"]).toBe("80"); + }); }); }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2312f69..4d3bc1f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -69,6 +69,9 @@ catalogs: clsx: specifier: ^2.1.1 version: 2.1.1 + elkjs: + specifier: ^0.11.1 + version: 0.11.1 husky: specifier: ^9.1.7 version: 9.1.7 @@ -198,6 +201,9 @@ importers: clsx: specifier: 'catalog:' version: 2.1.1 + elkjs: + specifier: 'catalog:' + version: 0.11.1 js-yaml: specifier: 'catalog:' version: 4.1.1 @@ -2570,6 +2576,9 @@ packages: electron-to-chromium@1.5.359: resolution: {integrity: sha512-8lPELWuYZIWk7NDvCNthtmMw/7Q5Wu25NpM4djFMHBmk8DubPAtL4YTOp7ou0e7HyJtwkVlWv8XMLURnrtgJQw==} + elkjs@0.11.1: + resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -5558,6 +5567,8 @@ snapshots: electron-to-chromium@1.5.359: {} + elkjs@0.11.1: {} + emoji-regex@10.6.0: {} empathic@2.0.1: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7abaf45..58ca1e2 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -22,6 +22,7 @@ catalog: "@xyflow/react": ^12.10.2 class-variance-authority: ^0.7.1 clsx: ^2.1.1 + elkjs: ^0.11.1 husky: ^9.1.7 "js-yaml": ^4.1.1 jsdom: ^29.1.0