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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,20 @@ 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,
minZoom: 0.1,
duration: 400,
};

const applyEdgeZIndex = <T extends RF.Edge>(edges: T[]): T[] =>
edges.map((edge) => ({
...edge,
zIndex: edge.selected ? ZINDEX.EDGE_SELECTED : ZINDEX.EDGE_REGULAR,
}));

/**
* Diagram component API
*/
Expand Down Expand Up @@ -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],
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -173,6 +175,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
},
}}
data-testid={"react-flow-canvas"}
elevateEdgesOnSelect={false}
nodesDraggable={!isReadOnly}
nodesConnectable={!isReadOnly}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -43,6 +44,7 @@ export type EdgeLabelProps = {
targetPosition?: RF.Position;
type?: EdgeTypes;
data?: BaseEdgeData | undefined;
selected?: boolean;
};

export function createPathFromWayPoints(
Expand Down Expand Up @@ -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 (
Expand All @@ -137,6 +139,7 @@ export function EdgeLabel(props: EdgeLabelProps) {
<div
style={{
transform: `translate(-50%, -50%) translate(${x}px,${y}px)`,
zIndex: selected ? ZINDEX.EDGE_LABEL_SELECTED : ZINDEX.EDGE_LABEL_REGULAR,
}}
Comment thread
handreyrc marked this conversation as resolved.
className={type ? `edge-label ${type}` : "edge-label"}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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: [
Expand Down Expand Up @@ -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)
});
});
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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(
<RF.ReactFlowProvider>
<Component
id="e1"
source="n1"
target="n2"
sourceX={0}
sourceY={0}
targetX={100}
targetY={100}
sourcePosition={RF.Position.Right}
targetPosition={RF.Position.Left}
data={{ label: "Test Label" }}
selected={selected}
/>
</RF.ReactFlowProvider>,
);

// 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");
}
},
);
});
});
Loading