Skip to content

Commit 786ee7c

Browse files
authored
feat(ui): Add drag-and-drop support for importing integration XMLs as flow diagrams (#32)
1 parent 39fd7d7 commit 786ee7c

20 files changed

+2636
-84
lines changed

ui/src/components/canvas/FlowCanvas.tsx

Lines changed: 2 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,14 @@ import {
1313
ControlButton,
1414
Controls,
1515
ReactFlow,
16-
ReactFlowInstance,
1716
useReactFlow,
1817
} from "@xyflow/react"
1918
import "@xyflow/react/dist/style.css"
2019
import { KeyboardEvent, useEffect } from "react"
21-
import { DropTargetMonitor, useDrop } from "react-dnd"
22-
import { NativeTypes } from "react-dnd-html5-backend"
2320
import { CustomNodeType, DYNAMIC_EDGE_TYPE } from "../../api/flow"
24-
import { EipId } from "../../api/generated/eipFlow"
2521
import {
2622
clearFlow,
2723
clearSelectedChildNode,
28-
createDroppedNode,
29-
importFlowFromJson,
3024
toggleLayoutDensity,
3125
updateLayoutOrientation,
3226
} from "../../singletons/store/appActions"
@@ -40,19 +34,13 @@ import {
4034
onEdgesChange,
4135
onNodesChange,
4236
} from "../../singletons/store/reactFlowActions"
43-
import { DragTypes } from "../palette/dragTypes"
4437
import DynamicEdge from "./DynamicEdge"
4538
import { EipNode, FollowerNode } from "./EipNode"
39+
import { useCanvasDrop } from "./canvasDropHook"
4640

4741
const FLOW_ERROR_MESSAGE =
4842
"Failed to load the canvas - the stored flow is malformed. Clearing the flow from the state store."
4943

50-
interface FileDrop {
51-
files: File[]
52-
}
53-
54-
type DropType = EipId | FileDrop
55-
5644
interface ErrorHandlerProps {
5745
message: string
5846
callback: () => void
@@ -64,29 +52,6 @@ const ErrorHandler = ({ message, callback }: ErrorHandlerProps) => {
6452
return null
6553
}
6654

67-
const acceptDroppedFile = (file: File, importFlow: (json: string) => void) => {
68-
const reader = new FileReader()
69-
reader.onload = (e) => {
70-
try {
71-
e.target && importFlow(e.target.result as string)
72-
} catch (e) {
73-
// TODO: Display an error pop-up on failed import
74-
// https://github.com/codice/keip-canvas/issues/7
75-
console.error((e as Error).message)
76-
}
77-
}
78-
reader.readAsText(file)
79-
}
80-
81-
const getDropPosition = (
82-
monitor: DropTargetMonitor,
83-
reactFlowInstance: ReactFlowInstance
84-
) => {
85-
let offset = monitor.getClientOffset()
86-
offset = offset ?? { x: 0, y: 0 }
87-
return reactFlowInstance.screenToFlowPosition(offset)
88-
}
89-
9055
const nodeTypes = {
9156
[CustomNodeType.EipNode]: EipNode,
9257
[CustomNodeType.FollowerNode]: FollowerNode,
@@ -123,40 +88,14 @@ const FlowCanvas = () => {
12388
const flowStore = useFlowStore()
12489
const layout = useGetLayout()
12590
const { undo, redo } = useUndoRedo()
91+
const drop = useCanvasDrop(reactFlowInstance)
12692

12793
useEffect(() => {
12894
reactFlowInstance
12995
.fitView()
13096
.catch((e) => console.warn("failed to call fitView", e))
13197
}, [layout, reactFlowInstance])
13298

133-
const [, drop] = useDrop(
134-
() => ({
135-
accept: [DragTypes.FLOWNODE, NativeTypes.FILE],
136-
drop: (item: DropType, monitor) => {
137-
if ("namespace" in item) {
138-
// Dropping a FLOWNODE creates a new node in the flow.
139-
const pos = getDropPosition(monitor, reactFlowInstance)
140-
createDroppedNode(item, pos)
141-
} else if ("files" in item) {
142-
// Dropping a JSON file imports it as a flow.
143-
acceptDroppedFile(item.files[0], importFlowFromJson)
144-
} else {
145-
console.warn("unknown drop type: ", item)
146-
}
147-
},
148-
canDrop: (item: DropType) => {
149-
if ("files" in item) {
150-
return (
151-
item.files.length == 1 && item.files[0].type == "application/json"
152-
)
153-
}
154-
return true
155-
},
156-
}),
157-
[reactFlowInstance]
158-
)
159-
16099
// TODO: See if there is a better way to select and clear child nodes,
161100
// to avoid having to clear the selection in multiple components.
162101

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import { ReactFlowInstance } from "@xyflow/react"
2+
import "@xyflow/react/dist/style.css"
3+
import { DropTargetMonitor, useDrop } from "react-dnd"
4+
import { NativeTypes } from "react-dnd-html5-backend"
5+
import { EipFlow, EipId } from "../../api/generated/eipFlow"
6+
import { FLOW_TRANSLATOR_BASE_URL } from "../../singletons/externalEndpoints"
7+
import { SerializedFlow } from "../../singletons/store/api"
8+
import {
9+
createDroppedNode,
10+
importFlowFromJson,
11+
importFlowFromObject,
12+
} from "../../singletons/store/appActions"
13+
import { eipFlowToDiagram } from "../../singletons/store/eipFlowToDiagram"
14+
import fetchWithTimeout from "../../utils/fetch/fetchWithTimeout"
15+
import { DragTypes } from "../palette/dragTypes"
16+
17+
interface XmlTranslationResponse {
18+
data?: EipFlow
19+
error?: {
20+
message: string
21+
type: string
22+
details: object[]
23+
}
24+
}
25+
26+
interface FileDrop {
27+
files: File[]
28+
}
29+
30+
type DropType = EipId | FileDrop
31+
32+
const FileTypes = {
33+
Json: "application/json",
34+
Xml: "text/xml",
35+
} as const
36+
37+
type FileType = (typeof FileTypes)[keyof typeof FileTypes]
38+
39+
const supportedFileTypes = new Set(Object.values(FileTypes))
40+
41+
const isSupportedFileType = (value: string): value is FileType => {
42+
return supportedFileTypes.has(value as FileType)
43+
}
44+
45+
const translateXmlToFlow = async (xml: string) => {
46+
const response = await fetchWithTimeout(
47+
`${FLOW_TRANSLATOR_BASE_URL}/translation/toFlow`,
48+
{
49+
method: "POST",
50+
body: xml,
51+
headers: {
52+
"Content-Type": "application/xml",
53+
},
54+
timeout: 10000,
55+
}
56+
)
57+
58+
const { data, error } = (await response.json()) as XmlTranslationResponse
59+
60+
if (!response.ok) {
61+
throw new Error(JSON.stringify(error))
62+
}
63+
64+
return data!
65+
}
66+
67+
const importFile = (
68+
readEvent: ProgressEvent<FileReader>,
69+
fileType: FileType
70+
) => {
71+
switch (fileType) {
72+
case FileTypes.Json: {
73+
readEvent.target && importFlowFromJson(readEvent.target.result as string)
74+
break
75+
}
76+
case FileTypes.Xml: {
77+
readEvent.target &&
78+
translateXmlToFlow(readEvent.target.result as string)
79+
.then((flow) => {
80+
const flowDiagram = eipFlowToDiagram(flow)
81+
importFlowFromObject(flowDiagram as SerializedFlow)
82+
})
83+
.catch((err: Error) =>
84+
console.log("Failed to import integration XML file:", err)
85+
)
86+
break
87+
}
88+
}
89+
}
90+
91+
const validateDroppedFiles = (files: File[]) => {
92+
if (files.length !== 1) {
93+
console.error("Multiple file drops are not supported")
94+
return false
95+
}
96+
97+
const fileType = files[0].type
98+
if (!isSupportedFileType(fileType)) {
99+
console.error(
100+
`${fileType} is not a supported type. Dropped file must either be an EIP Flow JSON or an Integration XML`
101+
)
102+
return false
103+
}
104+
105+
return true
106+
}
107+
108+
const acceptDroppedFile = (file: File) => {
109+
const reader = new FileReader()
110+
reader.onload = (e) => {
111+
try {
112+
importFile(e, file.type as FileType)
113+
} catch (e) {
114+
// TODO: Display an error pop-up on failed import
115+
// https://github.com/codice/keip-canvas/issues/7
116+
console.error((e as Error).message)
117+
}
118+
}
119+
reader.readAsText(file)
120+
}
121+
122+
const getDropPosition = (
123+
monitor: DropTargetMonitor,
124+
reactFlowInstance: ReactFlowInstance
125+
) => {
126+
let offset = monitor.getClientOffset()
127+
offset = offset ?? { x: 0, y: 0 }
128+
return reactFlowInstance.screenToFlowPosition(offset)
129+
}
130+
131+
export const useCanvasDrop = (reactFlowInstance: ReactFlowInstance) => {
132+
const [, drop] = useDrop(
133+
() => ({
134+
accept: [DragTypes.FLOWNODE, NativeTypes.FILE],
135+
drop: (item: DropType, monitor) => {
136+
if ("namespace" in item) {
137+
// Dropping a FLOWNODE creates a new node in the flow.
138+
const pos = getDropPosition(monitor, reactFlowInstance)
139+
createDroppedNode(item, pos)
140+
} else if ("files" in item) {
141+
validateDroppedFiles(item.files) && acceptDroppedFile(item.files[0])
142+
} else {
143+
console.warn("unknown drop type: ", item)
144+
}
145+
},
146+
}),
147+
[reactFlowInstance]
148+
)
149+
150+
return drop
151+
}

ui/src/components/config-panel/AttributeConfigForm.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
Toggle,
1010
} from "@carbon/react"
1111
import { ChangeEvent, useMemo } from "react"
12-
import { Attribute } from "../../api/generated/eipComponentDef"
12+
import { Attribute, AttributeType } from "../../api/generated/eipComponentDef"
1313
import {
1414
deleteEipAttribute,
1515
updateEipAttribute,
@@ -144,10 +144,17 @@ const AttributeInput = (props: AttributeInputFactoryProps) => {
144144
return <AttributeTextInput {...props} attrValue={attrValue as string} />
145145

146146
case "boolean":
147-
return <AttributeBoolInput {...props} attrValue={attrValue as boolean} />
147+
return <AttributeBoolInput {...props} attrValue={toBoolean(attrValue)} />
148148
}
149149
}
150150

151+
const toBoolean = (value: AttributeType) => {
152+
if (typeof value === "string") {
153+
return value === "true"
154+
}
155+
return Boolean(value)
156+
}
157+
151158
export const AttributeConfigForm = (props: AttributeFormProps) => {
152159
const required = props.attrs
153160
.filter((attr) => attr.required)

ui/src/components/toolbar/xml/XmlPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ const fetchXmlTranslation = async (
4545
) => {
4646
const queryStr = new URLSearchParams({ prettyPrint: "true" }).toString()
4747
const response = await fetchWithTimeout(
48-
`${FLOW_TRANSLATOR_BASE_URL}?` + queryStr,
48+
`${FLOW_TRANSLATOR_BASE_URL}/translation/toSpringXml?` + queryStr,
4949
{
5050
method: "POST",
5151
body: JSON.stringify(flow),

ui/src/singletons/followerNodeDefs.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,11 @@ import { ConnectionType, EipId } from "../api/generated/eipFlow"
33
import { lookupEipComponent } from "./eipDefinitions"
44
import { getEipId } from "./store/storeViews"
55

6-
interface FollowerNodeDescriptor {
6+
export interface FollowerNodeDescriptor {
77
eipId: EipId
88
generateLabel: (leaderLabel: string) => string
99

10-
hiddenEdges?: (leaderId: string, followerId: string) => Partial<CustomEdge>[]
10+
hiddenEdge?: (leaderId: string, followerId: string) => Partial<CustomEdge>
1111
overrides?: {
1212
connectionType: ConnectionType
1313
}
@@ -28,12 +28,10 @@ export const describeFollower = (
2828
return {
2929
eipId: { namespace: "integration", name: "channel" },
3030
generateLabel: (leaderLabel) => `${leaderLabel}-reply-channel`,
31-
hiddenEdges: (leaderId, followerId) => [
32-
{
33-
source: followerId,
34-
target: leaderId,
35-
},
36-
],
31+
hiddenEdge: (leaderId, followerId) => ({
32+
source: followerId,
33+
target: leaderId,
34+
}),
3735
overrides: {
3836
connectionType: "sink",
3937
},

0 commit comments

Comments
 (0)