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/edge-path-highlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@serverlessworkflow/diagram-editor": minor
---

Add edge path highlighting on selection.
Original file line number Diff line number Diff line change
Expand Up @@ -408,7 +408,8 @@
/* React flow SVG components do not work with classes defined into layers */
.dec-root .edge-line {
@apply dec:stroke-[#aea6a6]
dec:stroke-2;
dec:stroke-2
dec:transition-[filter];
}

.dec-root .edge-line.error {
Expand All @@ -420,6 +421,32 @@
@apply dec:stroke-blue-500;
}

/* Override React Flow's default selected edge styling to preserve original colors */
.dec-root .react-flow__edge.selected .edge-line {
stroke: var(--dec-edge-selected) !important;
}

.dec-root .react-flow__edge.selected .edge-line.error {
stroke: var(--dec-error-accent) !important;
}

.dec-root .react-flow__edge.selected .edge-line.condition {
stroke: var(--dec-edge-selected-condition) !important;
}

/* Selected edge shadow effect - lighter for light mode */
.dec-root .edge-line.selected {
filter: drop-shadow(0 0 2px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 6px rgba(59, 130, 246, 0.4))
drop-shadow(0 0 12px rgba(59, 130, 246, 0.2));
}

.dec-root.dark .edge-line.selected {
filter: drop-shadow(0 0 3px rgb(59 130 246))
drop-shadow(0 0 8px rgb(59 130 246))
drop-shadow(0 0 16px rgba(59, 130, 246, 0.8));
}

/* custom edge labels */
@layer custom-edge-labels {
.dec-root .edge-label {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,22 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => {
(changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)),
[setNodes],
);

const onEdgesChange = React.useCallback<RF.OnEdgesChange>(
(changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)),
(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,
}));
});
},
[setEdges],
);

const onSelectionChange = React.useCallback<RF.OnSelectionChangeFunc>(
({ nodes: selectedNodes }) => setSelectedNodeId(selectedNodes[0]?.id ?? null),
[setSelectedNodeId],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ function CustomBaseEdge({
data,
markerEnd,
className,
selected,
}: RF.EdgeProps & { data?: BaseEdgeData; className?: string }) {
const edgePath = data?.wayPoints
? createPathFromWayPoints(sourceX, sourceY, targetX, targetY, data.wayPoints)
Expand All @@ -172,13 +173,10 @@ function CustomBaseEdge({
targetPosition,
})[0];

const edgeClassName = `${className ?? "edge-line"}${selected ? " selected" : ""}`;

return (
<RF.BaseEdge
id={id}
path={edgePath}
markerEnd={markerEnd ?? ""}
className={className ?? "edge-line"}
/>
<RF.BaseEdge id={id} path={edgePath} markerEnd={markerEnd ?? ""} className={edgeClassName} />
);
}

Expand Down
4 changes: 4 additions & 0 deletions packages/serverless-workflow-diagram-editor/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,15 @@

--dec-error-accent: #ef4444;
--dec-error-glow: rgba(239, 68, 68, 0.35);
--dec-edge-selected: #aea6a6;
--dec-edge-selected-condition: rgb(59 130 246);
}

.dec-root.dark{
--dec-error-accent: #f87171;
--dec-error-glow: rgba(248, 113, 113, 0.4);
--dec-edge-selected: #aea6a6;
--dec-edge-selected-condition: rgb(59 130 246);
}

.dec-root .dec-diagram-content {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@
* limitations under the License.
*/

import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, act } 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, ReactFlow } from "@xyflow/react";
import * as RF from "@xyflow/react";
import * as autoLayoutModule from "../../../src/react-flow/diagram/autoLayout";

// Mock ReactFlow to capture props
Expand Down Expand Up @@ -181,4 +182,59 @@ describe("Diagram Component", () => {
expect(applyAutoLayoutSpy).toHaveBeenCalled();
});
});

