From b183d900b8888b50f40ebbb75ebb7dcf32c932a5 Mon Sep 17 00:00:00 2001 From: handreyrc Date: Thu, 30 Apr 2026 20:32:13 -0400 Subject: [PATCH] Add custom edges Signed-off-by: handreyrc --- .../src/react-flow/diagram/Diagram.css | 121 +++++--- .../src/react-flow/diagram/Diagram.tsx | 117 ++++++- .../src/react-flow/edges/Edges.tsx | 146 +++++++++ .../src/styles.css | 11 +- .../tests/react-flow/edges/Edges.test.tsx | 291 ++++++++++++++++++ .../edges/__snapshots__/Edges.test.tsx.snap | 11 + 6 files changed, 639 insertions(+), 58 deletions(-) create mode 100644 packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx create mode 100644 packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx create mode 100644 packages/serverless-workflow-diagram-editor/tests/react-flow/edges/__snapshots__/Edges.test.tsx.snap 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 92dd1de..a9d8873 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 @@ -23,9 +23,9 @@ background-color: #e5e4e2; } - .dec-root.dark .diagram-background{ - --xy-background-pattern-color: inherit; - background-color: inherit; + .dec-root.dark .diagram-background { + --xy-background-pattern-color: inherit; + background-color: inherit; } /* react flow pointer behavior */ @@ -35,7 +35,7 @@ /* node hovering */ .dec-root .react-flow__node { - cursor: pointer !important; + cursor: pointer !important; } /* node dragging */ @@ -61,59 +61,104 @@ /* custom nodes */ @layer custom-nodes { .dec-root .node-label-container { - @apply - dec:whitespace-pre - dec:p-0.75 - dec:text-black - dec:text-sm; + @apply dec:whitespace-pre + dec:p-0.75 + dec:text-black + dec:text-sm; } .dec-root.dark .node-label-container { - @apply - dec:text-white; + @apply dec:text-white; } .dec-root .custom-node-container { - @apply - dec:rounded-[5px] - dec:bg-white - dec:border dec:border-black - dec:transition-[border,box-shadow] - dec:h-full - dec:w-full; + @apply dec:rounded-[5px] + dec:bg-white + dec:border dec:border-black + dec:transition-[border,box-shadow] + dec:h-full + dec:w-full; } .dec-root.dark .custom-node-container { - @apply - dec:bg-[rgb(85,83,83)] - dec:border-[#e5e4e2]; + @apply dec:bg-[rgb(85,83,83)] + dec:border-[#e5e4e2]; } .dec-root .custom-node-container:hover { - @apply - dec:hover:border dec:hover:border-black - dec:hover:shadow-[0_0_10px_rgba(0,65,208,0.3)] - dec:hover:h-full - dec:hover:w-full; + @apply dec:hover:border dec:hover:border-black + dec:hover:shadow-[0_0_10px_rgba(0,65,208,0.3)] + dec:hover:h-full + dec:hover:w-full; } .dec-root.dark .custom-node-container:hover { - @apply - dec:hover:border-[#5dafd5] - dec:hover:shadow-[0_0_10px_rgba(255,255,255,0.3)]; + @apply dec:hover:border-[#5dafd5] + dec:hover:shadow-[0_0_10px_rgba(255,255,255,0.3)]; } .dec-root .custom-node-container.selected { - @apply - dec:bg-[#aebbd5] - dec:border dec:border-black - dec:shadow-[0_0_10px_rgba(1,79,248,0.3)]; + @apply dec:bg-[#aebbd5] + dec:border dec:border-black + dec:shadow-[0_0_10px_rgba(1,79,248,0.3)]; } .dec-root.dark .custom-node-container.selected { - @apply - dec:bg-[#727676] - dec:border-[#4324dc] - dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; + @apply dec:bg-[#727676] + dec:border-[#4324dc] + dec:shadow-[0_0_10px_rgba(255,255,255,0.3)]; } -} \ No newline at end of file +} + +/* custom edges */ +/* React flow SVG components do not work with classes defined into layers */ +.dec-root .edge-line { + @apply dec:stroke-[#aea6a6] + dec:stroke-2; +} + +.dec-root .edge-line.error { + @apply dec:stroke-red-500 + dec:[stroke-dasharray:5_5]; +} + +.dec-root .edge-line.condition { + @apply dec:stroke-blue-500; +} + +/* custom edge labels */ +@layer custom-edge-labels { + .dec-root .edge-label { + @apply dec:bg-white + dec:border dec:border-[#ccc] + dec:rounded-[3px] + dec:py-0.5 dec:px-1.5 + dec:absolute + dec:text-[10px] + dec:pointer-events-auto; + } + + .dec-root.dark .edge-label { + @apply dec:bg-gray-800 + dec:border-gray-600 + dec:text-gray-200; + } + + .dec-root .edge-label.error { + @apply dec:bg-red-50 + dec:border-red-500 + dec:text-red-500; + } + + .dec-root .edge-label.condition { + @apply dec:bg-blue-50 + dec:border-blue-500 + dec:text-blue-500; + } + + .dec-root.dark .edge-label.condition { + @apply dec:bg-gray-800 + dec:border-gray-600 + dec:text-gray-200; + } +} 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 418a0cd..558cad2 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 @@ -18,10 +18,11 @@ import * as React from "react"; import * as RF from "@xyflow/react"; import { GraphNodeType } from "@serverlessworkflow/sdk"; import { NodeTypes } from "../nodes/Nodes"; -import { DEFAULT_NODE_SIZE } from "../../core"; +import { DEFAULT_NODE_SIZE, GraphEdgeType } from "../../core"; import "@xyflow/react/dist/style.css"; import "./Diagram.css"; import { ResolvedColorMode } from "../../types/colorMode"; +import { EdgeTypes } from "../edges/Edges"; const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, @@ -129,20 +130,100 @@ const initialNodes: RF.Node[] = [ data: { label: "Node 12" }, }, ]; + const initialEdges: RF.Edge[] = [ - { id: "n1-n2", source: "n1", target: "n2" }, - { id: "n2-n3", source: "n2", target: "n3" }, - { id: "n3-n4", source: "n3", target: "n4" }, - { id: "n3-n5", source: "n3", target: "n5" }, - { id: "n3-n6", source: "n3", target: "n6" }, - { id: "n4-n7", source: "n4", target: "n7" }, - { id: "n5-n7", source: "n5", target: "n7" }, - { id: "n6-n7", source: "n6", target: "n7" }, - { id: "n7-n8", source: "n7", target: "n8" }, - { id: "n8-n9", source: "n8", target: "n9" }, - { id: "n9-n10", source: "n9", target: "n10" }, - { id: "n10-n11", source: "n10", target: "n11" }, - { id: "n11-n12", source: "n11", target: "n12" }, + { + id: "n1-n2", + source: "n1", + target: "n2", + type: GraphEdgeType.Default, + data: { + wayPoints: [ + { x: 145, y: 60 }, + { x: 170, y: 60 }, + { x: 170, y: 85 }, + { x: 145, y: 85 }, + ], + }, + }, + { + id: "n2-n3", + source: "n2", + target: "n3", + type: GraphEdgeType.Default, + data: { label: "Default" }, + }, + { + id: "n3-n4", + source: "n3", + target: "n4", + type: GraphEdgeType.Condition, + data: { label: "Case 1" }, + }, + { + id: "n3-n5", + source: "n3", + target: "n5", + type: GraphEdgeType.Condition, + data: { label: "Case 2" }, + }, + { + id: "n3-n6", + source: "n3", + target: "n6", + type: GraphEdgeType.Condition, + data: { label: "Default" }, + animated: true, + }, + { + id: "n4-n7", + source: "n4", + target: "n7", + type: GraphEdgeType.Default, + }, + { + id: "n5-n7", + source: "n5", + target: "n7", + type: GraphEdgeType.Default, + }, + { + id: "n6-n7", + source: "n6", + target: "n7", + type: GraphEdgeType.Default, + }, + { + id: "n7-n8", + source: "n7", + target: "n8", + type: GraphEdgeType.Default, + }, + { + id: "n8-n9", + source: "n8", + target: "n9", + type: GraphEdgeType.Error, + animated: true, + }, + { + id: "n9-n10", + source: "n9", + target: "n10", + type: GraphEdgeType.Default, + }, + { + id: "n10-n11", + source: "n10", + target: "n11", + type: GraphEdgeType.Default, + }, + { + id: "n11-n12", + source: "n11", + target: "n12", + type: GraphEdgeType.Default, + }, ]; /** @@ -187,6 +268,7 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { { selectionOnDrag={true} fitView colorMode={colorMode} + defaultEdgeOptions={{ + markerEnd: { + type: RF.MarkerType.ArrowClosed, + width: 10, + height: 10, + }, + }} data-testid={"react-flow-canvas"} > {minimapVisible && } 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 new file mode 100644 index 0000000..488bfe1 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/edges/Edges.tsx @@ -0,0 +1,146 @@ +/* + * 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 * as RF from "@xyflow/react"; +import { GraphEdgeType, WayPoints } from "../../core"; + +// Edge types must match GraphEdgeType enum +export const EdgeTypes: RF.EdgeTypes = { + [GraphEdgeType.Default]: DefaultEdge, + [GraphEdgeType.Error]: ErrorEdge, + [GraphEdgeType.Condition]: ConditionEdge, +}; + +export type BaseEdgeData = { + label?: string; + wayPoints?: WayPoints; +}; + +/* Base edge component*/ +function CustomBaseEdge({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + markerEnd, + classname, +}: RF.EdgeProps & { data?: BaseEdgeData; classname?: string }) { + const edgePath = data?.wayPoints + ? createPathFromWayPoints(sourceX, sourceY, targetX, targetY, data.wayPoints) + : RF.getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + })[0]; + + return ( + + ); +} + +/* Default Edge */ +export type DefaultEdgeType = RF.Edge; +export function DefaultEdge(props: RF.EdgeProps) { + return ( + <> + + + + ); +} + +/* Error Edge */ +export type ErrorEdgeType = RF.Edge; +export function ErrorEdge(props: RF.EdgeProps) { + return ( + <> + + + + ); +} + +/* Condition Edge */ +export type ConditionEdgeType = RF.Edge; +export function ConditionEdge(props: RF.EdgeProps) { + return ( + <> + + + + ); +} + +export type EdgeLabelProps = { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + type?: GraphEdgeType; + data?: BaseEdgeData | undefined; +}; + +export function EdgeLabel({ sourceX, sourceY, targetX, targetY, type, data }: EdgeLabelProps) { + return ( + <> + {data?.label && ( + +
+ {data.label} +
+
+ )} + + ); +} + +export function createPathFromWayPoints( + sourceX: number, + sourceY: number, + targetX: number, + targetY: number, + wayPoints?: WayPoints, +): string { + if (!wayPoints || wayPoints.length === 0) { + return `M ${sourceX},${sourceY} L ${targetX},${targetY}`; + } + + let path = `M ${sourceX},${sourceY}`; + for (const point of wayPoints) { + path += ` L ${point.x},${point.y}`; + } + + path += ` L ${targetX},${targetY}`; + + return path; +} diff --git a/packages/serverless-workflow-diagram-editor/src/styles.css b/packages/serverless-workflow-diagram-editor/src/styles.css index 91e5180..f6727b8 100644 --- a/packages/serverless-workflow-diagram-editor/src/styles.css +++ b/packages/serverless-workflow-diagram-editor/src/styles.css @@ -15,10 +15,10 @@ */ /* layer order (Priority: lowest -> highest) */ -@layer - base, +@layer base, react-flow-overrides, - custom-nodes; + custom-nodes, + custom-edge-labels; @import "tailwindcss" prefix(dec); @@ -41,7 +41,6 @@ /* global reset styles and element defaults */ @layer base { .dec-root { - @apply - dec:h-full; + @apply dec:h-full; } -} \ No newline at end of file +} 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 new file mode 100644 index 0000000..7479ced --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/Edges.test.tsx @@ -0,0 +1,291 @@ +/* + * 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 { vi, it, expect, afterEach, describe } from "vitest"; +import { render } from "@testing-library/react"; +import { GraphEdgeType } from "../../../src/core/graph"; +import { + ConditionEdge, + DefaultEdge, + EdgeLabel, + EdgeTypes, + ErrorEdge, + createPathFromWayPoints, +} from "../../../src/react-flow/edges/Edges"; +import * as RF from "@xyflow/react"; + +describe("React Flow custom edge types", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("exports all edge types from GraphEdgeType enum", () => { + expect(EdgeTypes).toHaveProperty(GraphEdgeType.Default); + expect(EdgeTypes).toHaveProperty(GraphEdgeType.Error); + expect(EdgeTypes).toHaveProperty(GraphEdgeType.Condition); + expect(Object.keys(EdgeTypes)).toHaveLength(3); + }); + + it.each([ + { component: DefaultEdge, edgeDescription: "default", edgeClass: "" }, + { component: ErrorEdge, edgeDescription: "error", edgeClass: "error" }, + { component: ConditionEdge, edgeDescription: "condition", edgeClass: "condition" }, + ])("renders $edgeDescription edge", ({ component, edgeClass }) => { + const Component = component; + const { container } = render( + + + , + ); + const path = container.querySelector("path.edge-line"); + expect(path).toBeInTheDocument(); + expect(path).toHaveClass(`edge-line ${edgeClass}`); + }); + + it("matches snapshot with waypoints", () => { + const { container } = render( + + + , + ); + + expect(container.firstChild).toMatchSnapshot(); + }); +}); + +describe("createPathFromWayPoints helper function", () => { + type context = { + description: string; + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + wayPoints?: Array<{ x: number; y: number }>; + expected: string; + }; + + it.each([ + { + description: "creates simple path without waypoints", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + wayPoints: undefined, + expected: "M 0,0 L 100,100", + }, + { + description: "creates simple path with empty waypoints array", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + wayPoints: [], + expected: "M 0,0 L 100,100", + }, + { + description: "creates path with single waypoint", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + wayPoints: [{ x: 50, y: 50 }], + expected: "M 0,0 L 50,50 L 100,100", + }, + { + description: "creates path with multiple waypoints", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + wayPoints: [ + { x: 25, y: 25 }, + { x: 50, y: 50 }, + { x: 75, y: 75 }, + ], + expected: "M 0,0 L 25,25 L 50,50 L 75,75 L 100,100", + }, + { + description: "creates path with negative coordinates", + sourceX: -100, + sourceY: -100, + targetX: 100, + targetY: 100, + wayPoints: [ + { x: -50, y: -50 }, + { x: 50, y: 50 }, + ], + expected: "M -100,-100 L -50,-50 L 50,50 L 100,100", + }, + { + description: "creates path with decimal coordinates", + sourceX: 0.5, + sourceY: 10.25, + targetX: 100.75, + targetY: 200.5, + wayPoints: [{ x: 50.5, y: 75.25 }], + expected: "M 0.5,10.25 L 50.5,75.25 L 100.75,200.5", + }, + { + description: "handles complex path with many waypoints", + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + wayPoints: [ + { x: 10, y: 10 }, + { x: 20, y: 30 }, + { x: 40, y: 20 }, + { x: 60, y: 50 }, + { x: 80, y: 40 }, + { x: 90, y: 90 }, + ], + expected: "M 0,0 L 10,10 L 20,30 L 40,20 L 60,50 L 80,40 L 90,90 L 100,100", + }, + { + description: "preserves coordinate precision", + sourceX: 0.1, + sourceY: 0.2, + targetX: 99.9, + targetY: 99.8, + wayPoints: [ + { x: 33.333333, y: 66.666666 }, + { x: 77.777777, y: 88.888888 }, + ], + expected: "M 0.1,0.2 L 33.333333,66.666666 L 77.777777,88.888888 L 99.9,99.8", + }, + ])("$description", ({ sourceX, sourceY, targetX, targetY, wayPoints, expected }) => { + const path = createPathFromWayPoints(sourceX, sourceY, targetX, targetY, wayPoints); + expect(path).toBe(expected); + }); +}); + +describe("EdgeLabel component", () => { + it("returns null when no label is provided", () => { + const result = EdgeLabel({ + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + }); + + expect(result).toEqual(<>); + }); + + it("returns JSX when label is provided", () => { + const result = EdgeLabel({ + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + data: { label: "Test" }, + }); + + expect(result).not.toBeNull(); + expect(result).toBeTruthy(); + }); + + it.each([ + { edgeType: GraphEdgeType.Default, description: "default" }, + { edgeType: GraphEdgeType.Error, description: "error" }, + { edgeType: GraphEdgeType.Condition, description: "condition" }, + ])("edge label $description", ({ edgeType }) => { + const result = EdgeLabel({ + sourceX: 0, + sourceY: 0, + targetX: 100, + targetY: 100, + type: edgeType, + data: { label: "Test" }, + }); + + const resultString = JSON.stringify(result); + expect(resultString).toContain("edge-label"); + expect(resultString).toContain(edgeType); + }); + + describe("integration with edges", () => { + it.each([ + { + component: DefaultEdge, + description: "DefaultEdge renders without crash when label not provided", + data: {}, + selector: "path.edge-line", + }, + { + component: DefaultEdge, + description: "DefaultEdge renders without crash when label provided", + data: { label: "Default Label" }, + selector: "path.edge-line", + }, + { + component: ErrorEdge, + description: "ErrorEdge renders without crash with label", + data: { label: "Error Label" }, + selector: "path.edge-line.error", + }, + { + component: ConditionEdge, + description: "ConditionEdge renders without crash with label", + data: { label: "Condition Label" }, + selector: "path.edge-line.condition", + }, + ])("$description", ({ component: Component, data, selector }) => { + const { container } = render( + + + , + ); + + const path = container.querySelector(selector); + expect(path).toBeTruthy(); + }); + }); +}); diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/__snapshots__/Edges.test.tsx.snap b/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/__snapshots__/Edges.test.tsx.snap new file mode 100644 index 0000000..0cdecd8 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/edges/__snapshots__/Edges.test.tsx.snap @@ -0,0 +1,11 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`React Flow custom edge types > matches snapshot with waypoints 1`] = ` + +`;