diff --git a/.changeset/edge-path-highlight.md b/.changeset/edge-path-highlight.md new file mode 100644 index 0000000..9a64e23 --- /dev/null +++ b/.changeset/edge-path-highlight.md @@ -0,0 +1,5 @@ +--- +"@serverlessworkflow/diagram-editor": minor +--- + +Add edge path highlighting on selection. 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 9f52b4f..42d7c67 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 @@ -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 { @@ -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 { 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 34dd9ca..861331f 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 @@ -66,10 +66,22 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { (changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)), [setNodes], ); + const onEdgesChange = React.useCallback( - (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( ({ nodes: selectedNodes }) => setSelectedNodeId(selectedNodes[0]?.id ?? null), [setSelectedNodeId], 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 1bc31ac..5a031e7 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 @@ -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) @@ -172,13 +173,10 @@ function CustomBaseEdge({ targetPosition, })[0]; + const edgeClassName = `${className ?? "edge-line"}${selected ? " selected" : ""}`; + return ( - + ); } diff --git a/packages/serverless-workflow-diagram-editor/src/styles.css b/packages/serverless-workflow-diagram-editor/src/styles.css index 3980adc..486f72d 100644 --- a/packages/serverless-workflow-diagram-editor/src/styles.css +++ b/packages/serverless-workflow-diagram-editor/src/styles.css @@ -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 { 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 e9bdb46..fbcc9b5 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,7 +14,7 @@ * 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"; @@ -22,6 +22,7 @@ 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 @@ -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 () => { + 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[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); + }); + }); + }); }); 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 7f13dc8..7f48f0c 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 @@ -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( + + + , + ); + 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(