Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 2 additions & 63 deletions ui/src/components/canvas/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -123,40 +88,14 @@ const FlowCanvas = () => {
const flowStore = useFlowStore()
const layout = useGetLayout()
const { undo, redo } = useUndoRedo()
const drop = useCanvasDrop(reactFlowInstance)

useEffect(() => {
reactFlowInstance
.fitView()
.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.

Expand Down
151 changes: 151 additions & 0 deletions ui/src/components/canvas/canvasDropHook.ts
Original file line number Diff line number Diff line change
@@ -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<FileReader>,
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
}
11 changes: 9 additions & 2 deletions ui/src/components/config-panel/AttributeConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -144,10 +144,17 @@ const AttributeInput = (props: AttributeInputFactoryProps) => {
return <AttributeTextInput {...props} attrValue={attrValue as string} />

case "boolean":
return <AttributeBoolInput {...props} attrValue={attrValue as boolean} />
return <AttributeBoolInput {...props} attrValue={toBoolean(attrValue)} />
}
}

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)
Expand Down
2 changes: 1 addition & 1 deletion ui/src/components/toolbar/xml/XmlPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
14 changes: 6 additions & 8 deletions ui/src/singletons/followerNodeDefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<CustomEdge>[]
hiddenEdge?: (leaderId: string, followerId: string) => Partial<CustomEdge>
overrides?: {
connectionType: ConnectionType
}
Expand All @@ -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",
},
Expand Down
Loading