diff --git a/ui/src/components/canvas/FlowCanvas.tsx b/ui/src/components/canvas/FlowCanvas.tsx index 0ddcda8a..24b740b7 100644 --- a/ui/src/components/canvas/FlowCanvas.tsx +++ b/ui/src/components/canvas/FlowCanvas.tsx @@ -13,20 +13,14 @@ import { ControlButton, Controls, ReactFlow, - ReactFlowInstance, useReactFlow, } from "@xyflow/react" import "@xyflow/react/dist/style.css" import { KeyboardEvent, useEffect } from "react" -import { DropTargetMonitor, useDrop } from "react-dnd" -import { NativeTypes } from "react-dnd-html5-backend" import { CustomNodeType, DYNAMIC_EDGE_TYPE } from "../../api/flow" -import { EipId } from "../../api/generated/eipFlow" import { clearFlow, clearSelectedChildNode, - createDroppedNode, - importFlowFromJson, toggleLayoutDensity, updateLayoutOrientation, } from "../../singletons/store/appActions" @@ -40,19 +34,13 @@ import { onEdgesChange, onNodesChange, } from "../../singletons/store/reactFlowActions" -import { DragTypes } from "../palette/dragTypes" import DynamicEdge from "./DynamicEdge" import { EipNode, FollowerNode } from "./EipNode" +import { useCanvasDrop } from "./canvasDropHook" const FLOW_ERROR_MESSAGE = "Failed to load the canvas - the stored flow is malformed. Clearing the flow from the state store." -interface FileDrop { - files: File[] -} - -type DropType = EipId | FileDrop - interface ErrorHandlerProps { message: string callback: () => void @@ -64,29 +52,6 @@ const ErrorHandler = ({ message, callback }: ErrorHandlerProps) => { return null } -const acceptDroppedFile = (file: File, importFlow: (json: string) => void) => { - const reader = new FileReader() - reader.onload = (e) => { - try { - e.target && importFlow(e.target.result as string) - } catch (e) { - // TODO: Display an error pop-up on failed import - // https://github.com/codice/keip-canvas/issues/7 - console.error((e as Error).message) - } - } - reader.readAsText(file) -} - -const getDropPosition = ( - monitor: DropTargetMonitor, - reactFlowInstance: ReactFlowInstance -) => { - let offset = monitor.getClientOffset() - offset = offset ?? { x: 0, y: 0 } - return reactFlowInstance.screenToFlowPosition(offset) -} - const nodeTypes = { [CustomNodeType.EipNode]: EipNode, [CustomNodeType.FollowerNode]: FollowerNode, @@ -123,6 +88,7 @@ const FlowCanvas = () => { const flowStore = useFlowStore() const layout = useGetLayout() const { undo, redo } = useUndoRedo() + const drop = useCanvasDrop(reactFlowInstance) useEffect(() => { reactFlowInstance @@ -130,33 +96,6 @@ const FlowCanvas = () => { .catch((e) => console.warn("failed to call fitView", e)) }, [layout, reactFlowInstance]) - const [, drop] = useDrop( - () => ({ - accept: [DragTypes.FLOWNODE, NativeTypes.FILE], - drop: (item: DropType, monitor) => { - if ("namespace" in item) { - // Dropping a FLOWNODE creates a new node in the flow. - const pos = getDropPosition(monitor, reactFlowInstance) - createDroppedNode(item, pos) - } else if ("files" in item) { - // Dropping a JSON file imports it as a flow. - acceptDroppedFile(item.files[0], importFlowFromJson) - } else { - console.warn("unknown drop type: ", item) - } - }, - canDrop: (item: DropType) => { - if ("files" in item) { - return ( - item.files.length == 1 && item.files[0].type == "application/json" - ) - } - return true - }, - }), - [reactFlowInstance] - ) - // TODO: See if there is a better way to select and clear child nodes, // to avoid having to clear the selection in multiple components. diff --git a/ui/src/components/canvas/canvasDropHook.ts b/ui/src/components/canvas/canvasDropHook.ts new file mode 100644 index 00000000..5d459807 --- /dev/null +++ b/ui/src/components/canvas/canvasDropHook.ts @@ -0,0 +1,151 @@ +import { ReactFlowInstance } from "@xyflow/react" +import "@xyflow/react/dist/style.css" +import { DropTargetMonitor, useDrop } from "react-dnd" +import { NativeTypes } from "react-dnd-html5-backend" +import { EipFlow, EipId } from "../../api/generated/eipFlow" +import { FLOW_TRANSLATOR_BASE_URL } from "../../singletons/externalEndpoints" +import { SerializedFlow } from "../../singletons/store/api" +import { + createDroppedNode, + importFlowFromJson, + importFlowFromObject, +} from "../../singletons/store/appActions" +import { eipFlowToDiagram } from "../../singletons/store/eipFlowToDiagram" +import fetchWithTimeout from "../../utils/fetch/fetchWithTimeout" +import { DragTypes } from "../palette/dragTypes" + +interface XmlTranslationResponse { + data?: EipFlow + error?: { + message: string + type: string + details: object[] + } +} + +interface FileDrop { + files: File[] +} + +type DropType = EipId | FileDrop + +const FileTypes = { + Json: "application/json", + Xml: "text/xml", +} as const + +type FileType = (typeof FileTypes)[keyof typeof FileTypes] + +const supportedFileTypes = new Set(Object.values(FileTypes)) + +const isSupportedFileType = (value: string): value is FileType => { + return supportedFileTypes.has(value as FileType) +} + +const translateXmlToFlow = async (xml: string) => { + const response = await fetchWithTimeout( + `${FLOW_TRANSLATOR_BASE_URL}/translation/toFlow`, + { + method: "POST", + body: xml, + headers: { + "Content-Type": "application/xml", + }, + timeout: 10000, + } + ) + + const { data, error } = (await response.json()) as XmlTranslationResponse + + if (!response.ok) { + throw new Error(JSON.stringify(error)) + } + + return data! +} + +const importFile = ( + readEvent: ProgressEvent, + fileType: FileType +) => { + switch (fileType) { + case FileTypes.Json: { + readEvent.target && importFlowFromJson(readEvent.target.result as string) + break + } + case FileTypes.Xml: { + readEvent.target && + translateXmlToFlow(readEvent.target.result as string) + .then((flow) => { + const flowDiagram = eipFlowToDiagram(flow) + importFlowFromObject(flowDiagram as SerializedFlow) + }) + .catch((err: Error) => + console.log("Failed to import integration XML file:", err) + ) + break + } + } +} + +const validateDroppedFiles = (files: File[]) => { + if (files.length !== 1) { + console.error("Multiple file drops are not supported") + return false + } + + const fileType = files[0].type + if (!isSupportedFileType(fileType)) { + console.error( + `${fileType} is not a supported type. Dropped file must either be an EIP Flow JSON or an Integration XML` + ) + return false + } + + return true +} + +const acceptDroppedFile = (file: File) => { + const reader = new FileReader() + reader.onload = (e) => { + try { + importFile(e, file.type as FileType) + } catch (e) { + // TODO: Display an error pop-up on failed import + // https://github.com/codice/keip-canvas/issues/7 + console.error((e as Error).message) + } + } + reader.readAsText(file) +} + +const getDropPosition = ( + monitor: DropTargetMonitor, + reactFlowInstance: ReactFlowInstance +) => { + let offset = monitor.getClientOffset() + offset = offset ?? { x: 0, y: 0 } + return reactFlowInstance.screenToFlowPosition(offset) +} + +export const useCanvasDrop = (reactFlowInstance: ReactFlowInstance) => { + const [, drop] = useDrop( + () => ({ + accept: [DragTypes.FLOWNODE, NativeTypes.FILE], + drop: (item: DropType, monitor) => { + if ("namespace" in item) { + // Dropping a FLOWNODE creates a new node in the flow. + const pos = getDropPosition(monitor, reactFlowInstance) + createDroppedNode(item, pos) + } else if ("files" in item) { + validateDroppedFiles(item.files) && acceptDroppedFile(item.files[0]) + } else { + console.warn("unknown drop type: ", item) + } + }, + }), + [reactFlowInstance] + ) + + return drop +} diff --git a/ui/src/components/config-panel/AttributeConfigForm.tsx b/ui/src/components/config-panel/AttributeConfigForm.tsx index e779cbe8..08309099 100644 --- a/ui/src/components/config-panel/AttributeConfigForm.tsx +++ b/ui/src/components/config-panel/AttributeConfigForm.tsx @@ -9,7 +9,7 @@ import { Toggle, } from "@carbon/react" import { ChangeEvent, useMemo } from "react" -import { Attribute } from "../../api/generated/eipComponentDef" +import { Attribute, AttributeType } from "../../api/generated/eipComponentDef" import { deleteEipAttribute, updateEipAttribute, @@ -144,10 +144,17 @@ const AttributeInput = (props: AttributeInputFactoryProps) => { return case "boolean": - return + return } } +const toBoolean = (value: AttributeType) => { + if (typeof value === "string") { + return value === "true" + } + return Boolean(value) +} + export const AttributeConfigForm = (props: AttributeFormProps) => { const required = props.attrs .filter((attr) => attr.required) diff --git a/ui/src/components/toolbar/xml/XmlPanel.tsx b/ui/src/components/toolbar/xml/XmlPanel.tsx index 8ba7e60a..6f16d68c 100644 --- a/ui/src/components/toolbar/xml/XmlPanel.tsx +++ b/ui/src/components/toolbar/xml/XmlPanel.tsx @@ -45,7 +45,7 @@ const fetchXmlTranslation = async ( ) => { const queryStr = new URLSearchParams({ prettyPrint: "true" }).toString() const response = await fetchWithTimeout( - `${FLOW_TRANSLATOR_BASE_URL}?` + queryStr, + `${FLOW_TRANSLATOR_BASE_URL}/translation/toSpringXml?` + queryStr, { method: "POST", body: JSON.stringify(flow), diff --git a/ui/src/singletons/followerNodeDefs.ts b/ui/src/singletons/followerNodeDefs.ts index 41b53a5d..55243358 100644 --- a/ui/src/singletons/followerNodeDefs.ts +++ b/ui/src/singletons/followerNodeDefs.ts @@ -3,11 +3,11 @@ import { ConnectionType, EipId } from "../api/generated/eipFlow" import { lookupEipComponent } from "./eipDefinitions" import { getEipId } from "./store/storeViews" -interface FollowerNodeDescriptor { +export interface FollowerNodeDescriptor { eipId: EipId generateLabel: (leaderLabel: string) => string - hiddenEdges?: (leaderId: string, followerId: string) => Partial[] + hiddenEdge?: (leaderId: string, followerId: string) => Partial overrides?: { connectionType: ConnectionType } @@ -28,12 +28,10 @@ export const describeFollower = ( return { eipId: { namespace: "integration", name: "channel" }, generateLabel: (leaderLabel) => `${leaderLabel}-reply-channel`, - hiddenEdges: (leaderId, followerId) => [ - { - source: followerId, - target: leaderId, - }, - ], + hiddenEdge: (leaderId, followerId) => ({ + source: followerId, + target: leaderId, + }), overrides: { connectionType: "sink", }, diff --git a/ui/src/singletons/store/__snapshots__/eipFlowToDiagram.test.ts.snap b/ui/src/singletons/store/__snapshots__/eipFlowToDiagram.test.ts.snap new file mode 100644 index 00000000..a3bbbbd4 --- /dev/null +++ b/ui/src/singletons/store/__snapshots__/eipFlowToDiagram.test.ts.snap @@ -0,0 +1,1200 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`EipFlow to diagram > 'custom entity' 1`] = ` +{ + "customEntities": { + "e1": "", + }, + "edges": [], + "eipConfigs": {}, + "nodes": [], +} +`; + +exports[`EipFlow to diagram > 'inbound gateway' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-gatewayIn-updatePayload", + "source": "n0", + "target": "n1", + }, + { + "id": "ch-updatePayload-gatewayIn-reply-channel", + "source": "n1", + "target": "n2", + }, + ], + "eipConfigs": { + "n0": { + "attributes": {}, + "children": [], + "eipId": { + "name": "inbound-gateway", + "namespace": "http", + }, + }, + "n1": { + "attributes": {}, + "children": [], + "eipId": { + "name": "transformer", + "namespace": "integration", + }, + }, + "n2": { + "attributes": {}, + "children": [], + "eipId": { + "name": "channel", + "namespace": "integration", + }, + }, + }, + "nodes": [ + { + "data": { + "followerId": "n2", + "label": "gatewayIn", + }, + "id": "n0", + "position": { + "x": 0, + "y": 55.75, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "updatePayload", + }, + "id": "n1", + "position": { + "x": 203, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "leaderId": "n0", + }, + "id": "n2", + "position": { + "x": 406, + "y": 55.75, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "followerNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'nested children' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-addPrefix", + "source": "n0", + "target": "n1", + }, + { + "id": "ch-addPrefix-out1", + "source": "n1", + "target": "n2", + }, + ], + "eipConfigs": { + "c0": { + "attributes": { + "fixed-rate": "3000", + }, + "children": [], + "eipId": { + "name": "poller", + "namespace": "integration", + }, + }, + "c1": { + "attributes": { + "maximum": "30000", + }, + "children": [], + "eipId": { + "name": "exponential-back-off", + "namespace": "integration", + }, + }, + "c2": { + "attributes": {}, + "children": [ + "c1", + ], + "eipId": { + "name": "retry-advice", + "namespace": "integration", + }, + }, + "c3": { + "attributes": {}, + "children": [ + "c2", + ], + "eipId": { + "name": "request-handler-advice-chain", + "namespace": "integration", + }, + }, + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [ + "c0", + ], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": { + "expression": "'test_' + payload", + }, + "children": [ + "c3", + ], + "eipId": { + "name": "transformer", + "namespace": "integration", + }, + }, + "n2": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "addPrefix", + }, + "id": "n1", + "position": { + "x": 203, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'payload-type router' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-router", + "source": "n0", + "target": "n1", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "The type of the payload that this mapping will match, e.g. 'java.lang.String' or 'java.lang.Integer' ", + "name": "type", + "required": false, + "type": "string", + }, + "matcherValue": "Integer", + }, + }, + "id": "ch-router-out2", + "source": "n1", + "target": "n2", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "The type of the payload that this mapping will match, e.g. 'java.lang.String' or 'java.lang.Integer' ", + "name": "type", + "required": false, + "type": "string", + }, + "matcherValue": "Boolean", + }, + }, + "id": "ch-router-logger", + "source": "n1", + "target": "n3", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "The type of the payload that this mapping will match, e.g. 'java.lang.String' or 'java.lang.Integer' ", + "name": "type", + "required": false, + "type": "string", + }, + "matcherValue": "String", + }, + }, + "id": "ch-router-out1", + "source": "n1", + "target": "n4", + "type": "dynamicEdge", + }, + ], + "eipConfigs": { + "c0": { + "attributes": { + "channel": "ch-router-out1", + "type": "String", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "c1": { + "attributes": { + "channel": "ch-router-out2", + "type": "Integer", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "c2": { + "attributes": { + "channel": "ch-router-logger", + "type": "Boolean", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": {}, + "children": [], + "eipId": { + "name": "payload-type-router", + "namespace": "integration", + }, + }, + "n2": { + "attributes": {}, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "http", + }, + }, + "n3": { + "attributes": {}, + "children": [], + "eipId": { + "name": "logging-channel-adapter", + "namespace": "integration", + }, + }, + "n4": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "router", + }, + "id": "n1", + "position": { + "x": 203, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out2", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "logger", + }, + "id": "n3", + "position": { + "x": 406, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n4", + "position": { + "x": 406, + "y": 406, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'recipient-list router' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-router", + "source": "n0", + "target": "n1", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "recipient", + "matcher": { + "description": "An expression to be evaluated to determine if this recipient should be included in the recipient list for a given input Message. The evaluation result of the expression must be a boolean. If this attribute is not defined, the channel will always be among the list of recipients.", + "name": "selector-expression", + "required": false, + "type": "string", + }, + }, + }, + "id": "ch-router-out2", + "source": "n1", + "target": "n2", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "recipient", + "matcher": { + "description": "An expression to be evaluated to determine if this recipient should be included in the recipient list for a given input Message. The evaluation result of the expression must be a boolean. If this attribute is not defined, the channel will always be among the list of recipients.", + "name": "selector-expression", + "required": false, + "type": "string", + }, + "matcherValue": "headers['log'] == true", + }, + }, + "id": "ch-router-logger", + "source": "n1", + "target": "n3", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "recipient", + "matcher": { + "description": "An expression to be evaluated to determine if this recipient should be included in the recipient list for a given input Message. The evaluation result of the expression must be a boolean. If this attribute is not defined, the channel will always be among the list of recipients.", + "name": "selector-expression", + "required": false, + "type": "string", + }, + }, + }, + "id": "ch-router-out1", + "source": "n1", + "target": "n4", + "type": "dynamicEdge", + }, + ], + "eipConfigs": { + "c0": { + "attributes": { + "channel": "ch-router-out1", + }, + "children": [], + "eipId": { + "name": "recipient", + "namespace": "integration", + }, + }, + "c1": { + "attributes": { + "channel": "ch-router-out2", + }, + "children": [], + "eipId": { + "name": "recipient", + "namespace": "integration", + }, + }, + "c2": { + "attributes": { + "channel": "ch-router-logger", + "selector-expression": "headers['log'] == true", + }, + "children": [], + "eipId": { + "name": "recipient", + "namespace": "integration", + }, + }, + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": { + "apply-sequence": "true", + }, + "children": [], + "eipId": { + "name": "recipient-list-router", + "namespace": "integration", + }, + }, + "n2": { + "attributes": {}, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "http", + }, + }, + "n3": { + "attributes": {}, + "children": [], + "eipId": { + "name": "logging-channel-adapter", + "namespace": "integration", + }, + }, + "n4": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "router", + }, + "id": "n1", + "position": { + "x": 203, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out2", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "logger", + }, + "id": "n3", + "position": { + "x": 406, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n4", + "position": { + "x": 406, + "y": 406, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'simple flow' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-addPrefix", + "source": "n0", + "target": "n1", + }, + { + "id": "ch-addPrefix-out1", + "source": "n1", + "target": "n2", + }, + ], + "eipConfigs": { + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": { + "expression": "'test_' + payload", + }, + "children": [], + "eipId": { + "name": "transformer", + "namespace": "integration", + }, + }, + "n2": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "addPrefix", + }, + "id": "n1", + "position": { + "x": 203, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'simple router' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-testRouter", + "source": "n0", + "target": "n1", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + "matcherValue": "first", + }, + }, + "id": "ch-testRouter-out1", + "source": "n1", + "target": "n2", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + "matcherValue": "second", + }, + }, + "id": "ch-testRouter-out2", + "source": "n1", + "target": "n3", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "isDefaultMapping": true, + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + }, + }, + "id": "ch-testRouter-logger", + "source": "n1", + "target": "n4", + "type": "dynamicEdge", + }, + ], + "eipConfigs": { + "c0": { + "attributes": { + "channel": "ch-testRouter-out1", + "value": "first", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "c1": { + "attributes": { + "channel": "ch-testRouter-out2", + "value": "second", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": { + "send-timeout": "2000", + }, + "children": [], + "eipId": { + "name": "router", + "namespace": "integration", + }, + "routerKey": { + "attributes": { + "expression": "payload", + }, + "name": "expression", + }, + }, + "n2": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + "n3": { + "attributes": {}, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "http", + }, + }, + "n4": { + "attributes": {}, + "children": [], + "eipId": { + "name": "logging-channel-adapter", + "namespace": "integration", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "testRouter", + }, + "id": "n1", + "position": { + "x": 203, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out2", + }, + "id": "n3", + "position": { + "x": 406, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "logger", + }, + "id": "n4", + "position": { + "x": 406, + "y": 406, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; + +exports[`EipFlow to diagram > 'xpath router' 1`] = ` +{ + "customEntities": {}, + "edges": [ + { + "id": "ch-in1-router", + "source": "n0", + "target": "n1", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + "matcherValue": "second", + }, + }, + "id": "ch-router-out2", + "source": "n1", + "target": "n2", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + "matcherValue": "third", + }, + }, + "id": "ch-router-logger", + "source": "n1", + "target": "n3", + "type": "dynamicEdge", + }, + { + "animated": true, + "data": { + "mapping": { + "mapperName": "mapping", + "matcher": { + "description": "A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')", + "name": "value", + "required": false, + "type": "string", + }, + "matcherValue": "first", + }, + }, + "id": "ch-router-out1", + "source": "n1", + "target": "n4", + "type": "dynamicEdge", + }, + ], + "eipConfigs": { + "c1": { + "attributes": { + "channel": "ch-router-out1", + "value": "first", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "c2": { + "attributes": { + "channel": "ch-router-out2", + "value": "second", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "c3": { + "attributes": { + "channel": "ch-router-logger", + "value": "third", + }, + "children": [], + "eipId": { + "name": "mapping", + "namespace": "integration", + }, + }, + "n0": { + "attributes": { + "expression": "'abc'", + }, + "children": [], + "eipId": { + "name": "inbound-channel-adapter", + "namespace": "integration", + }, + }, + "n1": { + "attributes": { + "phase": "init", + }, + "children": [], + "eipId": { + "name": "xpath-router", + "namespace": "int-xml", + }, + "routerKey": { + "attributes": { + "expression": "/test/xpath", + "id": "token", + "ns-prefix": "testing", + "ns-uri": "test.example.com", + }, + "name": "xpath-expression", + }, + }, + "n2": { + "attributes": {}, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "http", + }, + }, + "n3": { + "attributes": {}, + "children": [], + "eipId": { + "name": "logging-channel-adapter", + "namespace": "integration", + }, + }, + "n4": { + "attributes": { + "destination-name": "test_queue", + "pub-sub-domain": "false", + }, + "children": [], + "eipId": { + "name": "outbound-channel-adapter", + "namespace": "jms", + }, + }, + }, + "nodes": [ + { + "data": { + "label": "in1", + }, + "id": "n0", + "position": { + "x": 0, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "router", + }, + "id": "n1", + "position": { + "x": 203, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out2", + }, + "id": "n2", + "position": { + "x": 406, + "y": 0, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "logger", + }, + "id": "n3", + "position": { + "x": 406, + "y": 203, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + { + "data": { + "label": "out1", + }, + "id": "n4", + "position": { + "x": 406, + "y": 406, + }, + "sourcePosition": "right", + "targetPosition": "left", + "type": "eipNode", + }, + ], +} +`; diff --git a/ui/src/singletons/store/appActions.ts b/ui/src/singletons/store/appActions.ts index b26c74e7..fafe458a 100644 --- a/ui/src/singletons/store/appActions.ts +++ b/ui/src/singletons/store/appActions.ts @@ -1,6 +1,5 @@ import { Edge, XYPosition } from "@xyflow/react" import { produce } from "immer" -import { nanoid } from "nanoid/non-secure" import { ChannelMapping, CustomNode, @@ -15,6 +14,7 @@ import { import { AttributeType } from "../../api/generated/eipComponentDef" import { EipId } from "../../api/generated/eipFlow" import { newFlowLayout } from "../../components/layout/layouting" +import { generateChildId, generateNodeId } from "../../utils/nodeIdGenerator" import { describeFollower } from "../followerNodeDefs" import { AppStore, EipConfig, SerializedFlow } from "./api" import { useAppStore } from "./appStore" @@ -135,7 +135,7 @@ export const enableChild = (parentId: string, childEipId: EipId) => children: [], } - const childId = nanoid(11) + const childId = generateChildId() return produce(state, (draft: AppStore) => { draft.eipConfigs[parentId].children.push(childId) @@ -367,8 +367,9 @@ const generateNodes = ( return descriptors } -const newEipNode = (position: XYPosition) => { - const id = nanoid(10) +// TODO: Extract node generators +export const newEipNode = (position: XYPosition) => { + const id = generateNodeId() const node: EipFlowNode = { id: id, type: CustomNodeType.EipNode, @@ -379,7 +380,7 @@ const newEipNode = (position: XYPosition) => { } const newFollowerNode = (leaderId: string, position: XYPosition) => { - const id = nanoid(10) + const id = generateNodeId() const node: FollowerNode = { id: id, type: CustomNodeType.FollowerNode, diff --git a/ui/src/singletons/store/diagramToEipFlow.ts b/ui/src/singletons/store/diagramToEipFlow.ts index 30d812bc..6ec7c2e2 100644 --- a/ui/src/singletons/store/diagramToEipFlow.ts +++ b/ui/src/singletons/store/diagramToEipFlow.ts @@ -38,6 +38,27 @@ export const useEipFlow = () => // TODO: Dynamic router logic is over-complicating this method, extract to // a separate method and apply modifications after building original flow. + +// TODO: Consider deprecating the 'label' field and just using 'id' instead, +// to simplify the conversion between Diagram and EipFlow representations + +/** + * Converts the {@link AppStore} flow representation to an {@link EipFlow}. + * The EipFlow can then be sent to a backend server for translation. + * + * Much of this method's complexity is due to the impedance mismatch between the + * internal {@link AppStore} representation and the shared {@link EipFlow} API, + * especially when handling special case nodes such as content-based routers and + * inbound-request-reply nodes. + * + * One potential approach for reducing the mismatch is to create a richer model + * for the EipFlow, which could allow these special case nodes to be represented + * similarly across projects. However, care must be taken not to pollute the + * model unnecessarily, which could lead inflexibility and difficulty evolving the model. + * + * @param state the AppStore's current state object + * @returns An EipFlow + */ const diagramToEipFlow = (state: AppStore): EipFlow => { const nodeLookup = createNodeLookupMap(state.nodes) @@ -124,12 +145,12 @@ const addHiddenFollowerEdges = (nodes: CustomNode[], edges: CustomEdge[]) => { nodes.forEach((node) => { if (isFollowerNode(node)) { const followerDesc = describeFollowerFromId(node.data.leaderId) - if (followerDesc?.hiddenEdges) { - const edges = followerDesc.hiddenEdges( + if (followerDesc?.hiddenEdge) { + const edge = followerDesc.hiddenEdge( node.data.leaderId, node.id - ) as CustomEdge[] - combinedEdges.push(...edges) + ) as CustomEdge + combinedEdges.push(edge) } } }) diff --git a/ui/src/singletons/store/eipFlowToDiagram.test.ts b/ui/src/singletons/store/eipFlowToDiagram.test.ts new file mode 100644 index 00000000..29014636 --- /dev/null +++ b/ui/src/singletons/store/eipFlowToDiagram.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, test, vi } from "vitest" +import { EipFlow } from "../../api/generated/eipFlow" +import { eipFlowToDiagram } from "./eipFlowToDiagram" +import customEntityFlow from "./testdata/eipFlows/customEntity.json" +import inboundGatewayFlow from "./testdata/eipFlows/inboundGateway.json" +import nestedChildrenFlow from "./testdata/eipFlows/nestedChildren.json" +import payloadTypeRouterFlow from "./testdata/eipFlows/routers/payloadTypeRouter.json" +import recipientListRouterFlow from "./testdata/eipFlows/routers/recipientListRouter.json" +import simpleRouterFlow from "./testdata/eipFlows/routers/simpleRouter.json" +import xpathRouterFlow from "./testdata/eipFlows/routers/xpathRouter.json" +import simpleFlow from "./testdata/eipFlows/simple.json" + +vi.mock("zustand") + +let nodeCount = 0 +let childCount = 0 + +// Generate predictable node ids +vi.mock("../../utils/nodeIdGenerator", () => ({ + generateChildId: () => `c${childCount++}`, + generateNodeId: () => `n${nodeCount++}`, +})) + +beforeEach(() => { + nodeCount = 0 + childCount = 0 +}) + +describe("EipFlow to diagram", () => { + test.each([ + { + msg: "simple flow", + flow: simpleFlow, + }, + { + msg: "nested children", + flow: nestedChildrenFlow, + }, + { + msg: "simple router", + flow: simpleRouterFlow, + }, + { + msg: "payload-type router", + flow: payloadTypeRouterFlow, + }, + { + msg: "recipient-list router", + flow: recipientListRouterFlow, + }, + { + msg: "xpath router", + flow: xpathRouterFlow, + }, + { + msg: "inbound gateway", + flow: inboundGatewayFlow, + }, + { + msg: "custom entity", + flow: customEntityFlow, + }, + ])("$msg", ({ flow }) => { + const flowDiagram = eipFlowToDiagram(flow as unknown as EipFlow) + expect(flowDiagram).toMatchSnapshot() + }) +}) diff --git a/ui/src/singletons/store/eipFlowToDiagram.ts b/ui/src/singletons/store/eipFlowToDiagram.ts new file mode 100644 index 00000000..e2321cb0 --- /dev/null +++ b/ui/src/singletons/store/eipFlowToDiagram.ts @@ -0,0 +1,481 @@ +import { BuiltInEdge, Connection } from "@xyflow/react" +import isDeepEqual from "fast-deep-equal" +import { + CustomEdge, + CustomNode, + CustomNodeType, + DEFAULT_NAMESPACE, + DynamicEdge, + DynamicEdgeData, + EipFlowNode, + FollowerNode, + RouterKeyDef, +} from "../../api/flow" +import { + Attributes, + EipChildNode, + EipFlow, + EipNode, + FlowEdge, +} from "../../api/generated/eipFlow" +import { newFlowLayout } from "../../components/layout/layouting" +import { generateChildId } from "../../utils/nodeIdGenerator" +import { + DYNAMIC_ROUTING_CHILDREN, + eipIdToString, + lookupContentBasedRouterKeys, + lookupEipComponent, +} from "../eipDefinitions" +import { describeFollower, FollowerNodeDescriptor } from "../followerNodeDefs" +import { AppStore, EipConfig } from "./api" +import { newEipNode } from "./appActions" +import { createDynamicRoutingEdge } from "./reactFlowActions" +import { getLayoutView } from "./storeViews" + +/** + * Converts an {@link EipFlow} to a representation suitable for importing into the {@link AppStore}. + * This function performs the inverse transformation of the `diagramToEipFlow` function. + * + * + * @param flow the EipFlow to convert + * @returns An AppStore importable object + */ +export const eipFlowToDiagram = (flow: EipFlow): Partial => { + /* + The initial transformation (creating `EipFlowNodes` and `DefaultEdges`) is straightforward. + Much of the complexity arises from handling of the content-based routers and leader/follower + (e.g. inbound-gateways) special cases. + */ + + if (!flow.nodes) { + throw new Error("The provided EipFlow does not contain any nodes") + } + + const eipConfigs: AppStore["eipConfigs"] = {} + + const nodeLabelToIdMap = new Map() + + // Initial transformations + const nodes = flow.nodes.map((node) => + toEipFlowNode(node, eipConfigs, nodeLabelToIdMap) + ) + + const edges = flow.edges?.map((flowEdge) => + toDefaultEdge(flowEdge, nodeLabelToIdMap) + ) + + // Maps a node id to a filtered list of its children that + // contain routing information (e.g. 'mapping' or 'recipient') + const routingChildren = new Map() + + // Process content-based router nodes + nodes.forEach((node) => { + const nodeConfig = eipConfigs[node.id] + const eipId = nodeConfig.eipId + const eipComponent = lookupEipComponent(eipId) + + if (eipComponent?.connectionType !== "content_based_router") { + return + } + + updateContentRouterNode(node, eipConfigs, routingChildren) + }) + + // Process dynamic edges + const withDynamicEdges: CustomEdge[] = [] + edges?.forEach((edge) => { + // Is this edge exiting a content-based router node? + if (routingChildren.has(edge.source)) { + withDynamicEdges.push(toDynamicEdge(edge, eipConfigs, routingChildren)) + return + } + + withDynamicEdges.push(edge) + }) + + // Process leader/follower nodes and edges + const { followerEdgeMatchers, nodeReferenceMap } = findLeaderNodes( + nodes, + eipConfigs + ) + const filteredEdges = processFollowerEdges( + withDynamicEdges, + followerEdgeMatchers, + nodeReferenceMap + ) + + // Generate diagram layout + let positionedNodes: CustomNode[] = nodes + if (edges) { + positionedNodes = newFlowLayout(nodes, edges, getLayoutView()) + } + + return { + customEntities: flow.customEntities, + nodes: positionedNodes, + edges: filteredEdges, + eipConfigs, + } +} + +/** + * Converts an {@link EipNode}, from an {@link EipFlow}, to an @{@link EipFlowNode} + * which becomes part of the diagram. In addition, it populates the `eipConfigs` object with + * the required properties (e.g. attributes, children) from the provided node. + */ +const toEipFlowNode = ( + flowNode: EipNode, + eipConfigs: AppStore["eipConfigs"], + nodeLabelToIdMap: Map +): EipFlowNode => { + const eipComponent = lookupEipComponent(flowNode.eipId) + if (!eipComponent) { + throw new Error(`Unknown EipId: '${eipIdToString(flowNode.eipId)}'`) + } + + const diagramNode = newEipNode({ x: 0, y: 0 }) + diagramNode.data.label = flowNode.id + nodeLabelToIdMap.set(diagramNode.data.label, diagramNode.id) + + const childIds = flowNode.children?.map((c) => + collectEipChildren(c, eipConfigs) + ) + + eipConfigs[diagramNode.id] = { + attributes: flowNode.attributes ?? {}, + children: childIds ?? [], + eipId: flowNode.eipId, + } + + return diagramNode +} + +/** + * Maps a {@link FlowEdge} to a default diagram edge. + */ +const toDefaultEdge = ( + flowEdge: FlowEdge, + nodeLabelToIdMap: Map +): BuiltInEdge => { + const source = nodeLabelToIdMap.get(flowEdge.source) + const target = nodeLabelToIdMap.get(flowEdge.target) + + if (!source || !target) { + throw new Error(`Disconnected edge in EipFlow: '${flowEdge.id}'`) + } + + const edge: BuiltInEdge = { + id: flowEdge.id, + source, + target, + } + + if (flowEdge.type === "discard") { + edge.sourceHandle = "discard" + } + + return edge +} + +/** + * Recursively walks the child tree, adds an eipConfig entry for + * each child, and returns the child's generated id. + */ +const collectEipChildren = ( + child: EipChildNode, + eipConfigs: AppStore["eipConfigs"] +) => { + const childIds = child.children?.map((c) => collectEipChildren(c, eipConfigs)) + + const id = generateChildId() + eipConfigs[id] = { + eipId: { namespace: DEFAULT_NAMESPACE, name: child.name }, + attributes: child.attributes ?? {}, + children: childIds ?? [], + } + + return id +} + +/** + * Handles content-based router nodes: + * + * - The node's routing key is added to its `eipConfigs` entry + * - Children concerned with routing logic (e.g. 'mapping', 'recipient') are extracted + * and recorded in the`routingChildren` map. + */ +const updateContentRouterNode = ( + node: EipFlowNode, + eipConfigs: AppStore["eipConfigs"], + routingChildren: Map +) => { + const nodeConfig = eipConfigs[node.id] + + const routerKeyDef = lookupContentBasedRouterKeys(nodeConfig.eipId) + routerKeyDef && addRouterKeyToEipConfig(node.id, routerKeyDef, eipConfigs) + + const { routingChildList, remainingChildren } = filterRoutingChildren( + nodeConfig.children, + eipConfigs + ) + + // Add all routing children to 'routingChildren' map + routingChildList.forEach((id) => { + const childConfig = eipConfigs[id] + let configList = routingChildren.get(node.id) + configList = configList ? [...configList, childConfig] : [childConfig] + routingChildren.set(node.id, configList) + }) + + nodeConfig.children = remainingChildren +} + +/** + * Edges that exit a content-based router are upgraded to a {@link DynamicEdge}. + * The mappings stored in `routingChildren` are used to populate the updated + * edge's `data` field. + */ +const toDynamicEdge = ( + edge: BuiltInEdge, + eipConfigs: AppStore["eipConfigs"], + routingChildren: Map +): DynamicEdge => { + const sourceConfig = eipConfigs[edge.source] + const routerEipId = lookupEipComponent(sourceConfig.eipId) + if (!routerEipId) { + throw new Error( + `The source router has an unregistered eipId: '${eipIdToString(sourceConfig.eipId)}'` + ) + } + + const dynamicEdge = createDynamicRoutingEdge( + edge as Connection, + routerEipId + ) as DynamicEdge + + // Find the routing child that corresponds to the current edge + const mappingChildren = routingChildren.get(edge.source)! + const mapping = mappingChildren.find( + (config) => config.attributes.channel === edge.id + ) + + updateDynamicEdgeDataMatcher(dynamicEdge.data!, mapping) + return dynamicEdge +} + +const updateDynamicEdgeDataMatcher = ( + data: DynamicEdgeData, + mappingConfig: EipConfig | undefined +) => { + if (mappingConfig) { + const matcherName = data.mapping.matcher.name + const value = mappingConfig.attributes[matcherName] + if (value) { + data.mapping.matcherValue = String(value) + } + } else { + data.mapping.isDefaultMapping = true + } +} + +const filterRoutingChildren = ( + children: string[], + eipConfigs: AppStore["eipConfigs"] +) => { + const routingChildList: string[] = [] + const remainingChildren: string[] = [] + + children.forEach((childId) => { + const childConfig = eipConfigs[childId] + if (DYNAMIC_ROUTING_CHILDREN.has(childConfig.eipId.name)) { + routingChildList.push(childId) + } else { + remainingChildren.push(childId) + } + }) + + return { routingChildList, remainingChildren } +} + +const addRouterKeyToEipConfig = ( + nodeId: string, + routerKeyDef: RouterKeyDef, + eipConfigs: AppStore["eipConfigs"] +) => { + switch (routerKeyDef.type) { + case "attribute": { + addAttributeRouterKeyConfig(nodeId, routerKeyDef, eipConfigs) + break + } + case "child": { + addChildRouterKeyConfig(nodeId, routerKeyDef, eipConfigs) + break + } + } +} + +const addAttributeRouterKeyConfig = ( + nodeId: string, + routerKeyDef: RouterKeyDef, + eipConfigs: AppStore["eipConfigs"] +) => { + const nodeConfig = eipConfigs[nodeId] + const targetAttrs = new Set(routerKeyDef.attributesDef.map((a) => a.name)) + const extractedRoutingAttrs = Object.keys(nodeConfig.attributes) + .filter((key) => targetAttrs.has(key)) + .reduce((acc, key) => { + acc[key] = nodeConfig.attributes[key] + delete nodeConfig.attributes[key] + return acc + }, {} as Attributes) + + eipConfigs[nodeId].routerKey = { + name: routerKeyDef.name, + attributes: extractedRoutingAttrs, + } +} + +const addChildRouterKeyConfig = ( + nodeId: string, + routerKeyDef: RouterKeyDef, + eipConfigs: AppStore["eipConfigs"] +) => { + const nodeConfig = eipConfigs[nodeId] + const routingChildId = nodeConfig.children.find( + (childId) => eipConfigs[childId]?.eipId.name === routerKeyDef.name + ) + + if (routingChildId) { + eipConfigs[nodeId].routerKey = { + name: routerKeyDef.name, + attributes: eipConfigs[routingChildId].attributes, + } + delete eipConfigs[routingChildId] + nodeConfig.children = nodeConfig.children.filter( + (id) => id !== routingChildId + ) + } +} + +/** + * Scans the list of nodes for "leader" nodes. Leaders are determined by checking if they have + * any followers defined according to {@link describeFollower}. Once a leader is identified, + * an EdgeMatcher is built to identify its follower within the EipFlow. + */ +const findLeaderNodes = ( + nodes: CustomNode[], + eipConfigs: AppStore["eipConfigs"] +) => { + const followerEdgeMatchers = new Map boolean>() + const nodeReferenceMap = new Map() + + nodes.forEach((node) => { + nodeReferenceMap.set(node.id, node) + const nodeConfig = eipConfigs[node.id] + const descriptor = describeFollower(nodeConfig.eipId) + if (descriptor) { + const hiddenEdge = descriptor.hiddenEdge?.(node.id, "") + + const hiddenEdgeMatcher = getHiddenEdgeMatcher( + hiddenEdge, + node.id, + descriptor, + eipConfigs + ) + + hiddenEdgeMatcher && followerEdgeMatchers.set(node.id, hiddenEdgeMatcher) + } + }) + + return { followerEdgeMatchers, nodeReferenceMap } +} + +/** + * Iterates through the list of edges and checks if it's a follower edge + * (connects a leader and a follower). If so, the edge is removed from the list, + * and `id` references are added to both leader and follower node data. + * + * @returns a filtered list of edges, excluding any hidden follower edges. + */ +const processFollowerEdges = ( + edges: CustomEdge[], + followerEdgeMatchers: Map boolean>, + nodeReferenceMap: Map +) => { + const filteredEdges: CustomEdge[] = [] + + edges.forEach((edge) => { + const ids = getIdsFromLeaderEdge(edge, followerEdgeMatchers) + if (ids) { + const { leaderId, followerId } = ids + const isHiddenEdge = followerEdgeMatchers.get(leaderId)! + if (isHiddenEdge(edge)) { + updateNodeReferences(leaderId, followerId, nodeReferenceMap) + + // exclude hidden edge + return + } + } + + filteredEdges.push(edge) + }) + + return filteredEdges +} + +const updateNodeReferences = ( + leaderId: string, + followerId: string, + nodeReferenceMap: Map +) => { + const leaderNode = nodeReferenceMap.get(leaderId) as EipFlowNode + if (leaderNode) { + leaderNode.data.followerId = followerId + } + + const followerNode = nodeReferenceMap.get(followerId) as FollowerNode + if (followerNode) { + followerNode.type = CustomNodeType.FollowerNode + followerNode.data = { leaderId } + } +} + +/** + * The returned function can be applied to a {@link CustomEdge} to determine + * if it is a leader-follower edge. + */ +const getHiddenEdgeMatcher = ( + edge: Partial | undefined, + leaderId: string, + descriptor: FollowerNodeDescriptor, + eipConfigs: AppStore["eipConfigs"] +) => { + if (!edge) { + return null + } + + if (edge.source === leaderId) { + // leader -> follower edge + return (edge: CustomEdge) => + edge.source === leaderId && + isDeepEqual(eipConfigs[edge.target].eipId, descriptor.eipId) + } + + // follower -> leader edge + return (edge: CustomEdge) => + edge.target === leaderId && + isDeepEqual(eipConfigs[edge.source].eipId, descriptor.eipId) +} + +const getIdsFromLeaderEdge = ( + edge: CustomEdge, + leaderNodes: Map +) => { + if (leaderNodes.has(edge.source)) { + return { leaderId: edge.source, followerId: edge.target } + } else if (leaderNodes.has(edge.target)) { + return { leaderId: edge.target, followerId: edge.source } + } else { + return null + } +} diff --git a/ui/src/singletons/store/reactFlowActions.ts b/ui/src/singletons/store/reactFlowActions.ts index 6ab412c2..e0980965 100644 --- a/ui/src/singletons/store/reactFlowActions.ts +++ b/ui/src/singletons/store/reactFlowActions.ts @@ -136,7 +136,7 @@ const removeNestedConfigs = (root: string, configs: AppStore["eipConfigs"]) => { } // TODO: Refactor -const createDynamicRoutingEdge = ( +export const createDynamicRoutingEdge = ( connection: Connection, sourceComponent: EipComponent ) => { diff --git a/ui/src/singletons/store/testdata/eipFlows/customEntity.json b/ui/src/singletons/store/testdata/eipFlows/customEntity.json new file mode 100644 index 00000000..8a3a51c5 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/customEntity.json @@ -0,0 +1,7 @@ +{ + "nodes": [], + "edges": [], + "customEntities": { + "e1": "" + } +} diff --git a/ui/src/singletons/store/testdata/eipFlows/inboundGateway.json b/ui/src/singletons/store/testdata/eipFlows/inboundGateway.json new file mode 100644 index 00000000..3cfead8a --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/inboundGateway.json @@ -0,0 +1,58 @@ +{ + "nodes": [ + { + "id": "gatewayIn", + "eipId": { + "namespace": "http", + "name": "inbound-gateway" + }, + "role": "endpoint", + "connectionType": "inbound_request_reply", + "attributes": {}, + "children": [] + }, + { + "id": "updatePayload", + "eipId": { + "namespace": "integration", + "name": "transformer" + }, + "role": "transformer", + "connectionType": "passthru", + "attributes": {}, + "children": [] + }, + { + "id": "gatewayIn-reply-channel", + "eipId": { + "namespace": "integration", + "name": "channel" + }, + "role": "channel", + "connectionType": "passthru", + "attributes": {}, + "children": [] + } + ], + "edges": [ + { + "id": "ch-gatewayIn-updatePayload", + "source": "gatewayIn", + "target": "updatePayload", + "type": "default" + }, + { + "id": "ch-updatePayload-gatewayIn-reply-channel", + "source": "updatePayload", + "target": "gatewayIn-reply-channel", + "type": "default" + }, + { + "id": "ch-gatewayIn-reply-channel-gatewayIn", + "source": "gatewayIn-reply-channel", + "target": "gatewayIn", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/nestedChildren.json b/ui/src/singletons/store/testdata/eipFlows/nestedChildren.json new file mode 100644 index 00000000..9e8407e0 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/nestedChildren.json @@ -0,0 +1,87 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [ + { + "name": "poller", + "attributes": { + "fixed-rate": "3000" + }, + "children": [] + } + ] + }, + { + "id": "addPrefix", + "eipId": { + "namespace": "integration", + "name": "transformer" + }, + "role": "transformer", + "connectionType": "passthru", + "attributes": { + "expression": "'test_' + payload" + }, + "children": [ + { + "name": "request-handler-advice-chain", + "attributes": {}, + "children": [ + { + "name": "retry-advice", + "attributes": {}, + "children": [ + { + "name": "exponential-back-off", + "attributes": { + "maximum": "30000" + }, + "children": [] + } + ] + } + ] + } + ] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-addPrefix", + "source": "in1", + "target": "addPrefix", + "type": "default" + }, + { + "id": "ch-addPrefix-out1", + "source": "addPrefix", + "target": "out1", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/routers/payloadTypeRouter.json b/ui/src/singletons/store/testdata/eipFlows/routers/payloadTypeRouter.json new file mode 100644 index 00000000..0c79d003 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/routers/payloadTypeRouter.json @@ -0,0 +1,116 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [] + }, + { + "id": "router", + "eipId": { + "namespace": "integration", + "name": "payload-type-router" + }, + "role": "router", + "connectionType": "content_based_router", + "attributes": {}, + "children": [ + { + "name": "mapping", + "attributes": { + "channel": "ch-router-out1", + "type": "String" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-router-out2", + "type": "Integer" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-router-logger", + "type": "Boolean" + }, + "children": [] + } + ] + }, + { + "id": "out2", + "eipId": { + "namespace": "http", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "logger", + "eipId": { + "namespace": "integration", + "name": "logging-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-router", + "source": "in1", + "target": "router", + "type": "default" + }, + { + "id": "ch-router-out2", + "source": "router", + "target": "out2", + "type": "default" + }, + { + "id": "ch-router-logger", + "source": "router", + "target": "logger", + "type": "default" + }, + { + "id": "ch-router-out1", + "source": "router", + "target": "out1", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/routers/recipientListRouter.json b/ui/src/singletons/store/testdata/eipFlows/routers/recipientListRouter.json new file mode 100644 index 00000000..93709e87 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/routers/recipientListRouter.json @@ -0,0 +1,116 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [] + }, + { + "id": "router", + "eipId": { + "namespace": "integration", + "name": "recipient-list-router" + }, + "role": "router", + "connectionType": "content_based_router", + "attributes": { + "apply-sequence": "true" + }, + "children": [ + { + "name": "recipient", + "attributes": { + "channel": "ch-router-out1" + }, + "children": [] + }, + { + "name": "recipient", + "attributes": { + "channel": "ch-router-out2" + }, + "children": [] + }, + { + "name": "recipient", + "attributes": { + "channel": "ch-router-logger", + "selector-expression": "headers['log'] == true" + }, + "children": [] + } + ] + }, + { + "id": "out2", + "eipId": { + "namespace": "http", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "logger", + "eipId": { + "namespace": "integration", + "name": "logging-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-router", + "source": "in1", + "target": "router", + "type": "default" + }, + { + "id": "ch-router-out2", + "source": "router", + "target": "out2", + "type": "default" + }, + { + "id": "ch-router-logger", + "source": "router", + "target": "logger", + "type": "default" + }, + { + "id": "ch-router-out1", + "source": "router", + "target": "out1", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/routers/simpleRouter.json b/ui/src/singletons/store/testdata/eipFlows/routers/simpleRouter.json new file mode 100644 index 00000000..0b396a5b --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/routers/simpleRouter.json @@ -0,0 +1,111 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [] + }, + { + "id": "testRouter", + "eipId": { + "namespace": "integration", + "name": "router" + }, + "role": "router", + "connectionType": "content_based_router", + "attributes": { + "expression": "payload", + "send-timeout": "2000" + }, + "children": [ + { + "name": "mapping", + "attributes": { + "channel": "ch-testRouter-out1", + "value": "first" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-testRouter-out2", + "value": "second" + }, + "children": [] + } + ] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + }, + { + "id": "out2", + "eipId": { + "namespace": "http", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "logger", + "eipId": { + "namespace": "integration", + "name": "logging-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-testRouter", + "source": "in1", + "target": "testRouter", + "type": "default" + }, + { + "id": "ch-testRouter-out1", + "source": "testRouter", + "target": "out1", + "type": "default" + }, + { + "id": "ch-testRouter-out2", + "source": "testRouter", + "target": "out2", + "type": "default" + }, + { + "id": "ch-testRouter-logger", + "source": "testRouter", + "target": "logger", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/routers/xpathRouter.json b/ui/src/singletons/store/testdata/eipFlows/routers/xpathRouter.json new file mode 100644 index 00000000..0a3b4d13 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/routers/xpathRouter.json @@ -0,0 +1,128 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [] + }, + { + "id": "router", + "eipId": { + "namespace": "int-xml", + "name": "xpath-router" + }, + "role": "router", + "connectionType": "content_based_router", + "attributes": { + "phase": "init" + }, + "children": [ + { + "name": "xpath-expression", + "attributes": { + "expression": "/test/xpath", + "id": "token", + "ns-prefix": "testing", + "ns-uri": "test.example.com" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-router-out1", + "value": "first" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-router-out2", + "value": "second" + }, + "children": [] + }, + { + "name": "mapping", + "attributes": { + "channel": "ch-router-logger", + "value": "third" + }, + "children": [] + } + ] + }, + { + "id": "out2", + "eipId": { + "namespace": "http", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "logger", + "eipId": { + "namespace": "integration", + "name": "logging-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-router", + "source": "in1", + "target": "router", + "type": "default" + }, + { + "id": "ch-router-out2", + "source": "router", + "target": "out2", + "type": "default" + }, + { + "id": "ch-router-logger", + "source": "router", + "target": "logger", + "type": "default" + }, + { + "id": "ch-router-out1", + "source": "router", + "target": "out1", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/singletons/store/testdata/eipFlows/simple.json b/ui/src/singletons/store/testdata/eipFlows/simple.json new file mode 100644 index 00000000..5553f313 --- /dev/null +++ b/ui/src/singletons/store/testdata/eipFlows/simple.json @@ -0,0 +1,59 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": { + "expression": "'abc'" + }, + "children": [] + }, + { + "id": "addPrefix", + "eipId": { + "namespace": "integration", + "name": "transformer" + }, + "role": "transformer", + "connectionType": "passthru", + "attributes": { + "expression": "'test_' + payload" + }, + "children": [] + }, + { + "id": "out1", + "eipId": { + "namespace": "jms", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": { + "pub-sub-domain": "false", + "destination-name": "test_queue" + }, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-addPrefix", + "source": "in1", + "target": "addPrefix", + "type": "default" + }, + { + "id": "ch-addPrefix-out1", + "source": "addPrefix", + "target": "out1", + "type": "default" + } + ], + "customEntities": {} +} diff --git a/ui/src/utils/nodeIdGenerator.ts b/ui/src/utils/nodeIdGenerator.ts new file mode 100644 index 00000000..801ae730 --- /dev/null +++ b/ui/src/utils/nodeIdGenerator.ts @@ -0,0 +1,5 @@ +import { nanoid } from "nanoid/non-secure" + +export const generateNodeId = () => nanoid(10) + +export const generateChildId = () => nanoid(11)