From 7105f1e606338c7e4f44cc10f424da42e59af015 Mon Sep 17 00:00:00 2001 From: lornakelly Date: Fri, 8 May 2026 15:37:05 +0100 Subject: [PATCH 1/2] Add custom catch container node Signed-off-by: lornakelly --- .../src/react-flow/diagram/diagramBuilder.ts | 43 +++++- .../src/react-flow/nodes/Nodes.tsx | 17 ++- .../tests/core/graph.test.ts | 20 +-- .../react-flow/diagram/diagramBuilder.test.ts | 139 +++++++++++++++++- .../tests/test-utils/graph-helpers.ts | 34 +++++ .../tests/test-utils/index.ts | 1 + 6 files changed, 228 insertions(+), 26 deletions(-) create mode 100644 packages/serverless-workflow-diagram-editor/tests/test-utils/graph-helpers.ts 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 index 5a6cf59b..7912e587 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -16,7 +16,7 @@ import * as RF from "@xyflow/react"; import { buildFlatGraph } from "../../core"; -import { BaseNodeData, ReactFlowNodeTypes } from "../nodes/Nodes"; +import { BaseNodeData, CATCH_CONTAINER_NODE_TYPE, ReactFlowNodeTypes } from "../nodes/Nodes"; import { BaseEdgeData, EdgeTypes } from "../edges/Edges"; import * as sdk from "@serverlessworkflow/sdk"; import { applyAutoLayout, DEFAULT_NODE_SIZE } from "./autoLayout"; @@ -46,15 +46,45 @@ export function edgeSourceAndTargetExist( return nodeIdSet.has(graphEdge.sourceId) && nodeIdSet.has(graphEdge.targetId); } -function buildReactFlowNode(graphNode: sdk.FlatGraph | sdk.FlatGraphNode): RF.Node { +/* Return ids of catch nodes that are containers (have child nodes with parentIds pointing to them) */ +export function getCatchContainerNodeIds(graph: sdk.FlatGraph): Set { + const parentIds = new Set(); + for (const node of graph.nodes) { + if (node.parentId) { + parentIds.add(node.parentId); + } + } + + const containerIds = new Set(); + for (const node of graph.nodes) { + if (node.type === sdk.GraphNodeType.Catch && parentIds.has(node.id)) { + containerIds.add(node.id); + } + } + + return containerIds; +} + +function resolveNodeType(graphNode: sdk.FlatGraphNode, catchContainerIds: Set): string { + if (graphNode.type === sdk.GraphNodeType.Catch && catchContainerIds.has(graphNode.id)) { + return CATCH_CONTAINER_NODE_TYPE; + } + return graphNode.type; +} + +function buildReactFlowNode( + graphNode: sdk.FlatGraphNode, + catchContainerIds: Set, +): RF.Node { + const type = resolveNodeType(graphNode, catchContainerIds); // There is no corresponding react flow component implemented - if (!Object.keys(ReactFlowNodeTypes).includes(graphNode.type)) { - throw new Error(`Unsupported GraphNodeType: ${graphNode.type}!`); + if (!Object.keys(ReactFlowNodeTypes).includes(type)) { + throw new Error(`Unsupported GraphNodeType: ${type}!`); } return { id: graphNode.id, - type: graphNode.type, + type, data: { label: graphNode.label ?? "", ...(graphNode.task !== undefined && { task: structuredClone(graphNode.task) }), @@ -89,11 +119,12 @@ export function buildDiagramElements(model: sdk.Specification.Workflow | null): if (model) { const graph = buildFlatGraph(model); + const catchContainerIds = getCatchContainerNodeIds(graph); graph.nodes.forEach((graphNode) => { // TODO: only nodes on root level are supported for now if (graphNode.parentId === "root") { - nodes.push(buildReactFlowNode(graphNode)); + nodes.push(buildReactFlowNode(graphNode, catchContainerIds)); } }); 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 f202ab81..dafeadfa 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 @@ -21,7 +21,9 @@ import { type LeafNodeType, taskNodeConfigMap } from "./taskNodeConfig"; import { Info } from "lucide-react"; import { getCallSubType, getListenSubType, getRunSubType } from "../../core"; -// Node types must match sdk GraphNodeType enum +export const CATCH_CONTAINER_NODE_TYPE = "catch-container"; +/* Node types are primarily keyed by the sdk GraphNodeType enum, with custom + React Flow-only node types such as catch-container added where needed. */ export const ReactFlowNodeTypes: RF.NodeTypes = { [GraphNodeType.Start]: StartNode, [GraphNodeType.End]: EndNode, @@ -38,6 +40,7 @@ export const ReactFlowNodeTypes: RF.NodeTypes = { [GraphNodeType.TryCatch]: TryCatchNode, [GraphNodeType.Try]: TryNode, [GraphNodeType.Catch]: CatchNode, + [CATCH_CONTAINER_NODE_TYPE]: CatchContainerNode, [GraphNodeType.Wait]: WaitNode, }; @@ -250,6 +253,18 @@ export function CatchNode({ id, data, selected, type }: RF.NodeProps; } +/* catch container node */ +export type CatchContainerNodeType = RF.Node; +export function CatchContainerNode({ + id, + data, + selected, + type, +}: RF.NodeProps) { + // TODO: This component is just a placeholder + return ; +} + /* wait leaf node */ export type WaitNodeType = RF.Node, typeof GraphNodeType.Wait>; export function WaitNode({ id, data, selected, type }: RF.NodeProps) { 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 34b25fda..0e9d7cdd 100644 --- a/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts +++ b/packages/serverless-workflow-diagram-editor/tests/core/graph.test.ts @@ -15,25 +15,9 @@ */ import { describe, it, expect } from "vitest"; -import { FlatGraph, FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; +import { FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; import { getNodesByType, fixNodesConnections } from "../../src/core/graph"; - -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; -} +import { createFlatGraph } from "../test-utils/graph-helpers"; describe("graph utils", () => { describe("getNodesByType", () => { 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 index 1f8a93f3..1660a04f 100644 --- 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 @@ -16,11 +16,12 @@ import { describe, it, expect, beforeAll } from "vitest"; import * as RF from "@xyflow/react"; -import { GraphNodeType } from "@serverlessworkflow/sdk"; +import { FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; import { getEdgeType, edgeSourceAndTargetExist, buildDiagramElements, + getCatchContainerNodeIds, } from "../../../src/react-flow/diagram/diagramBuilder"; import { EdgeTypes } from "../../../src/react-flow/edges/Edges"; import { parseWorkflow } from "../../../src/core"; @@ -28,6 +29,7 @@ import { BASIC_VALID_WORKFLOW_JSON, BASIC_VALID_WORKFLOW_JSON_TASKS, } from "../../fixtures/workflows"; +import { createFlatGraph } from "../../test-utils/graph-helpers"; // Type alias for diagram elements to reduce verbosity type DiagramElements = ReturnType; @@ -363,4 +365,139 @@ describe("diagramBuilder", () => { }); }); }); + + describe("getCatchContainerNodeIds", () => { + it("should not include leaf catch nodes", () => { + const sdkGraph = createFlatGraph( + [ + { + id: "start", + type: GraphNodeType.Start, + } as FlatGraphNode, + { + id: "end", + type: GraphNodeType.End, + } as FlatGraphNode, + { + id: "/do/0/CatchError", + type: GraphNodeType.Catch, + label: "CatchError", + } as FlatGraphNode, + ], + [], + ); + + const result = getCatchContainerNodeIds(sdkGraph); + expect(result.size).toBe(0); + }); + + it("should include catch nodes that have children", () => { + const sdkGraph = createFlatGraph( + [ + { + id: "start", + type: GraphNodeType.Start, + } as FlatGraphNode, + { + id: "end", + type: GraphNodeType.End, + } as FlatGraphNode, + { + id: "/do/0/CatchContainer", + type: GraphNodeType.Catch, + label: "CatchContainer", + } as FlatGraphNode, + { + id: "/do/0/CatchContainer-entry-node", + type: GraphNodeType.Entry, + parentId: "/do/0/CatchContainer", + } as FlatGraphNode, + { + id: "/do/0/CatchContainer-exit-node", + type: GraphNodeType.Exit, + parentId: "/do/0/CatchContainer", + } as FlatGraphNode, + { + id: "/do/0/CatchContainer/do/0/step1", + type: GraphNodeType.Set, + label: "step1", + parentId: "/do/0/CatchContainer", + } as FlatGraphNode, + ], + [], + ); + + const result = getCatchContainerNodeIds(sdkGraph); + + expect(result.has("/do/0/CatchContainer")).toBe(true); + expect(result.size).toBe(1); + }); + + it("should not include non-catch nodes", () => { + const sdkGraph = createFlatGraph( + [ + { + id: "start", + type: GraphNodeType.Start, + } as FlatGraphNode, + { + id: "end", + type: GraphNodeType.End, + } as FlatGraphNode, + { + id: "/do/0/step1", + type: GraphNodeType.Set, + label: "step1", + } as FlatGraphNode, + { + id: "/do/0/step2", + type: GraphNodeType.Call, + label: "step2", + } as FlatGraphNode, + ], + [], + ); + + const result = getCatchContainerNodeIds(sdkGraph); + expect(result.size).toBe(0); + }); + + it("should inclyde nested catch containers insode a parent container", () => { + const sdkGraph = createFlatGraph( + [ + { + id: "start", + type: GraphNodeType.Start, + } as FlatGraphNode, + { + id: "end", + type: GraphNodeType.End, + } as FlatGraphNode, + { + id: "/do/0/TryCatch", + type: GraphNodeType.TryCatch, + label: "TryCatch", + } as FlatGraphNode, + { + id: "/do/0/TryCatch/catch", + type: GraphNodeType.Catch, + label: "catch", + parentId: "/do/0/TryCatch", + } as FlatGraphNode, + { + id: "/do/0/TryCatch/catch/do/0/recover", + type: GraphNodeType.Set, + label: "recover", + parentId: "/do/0/TryCatch/catch", + } as FlatGraphNode, + ], + [], + ); + + const result = getCatchContainerNodeIds(sdkGraph); + + expect(result.has("/do/0/TryCatch/catch")).toBe(true); + expect(result.has("/do/0/TryCatch")).toBe(false); + }); + }); }); diff --git a/packages/serverless-workflow-diagram-editor/tests/test-utils/graph-helpers.ts b/packages/serverless-workflow-diagram-editor/tests/test-utils/graph-helpers.ts new file mode 100644 index 00000000..59f4c052 --- /dev/null +++ b/packages/serverless-workflow-diagram-editor/tests/test-utils/graph-helpers.ts @@ -0,0 +1,34 @@ +/* + * 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 { FlatGraph, FlatGraphNode, GraphNodeType } from "@serverlessworkflow/sdk"; + +export 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; +} diff --git a/packages/serverless-workflow-diagram-editor/tests/test-utils/index.ts b/packages/serverless-workflow-diagram-editor/tests/test-utils/index.ts index 26c207ed..dff8536e 100644 --- a/packages/serverless-workflow-diagram-editor/tests/test-utils/index.ts +++ b/packages/serverless-workflow-diagram-editor/tests/test-utils/index.ts @@ -16,3 +16,4 @@ export { renderWithProviders } from "./render-helpers"; export { t } from "./translation-helpers"; +export { createFlatGraph } from "./graph-helpers"; From 9d50bcdc22fdc0d280ba1caa515a2cb87ac1300c Mon Sep 17 00:00:00 2001 From: lornakelly Date: Thu, 14 May 2026 14:57:05 +0100 Subject: [PATCH 2/2] PR fixes Signed-off-by: lornakelly --- .../src/react-flow/diagram/diagramBuilder.ts | 2 +- .../tests/react-flow/diagram/diagramBuilder.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index 7912e587..d75e4738 100644 --- a/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts +++ b/packages/serverless-workflow-diagram-editor/src/react-flow/diagram/diagramBuilder.ts @@ -79,7 +79,7 @@ function buildReactFlowNode( const type = resolveNodeType(graphNode, catchContainerIds); // There is no corresponding react flow component implemented if (!Object.keys(ReactFlowNodeTypes).includes(type)) { - throw new Error(`Unsupported GraphNodeType: ${type}!`); + throw new Error(`Unsupported React flow node type: ${type}!`); } return { 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 index 1660a04f..2c7e0d38 100644 --- 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 @@ -462,7 +462,7 @@ describe("diagramBuilder", () => { expect(result.size).toBe(0); }); - it("should inclyde nested catch containers insode a parent container", () => { + it("should include nested catch containers inside a parent container", () => { const sdkGraph = createFlatGraph( [ {