describe("onEdgesChange with zIndex updates", () => {
it("should provide onEdgesChange callback to ReactFlow", async () => {
renderDiagram({ isReadOnly: false });

// Wait for initial render
await waitFor(() => {
expect(applyAutoLayoutSpy).toHaveBeenCalled();
});

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

it("should apply zIndex correctly when edges are updated", async () => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test shows this warning, can we fix it?

stderr | tests/react-flow/diagram/Diagram.test.tsx > Diagram Component > onEdgesChange with zIndex updates > should apply zIndex correctly when edges are updated
An update to DiagramEditorContextProvider inside a test was not wrapped in act(...).

When testing, code that causes React state updates should be wrapped into act(...):

act(() => {
  /* fire events that update state */
});
/* assert on the output */

This ensures that you're testing the behavior the user would see in the browser. Learn more at https://react.dev/link/wrap-tests-with-act

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed!

applyAutoLayoutSpy.mockResolvedValueOnce({
nodes: [],
edges: [
{ id: "edge1", source: "n1", target: "n2", selected: false },
{ id: "edge2", source: "n2", target: "n3", selected: true },
],
});

renderDiagram({ isReadOnly: false });

await waitFor(() => {
const lastCall = vi.mocked(ReactFlow).mock.calls.at(-1);
expect(lastCall).toBeDefined();
expect(lastCall![0].edges).toHaveLength(2);
});

const onEdgesChange = vi.mocked(ReactFlow).mock.calls.at(-1)![0].onEdgesChange;
const changes: Parameters<RF.OnEdgesChange>[0] = [
{ id: "edge1", type: "select", selected: true },
];

act(() => {
onEdgesChange?.(changes);
});

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);
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,123 @@ describe("React Flow custom edge types", () => {
expect(path).toHaveClass(`edge-line ${edgeClass}`);
});

it.each([
{
component: DefaultEdge,
edgeDescription: "default",
edgeClass: "",
selected: true,
description: "renders $edgeDescription edge with selected class when selected=true",
shouldHaveSelected: true,
expectedClasses: "edge-line selected",
},
{
component: ErrorEdge,
edgeDescription: "error",
edgeClass: "error",
selected: true,
description: "renders $edgeDescription edge with selected class when selected=true",
shouldHaveSelected: true,
expectedClasses: "edge-line error selected",
},
{
component: ConditionEdge,
edgeDescription: "condition",
edgeClass: "condition",
selected: true,
description: "renders $edgeDescription edge with selected class when selected=true",
shouldHaveSelected: true,
expectedClasses: "edge-line condition selected",
},
{
component: DefaultEdge,
edgeDescription: "default",
edgeClass: "",
selected: false,
description: "renders $edgeDescription edge without selected class when selected=false",
shouldHaveSelected: false,
expectedClasses: "edge-line",
},
{
component: ErrorEdge,
edgeDescription: "error",
edgeClass: "error",
selected: false,
description: "renders $edgeDescription edge without selected class when selected=false",
shouldHaveSelected: false,
expectedClasses: "edge-line error",
},
{
component: ConditionEdge,
edgeDescription: "condition",
edgeClass: "condition",
selected: false,
description: "renders $edgeDescription edge without selected class when selected=false",
shouldHaveSelected: false,
expectedClasses: "edge-line condition",
},
{
component: DefaultEdge,
edgeDescription: "default",
edgeClass: "",
selected: undefined,
description:
"renders $edgeDescription edge without selected class when selected is undefined",
shouldHaveSelected: false,
expectedClasses: "edge-line",
},
{
component: ErrorEdge,
edgeDescription: "error",
edgeClass: "error",
selected: undefined,
description:
"renders $edgeDescription edge without selected class when selected is undefined",
shouldHaveSelected: false,
expectedClasses: "edge-line error",
},
{
component: ConditionEdge,
edgeDescription: "condition",
edgeClass: "condition",
selected: undefined,
description:
"renders $edgeDescription edge without selected class when selected is undefined",
shouldHaveSelected: false,
expectedClasses: "edge-line condition",
},
])(
"renders $edgeDescription edge with selected=$selected",
({ component: Component, selected, shouldHaveSelected, expectedClasses }) => {
const { container } = render(
<RF.ReactFlowProvider>
<Component
id={"n1-n2"}
source={"n1"}
target={"n2"}
sourceX={0}
sourceY={0}
targetX={0}
targetY={0}
sourcePosition={RF.Position.Left}
targetPosition={RF.Position.Left}
selected={selected}
/>
</RF.ReactFlowProvider>,
);
const path = container.querySelector("path.edge-line");
expect(path).toBeInTheDocument();

if (shouldHaveSelected) {
expect(path).toHaveClass("selected");
} else {
expect(path).not.toHaveClass("selected");
}

expect(path).toHaveClass(expectedClasses);
},
);

it("matches snapshot with waypoints", () => {
const { container } = render(
<RF.ReactFlowProvider>
Expand Down
Loading