diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css index 42d7c67..47afe0a 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/Diagram.css @@ -18,6 +18,13 @@ /* react flow overrides */ @layer react-flow-overrides { + /* Elevate the edge-label renderer container above nodes. + React Flow renders .react-flow__edgelabel-renderer before NodeRenderer + in the DOM, so without an explicit z-index it is painted behind nodes. */ + .dec-root .react-flow__edgelabel-renderer { + z-index: var(--dec-zindex-edge-label-regular); + } + .dec-root .diagram-background { --xy-background-pattern-color: #ccc; background-color: #e5e4e2; 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 861331f..ac542f1 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 @@ -25,6 +25,7 @@ import { useDiagramEditorContext } from "../../store/DiagramEditorContext"; import { buildDiagramElements } from "./diagramBuilder"; import { applyAutoLayout } from "./autoLayout"; import { SidePanelTrigger } from "@/side-panel/SidePanelTrigger"; +import { ZINDEX } from "../zIndexConstants"; const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, @@ -32,6 +33,12 @@ const FIT_VIEW_OPTIONS: RF.FitViewOptions = { duration: 400, }; +const applyEdgeZIndex = (edges: T[]): T[] => + edges.map((edge) => ({ + ...edge, + zIndex: edge.selected ? ZINDEX.EDGE_SELECTED : ZINDEX.EDGE_REGULAR, + })); + /** * Diagram component API */ @@ -71,12 +78,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { (changes) => { setEdges((edgesSnapshot) => { const updatedEdges = RF.applyEdgeChanges(changes, edgesSnapshot); - - // Update zIndex for selected edges to bring them to front - return updatedEdges.map((edge) => ({ - ...edge, - zIndex: edge.selected ? 1000 : 0, - })); + return applyEdgeZIndex(updatedEdges); }); }, [setEdges], @@ -105,7 +107,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { // Only update if this effect is still active (not cancelled by cleanup) if (isActive && !abortController?.signal.aborted) { setNodes(nodes); - setEdges(edges); + setEdges(applyEdgeZIndex(edges)); // Queue fitView to run after React updates the DOM fitViewTimeoutId = setTimeout(() => reactFlowInstance.fitView(), 0); @@ -173,6 +175,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { }, }} data-testid={"react-flow-canvas"} + elevateEdgesOnSelect={false} nodesDraggable={!isReadOnly} nodesConnectable={!isReadOnly} > diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx index 5a031e7..40fdb2a 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx @@ -16,6 +16,7 @@ import * as RF from "@xyflow/react"; import type { Point, WayPoints } from "../diagram/autoLayout"; +import { ZINDEX } from "../zIndexConstants"; export enum EdgeTypes { Default = "default", @@ -43,6 +44,7 @@ export type EdgeLabelProps = { targetPosition?: RF.Position; type?: EdgeTypes; data?: BaseEdgeData | undefined; + selected?: boolean; }; export function createPathFromWayPoints( @@ -127,7 +129,7 @@ function getEdgeLabelPosition({ } export function EdgeLabel(props: EdgeLabelProps) { - const { type, data } = props; + const { type, data, selected } = props; const { x, y } = getEdgeLabelPosition(props); return ( @@ -137,6 +139,7 @@ export function EdgeLabel(props: EdgeLabelProps) {
diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/zIndexConstants.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/zIndexConstants.ts new file mode 100644 index 0000000..b4cda4f --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/zIndexConstants.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/** + * Z-index layering constants for diagram edges and labels. + * + * Layering hierarchy (bottom to top): + * 1. Regular edges (0) + * 2. Selected edges (100) + * 3. Edge labels - regular (1000) + * 4. Edge labels - selected (1001) + * + * This ensures: + * - Selected edges appear above regular edges + * - All labels appear above all edges (preventing overlap) + * - Selected edge labels appear above regular labels + */ +export const ZINDEX = { + /** Regular (unselected) edges */ + EDGE_REGULAR: 0, + + /** Selected edges - appear above regular edges but below all labels */ + EDGE_SELECTED: 100, + + /** Regular (unselected) edge labels - appear above all edges */ + EDGE_LABEL_REGULAR: 1000, + + /** Selected edge labels - appear above everything */ + EDGE_LABEL_SELECTED: 1001, +} as const; diff --git a/packages/serverless-workflow-diagram-editor/src/styles.css b/packages/serverless-workflow-diagram-editor/src/styles.css index 486f72d..ca40758 100644 --- a/packages/serverless-workflow-diagram-editor/src/styles.css +++ b/packages/serverless-workflow-diagram-editor/src/styles.css @@ -45,6 +45,9 @@ .dec-root { @apply dec:h-full; + /* Z-index layering for edges and labels */ + --dec-zindex-edge-label-regular: 1000; + --dec-error-accent: #ef4444; --dec-error-glow: rgba(239, 68, 68, 0.35); --dec-edge-selected: #aea6a6; 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 fbcc9b5..4628d21 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 @@ -24,6 +24,7 @@ import { en } from "../../../src/i18n/locales/en"; import { ReactFlowProvider, ReactFlow } from "@xyflow/react"; import * as RF from "@xyflow/react"; import * as autoLayoutModule from "../../../src/react-flow/diagram/autoLayout"; +import { ZINDEX } from "../../../src/react-flow/zIndexConstants"; // Mock ReactFlow to capture props vi.mock("@xyflow/react", async () => { @@ -183,27 +184,51 @@ describe("Diagram Component", () => { }); }); - describe("onEdgesChange with zIndex updates", () => { - it("should provide onEdgesChange callback to ReactFlow", async () => { + it("should disable automatic edge elevation on select", async () => { + renderDiagram({ isReadOnly: false }); + + // Wait for ReactFlow to be called + await waitFor(() => { + expect(ReactFlow).toHaveBeenCalled(); + }); + + // Verify that ReactFlow was called with elevateEdgesOnSelect={false} + const mockReactFlow = vi.mocked(ReactFlow); + const lastCall = mockReactFlow.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const reactFlowProps = lastCall![0]; + expect(reactFlowProps.elevateEdgesOnSelect).toBe(false); + + await waitFor(() => { + expect(applyAutoLayoutSpy).toHaveBeenCalled(); + }); + }); + + describe("edge z-index management", () => { + it("should apply correct z-index to edges based on selection state", async () => { + applyAutoLayoutSpy.mockResolvedValueOnce({ + nodes: [], + edges: [ + { id: "edge1", source: "n1", target: "n2", selected: false }, + { id: "edge2", source: "n2", target: "n3", selected: false }, + ], + }); + renderDiagram({ isReadOnly: false }); - // Wait for initial render await waitFor(() => { - expect(applyAutoLayoutSpy).toHaveBeenCalled(); + const lastCall = vi.mocked(ReactFlow).mock.calls.at(-1); + expect(lastCall).toBeDefined(); + expect(lastCall![0].edges).toHaveLength(2); }); - // Get the onEdgesChange callback from ReactFlow mock - const mockReactFlow = vi.mocked(ReactFlow); - const lastCall = mockReactFlow.mock.calls.at(-1); - expect(lastCall).toBeDefined(); - const reactFlowProps = lastCall![0]; - const onEdgesChange = reactFlowProps.onEdgesChange; - - expect(onEdgesChange).toBeDefined(); - expect(typeof onEdgesChange).toBe("function"); + // All unselected edges should have zIndex: 0 + const edges = vi.mocked(ReactFlow).mock.calls.at(-1)![0].edges!; + expect(edges.find((e: RF.Edge) => e.id === "edge1")?.zIndex).toBe(ZINDEX.EDGE_REGULAR); + expect(edges.find((e: RF.Edge) => e.id === "edge2")?.zIndex).toBe(ZINDEX.EDGE_REGULAR); }); - it("should apply zIndex correctly when edges are updated", async () => { + it("should elevate selected edge above regular edges but below labels", async () => { applyAutoLayoutSpy.mockResolvedValueOnce({ nodes: [], edges: [ @@ -232,8 +257,39 @@ describe("Diagram Component", () => { await waitFor(() => { const edges = vi.mocked(ReactFlow).mock.calls.at(-1)![0].edges!; - expect(edges.find((e: RF.Edge) => e.id === "edge1")?.zIndex).toBe(1000); - expect(edges.find((e: RF.Edge) => e.id === "edge2")?.zIndex).toBe(1000); + // Selected edges should have elevated z-index (above regular edges, below labels) + expect(edges.find((e: RF.Edge) => e.id === "edge1")?.zIndex).toBe(ZINDEX.EDGE_SELECTED); + expect(edges.find((e: RF.Edge) => e.id === "edge2")?.zIndex).toBe(ZINDEX.EDGE_SELECTED); + }); + }); + + it("should maintain z-index hierarchy: regular edges (0) < selected edges (100) < labels (1000+)", async () => { + applyAutoLayoutSpy.mockResolvedValueOnce({ + nodes: [], + edges: [ + { id: "edge1", source: "n1", target: "n2", selected: true }, + { id: "edge2", source: "n2", target: "n3", selected: false }, + ], + }); + + renderDiagram({ isReadOnly: false }); + + await waitFor(() => { + const lastCall = vi.mocked(ReactFlow).mock.calls.at(-1); + expect(lastCall).toBeDefined(); + const edges = lastCall![0].edges!; + + // Verify z-index hierarchy + const selectedEdge = edges.find((e: RF.Edge) => e.id === "edge1"); + const regularEdge = edges.find((e: RF.Edge) => e.id === "edge2"); + + expect(selectedEdge?.zIndex).toBe(ZINDEX.EDGE_SELECTED); + expect(regularEdge?.zIndex).toBe(ZINDEX.EDGE_REGULAR); + + // Hierarchy in the test + // Regular edges: 0 + // Selected edges: 100 + // Edge labels: 1000+ (tested in Edges.test.tsx) }); }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx b/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx index 7f48f0c..734376f 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx @@ -27,6 +27,7 @@ import { createPathFromWayPoints, getWayPointsMidpoint, } from "../../../src/react-flow/edges/Edges"; +import { ZINDEX } from "../../../src/react-flow/zIndexConstants"; import * as RF from "@xyflow/react"; describe("React Flow custom edge types", () => { @@ -498,3 +499,91 @@ describe("EdgeLabel positioning", () => { expect(JSON.stringify(result)).toContain(`translate(${labelX}px,${labelY}px)`); }); }); + +describe("EdgeLabel z-index behavior", () => { + it.each([ + { + selected: false, + expectedZIndex: ZINDEX.EDGE_LABEL_REGULAR, + description: "applies regular label z-index when selected=false", + }, + { + selected: true, + expectedZIndex: ZINDEX.EDGE_LABEL_SELECTED, + description: "applies selected label z-index when selected=true", + }, + { + selected: undefined, + expectedZIndex: ZINDEX.EDGE_LABEL_REGULAR, + description: "applies default regular label z-index when selected=undefined", + }, + ])("$description", ({ selected, expectedZIndex }) => { + const result = EdgeLabel({ + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + data: { label: "Test Label" }, + selected, + }); + + const resultString = JSON.stringify(result); + expect(resultString).toContain(`"zIndex":${expectedZIndex}`); + }); + + it("applies default regular label z-index when selected prop is not provided", () => { + const result = EdgeLabel({ + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + data: { label: "Test Label" }, + }); + + const resultString = JSON.stringify(result); + expect(resultString).toContain(`"zIndex":${ZINDEX.EDGE_LABEL_REGULAR}`); + }); + + describe("integration with edge components", () => { + it.each([ + { component: DefaultEdge, edgeType: "default", edgeClass: "", selected: true }, + { component: ErrorEdge, edgeType: "error", edgeClass: "error", selected: true }, + { component: ConditionEdge, edgeType: "condition", edgeClass: "condition", selected: true }, + { component: DefaultEdge, edgeType: "default", edgeClass: "", selected: false }, + { component: ErrorEdge, edgeType: "error", edgeClass: "error", selected: false }, + { component: ConditionEdge, edgeType: "condition", edgeClass: "condition", selected: false }, + ])( + "$edgeType edge applies selected class when selected=$selected", + ({ component: Component, edgeClass, selected }) => { + const { container } = render( + + + , + ); + + // Verify the edge path is rendered with correct selected class + const pathSelector = edgeClass ? `path.edge-line.${edgeClass}` : "path.edge-line"; + const path = container.querySelector(pathSelector); + expect(path).toBeInTheDocument(); + + if (selected) { + expect(path).toHaveClass("selected"); + } else { + expect(path).not.toHaveClass("selected"); + } + }, + ); + }); +});