diff --git a/packages/serverless-workflow-diagram-editor/src/core/graph.ts b/packages/serverless-workflow-diagram-editor/src/core/graph.ts index c2980bb3..98c85b8c 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/graph.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/graph.ts @@ -14,94 +14,48 @@ * limitations under the License. */ -import { Graph, GraphEdge, GraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; +import { FlatGraph, FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; -// Override / add multiple properties of a type in a generic way -export type Override = Omit & NewProps; - -// Supported edge types -export enum GraphEdgeType { - Default = "default", - Error = "error", - Condition = "condition", +export function getNodesByType(graph: FlatGraph, type: GraphNodeType): FlatGraphNode[] { + return graph.nodes.filter((node) => node.type === type); } -export type Point = { - x: number; - y: number; -}; - -export type Position = Point; - -export type Size = { - height: number; - width: number; -}; - -export type WayPoints = Point[]; +// Inner entry and exit nodes cannot be connected external nodes so connections shall be moved to parent node +export function fixNodesConnections(graph: FlatGraph): FlatGraph { + const entryNodes = getNodesByType(graph, GraphNodeType.Entry); + const exitNodes = getNodesByType(graph, GraphNodeType.Exit); -// Add extra properties to GraphNode -export type ExtendedGraphNode = Override< - GraphNode, - { - position?: Position; - size?: Size; - } ->; - -// Add extra properties to GraphEdge -export type ExtendedGraphEdge = GraphEdge & { - type?: GraphEdgeType; - wayPoints?: WayPoints; -}; + // Build maps of {entryNodeId -> parentId} and {exitNodeId -> parentId} + const entryNodeToParent = new Map(); + entryNodes.forEach((node) => { + if (node.parentId) { + entryNodeToParent.set(node.id, node.parentId); + } + }); -export type ExtendedGraph = Override< - Graph, - { - parent?: ExtendedGraph | null; - nodes: ExtendedGraphNode[]; - edges: ExtendedGraphEdge[]; - entryNode: ExtendedGraphNode; - exitNode: ExtendedGraphNode; - } ->; + const exitNodeToParent = new Map(); + exitNodes.forEach((node) => { + if (node.parentId) { + exitNodeToParent.set(node.id, node.parentId); + } + }); -export function solveEdgeTypes(graph: ExtendedGraph): ExtendedGraph { const graphClone = structuredClone(graph); - // root level - setEdgeTypes(graphClone); - // children n level - graphClone.nodes.flat().forEach((node) => setEdgeTypes(node as ExtendedGraph)); - - return graphClone; -} - -function setEdgeTypes(graph: ExtendedGraph): ExtendedGraph { - if (!graph.edges || !graph.nodes) { - return graph; - } - - for (let i = 0; i < graph.nodes.length; i++) { - const graphNode = graph.nodes[i]! as ExtendedGraph; - - for (let j = 0; j < graph.edges.length; j++) { - const graphEdge = graph.edges[j]!; + // Single pass over edges to rewrite sourceId/targetId + graphClone.edges.forEach((edge) => { + // Move entry node incoming connections to parent + const entryParent = entryNodeToParent.get(edge.targetId); + if (entryParent) { + edge.targetId = entryParent; + } - if (graphNode.id === graphEdge.sourceId) { - switch (graphNode.type) { - case GraphNodeType.Raise: - graphEdge.type = GraphEdgeType.Error; - break; - case GraphNodeType.Switch: - graphEdge.type = GraphEdgeType.Condition; - break; - default: - graphEdge.type = GraphEdgeType.Default; - } - } + // Move exit node outgoing connections to parent + const exitParent = exitNodeToParent.get(edge.sourceId); + if (exitParent) { + edge.sourceId = exitParent; } - } + }); - return graph; + return graphClone; } diff --git a/packages/serverless-workflow-diagram-editor/src/core/index.ts b/packages/serverless-workflow-diagram-editor/src/core/index.ts index 800557db..0bbbd0e2 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/index.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/index.ts @@ -16,4 +16,3 @@ export * from "./workflowSdk"; export * from "./graph"; -export * from "./autoLayout"; diff --git a/packages/serverless-workflow-diagram-editor/src/core/workflowSdk.ts b/packages/serverless-workflow-diagram-editor/src/core/workflowSdk.ts index a5600311..364bd2e5 100644 --- a/packages/serverless-workflow-diagram-editor/src/core/workflowSdk.ts +++ b/packages/serverless-workflow-diagram-editor/src/core/workflowSdk.ts @@ -16,7 +16,7 @@ import yaml from "js-yaml"; import * as sdk from "@serverlessworkflow/sdk"; -import { ExtendedGraph, solveEdgeTypes } from "./graph"; +import { fixNodesConnections } from "./graph"; export type WorkflowParseResult = { model: sdk.Specification.Workflow | null; @@ -55,6 +55,6 @@ export function parseWorkflow(text: string): WorkflowParseResult { return { model, errors }; } -export function buildGraph(model: sdk.Specification.Workflow): ExtendedGraph { - return solveEdgeTypes(sdk.buildGraph(model)); +export function buildFlatGraph(model: sdk.Specification.Workflow): sdk.FlatGraph { + return fixNodesConnections(sdk.buildFlatGraph(model)); } 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 64a24ce4..77868c09 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 @@ -16,13 +16,13 @@ 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, GraphEdgeType } from "../../core"; +import { ReactFlowNodeTypes } from "../nodes/Nodes"; import "@xyflow/react/dist/style.css"; import "./Diagram.css"; import { ResolvedColorMode } from "../../types/colorMode"; -import { EdgeTypes } from "../edges/Edges"; +import { ReactFlowEdgeTypes } from "../edges/Edges"; +import { useDiagramEditorContext } from "../../store/DiagramEditorContext"; +import { buildDiagramElements } from "./diagramBuilder"; const FIT_VIEW_OPTIONS: RF.FitViewOptions = { maxZoom: 1, @@ -30,204 +30,6 @@ const FIT_VIEW_OPTIONS: RF.FitViewOptions = { duration: 400, }; -// TODO: Nodes and Edges are hardcoded for now to generate a renderable basic workflow -// It shall be replaced by the actual implementation based on graph structure -const initialNodes: RF.Node[] = [ - { - id: "n1", - type: GraphNodeType.Call, - position: { x: 100, y: 0 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "CallNode" }, - }, - { - id: "n2", - type: GraphNodeType.Do, - position: { x: 100, y: 100 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "Node 2" }, - }, - { - id: "n3", - type: GraphNodeType.Switch, - position: { x: 100, y: 200 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "SwitchNode" }, - }, - { - id: "n4", - type: GraphNodeType.Emit, - position: { x: -100, y: 300 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "EmitNode" }, - }, - { - id: "n5", - type: GraphNodeType.For, - position: { x: 100, y: 300 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "Node 5" }, - }, - { - id: "n6", - type: GraphNodeType.Fork, - position: { x: 300, y: 300 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "Node 6" }, - }, - { - id: "n7", - type: GraphNodeType.Listen, - position: { x: 100, y: 400 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "ListenNode" }, - }, - { - id: "n8", - type: GraphNodeType.Raise, - position: { x: 100, y: 500 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "RaiseNode" }, - }, - { - id: "n9", - type: GraphNodeType.Run, - position: { x: 100, y: 600 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "RunNode" }, - }, - { - id: "n10", - type: GraphNodeType.Set, - position: { x: 100, y: 700 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "SetNode" }, - }, - { - id: "n11", - type: GraphNodeType.Try, - position: { x: 100, y: 800 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "Node 11" }, - }, - { - id: "n12", - type: GraphNodeType.Wait, - position: { x: 100, y: 900 }, - height: DEFAULT_NODE_SIZE.height, - width: DEFAULT_NODE_SIZE.width, - data: { label: "WaitNode" }, - }, -]; - -const initialEdges: RF.Edge[] = [ - { - id: "n1-n2", - source: "n1", - target: "n2", - type: GraphEdgeType.Default, - data: { - wayPoints: [ - { x: 190, y: 60 }, - { x: 190, y: 70 }, - { x: 140, y: 70 }, - { x: 140, y: 85 }, - { x: 190, y: 85 }, - { x: 190, y: 95 }, - ], - }, - }, - { - 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, - }, -]; - /** * Diagram component API */ @@ -242,18 +44,9 @@ export type DiagramProps = { }; export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { - const [minimapVisible, setMinimapVisible] = React.useState(false); - const [nodes, setNodes] = React.useState(initialNodes); - const [edges, setEdges] = React.useState(initialEdges); + const { model, nodes, edges, setNodes, setEdges } = useDiagramEditorContext(); - const onNodesChange = React.useCallback( - (changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)), - [], - ); - const onEdgesChange = React.useCallback( - (changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)), - [], - ); + const [minimapVisible, setMinimapVisible] = React.useState(false); React.useImperativeHandle( ref, @@ -265,12 +58,28 @@ export const Diagram = ({ divRef, ref, colorMode = "light" }: DiagramProps) => { [], ); + const onNodesChange = React.useCallback( + (changes) => setNodes((nodesSnapshot) => RF.applyNodeChanges(changes, nodesSnapshot)), + [setNodes], + ); + const onEdgesChange = React.useCallback( + (changes) => setEdges((edgesSnapshot) => RF.applyEdgeChanges(changes, edgesSnapshot)), + [setEdges], + ); + + // Rebuild nodes and edges as model changes + React.useEffect(() => { + const { nodes, edges } = buildDiagramElements(model); + setNodes(nodes); + setEdges(edges); + }, [model, setNodes, setEdges]); + return (
{ - node.size = { ...nodeSize }; + node.height = DEFAULT_NODE_SIZE.height; + node.width = DEFAULT_NODE_SIZE.width; node.position = { ...position }; position.y = position.y + 100; }); diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts new file mode 100644 index 00000000..5a6cf59b --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -0,0 +1,114 @@ +/* + * 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 { buildFlatGraph } from "../../core"; +import { BaseNodeData, ReactFlowNodeTypes } from "../nodes/Nodes"; +import { BaseEdgeData, EdgeTypes } from "../edges/Edges"; +import * as sdk from "@serverlessworkflow/sdk"; +import { applyAutoLayout, DEFAULT_NODE_SIZE } from "./autoLayout"; + +export type ReactFlowGraph = { + nodes: RF.Node[]; + edges: RF.Edge[]; +}; + +export function getEdgeType(graphEdge: sdk.GraphEdge, nodeMap: Map): EdgeTypes { + const source = nodeMap.get(graphEdge.sourceId); + + if (!source) return EdgeTypes.Default; + + const typeMap: Partial> = { + [sdk.GraphNodeType.Raise]: EdgeTypes.Error, + [sdk.GraphNodeType.Switch]: EdgeTypes.Condition, + }; + + return typeMap[source.type as sdk.GraphNodeType] ?? EdgeTypes.Default; +} + +export function edgeSourceAndTargetExist( + graphEdge: sdk.GraphEdge, + nodeIdSet: Set, +): boolean { + return nodeIdSet.has(graphEdge.sourceId) && nodeIdSet.has(graphEdge.targetId); +} + +function buildReactFlowNode(graphNode: sdk.FlatGraph | sdk.FlatGraphNode): RF.Node { + // There is no corresponding react flow component implemented + if (!Object.keys(ReactFlowNodeTypes).includes(graphNode.type)) { + throw new Error(`Unsupported GraphNodeType: ${graphNode.type}!`); + } + + return { + id: graphNode.id, + type: graphNode.type, + data: { + label: graphNode.label ?? "", + ...(graphNode.task !== undefined && { task: structuredClone(graphNode.task) }), + }, + height: DEFAULT_NODE_SIZE.height, + width: DEFAULT_NODE_SIZE.width, + position: { x: 0, y: 0 }, + }; +} + +function buildReactFlowEdge( + graphEdge: sdk.GraphEdge, + nodeMap: Map, +): RF.Edge { + const type = getEdgeType(graphEdge, nodeMap); + + return { + id: graphEdge.id, + source: graphEdge.sourceId, + target: graphEdge.targetId, + type, + data: { + label: graphEdge.label ?? "", + }, + animated: graphEdge.label === "default" || type === EdgeTypes.Error, + }; +} + +export function buildDiagramElements(model: sdk.Specification.Workflow | null): ReactFlowGraph { + const nodes: RF.Node[] = []; + const edges: RF.Edge[] = []; + + if (model) { + const graph = buildFlatGraph(model); + + graph.nodes.forEach((graphNode) => { + // TODO: only nodes on root level are supported for now + if (graphNode.parentId === "root") { + nodes.push(buildReactFlowNode(graphNode)); + } + }); + + // Precompute node ID set for O(1) membership checks + const nodeIdSet = new Set(nodes.map((node) => node.id)); + // Build nodeId->node map for edge type determination + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + + graph.edges.forEach((graphEdge) => { + // Only create edges for existing nodes + if (edgeSourceAndTargetExist(graphEdge, nodeIdSet)) { + edges.push(buildReactFlowEdge(graphEdge, nodeMap)); + } + }); + } + + return applyAutoLayout({ nodes, edges }); +} 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 46c2da5a..2673a186 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 @@ -15,13 +15,18 @@ */ import * as RF from "@xyflow/react"; -import { GraphEdgeType, WayPoints } from "../../core"; +import type { WayPoints } from "../diagram/autoLayout"; -// Edge types must match GraphEdgeType enum -export const EdgeTypes: RF.EdgeTypes = { - [GraphEdgeType.Default]: DefaultEdge, - [GraphEdgeType.Error]: ErrorEdge, - [GraphEdgeType.Condition]: ConditionEdge, +export enum EdgeTypes { + Default = "default", + Error = "error", + Condition = "condition", +} + +export const ReactFlowEdgeTypes: RF.EdgeTypes = { + [EdgeTypes.Default]: DefaultEdge, + [EdgeTypes.Error]: ErrorEdge, + [EdgeTypes.Condition]: ConditionEdge, }; export type BaseEdgeData = { @@ -34,7 +39,7 @@ export type EdgeLabelProps = { sourceY: number; targetX: number; targetY: number; - type?: GraphEdgeType; + type?: EdgeTypes; data?: BaseEdgeData | undefined; }; @@ -113,7 +118,7 @@ function CustomBaseEdge({ } /* Default Edge */ -export type DefaultEdgeType = RF.Edge; +export type DefaultEdgeType = RF.Edge; export function DefaultEdge(props: RF.EdgeProps) { return ( <> @@ -124,7 +129,7 @@ export function DefaultEdge(props: RF.EdgeProps) { } /* Error Edge */ -export type ErrorEdgeType = RF.Edge; +export type ErrorEdgeType = RF.Edge; export function ErrorEdge(props: RF.EdgeProps) { return ( <> @@ -135,7 +140,7 @@ export function ErrorEdge(props: RF.EdgeProps) { } /* Condition Edge */ -export type ConditionEdgeType = RF.Edge; +export type ConditionEdgeType = RF.Edge; export function ConditionEdge(props: RF.EdgeProps) { return ( <> diff --git a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx index d5f7bb00..aca8f100 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/nodes/Nodes.tsx @@ -15,12 +15,14 @@ */ import type React from "react"; -import { GraphNodeType } from "@serverlessworkflow/sdk"; +import { GraphNodeType, type Specification } from "@serverlessworkflow/sdk"; import * as RF from "@xyflow/react"; import { type LeafNodeType, taskNodeConfigMap } from "./taskNodeConfig"; // Node types must match sdk GraphNodeType enum -export const NodeTypes: RF.NodeTypes = { +export const ReactFlowNodeTypes: RF.NodeTypes = { + [GraphNodeType.Start]: StartNode, + [GraphNodeType.End]: EndNode, [GraphNodeType.Call]: CallNode, [GraphNodeType.Do]: DoNode, [GraphNodeType.Emit]: EmitNode, @@ -35,9 +37,9 @@ export const NodeTypes: RF.NodeTypes = { [GraphNodeType.Wait]: WaitNode, }; -export type BaseNodeData = { - // TODO: It is a placeholder, add properties to be consumed internally by node components +export type BaseNodeData = { label: string; + task?: T; }; interface NodeContentProps { @@ -93,78 +95,101 @@ function PlaceholderContent({ id, data, selected, type }: PlaceholderProps) { ); } +/* start node */ +export type StartNodeType = RF.Node; +export function StartNode({ id, data, selected, type }: RF.NodeProps) { + // TODO: This component is just a placeholder + return ; +} + +/* end node */ +export type EndNodeType = RF.Node; +export function EndNode({ id, data, selected, type }: RF.NodeProps) { + // TODO: This component is just a placeholder + return ; +} + /* call node */ -export type CallNodeType = RF.Node; +export type CallNodeType = RF.Node, typeof GraphNodeType.Call>; export function CallNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* do node */ -export type DoNodeType = RF.Node; +export type DoNodeType = RF.Node, typeof GraphNodeType.Do>; export function DoNode({ id, data, selected, type }: RF.NodeProps) { // TODO: This component is just a placeholder return ; } /* emit node */ -export type EmitNodeType = RF.Node; +export type EmitNodeType = RF.Node, typeof GraphNodeType.Emit>; export function EmitNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* for node */ -export type ForNodeType = RF.Node; +export type ForNodeType = RF.Node, typeof GraphNodeType.For>; export function ForNode({ id, data, selected, type }: RF.NodeProps) { // TODO: This component is just a placeholder return ; } /* fork node */ -export type ForkNodeType = RF.Node; +export type ForkNodeType = RF.Node, typeof GraphNodeType.Fork>; export function ForkNode({ id, data, selected, type }: RF.NodeProps) { // TODO: This component is just a placeholder return ; } /* listen node */ -export type ListenNodeType = RF.Node; +export type ListenNodeType = RF.Node< + BaseNodeData, + typeof GraphNodeType.Listen +>; export function ListenNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* raise node */ -export type RaiseNodeType = RF.Node; +export type RaiseNodeType = RF.Node< + BaseNodeData, + typeof GraphNodeType.Raise +>; export function RaiseNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* run node */ -export type RunNodeType = RF.Node; +export type RunNodeType = RF.Node, typeof GraphNodeType.Run>; export function RunNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* set node */ -export type SetNodeType = RF.Node; +export type SetNodeType = RF.Node, typeof GraphNodeType.Set>; export function SetNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* switch node */ -export type SwitchNodeType = RF.Node; +export type SwitchNodeType = RF.Node< + BaseNodeData, + typeof GraphNodeType.Switch +>; export function SwitchNode({ id, data, selected, type }: RF.NodeProps) { return ; } /* try node */ -export type TryNodeType = RF.Node; +export type TryNodeType = RF.Node, typeof GraphNodeType.Try>; export function TryNode({ id, data, selected, type }: RF.NodeProps) { // TODO: This component is just a placeholder return ; } /* wait node */ -export type WaitNodeType = RF.Node; +export type WaitNodeType = RF.Node, typeof GraphNodeType.Wait>; export function WaitNode({ id, data, selected, type }: RF.NodeProps) { return ; } diff --git a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx index d61034c9..fde6715b 100644 --- a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx +++ b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContext.tsx @@ -16,15 +16,20 @@ import type { Specification } from "@serverlessworkflow/sdk"; import * as React from "react"; +import type * as RF from "@xyflow/react"; export type DiagramEditorContextType = { isReadOnly: boolean; locale: string; model: Specification.Workflow | null; errors: Error[]; + nodes: RF.Node[]; + edges: RF.Edge[]; - updateIsReadOnly: (isReadOnly: boolean) => void; - updateLocale: (locale: string) => void; + setIsReadOnly: React.Dispatch>; + setLocale: React.Dispatch>; + setNodes: React.Dispatch>; + setEdges: React.Dispatch>; }; export const DiagramEditorContext = React.createContext( diff --git a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx index 6c2733f5..aeeb45f1 100644 --- a/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx +++ b/packages/serverless-workflow-diagram-editor/src/store/DiagramEditorContextProvider.tsx @@ -18,6 +18,7 @@ import * as React from "react"; import { parseWorkflow } from "../core"; import { DiagramEditorProps } from "../diagram-editor/DiagramEditor"; import { DiagramEditorContext, DiagramEditorContextType } from "./DiagramEditorContext"; +import type * as RF from "@xyflow/react"; export type ContextProviderProps = Omit; @@ -27,22 +28,16 @@ export const DiagramEditorContextProvider = ( // Initialize states with props values const [isReadOnly, setIsReadOnly] = React.useState(props.isReadOnly); const [locale, setLocale] = React.useState(props.locale); + const [nodes, setNodes] = React.useState([] as RF.Node[]); + const [edges, setEdges] = React.useState([] as RF.Edge[]); const { model, errors } = React.useMemo(() => parseWorkflow(props.content), [props.content]); - const updateIsReadOnly = React.useCallback((isReadOnly: boolean) => { - setIsReadOnly(isReadOnly); - }, []); - - const updateLocale = React.useCallback((locale: string) => { - setLocale(locale); - }, []); - // Update states on props changes React.useEffect(() => { - updateIsReadOnly(props.isReadOnly); - updateLocale(props.locale); - }, [props.isReadOnly, props.locale, updateIsReadOnly, updateLocale]); + setIsReadOnly(props.isReadOnly); + setLocale(props.locale); + }, [props.isReadOnly, props.locale, setIsReadOnly, setLocale]); // Memoize context value to prevent unnecessary re-renders of consumers const context = React.useMemo( @@ -51,10 +46,14 @@ export const DiagramEditorContextProvider = ( locale, model, errors, - updateIsReadOnly, - updateLocale, + nodes, + edges, + setIsReadOnly, + setLocale, + setNodes, + setEdges, }), - [isReadOnly, locale, model, errors, updateIsReadOnly, updateLocale], + [isReadOnly, locale, model, errors, nodes, edges, setIsReadOnly, setLocale, setNodes, setEdges], ); return ( diff --git a/packages/serverless-workflow-diagram-editor/tests-e2e/diagram-editor.spec.ts b/packages/serverless-workflow-diagram-editor/tests-e2e/diagram-editor.spec.ts index 98cd46b8..85f76127 100644 --- a/packages/serverless-workflow-diagram-editor/tests-e2e/diagram-editor.spec.ts +++ b/packages/serverless-workflow-diagram-editor/tests-e2e/diagram-editor.spec.ts @@ -23,13 +23,13 @@ test("diagram editor renders correctly", async ({ page }) => { await expect(page.getByTestId("diagram-container")).toBeVisible(); // Check at least one specific node - await expect(page.getByTestId("rf__node-n1")).toContainText("CallNodeCALL"); + await expect(page.getByTestId("rf__node-/do/0/step1")).toContainText("step1SET"); // Check total nodes const nodes = page.locator('[data-testid^="rf__node-"]'); - await expect(nodes).toHaveCount(12); + await expect(nodes).toHaveCount(3); // Check total edge const edges = page.locator('[data-testid^="rf__edge-"]'); - await expect(edges).toHaveCount(13); + await expect(edges).toHaveCount(2); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/core/__snapshots__/workflowSdk.integration.test.ts.snap b/packages/serverless-workflow-diagram-editor/tests/core/__snapshots__/workflowSdk.integration.test.ts.snap index ab9d664e..d3a50b78 100644 --- a/packages/serverless-workflow-diagram-editor/tests/core/__snapshots__/workflowSdk.integration.test.ts.snap +++ b/packages/serverless-workflow-diagram-editor/tests/core/__snapshots__/workflowSdk.integration.test.ts.snap @@ -1,57 +1,279 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`buildGraph > returns a loaded extended graph object from model 1`] = ` +exports[`buildFlatGraph > returns a loaded extended graph object from model 1`] = ` { "edges": [ { - "destinationId": "root-exit-node", "id": "/do/4/step5-root-exit-node", "label": "", "sourceId": "/do/4/step5", - "type": "default", + "targetId": "root-exit-node", }, { - "destinationId": "/do/4/step5", "id": "/do/3/step4-/do/4/step5", "label": "", "sourceId": "/do/3/step4", - "type": "default", + "targetId": "/do/4/step5", }, { - "destinationId": "/do/3/step4", "id": "/do/2/step3-/do/3/step4", "label": "", "sourceId": "/do/2/step3", - "type": "default", + "targetId": "/do/3/step4", }, { - "destinationId": "/do/2/step3", "id": "/do/1/step2-/do/2/step3", "label": "", "sourceId": "/do/1/step2", - "type": "default", + "targetId": "/do/2/step3", }, { - "destinationId": "/do/1/step2", "id": "/do/0/step1-/do/1/step2", "label": "", "sourceId": "/do/0/step1", - "type": "default", + "targetId": "/do/1/step2", }, { - "destinationId": "/do/0/step1", "id": "root-entry-node-/do/0/step1", "label": "", "sourceId": "root-entry-node", - "type": "default", + "targetId": "/do/0/step1", }, ], "entryNode": { "id": "root-entry-node", + "parent": { + "edges": [ + { + "id": "/do/4/step5-root-exit-node", + "label": "", + "sourceId": "/do/4/step5", + "targetId": "root-exit-node", + }, + { + "id": "/do/3/step4-/do/4/step5", + "label": "", + "sourceId": "/do/3/step4", + "targetId": "/do/4/step5", + }, + { + "id": "/do/2/step3-/do/3/step4", + "label": "", + "sourceId": "/do/2/step3", + "targetId": "/do/3/step4", + }, + { + "id": "/do/1/step2-/do/2/step3", + "label": "", + "sourceId": "/do/1/step2", + "targetId": "/do/2/step3", + }, + { + "id": "/do/0/step1-/do/1/step2", + "label": "", + "sourceId": "/do/0/step1", + "targetId": "/do/1/step2", + }, + { + "id": "root-entry-node-/do/0/step1", + "label": "", + "sourceId": "root-entry-node", + "targetId": "/do/0/step1", + }, + ], + "entryNode": [Circular], + "exitNode": { + "id": "root-exit-node", + "parent": [Circular], + "type": "end", + }, + "id": "root", + "label": undefined, + "nodes": [ + [Circular], + { + "id": "root-exit-node", + "parent": [Circular], + "type": "end", + }, + { + "id": "/do/0/step1", + "label": "step1", + "parent": [Circular], + "task": { + "set": { + "variable": "first task", + }, + }, + "type": "set", + }, + { + "id": "/do/1/step2", + "label": "step2", + "parent": [Circular], + "task": { + "set": { + "variable": "second task", + }, + }, + "type": "set", + }, + { + "id": "/do/2/step3", + "label": "step3", + "parent": [Circular], + "task": { + "set": { + "variable": "third task", + }, + }, + "type": "set", + }, + { + "id": "/do/3/step4", + "label": "step4", + "parent": [Circular], + "task": { + "set": { + "variable": "fourth task", + }, + }, + "type": "set", + }, + { + "id": "/do/4/step5", + "label": "step5", + "parent": [Circular], + "task": { + "set": { + "variable": "fifth task", + }, + }, + "type": "set", + }, + ], + "parent": undefined, + "task": undefined, + "type": "root", + }, "type": "start", }, "exitNode": { "id": "root-exit-node", + "parent": { + "edges": [ + { + "id": "/do/4/step5-root-exit-node", + "label": "", + "sourceId": "/do/4/step5", + "targetId": "root-exit-node", + }, + { + "id": "/do/3/step4-/do/4/step5", + "label": "", + "sourceId": "/do/3/step4", + "targetId": "/do/4/step5", + }, + { + "id": "/do/2/step3-/do/3/step4", + "label": "", + "sourceId": "/do/2/step3", + "targetId": "/do/3/step4", + }, + { + "id": "/do/1/step2-/do/2/step3", + "label": "", + "sourceId": "/do/1/step2", + "targetId": "/do/2/step3", + }, + { + "id": "/do/0/step1-/do/1/step2", + "label": "", + "sourceId": "/do/0/step1", + "targetId": "/do/1/step2", + }, + { + "id": "root-entry-node-/do/0/step1", + "label": "", + "sourceId": "root-entry-node", + "targetId": "/do/0/step1", + }, + ], + "entryNode": { + "id": "root-entry-node", + "parent": [Circular], + "type": "start", + }, + "exitNode": [Circular], + "id": "root", + "label": undefined, + "nodes": [ + { + "id": "root-entry-node", + "parent": [Circular], + "type": "start", + }, + [Circular], + { + "id": "/do/0/step1", + "label": "step1", + "parent": [Circular], + "task": { + "set": { + "variable": "first task", + }, + }, + "type": "set", + }, + { + "id": "/do/1/step2", + "label": "step2", + "parent": [Circular], + "task": { + "set": { + "variable": "second task", + }, + }, + "type": "set", + }, + { + "id": "/do/2/step3", + "label": "step3", + "parent": [Circular], + "task": { + "set": { + "variable": "third task", + }, + }, + "type": "set", + }, + { + "id": "/do/3/step4", + "label": "step4", + "parent": [Circular], + "task": { + "set": { + "variable": "fourth task", + }, + }, + "type": "set", + }, + { + "id": "/do/4/step5", + "label": "step5", + "parent": [Circular], + "task": { + "set": { + "variable": "fifth task", + }, + }, + "type": "set", + }, + ], + "parent": undefined, + "task": undefined, + "type": "root", + }, "type": "end", }, "id": "root", @@ -59,39 +281,76 @@ exports[`buildGraph > returns a loaded extended graph object from model 1`] = ` "nodes": [ { "id": "root-entry-node", + "label": undefined, + "parentId": "root", + "task": undefined, "type": "start", }, { "id": "root-exit-node", + "label": undefined, + "parentId": "root", + "task": undefined, "type": "end", }, { "id": "/do/0/step1", "label": "step1", + "parentId": "root", + "task": { + "set": { + "variable": "first task", + }, + }, "type": "set", }, { "id": "/do/1/step2", "label": "step2", + "parentId": "root", + "task": { + "set": { + "variable": "second task", + }, + }, "type": "set", }, { "id": "/do/2/step3", "label": "step3", + "parentId": "root", + "task": { + "set": { + "variable": "third task", + }, + }, "type": "set", }, { "id": "/do/3/step4", "label": "step4", + "parentId": "root", + "task": { + "set": { + "variable": "fourth task", + }, + }, "type": "set", }, { "id": "/do/4/step5", "label": "step5", + "parentId": "root", + "task": { + "set": { + "variable": "fifth task", + }, + }, "type": "set", }, ], "parent": undefined, + "task": undefined, "type": "root", } `; @@ -99,18 +358,18 @@ exports[`buildGraph > returns a loaded extended graph object from model 1`] = ` exports[`parseWorkflow > parses valid 'JSON' and returns model with no errors 1`] = ` { "errors": [], - "model": t { - "do": t [ - t { - "step1": t { - "set": t { + "model": Workflow { + "do": TaskList [ + TaskItem { + "step1": Task { + "set": { "variable": "my first workflow", }, }, }, ], - "document": t { - "dsl": "1.0.0", + "document": Document { + "dsl": "1.0.3", "name": "valid-workflow-json", "namespace": "default", "version": "1.0.0", @@ -122,18 +381,18 @@ exports[`parseWorkflow > parses valid 'JSON' and returns model with no errors 1` exports[`parseWorkflow > parses valid 'YAML' and returns model with no errors 1`] = ` { "errors": [], - "model": t { - "do": t [ - t { - "step1": t { - "set": t { + "model": Workflow { + "do": TaskList [ + TaskItem { + "step1": Task { + "set": { "variable": "my first workflow", }, }, }, ], - "document": t { - "dsl": "1.0.0", + "document": Document { + "dsl": "1.0.3", "name": "valid-workflow-yaml", "namespace": "default", "version": "1.0.0", @@ -145,13 +404,13 @@ exports[`parseWorkflow > parses valid 'YAML' and returns model with no errors 1` exports[`parseWorkflow > returns model and errors for invalid but parseable 'JSON' 1`] = ` { "errors": [ - [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.0'.], + [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.3'.], ], - "model": t { - "do": t [ - t { - "step1": t { - "set": t { + "model": Workflow { + "do": TaskList [ + TaskItem { + "step1": Task { + "set": { "variable": "my first invalid json workflow", }, }, @@ -164,13 +423,13 @@ exports[`parseWorkflow > returns model and errors for invalid but parseable 'JSO exports[`parseWorkflow > returns model and errors for invalid but parseable 'YAML' 1`] = ` { "errors": [ - [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.0'.], + [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.3'.], ], - "model": t { - "do": t [ - t { - "step1": t { - "set": t { + "model": Workflow { + "do": TaskList [ + TaskItem { + "step1": Task { + "set": { "variable": "my first invalid yaml workflow", }, }, @@ -182,6 +441,6 @@ exports[`parseWorkflow > returns model and errors for invalid but parseable 'YAM exports[`validateWorkflow > returns errors for an invalid workflow 1`] = ` [ - [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.0'.], + [Error: 'Workflow' is invalid - The DSL version of the workflow 'undefined' doesn't match the supported version of the SDK '1.0.3'.], ] `; diff --git a/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts b/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts index 0fa6e3ae..34b25fda 100644 --- a/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts @@ -15,254 +15,364 @@ */ import { describe, it, expect } from "vitest"; -import { ExtendedGraph, GraphEdgeType, solveEdgeTypes } from "../../src/core"; -import * as sdk from "@serverlessworkflow/sdk"; - -describe("ExtendedGraph", () => { - it("Add type property to default edges", () => { - const sdkGraph = { - id: "root", - type: "root", - entryNode: { type: "start", id: "root-entry-node" }, - exitNode: { type: "end", id: "root-exit-node" }, - nodes: [ - { type: "start", id: "root-entry-node" }, - { type: "end", id: "root-exit-node" }, - { type: "set", id: "/do/0/step1", label: "step1" }, - { type: "set", id: "/do/1/step2", label: "step2" }, - { type: "set", id: "/do/2/step3", label: "step3" }, - { type: "set", id: "/do/3/step4", label: "step4" }, - { type: "set", id: "/do/4/step5", label: "step5" }, - ], - edges: [ - { - label: "", - id: "/do/4/step5-root-exit-node", - sourceId: "/do/4/step5", - destinationId: "root-exit-node", - }, - { - label: "", - id: "/do/3/step4-/do/4/step5", - sourceId: "/do/3/step4", - destinationId: "/do/4/step5", - }, - { - label: "", - id: "/do/2/step3-/do/3/step4", - sourceId: "/do/2/step3", - destinationId: "/do/3/step4", - }, - { - label: "", - id: "/do/1/step2-/do/2/step3", - sourceId: "/do/1/step2", - destinationId: "/do/2/step3", - }, - { - label: "", - id: "/do/0/step1-/do/1/step2", - sourceId: "/do/0/step1", - destinationId: "/do/1/step2", - }, - { - label: "", - id: "root-entry-node-/do/0/step1", - sourceId: "root-entry-node", - destinationId: "/do/0/step1", - }, - ], - } as sdk.Graph; - - const graph = solveEdgeTypes(sdkGraph); - - expect(graph.edges).toHaveLength(6); - graph.edges.forEach((edge) => expect(edge.type!).toBe(GraphEdgeType.Default)); - }); +import { FlatGraph, FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; +import { getNodesByType, fixNodesConnections } from "../../src/core/graph"; - it("Add type property to error edges", () => { - const sdkGraph = { - id: "root", - type: "root", - entryNode: { id: "root-entry-node", type: "start" }, - exitNode: { id: "root-exit-node", type: "end" }, - nodes: [ - { id: "root-entry-node", type: "start" }, - { id: "root-exit-node", type: "end" }, - { - id: "/do/0/raiseUndefinedPriorityError", - label: "raiseUndefinedPriorityError", - type: "raise", - }, - { id: "/do/1/escalateToManager", label: "escalateToManager", type: "call" }, - ], - edges: [ - { - destinationId: "root-exit-node", - id: "/do/1/escalateToManager-root-exit-node", - sourceId: "/do/1/escalateToManager", - }, - { - destinationId: "/do/1/escalateToManager", - id: "/do/0/raiseUndefinedPriorityError-/do/1/escalateToManager", - sourceId: "/do/0/raiseUndefinedPriorityError", - }, - { - destinationId: "/do/0/raiseUndefinedPriorityError", - id: "root-entry-node-/do/0/raiseUndefinedPriorityError", - sourceId: "root-entry-node", - }, - ], - } as sdk.Graph; - - const graph = solveEdgeTypes(sdkGraph); - - expect(graph.edges[1].type!).toBe(GraphEdgeType.Error); - }); +function createFlatGraph( + nodes: FlatGraphNode[], + edges: Array<{ id: string; sourceId: string; targetId: string; label: string }>, +): FlatGraph { + const entryNode = nodes.find((n) => n.type === GraphNodeType.Entry); + const exitNode = nodes.find((n) => n.type === GraphNodeType.Exit); + + return { + id: "root", + type: GraphNodeType.Do, + nodes, + edges, + entryNode, + exitNode, + } as FlatGraph; +} + +describe("graph utils", () => { + describe("getNodesByType", () => { + it("returns all nodes matching the specified type", () => { + const entryNode1: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + } as FlatGraphNode; + const entryNode2: FlatGraphNode = { + id: "entry-2", + type: GraphNodeType.Entry, + } as FlatGraphNode; + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const exitNode1: FlatGraphNode = { id: "exit-1", type: GraphNodeType.Exit } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Set } as FlatGraphNode; + + const graph = createFlatGraph([entryNode1, taskNode1, entryNode2, exitNode1, taskNode2], []); + + const entryNodes = getNodesByType(graph, GraphNodeType.Entry); + + expect(entryNodes).toHaveLength(2); + expect(entryNodes[0]?.id).toBe("entry-1"); + expect(entryNodes[1]?.id).toBe("entry-2"); + }); + + it("returns empty array when no nodes match the type", () => { + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Set } as FlatGraphNode; + + const graph = createFlatGraph([taskNode1, taskNode2], []); + + const exitNodes = getNodesByType(graph, GraphNodeType.Exit); + + expect(exitNodes).toEqual([]); + }); + + it("returns empty array when graph has no nodes", () => { + const graph = createFlatGraph([], []); + + const entryNodes = getNodesByType(graph, GraphNodeType.Entry); + + expect(entryNodes).toEqual([]); + }); + + it("returns all nodes when all nodes match the type", () => { + const callNode1: FlatGraphNode = { id: "call-1", type: GraphNodeType.Call } as FlatGraphNode; + const callNode2: FlatGraphNode = { id: "call-2", type: GraphNodeType.Call } as FlatGraphNode; + const callNode3: FlatGraphNode = { id: "call-3", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph([callNode1, callNode2, callNode3], []); - it("Add type property to condition edges", () => { - const sdkGraph = { - type: "root", - id: "root", - entryNode: { id: "root-entry-node", type: "start" }, - exitNode: { id: "root-exit-node", type: "end" }, - nodes: [ - { id: "root-entry-node", type: "start" }, - { id: "root-exit-node", type: "end" }, - { id: "/do/0/processOrder", label: "processOrder", type: "switch" }, - { id: "/do/1/step1", label: "step1", type: "set" }, - { id: "/do/2/step2", label: "step2", type: "set" }, - { id: "/do/3/stepDefault", label: "stepDefault", type: "set" }, - ], - edges: [ - { - destinationId: "/do/0/processOrder", - id: "root-entry-node-/do/0/processOrder", - sourceId: "root-entry-node", - }, - { - destinationId: "root-exit-node", - id: "/do/1/step1-root-exit-node", - sourceId: "/do/1/step1", - }, - { - destinationId: "root-exit-node", - id: "/do/2/step2-root-exit-node", - sourceId: "/do/2/step2", - }, - { - destinationId: "root-exit-node", - id: "/do/3/stepDefault-root-exit-node", - sourceId: "/do/3/stepDefault", - }, - { - destinationId: "/do/1/step1", - id: "/do/0/processOrder-/do/1/step1", - sourceId: "/do/0/processOrder", - }, - { - destinationId: "/do/2/step2", - id: "/do/0/processOrder-/do/2/step2-case2", - sourceId: "/do/0/processOrder", - }, - { - destinationId: "/do/3/stepDefault", - id: "/do/0/processOrder-/do/3/stepDefault-default", - sourceId: "/do/0/processOrder", - }, - ], - } as sdk.Graph; - - const graph = solveEdgeTypes(sdkGraph); - - const switchNode = graph.nodes.find((node) => node.type === sdk.GraphNodeType.Switch); - expect(switchNode).toBeDefined(); - - const conditionEdges = graph.edges.filter((edge) => edge.sourceId === switchNode!.id); - expect(conditionEdges).toHaveLength(3); - conditionEdges.forEach((edge) => expect(edge.type!).toBe(GraphEdgeType.Condition)); + const callNodes = getNodesByType(graph, GraphNodeType.Call); + + expect(callNodes).toHaveLength(3); + expect(callNodes.every((node) => node.type === GraphNodeType.Call)).toBe(true); + }); }); - it("Add type property to nested edges", () => { - const sdkGraph = { - id: "root", - type: "root", - entryNode: { id: "root-entry-node", type: "start" }, - exitNode: { id: "root-exit-node", type: "end" }, - nodes: [ - { id: "root-entry-node", type: "start" }, - { id: "root-exit-node", type: "end" }, - { - id: "/do/0/checkup", - type: "for", - label: "checkup", - entryNode: { id: "/do/0/checkup-entry-node", type: "entry" }, - exitNode: { id: "/do/0/checkup-exit-node", type: "exit" }, - nodes: [ - { id: "/do/0/checkup-entry-node", type: "entry" }, - { id: "/do/0/checkup-exit-node", type: "exit" }, - { id: "/do/0/checkup/for/do/0/step1", label: "step1", type: "set" }, - { - id: "/do/0/checkup/for/do/1/raiseUndefinedPriorityError", - label: "raiseUndefinedPriorityError", - type: "raise", - }, - { - id: "/do/0/checkup/for/do/2/escalateToManager", - label: "escalateToManager", - type: "call", - }, - ], - edges: [ - { - destinationId: "/do/0/checkup-exit-node", - id: "/do/0/checkup/for/do/2/escalateToManager-/do/0/checkup-exit-node", - sourceId: "/do/0/checkup/for/do/2/escalateToManager", - }, - { - destinationId: "/do/0/checkup/for/do/2/escalateToManager", - id: "/do/0/checkup/for/do/1/raiseUndefinedPriorityError-/do/0/checkup/for/do/2/escalateToManager", - sourceId: "/do/0/checkup/for/do/1/raiseUndefinedPriorityError", - }, - { - destinationId: "/do/0/checkup/for/do/1/raiseUndefinedPriorityError", - id: "/do/0/checkup/for/do/0/step1-/do/0/checkup/for/do/1/raiseUndefinedPriorityError", - sourceId: "/do/0/checkup/for/do/0/step1", - }, - { - destinationId: "/do/0/checkup/for/do/0/step1", - id: "/do/0/checkup-entry-node-/do/0/checkup/for/do/0/step1", - sourceId: "/do/0/checkup-entry-node", - }, - ], - }, - ], - edges: [ - { - destinationId: "root-exit-node", - id: "/do/0/checkup-exit-node-root-exit-node", - sourceId: "/do/0/checkup-exit-node", - }, - { - destinationId: "/do/0/checkup-entry-node", - id: "root-entry-node-/do/0/checkup-entry-node", - sourceId: "root-entry-node", - }, - ], - } as sdk.Graph; - - const extendedGraph = solveEdgeTypes(sdkGraph); - - const forNode = extendedGraph.nodes.find((node) => node.type === sdk.GraphNodeType.For) as - | ExtendedGraph - | undefined; - - // looking into for task nodes - expect(forNode).toBeDefined(); - expect(forNode!.type!).toBe(sdk.GraphNodeType.For); - expect(forNode!.edges[0].type!).toBe(GraphEdgeType.Default); - expect(forNode!.edges[1].type!).toBe(GraphEdgeType.Error); - expect(forNode!.edges[2].type!).toBe(GraphEdgeType.Default); - expect(forNode!.edges[3].type!).toBe(GraphEdgeType.Default); + describe("fixNodesConnections", () => { + it("moves entry node incoming connections to parent node", () => { + const parentNode: FlatGraphNode = { id: "parent-1", type: GraphNodeType.Do } as FlatGraphNode; + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const taskNode: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode, entryNode, taskNode], + [{ id: "edge-1", sourceId: "task-1", targetId: "entry-1", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.targetId).toBe("parent-1"); + expect(fixedGraph.edges[0]?.sourceId).toBe("task-1"); + }); + + it("moves exit node outgoing connections to parent node", () => { + const parentNode: FlatGraphNode = { id: "parent-1", type: GraphNodeType.Do } as FlatGraphNode; + const exitNode: FlatGraphNode = { + id: "exit-1", + type: GraphNodeType.Exit, + parentId: "parent-1", + } as FlatGraphNode; + const taskNode: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode, exitNode, taskNode], + [{ id: "edge-1", sourceId: "exit-1", targetId: "task-1", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.sourceId).toBe("parent-1"); + expect(fixedGraph.edges[0]?.targetId).toBe("task-1"); + }); + + it("handles both entry and exit node connections in the same graph", () => { + const parentNode: FlatGraphNode = { id: "parent-1", type: GraphNodeType.Do } as FlatGraphNode; + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const exitNode: FlatGraphNode = { + id: "exit-1", + type: GraphNodeType.Exit, + parentId: "parent-1", + } as FlatGraphNode; + const taskBefore: FlatGraphNode = { + id: "task-before", + type: GraphNodeType.Call, + } as FlatGraphNode; + const taskAfter: FlatGraphNode = { + id: "task-after", + type: GraphNodeType.Call, + } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode, entryNode, exitNode, taskBefore, taskAfter], + [ + { id: "edge-1", sourceId: "task-before", targetId: "entry-1", label: "" }, + { id: "edge-2", sourceId: "exit-1", targetId: "task-after", label: "" }, + ], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.targetId).toBe("parent-1"); + expect(fixedGraph.edges[1]?.sourceId).toBe("parent-1"); + }); + + it("does not modify entry node connections when parentId is undefined", () => { + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + } as FlatGraphNode; + const taskNode: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [entryNode, taskNode], + [{ id: "edge-1", sourceId: "task-1", targetId: "entry-1", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.targetId).toBe("entry-1"); + }); + + it("does not modify exit node connections when parentId is undefined", () => { + const exitNode: FlatGraphNode = { id: "exit-1", type: GraphNodeType.Exit } as FlatGraphNode; + const taskNode: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [exitNode, taskNode], + [{ id: "edge-1", sourceId: "exit-1", targetId: "task-1", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.sourceId).toBe("exit-1"); + }); + + it("does not modify edges that do not involve entry or exit nodes", () => { + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Set } as FlatGraphNode; + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + + const graph = createFlatGraph( + [taskNode1, taskNode2, entryNode], + [{ id: "edge-1", sourceId: "task-1", targetId: "task-2", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.sourceId).toBe("task-1"); + expect(fixedGraph.edges[0]?.targetId).toBe("task-2"); + }); + + it("returns a new graph object (does not mutate original)", () => { + const parentNode: FlatGraphNode = { id: "parent-1", type: GraphNodeType.Do } as FlatGraphNode; + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const taskNode: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode, entryNode, taskNode], + [{ id: "edge-1", sourceId: "task-1", targetId: "entry-1", label: "" }], + ); + + const originalTargetId = graph.edges[0]?.targetId; + const fixedGraph = fixNodesConnections(graph); + + expect(graph.edges[0]?.targetId).toBe(originalTargetId); + expect(fixedGraph.edges[0]?.targetId).toBe("parent-1"); + expect(graph).not.toBe(fixedGraph); + }); + + it("handles graph with no edges", () => { + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const exitNode: FlatGraphNode = { + id: "exit-1", + type: GraphNodeType.Exit, + parentId: "parent-1", + } as FlatGraphNode; + + const graph = createFlatGraph([entryNode, exitNode], []); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges).toEqual([]); + }); + + it("handles graph with no entry or exit nodes", () => { + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Set } as FlatGraphNode; + + const graph = createFlatGraph( + [taskNode1, taskNode2], + [{ id: "edge-1", sourceId: "task-1", targetId: "task-2", label: "" }], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges).toEqual(graph.edges); + }); + + it("handles multiple entry nodes with different parents", () => { + const parentNode1: FlatGraphNode = { + id: "parent-1", + type: GraphNodeType.Do, + } as FlatGraphNode; + const parentNode2: FlatGraphNode = { + id: "parent-2", + type: GraphNodeType.Do, + } as FlatGraphNode; + const entryNode1: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const entryNode2: FlatGraphNode = { + id: "entry-2", + type: GraphNodeType.Entry, + parentId: "parent-2", + } as FlatGraphNode; + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode1, parentNode2, entryNode1, entryNode2, taskNode1, taskNode2], + [ + { id: "edge-1", sourceId: "task-1", targetId: "entry-1", label: "" }, + { id: "edge-2", sourceId: "task-2", targetId: "entry-2", label: "" }, + ], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.targetId).toBe("parent-1"); + expect(fixedGraph.edges[1]?.targetId).toBe("parent-2"); + }); + + it("handles multiple exit nodes with different parents", () => { + const parentNode1: FlatGraphNode = { + id: "parent-1", + type: GraphNodeType.Do, + } as FlatGraphNode; + const parentNode2: FlatGraphNode = { + id: "parent-2", + type: GraphNodeType.Do, + } as FlatGraphNode; + const exitNode1: FlatGraphNode = { + id: "exit-1", + type: GraphNodeType.Exit, + parentId: "parent-1", + } as FlatGraphNode; + const exitNode2: FlatGraphNode = { + id: "exit-2", + type: GraphNodeType.Exit, + parentId: "parent-2", + } as FlatGraphNode; + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Call } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode1, parentNode2, exitNode1, exitNode2, taskNode1, taskNode2], + [ + { id: "edge-1", sourceId: "exit-1", targetId: "task-1", label: "" }, + { id: "edge-2", sourceId: "exit-2", targetId: "task-2", label: "" }, + ], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.sourceId).toBe("parent-1"); + expect(fixedGraph.edges[1]?.sourceId).toBe("parent-2"); + }); + + it("only modifies edges where entry/exit nodes are involved, not other edges", () => { + const parentNode: FlatGraphNode = { id: "parent-1", type: GraphNodeType.Do } as FlatGraphNode; + const entryNode: FlatGraphNode = { + id: "entry-1", + type: GraphNodeType.Entry, + parentId: "parent-1", + } as FlatGraphNode; + const taskNode1: FlatGraphNode = { id: "task-1", type: GraphNodeType.Call } as FlatGraphNode; + const taskNode2: FlatGraphNode = { id: "task-2", type: GraphNodeType.Set } as FlatGraphNode; + const taskNode3: FlatGraphNode = { id: "task-3", type: GraphNodeType.Wait } as FlatGraphNode; + + const graph = createFlatGraph( + [parentNode, entryNode, taskNode1, taskNode2, taskNode3], + [ + { id: "edge-1", sourceId: "task-1", targetId: "entry-1", label: "" }, + { id: "edge-2", sourceId: "task-2", targetId: "task-3", label: "" }, + { id: "edge-3", sourceId: "task-1", targetId: "task-2", label: "" }, + ], + ); + + const fixedGraph = fixNodesConnections(graph); + + expect(fixedGraph.edges[0]?.targetId).toBe("parent-1"); + expect(fixedGraph.edges[1]?.sourceId).toBe("task-2"); + expect(fixedGraph.edges[1]?.targetId).toBe("task-3"); + expect(fixedGraph.edges[2]?.sourceId).toBe("task-1"); + expect(fixedGraph.edges[2]?.targetId).toBe("task-2"); + }); }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts b/packages/serverless-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts index 6bb0b321..0f1bac2a 100644 --- a/packages/serverless-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/core/workflowSdk.integration.test.ts @@ -14,15 +14,8 @@ * limitations under the License. */ -import { describe, it, expect, expectTypeOf } from "vitest"; -import { - parseWorkflow, - validateWorkflow, - buildGraph, - ExtendedGraphNode, - ExtendedGraph, - ExtendedGraphEdge, -} from "../../src/core"; +import { describe, it, expect } from "vitest"; +import { parseWorkflow, validateWorkflow, buildFlatGraph } from "../../src/core"; import { BASIC_VALID_WORKFLOW_YAML, BASIC_VALID_WORKFLOW_JSON, @@ -74,7 +67,7 @@ describe("parseWorkflow", () => { describe("validateWorkflow", () => { it("returns empty array for a valid workflow", () => { const valid = new Classes.Workflow({ - document: { dsl: "1.0.0", name: "valid-workflow", version: "1.0.0", namespace: "default" }, + document: { dsl: "1.0.3", name: "valid-workflow", version: "1.0.0", namespace: "default" }, do: [{ step1: { set: { variable: "value" } } }], }) as Specification.Workflow; const errors = validateWorkflow(valid); @@ -91,24 +84,21 @@ describe("validateWorkflow", () => { }); }); -describe("buildGraph", () => { +describe("buildFlatGraph", () => { it("returns a loaded extended graph object from model", () => { const { model } = parseWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); expect(model).not.toBeNull(); - const graph = buildGraph(model!); + const graph = buildFlatGraph(model!); - expectTypeOf(graph).toEqualTypeOf(); - expectTypeOf(graph!.nodes).toEqualTypeOf(); - expectTypeOf(graph!.edges).toEqualTypeOf(); expect(graph).toMatchSnapshot(); }); - it("buildGraph exception", () => { + it("buildFlatGraph exception", () => { const { model } = parseWorkflow(EMPTY_WORKFLOW_JSON); expect(model).not.toBeNull(); // A model without tasks is invalid however it produces a viable model instance - expect(() => buildGraph(model!)).toThrow("Cannot read properties of undefined (reading '0')"); + expect(() => buildFlatGraph(model!)).toThrow(); }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/fixtures/workflows.ts b/packages/serverless-workflow-diagram-editor/tests/fixtures/workflows.ts index 6c4482f6..0fc656c7 100644 --- a/packages/serverless-workflow-diagram-editor/tests/fixtures/workflows.ts +++ b/packages/serverless-workflow-diagram-editor/tests/fixtures/workflows.ts @@ -21,7 +21,7 @@ export const BASIC_VALID_WORKFLOW_YAML = ` document: - dsl: 1.0.0 + dsl: 1.0.3 name: valid-workflow-yaml version: 1.0.0 namespace: default @@ -33,7 +33,7 @@ export const BASIC_VALID_WORKFLOW_YAML = ` export const BASIC_VALID_WORKFLOW_JSON = JSON.stringify({ document: { - dsl: "1.0.0", + dsl: "1.0.3", name: "valid-workflow-json", version: "1.0.0", namespace: "default", @@ -72,7 +72,7 @@ export const BASIC_INVALID_WORKFLOW_JSON = JSON.stringify({ export const BASIC_VALID_WORKFLOW_JSON_TASKS = JSON.stringify({ document: { - dsl: "1.0.0", + dsl: "1.0.3", name: "valid-workflow-json", version: "1.0.0", namespace: "default", @@ -118,7 +118,7 @@ export const BASIC_VALID_WORKFLOW_JSON_TASKS = JSON.stringify({ export const EMPTY_WORKFLOW_JSON = JSON.stringify({ document: { - dsl: "1.0.0", + dsl: "1.0.3", name: "valid-workflow-json", version: "1.0.0", namespace: "default", 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 cbe55ee7..9313dece 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 @@ -15,16 +15,21 @@ */ import { render, screen } from "@testing-library/react"; -import { vi, test, expect, afterEach, describe } from "vitest"; +import { vi, it, expect, afterEach, describe } from "vitest"; import { Diagram } from "../../../src/react-flow/diagram/Diagram"; +import { DiagramEditorContextProvider } from "../../../src/store/DiagramEditorContextProvider"; describe("Diagram Component", () => { afterEach(() => { vi.restoreAllMocks(); }); - test("render Diagram component and canvas", () => { - render(); + it("render Diagram component and canvas", () => { + render( + + + , + ); const diagram = screen.getByTestId("diagram-container"); const canvas = screen.getByTestId("react-flow-canvas"); diff --git a/packages/serverless-workflow-diagram-editor/tests/core/autoLayout.integration.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts similarity index 66% rename from packages/serverless-workflow-diagram-editor/tests/core/autoLayout.integration.test.ts rename to packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts index d5ff34cd..37914618 100644 --- a/packages/serverless-workflow-diagram-editor/tests/core/autoLayout.integration.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/autoLayout.integration.test.ts @@ -15,20 +15,23 @@ */ import { describe, it, expect } from "vitest"; -import { applyAutoLayout, buildGraph, parseWorkflow } from "../../src/core"; -import { BASIC_VALID_WORKFLOW_JSON_TASKS } from "../fixtures/workflows"; + +import { BASIC_VALID_WORKFLOW_JSON_TASKS } from "../../fixtures/workflows"; +import { applyAutoLayout } from "../../../src/react-flow/diagram/autoLayout"; +import { parseWorkflow } from "../../../src/core"; +import { buildDiagramElements } from "../../../src/react-flow/diagram/diagramBuilder"; describe("applyAutoLayout", () => { it("apply auto-layout calculated layout to graph elements", () => { const result = parseWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); - const graph = applyAutoLayout(buildGraph(result.model!)); + const reacflowGraph = applyAutoLayout(buildDiagramElements(result.model)); - expect(graph!.nodes).toHaveLength(7); - expect(graph!.edges).toHaveLength(6); + expect(reacflowGraph.nodes).toHaveLength(7); + expect(reacflowGraph.edges).toHaveLength(6); let y = 0; - graph!.nodes.forEach((node) => { + reacflowGraph.nodes.forEach((node) => { // TODO coordinates are fixed (y = y + 100) for now expect(node.position!.y).toBe(y); y += 100; diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts new file mode 100644 index 00000000..1f8a93f3 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/diagram/diagramBuilder.test.ts @@ -0,0 +1,366 @@ +/* + * 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 { describe, it, expect, beforeAll } from "vitest"; +import * as RF from "@xyflow/react"; +import { GraphNodeType } from "@serverlessworkflow/sdk"; +import { + getEdgeType, + edgeSourceAndTargetExist, + buildDiagramElements, +} from "../../../src/react-flow/diagram/diagramBuilder"; +import { EdgeTypes } from "../../../src/react-flow/edges/Edges"; +import { parseWorkflow } from "../../../src/core"; +import { + BASIC_VALID_WORKFLOW_JSON, + BASIC_VALID_WORKFLOW_JSON_TASKS, +} from "../../fixtures/workflows"; + +// Type alias for diagram elements to reduce verbosity +type DiagramElements = ReturnType; + +// Test data factories for better reusability and maintainability +const createNode = (id: string, type: GraphNodeType, label: string, yPosition = 0): RF.Node => ({ + id, + type, + position: { x: 0, y: yPosition }, + data: { label }, +}); + +const createEdge = (id: string, sourceId: string, targetId: string, label = "") => ({ + id, + sourceId, + targetId, + label, +}); + +// Helper function to build diagram from workflow JSON +const buildDiagramFromWorkflow = ( + workflowJson: string, +): ReturnType => { + const parseResult = parseWorkflow(workflowJson); + return buildDiagramElements(parseResult.model); +}; + +// Helper function to setup diagram data for edge tests +const setupDiagramForEdgeTests = (workflowJson: string) => { + const diagram = buildDiagramFromWorkflow(workflowJson); + return { + diagram, + nodeIdSet: new Set(diagram.nodes.map((node) => node.id)), + nodeMap: new Map(diagram.nodes.map((node) => [node.id, node])), + }; +}; + +// Test assertion helpers for node properties +const assertAllNodesHaveBaseProperties = (nodes: RF.Node[]) => { + nodes.forEach((node) => { + expect(node).toHaveProperty("id"); + expect(node).toHaveProperty("type"); + expect(node).toHaveProperty("data"); + expect(node).toHaveProperty("position"); + expect(node.data).toHaveProperty("label"); + }); +}; + +const assertAllNodesHavePositions = (nodes: RF.Node[]) => { + nodes.forEach((node) => { + expect(node.position).toBeDefined(); + expect(typeof node.position.x).toBe("number"); + expect(typeof node.position.y).toBe("number"); + }); +}; + +const assertAllNodesHaveDimensions = (nodes: RF.Node[]) => { + nodes.forEach((node) => { + expect(node.width).toBeDefined(); + expect(node.height).toBeDefined(); + expect(typeof node.width).toBe("number"); + expect(typeof node.height).toBe("number"); + }); +}; + +// Test assertion helpers for edge properties +const assertEdgeHasBaseProperties = (edge: RF.Edge) => { + expect(edge).toMatchObject({ + id: expect.any(String), + source: expect.any(String), + target: expect.any(String), + type: expect.any(String), + data: expect.any(Object), + }); +}; + +const assertEdgeIsAnimated = (edge: RF.Edge) => { + expect(edge.animated).toBe(true); +}; + +const assertEdgeNodesExist = (edge: RF.Edge, nodeIdSet: Set) => { + expect(nodeIdSet.has(edge.source)).toBe(true); + expect(nodeIdSet.has(edge.target)).toBe(true); +}; + +const assertEdgeTypeMatchesSourceNode = (edge: RF.Edge, nodeMap: Map) => { + const graphEdge = { + id: edge.id, + sourceId: edge.source, + targetId: edge.target, + label: edge.data?.label as string | undefined, + }; + const expectedType = getEdgeType(graphEdge, nodeMap); + expect(edge.type).toBe(expectedType); +}; + +describe("diagramBuilder", () => { + describe("getEdgeType", () => { + it.each([ + { + nodeType: GraphNodeType.Raise, + nodeId: "raise-1", + label: "Raise Error", + expectedEdgeType: EdgeTypes.Error, + description: "returns Error edge type when source node is a Raise node", + }, + { + nodeType: GraphNodeType.Switch, + nodeId: "switch-1", + label: "Switch", + expectedEdgeType: EdgeTypes.Condition, + description: "returns Condition edge type when source node is a Switch node", + }, + { + nodeType: GraphNodeType.Start, + nodeId: "start-1", + label: "Start", + expectedEdgeType: EdgeTypes.Default, + description: "returns Default edge type when source node is a Start node", + }, + { + nodeType: GraphNodeType.Do, + nodeId: "do-1", + label: "Do", + expectedEdgeType: EdgeTypes.Default, + description: "returns Default edge type when source node is a Do node", + }, + { + nodeType: GraphNodeType.Call, + nodeId: "task-1", + label: "Task 1", + expectedEdgeType: EdgeTypes.Default, + description: "returns Default edge type for regular task nodes", + }, + ])("$description", ({ nodeType, nodeId, label, expectedEdgeType }) => { + const nodes: RF.Node[] = [ + createNode(nodeId, nodeType, label), + createNode("task-2", GraphNodeType.Call, "Task 2", 100), + ]; + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + + const edge = createEdge("edge-1", nodeId, "task-2"); + const edgeType = getEdgeType(edge, nodeMap); + + expect(edgeType).toBe(expectedEdgeType); + }); + + it("returns Default edge type when source node is not found", () => { + const nodes: RF.Node[] = [createNode("task-1", GraphNodeType.Call, "Task 1")]; + const nodeMap = new Map(nodes.map((node) => [node.id, node])); + + const edge = createEdge("edge-1", "non-existent", "task-1"); + const edgeType = getEdgeType(edge, nodeMap); + + expect(edgeType).toBe(EdgeTypes.Default); + }); + }); + + describe("EdgeSourceAndTargetExist", () => { + describe("valid edge scenarios", () => { + it("returns true when both source and target nodes exist", () => { + const nodes: RF.Node[] = [ + createNode("task-1", GraphNodeType.Call, "Task 1"), + createNode("task-2", GraphNodeType.Set, "Task 2", 100), + ]; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "task-1", "task-2"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(true); + }); + + it("returns true for self-referencing edge when node exists", () => { + const nodes: RF.Node[] = [createNode("task-1", GraphNodeType.Call, "Task 1")]; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "task-1", "task-1"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(true); + }); + }); + + describe("invalid edge scenarios", () => { + it("returns false when source node does not exist", () => { + const nodes: RF.Node[] = [createNode("task-2", GraphNodeType.Set, "Task 2", 100)]; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "non-existent", "task-2"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(false); + }); + + it("returns false when target node does not exist", () => { + const nodes: RF.Node[] = [createNode("task-1", GraphNodeType.Call, "Task 1")]; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "task-1", "non-existent"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(false); + }); + + it("returns false when both source and target nodes do not exist", () => { + const nodes: RF.Node[] = [createNode("task-1", GraphNodeType.Call, "Task 1")]; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "non-existent-1", "non-existent-2"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(false); + }); + + it("returns false when nodes array is empty", () => { + const nodes: RF.Node[] = []; + const nodeIdSet = new Set(nodes.map((node) => node.id)); + + const edge = createEdge("edge-1", "task-1", "task-2"); + const result = edgeSourceAndTargetExist(edge, nodeIdSet); + + expect(result).toBe(false); + }); + }); + }); + + describe("buildDiagramElements", () => { + describe("basic functionality", () => { + it("builds diagram elements from a valid workflow model", () => { + const diagram: DiagramElements = buildDiagramFromWorkflow(BASIC_VALID_WORKFLOW_JSON); + + expect(diagram.nodes.length).toBeGreaterThan(0); + expect(diagram.edges.length).toBeGreaterThanOrEqual(0); + }); + + it("builds multiple nodes and edges from workflow with tasks", () => { + const diagram: DiagramElements = buildDiagramFromWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); + + expect(diagram.nodes).toHaveLength(7); + expect(diagram.edges).toHaveLength(6); + }); + + it("returns empty nodes and edges when model is null", () => { + const diagram: DiagramElements = buildDiagramElements(null); + + expect(diagram.nodes).toEqual([]); + expect(diagram.edges).toEqual([]); + }); + }); + + describe("node properties", () => { + let diagram: DiagramElements; + + beforeAll(() => { + diagram = buildDiagramFromWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); + }); + + it("creates nodes with correct base properties", () => { + assertAllNodesHaveBaseProperties(diagram.nodes); + }); + + it("applies auto-layout to nodes with positions", () => { + assertAllNodesHavePositions(diagram.nodes); + }); + + it("sets node dimensions from DEFAULT_NODE_SIZE", () => { + assertAllNodesHaveDimensions(diagram.nodes); + }); + + it("preserves node task data when available", () => { + const taskNodes = diagram.nodes.filter((node) => node.data.task !== undefined); + taskNodes.forEach((node) => { + expect(node.data.task).toBeDefined(); + }); + }); + + it("creates unique IDs for all nodes", () => { + const nodeIds = diagram.nodes.map((node) => node.id); + const uniqueIds = new Set(nodeIds); + expect(uniqueIds.size).toBe(nodeIds.length); + }); + + it("creates nodes with expected types from workflow", () => { + const nodeTypes = new Set(diagram.nodes.map((node) => node.type)); + expect(nodeTypes).toContain(GraphNodeType.Start); + expect(nodeTypes).toContain(GraphNodeType.Set); + expect(nodeTypes.size).toBeGreaterThan(1); + }); + }); + + describe("edge properties", () => { + let diagram: DiagramElements; + let nodeIdSet: Set; + let nodeMap: Map; + + beforeAll(() => { + const testData = setupDiagramForEdgeTests(BASIC_VALID_WORKFLOW_JSON_TASKS); + diagram = testData.diagram; + nodeIdSet = testData.nodeIdSet; + nodeMap = testData.nodeMap; + }); + + it("creates edges with correct base properties", () => { + diagram.edges.forEach(assertEdgeHasBaseProperties); + }); + + it("creates edges with animated property for default label", () => { + const defaultEdges = diagram.edges.filter((edge) => edge.data?.label === "default"); + defaultEdges.forEach(assertEdgeIsAnimated); + }); + + it("only creates edges for existing nodes", () => { + diagram.edges.forEach((edge) => assertEdgeNodesExist(edge, nodeIdSet)); + }); + + it("assigns correct edge types based on source node type", () => { + diagram.edges.forEach((edge) => assertEdgeTypeMatchesSourceNode(edge, nodeMap)); + }); + + it("creates unique IDs for all edges", () => { + const edgeIds = diagram.edges.map((edge) => edge.id); + const uniqueIds = new Set(edgeIds); + expect(uniqueIds.size).toBe(edgeIds.length); + }); + }); + + describe("graph structure", () => { + it("creates nodes from workflow", () => { + const diagram: DiagramElements = buildDiagramFromWorkflow(BASIC_VALID_WORKFLOW_JSON_TASKS); + + expect(diagram.nodes.length).toBeGreaterThan(0); + }); + }); + }); +}); 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 10a85c3c..fe542b71 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 @@ -16,12 +16,13 @@ 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, + ReactFlowEdgeTypes, ErrorEdge, createPathFromWayPoints, } from "../../../src/react-flow/edges/Edges"; @@ -32,11 +33,11 @@ describe("React Flow custom edge types", () => { 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("exports all edge types from EdgeTypes enum", () => { + expect(ReactFlowEdgeTypes).toHaveProperty(EdgeTypes.Default); + expect(ReactFlowEdgeTypes).toHaveProperty(EdgeTypes.Error); + expect(ReactFlowEdgeTypes).toHaveProperty(EdgeTypes.Condition); + expect(Object.keys(ReactFlowEdgeTypes)).toHaveLength(3); }); it.each([ @@ -222,9 +223,9 @@ describe("EdgeLabel component", () => { }); it.each([ - { edgeType: GraphEdgeType.Default, description: "default" }, - { edgeType: GraphEdgeType.Error, description: "error" }, - { edgeType: GraphEdgeType.Condition, description: "condition" }, + { edgeType: EdgeTypes.Default, description: "default" }, + { edgeType: EdgeTypes.Error, description: "error" }, + { edgeType: EdgeTypes.Condition, description: "condition" }, ])("edge label $description", ({ edgeType }) => { const result = EdgeLabel({ sourceX: 0, diff --git a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx index b5a1896e..12459874 100644 --- a/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/react-flow/nodes/Nodes.test.tsx @@ -18,9 +18,9 @@ import { render, screen } from "@testing-library/react"; import { vi, it, expect, afterEach, describe } from "vitest"; import * as RF from "@xyflow/react"; import { GraphNodeType } from "@serverlessworkflow/sdk"; -import { NodeTypes } from "../../../src/react-flow/nodes/Nodes"; -import { DEFAULT_NODE_SIZE } from "../../../src/core"; +import { ReactFlowNodeTypes } from "../../../src/react-flow/nodes/Nodes"; import { taskNodeConfigMap, type LeafNodeType } from "../../../src/react-flow/nodes/taskNodeConfig"; +import { DEFAULT_NODE_SIZE } from "../../../src/react-flow/diagram/autoLayout"; function testNode(id: string, type: string, y: number, label: string): RF.Node { return { @@ -34,6 +34,7 @@ function testNode(id: string, type: string, y: number, label: string): RF.Node { } const allNodes: RF.Node[] = [ + testNode("start", GraphNodeType.Start, 0, "Start"), testNode("n1", GraphNodeType.Call, 0, "Node 1"), testNode("n2", GraphNodeType.Do, 100, "Node 2"), testNode("n3", GraphNodeType.Switch, 200, "Node 3"), @@ -46,9 +47,11 @@ const allNodes: RF.Node[] = [ testNode("n10", GraphNodeType.Set, 900, "Node 10"), testNode("n11", GraphNodeType.Try, 1000, "Node 11"), testNode("n12", GraphNodeType.Wait, 1100, "Node 12"), + testNode("end", GraphNodeType.End, 0, "End"), ]; const allEdges: RF.Edge[] = [ + { id: "start-n1", source: "start", target: "n1" }, { id: "n1-n2", source: "n1", target: "n2" }, { id: "n2-n3", source: "n2", target: "n3" }, { id: "n3-n4", source: "n3", target: "n4" }, @@ -62,6 +65,7 @@ const allEdges: RF.Edge[] = [ { id: "n9-n10", source: "n9", target: "n10" }, { id: "n10-n11", source: "n10", target: "n11" }, { id: "n11-n12", source: "n11", target: "n12" }, + { id: "n12-end", source: "n12", target: "end" }, ]; describe("React Flow custom node types", () => { @@ -72,10 +76,11 @@ describe("React Flow custom node types", () => { it("render react flow custom node types", () => { render(
- +
, ); + expect(screen.getByTestId("start-node-start")).toBeInTheDocument(); expect(screen.getByTestId("call-node-n1")).toBeInTheDocument(); expect(screen.getByTestId("do-node-n2")).toBeInTheDocument(); expect(screen.getByTestId("switch-node-n3")).toBeInTheDocument(); @@ -88,6 +93,7 @@ describe("React Flow custom node types", () => { expect(screen.getByTestId("set-node-n10")).toBeInTheDocument(); expect(screen.getByTestId("try-node-n11")).toBeInTheDocument(); expect(screen.getByTestId("wait-node-n12")).toBeInTheDocument(); + expect(screen.getByTestId("end-node-end")).toBeInTheDocument(); }); describe("should render leaf nodes with TaskNodeContent", () => { @@ -105,7 +111,7 @@ describe("React Flow custom node types", () => { it.each(leafNodes)("should render %s node with correct config", ({ id, type, testId }) => { render(
- +
, ); diff --git a/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx b/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx index bbd3708c..a915f09f 100644 --- a/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx +++ b/packages/serverless-workflow-diagram-editor/tests/test-utils/render-helpers.tsx @@ -35,8 +35,12 @@ export const createMockContextValue = ( locale: "en", model: null, errors: [], - updateIsReadOnly: noop, - updateLocale: noop, + nodes: [], + edges: [], + setIsReadOnly: noop, + setLocale: noop, + setEdges: noop, + setNodes: noop, ...overrides, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5f736c60..eb8b11dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,8 +13,8 @@ catalogs: specifier: ^1.59.1 version: 1.59.1 '@serverlessworkflow/sdk': - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^1.0.3-alpha1 + version: 1.0.3-alpha1 '@storybook/addon-a11y': specifier: ^10.3.6 version: 10.3.6 @@ -179,7 +179,7 @@ importers: version: link:../i18n '@serverlessworkflow/sdk': specifier: 'catalog:' - version: 1.0.1 + version: 1.0.3-alpha1 '@xyflow/react': specifier: 'catalog:' version: 12.10.2(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -1127,9 +1127,9 @@ packages: cpu: [x64] os: [win32] - '@serverlessworkflow/sdk@1.0.1': - resolution: {integrity: sha512-ds/FsRbFI/l1W89wWOZxzuiIAeuZLm3U5wtrDrpMrfHJBFex8hiYDDg0Db03V+CGEQZR6eki1KdnmvSX9JeBRg==} - engines: {node: '>=20.0', npm: '>=10.0.0'} + '@serverlessworkflow/sdk@1.0.3-alpha1': + resolution: {integrity: sha512-I0h+AMnWC/monzHYC6x7Cy3RzHHCSsOfDQkZQXyXu7vc2jYIs95Mk0XUJdCr+FDyamdErp72w0N9Oj58ybYthA==} + engines: {node: '>=22.0.0', npm: '>=10.0.0'} '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -3179,7 +3179,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.1': optional: true - '@serverlessworkflow/sdk@1.0.1': + '@serverlessworkflow/sdk@1.0.3-alpha1': dependencies: ajv: 8.18.0 ajv-formats: 3.0.1(ajv@8.18.0) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 220b40b3..5b1b6977 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,7 +3,7 @@ packages: catalog: "@chromatic-com/storybook": ^5.1.2 "@playwright/test": ^1.59.1 - "@serverlessworkflow/sdk": ^1.0.1 + "@serverlessworkflow/sdk": ^1.0.3-alpha1 "@storybook/addon-a11y": ^10.3.6 "@storybook/addon-docs": ^10.3.6 "@storybook/addon-vitest": ^10.3.6