diff --git a/ui/src/api/generated/eipFlow.ts b/ui/src/api/generated/eipFlow.ts
index ea428a83..6b346577 100644
--- a/ui/src/api/generated/eipFlow.ts
+++ b/ui/src/api/generated/eipFlow.ts
@@ -31,6 +31,12 @@ export type AttributeType = string | number | boolean;
export interface EipFlow {
nodes?: EipNode[];
edges?: FlowEdge[];
+ /**
+ * Custom entities do not appear on the flow diagram, but can be referenced by flow node attributes.
+ */
+ customEntities?: {
+ [k: string]: string;
+ };
}
/**
* An instance of an 'EipComponent' as a node in the flow diagram
diff --git a/ui/src/carbon-themes.d.ts b/ui/src/carbon-themes.d.ts
index 9785e7e0..6cd0ec55 100644
--- a/ui/src/carbon-themes.d.ts
+++ b/ui/src/carbon-themes.d.ts
@@ -8,4 +8,5 @@
declare module "@carbon/themes" {
export const interactive: string
export const layer01: string
+ export const supportError: string
}
diff --git a/ui/src/components/canvas/FlowCanvas.tsx b/ui/src/components/canvas/FlowCanvas.tsx
index 4993f307..0ddcda8a 100644
--- a/ui/src/components/canvas/FlowCanvas.tsx
+++ b/ui/src/components/canvas/FlowCanvas.tsx
@@ -40,7 +40,7 @@ import {
onEdgesChange,
onNodesChange,
} from "../../singletons/store/reactFlowActions"
-import { DragTypes } from "../draggable-panel/dragTypes"
+import { DragTypes } from "../palette/dragTypes"
import DynamicEdge from "./DynamicEdge"
import { EipNode, FollowerNode } from "./EipNode"
@@ -235,6 +235,7 @@ const FlowCanvas = () => {
showZoom={false}
>
+ {/* TODO: style this button as a dangerous action */}
diff --git a/ui/src/components/editor/ModalCodeEditor.tsx b/ui/src/components/editor/ModalCodeEditor.tsx
new file mode 100644
index 00000000..b1a20284
--- /dev/null
+++ b/ui/src/components/editor/ModalCodeEditor.tsx
@@ -0,0 +1,47 @@
+import { Stack } from "@carbon/react"
+import { supportError } from "@carbon/themes"
+import hljs from "highlight.js/lib/core"
+import json from "highlight.js/lib/languages/json"
+import xml from "highlight.js/lib/languages/xml"
+import Editor from "react-simple-code-editor"
+
+hljs.registerLanguage("json", json)
+hljs.registerLanguage("xml", xml)
+
+interface ModalCodeEditorProps {
+ content: string
+ setContent: (content: string) => void
+ language: "json" | "xml"
+ helperText?: string
+ invalid?: boolean
+ invalidText?: string
+}
+
+export const ModalCodeEditor = ({
+ content,
+ setContent,
+ language,
+ helperText,
+ invalid,
+ invalidText,
+}: ModalCodeEditorProps) => {
+ const errorOutline = invalid ? { outline: `2px solid ${supportError}` } : {}
+
+ return (
+
+
+ setContent(code)}
+ highlight={(code) => hljs.highlight(code, { language }).value}
+ padding={16}
+ textareaClassName="modal__code-editor-textarea"
+ />
+
+ {invalid && {invalidText}
}
+ {!invalid && helperText && (
+ {helperText}
+ )}
+
+ )
+}
diff --git a/ui/src/components/options-menu/modals/ImportFlowModal.tsx b/ui/src/components/options-menu/modals/ImportFlowModal.tsx
index 4699d84b..2e455850 100644
--- a/ui/src/components/options-menu/modals/ImportFlowModal.tsx
+++ b/ui/src/components/options-menu/modals/ImportFlowModal.tsx
@@ -1,24 +1,15 @@
import { Modal } from "@carbon/react"
import { InlineLoadingStatus } from "@carbon/react/lib/components/InlineLoading/InlineLoading"
-import hljs from "highlight.js/lib/core"
-import json from "highlight.js/lib/languages/json"
import { useState } from "react"
import { createPortal } from "react-dom"
-import Editor from "react-simple-code-editor"
import { importFlowFromJson } from "../../../singletons/store/appActions"
-
-hljs.registerLanguage("json", json)
+import { ModalCodeEditor } from "../../editor/ModalCodeEditor"
interface ImportFlowModalProps {
open: boolean
setOpen: (open: boolean) => void
}
-interface JsonEditorProps {
- content: string
- setContent: (content: string) => void
-}
-
const getLoadingDescription = (status: InlineLoadingStatus) => {
switch (status) {
case "active":
@@ -31,20 +22,6 @@ const getLoadingDescription = (status: InlineLoadingStatus) => {
}
}
-const FlowJsonEditor = ({ content, setContent }: JsonEditorProps) => {
- return (
-
- setContent(code)}
- highlight={(code) => hljs.highlight(code, { language: "json" }).value}
- padding={16}
- textareaClassName="options-modal__editor-textarea"
- />
-
- )
-}
-
export const ImportFlowModal = ({ open, setOpen }: ImportFlowModalProps) => {
const [loadingStatus, setLoadingStatus] =
useState("inactive")
@@ -84,7 +61,11 @@ export const ImportFlowModal = ({ open, setOpen }: ImportFlowModalProps) => {
loadingDescription={getLoadingDescription(loadingStatus)}
onRequestSubmit={doImport}
>
-
+
,
document.body
)
diff --git a/ui/src/components/palette/CustomEntityPanel.tsx b/ui/src/components/palette/CustomEntityPanel.tsx
new file mode 100644
index 00000000..766d477b
--- /dev/null
+++ b/ui/src/components/palette/CustomEntityPanel.tsx
@@ -0,0 +1,285 @@
+import {
+ Button,
+ ContainedList,
+ ContainedListItem,
+ FormLabel,
+ Modal,
+ OverflowMenu,
+ OverflowMenuItem,
+ Stack,
+ TextInput,
+} from "@carbon/react"
+
+import {
+ AddLarge,
+ Close,
+ Maximize,
+ Minimize,
+ Settings,
+} from "@carbon/react/icons"
+import { InlineLoadingStatus } from "@carbon/react/lib/components/InlineLoading/InlineLoading"
+import { useState } from "react"
+import { createPortal } from "react-dom"
+import {
+ clearAllCustomEntities,
+ removeCustomEntity,
+ updateCustomEntity,
+} from "../../singletons/store/appActions"
+import { useGetCustomEntityIds } from "../../singletons/store/getterHooks"
+import { getCustomEntityContent } from "../../singletons/store/storeViews"
+import { ModalCodeEditor } from "../editor/ModalCodeEditor"
+
+interface CustomEntityPanelProps {
+ isCollapsed: boolean
+ setCollapsed: (isCollapsed: boolean) => void
+}
+
+interface ExpandedPanelProps {
+ onAddEntity: () => void
+ onCollapsePanel: () => void
+}
+
+interface CollapsedPanelProps {
+ onExpandPanel: () => void
+}
+
+interface CreateEntityModalProps {
+ entityId: string | null
+ open: boolean
+ setOpen: (open: boolean) => void
+}
+
+const CONTENT_HELPER_TEXT =
+ "Note: The root element’s ID is set automatically from the Entity ID above. No need to add an 'id' attribute manually."
+
+const ExpandedPanelActions = ({
+ onAddEntity,
+ onCollapsePanel,
+}: ExpandedPanelProps) => (
+ <>
+
+
+ clearAllCustomEntities()}
+ />
+
+
+ >
+)
+
+const CollapsedPanelActions = ({ onExpandPanel }: CollapsedPanelProps) => (
+
+)
+
+export const CustomEntityPanel = ({
+ isCollapsed,
+ setCollapsed,
+}: CustomEntityPanelProps) => {
+ const [entityModalOpen, setEntityModalOpen] = useState(false)
+ const [selectedEntity, setSelectedEntity] = useState(null)
+ const entityIds = useGetCustomEntityIds()
+
+ const entityIdsToListItems = (ids: string[]) =>
+ ids.sort().map((id) => (
+ removeCustomEntity(id)}
+ />
+ }
+ onClick={() => {
+ setSelectedEntity(id)
+ setEntityModalOpen(true)
+ }}
+ >
+ {id}
+
+ ))
+
+ return (
+ <>
+ setCollapsed(false)} />
+ ) : (
+ {
+ setSelectedEntity(null)
+ setEntityModalOpen(true)
+ }}
+ onCollapsePanel={() => setCollapsed(true)}
+ />
+ )
+ }
+ >
+ {!isCollapsed && entityIdsToListItems(entityIds)}
+
+
+ {entityModalOpen && (
+
+ )}
+ >
+ )
+}
+
+const getLoadingDescription = (status: InlineLoadingStatus) => {
+ switch (status) {
+ case "active":
+ return "Saving"
+ case "finished":
+ return "Saved"
+ case "error":
+ return "Failed to save"
+ case "inactive":
+ return ""
+ }
+}
+
+const getEntityContentFromId = (entityId: string | null) => {
+ if (!entityId) {
+ return ""
+ }
+ return getCustomEntityContent(entityId) ?? ""
+}
+
+const CreateEntityModal = ({
+ entityId,
+ open,
+ setOpen,
+}: CreateEntityModalProps) => {
+ const [localId, setLocalId] = useState(entityId ?? "")
+ const [content, setContent] = useState(() => getEntityContentFromId(entityId))
+ const [loadingStatus, setLoadingStatus] =
+ useState("inactive")
+ const [idErrorMessage, setIdErrorMessage] = useState("")
+ const [contentErrorMessage, setContentErrorMessage] = useState("")
+
+ const resetAndCloseModal = () => {
+ setOpen(false)
+ setLoadingStatus("inactive")
+ }
+
+ const saveUpdates = () => {
+ setLoadingStatus("active")
+
+ const result = updateCustomEntity(entityId, localId, content)
+
+ if (!result.success) {
+ setLoadingStatus("error")
+ setIdErrorMessage(result.idError ?? "")
+ setContentErrorMessage(result.contentError ?? "")
+ } else {
+ resetAndCloseModal()
+ }
+ }
+
+ const resetLoadingStatus = () =>
+ loadingStatus !== "inactive" && setLoadingStatus("inactive")
+
+ const updateId = (id: string) => {
+ resetLoadingStatus()
+ setLocalId(id)
+ }
+
+ const updateContent = (content: string) => {
+ resetLoadingStatus()
+ setContent(content)
+ }
+
+ const isErrorState = loadingStatus === "error"
+
+ return createPortal(
+
+
+
+ Custom entities do not appear on the flow diagram, but can be
+ referenced by flow component attributes.
+
+
+ updateId(e.target.value)}
+ invalid={isErrorState && idErrorMessage !== ""}
+ invalidText={idErrorMessage}
+ onKeyDown={(e) => e.key === "Enter" && saveUpdates()}
+ />
+
+
+ Content
+
+
+
+ ,
+ document.body
+ )
+}
diff --git a/ui/src/components/draggable-panel/NodeChooserPanel.tsx b/ui/src/components/palette/EipComponentPanel.tsx
similarity index 92%
rename from ui/src/components/draggable-panel/NodeChooserPanel.tsx
rename to ui/src/components/palette/EipComponentPanel.tsx
index fecf9bc6..a829a068 100644
--- a/ui/src/components/draggable-panel/NodeChooserPanel.tsx
+++ b/ui/src/components/palette/EipComponentPanel.tsx
@@ -2,7 +2,6 @@ import {
Accordion,
AccordionItem,
Search,
- SideNav,
SideNavMenuItem,
} from "@carbon/react"
@@ -95,8 +94,7 @@ const namespacesToDisplay = (
)
}
-// TODO: Make node chooser panel collapsable
-const NodeChooserPanel = () => {
+export const EipComponentPanel = () => {
const [searchTerm, setSearchTerm] = useState("")
const [expandedNamespace, setExpandedNamespace] = useState("")
const nodeCount = useNodeCount()
@@ -123,13 +121,7 @@ const NodeChooserPanel = () => {
))
return (
-
+ <>
{
{collections}
-
+ >
)
}
-
-export default NodeChooserPanel
diff --git a/ui/src/components/palette/Palette.tsx b/ui/src/components/palette/Palette.tsx
new file mode 100644
index 00000000..09db9c14
--- /dev/null
+++ b/ui/src/components/palette/Palette.tsx
@@ -0,0 +1,54 @@
+import { useState } from "react"
+import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"
+import { CustomEntityPanel } from "./CustomEntityPanel"
+import { EipComponentPanel } from "./EipComponentPanel"
+
+// TODO: Make node chooser panel collapsable
+const Palette = () => {
+ const [isEntityPanelCollapsed, setEntityPanelCollapsed] = useState(true)
+
+ const entityPanel = (
+
+ )
+
+ const wrappedEntityPanel = (
+
+ {entityPanel}
+
+ )
+
+ return (
+
+
+
+
+
+
+ {isEntityPanelCollapsed ? entityPanel : wrappedEntityPanel}
+
+ )
+}
+
+export default Palette
diff --git a/ui/src/components/draggable-panel/dragTypes.ts b/ui/src/components/palette/dragTypes.ts
similarity index 100%
rename from ui/src/components/draggable-panel/dragTypes.ts
rename to ui/src/components/palette/dragTypes.ts
diff --git a/ui/src/singletons/store/__snapshots__/diagramToEipFlow.test.ts.snap b/ui/src/singletons/store/__snapshots__/diagramToEipFlow.test.ts.snap
index 045949ba..92840687 100644
--- a/ui/src/singletons/store/__snapshots__/diagramToEipFlow.test.ts.snap
+++ b/ui/src/singletons/store/__snapshots__/diagramToEipFlow.test.ts.snap
@@ -2,6 +2,7 @@
exports[`diagram includes a router with child based matcher to EipFlow success 1`] = `
{
+ "customEntities": {},
"edges": [
{
"id": "ch-rn79OHQyAI-cGUxaVOQ7L",
@@ -96,8 +97,72 @@ exports[`diagram includes a router with child based matcher to EipFlow success 1
}
`;
+exports[`diagram with custom entities 1`] = `
+{
+ "customEntities": {
+ "customTransform": "",
+ },
+ "edges": [
+ {
+ "id": "ch-in1-xmlToJson",
+ "source": "in1",
+ "target": "xmlToJson",
+ "type": "default",
+ },
+ {
+ "id": "ch-xmlToJson-out1",
+ "source": "xmlToJson",
+ "target": "out1",
+ "type": "default",
+ },
+ ],
+ "nodes": [
+ {
+ "attributes": {},
+ "children": [],
+ "connectionType": "source",
+ "description": undefined,
+ "eipId": {
+ "name": "inbound-channel-adapter",
+ "namespace": "integration",
+ },
+ "id": "in1",
+ "role": "endpoint",
+ },
+ {
+ "attributes": {
+ "method": "toJson",
+ "ref": "customTransform",
+ },
+ "children": [],
+ "connectionType": "passthru",
+ "description": undefined,
+ "eipId": {
+ "name": "transformer",
+ "namespace": "integration",
+ },
+ "id": "xmlToJson",
+ "role": "transformer",
+ },
+ {
+ "attributes": {},
+ "children": [],
+ "connectionType": "sink",
+ "description": undefined,
+ "eipId": {
+ "name": "outbound-channel-adapter",
+ "namespace": "integration",
+ },
+ "id": "out1",
+ "role": "endpoint",
+ },
+ ],
+}
+`;
+
exports[`diagram with deep child nesting 1`] = `
{
+ "customEntities": {},
"edges": [],
"nodes": [
{
@@ -153,6 +218,7 @@ exports[`diagram with deep child nesting 1`] = `
exports[`diagram with inbound-request-reply node 1`] = `
{
+ "customEntities": {},
"edges": [
{
"id": "ch-testIn-QvmTf6Jm4O",
@@ -216,6 +282,7 @@ exports[`diagram with inbound-request-reply node 1`] = `
exports[`standard diagram to EipFlow success 1`] = `
{
+ "customEntities": {},
"edges": [
{
"id": "ch-inbound-test-router",
diff --git a/ui/src/singletons/store/__snapshots__/getterHooks.test.ts.snap b/ui/src/singletons/store/__snapshots__/getterHooks.test.ts.snap
index 002c1c4f..08e8f6d6 100644
--- a/ui/src/singletons/store/__snapshots__/getterHooks.test.ts.snap
+++ b/ui/src/singletons/store/__snapshots__/getterHooks.test.ts.snap
@@ -1,3 +1,3 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-exports[`serialized store includes nodes, edges, and eipNodeConfigs only 1`] = `"{"nodes":[{"id":"9KWCqlIyy7","type":"eipNode","position":{"x":0,"y":91.25},"targetPosition":"left","sourcePosition":"right","data":{"label":"inbound"},"width":114,"height":129,"selected":false,"dragging":false},{"id":"LoiC2CFbLP","type":"eipNode","position":{"x":201.02071435884494,"y":121.16003882408089},"targetPosition":"left","sourcePosition":"right","data":{"label":"test-router"},"width":114,"height":71,"selected":false,"dragging":false},{"id":"H4-ED4F0XT","type":"eipNode","position":{"x":402.5684310475592,"y":-19.39612977438884},"targetPosition":"left","sourcePosition":"right","data":{"label":"httpOut"},"width":128,"height":132,"selected":false,"dragging":false},{"id":"SV43RVeijQ","type":"eipNode","position":{"x":378.5346234085371,"y":244.49918423048507},"targetPosition":"left","sourcePosition":"right","data":{"label":"test-filter"},"width":114,"height":77,"selected":false,"dragging":false},{"id":"MrMneIdthg","type":"eipNode","position":{"x":557.741165016793,"y":220.00829459816538},"targetPosition":"left","sourcePosition":"right","data":{"label":"fileOut"},"width":128,"height":140,"selected":false,"dragging":false},{"id":"MZ1rIWIK3s","type":"eipNode","position":{"x":526.8005098062063,"y":400.71416837852064},"targetPosition":"left","sourcePosition":"right","data":{},"width":111,"height":121,"selected":false,"dragging":false}],"edges":[{"source":"9KWCqlIyy7","sourceHandle":"output","target":"LoiC2CFbLP","targetHandle":"input","id":"reactflow__edge-9KWCqlIyy7output-LoiC2CFbLPinput"},{"source":"LoiC2CFbLP","sourceHandle":"output","target":"H4-ED4F0XT","targetHandle":"input","type":"dynamicEdge","data":{"mapping":{"mapperName":"mapping","matcher":{"name":"value","type":"string","description":"A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')","required":false},"isDefaultMapping":true}},"animated":true,"id":"reactflow__edge-LoiC2CFbLPoutput-H4-ED4F0XTinput","selected":false},{"source":"LoiC2CFbLP","sourceHandle":"output","target":"SV43RVeijQ","targetHandle":"input","type":"dynamicEdge","data":{"mapping":{"mapperName":"mapping","matcher":{"name":"value","type":"string","description":"A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')","required":false},"matcherValue":"file"}},"animated":true,"id":"reactflow__edge-LoiC2CFbLPoutput-SV43RVeijQinput","selected":false},{"source":"SV43RVeijQ","sourceHandle":"output","target":"MrMneIdthg","targetHandle":"input","id":"reactflow__edge-SV43RVeijQoutput-MrMneIdthginput"},{"source":"SV43RVeijQ","sourceHandle":"discard","target":"MZ1rIWIK3s","targetHandle":"input","id":"reactflow__edge-SV43RVeijQdiscard-MZ1rIWIK3sinput"}],"eipConfigs":{"9KWCqlIyy7":{"attributes":{},"children":["mcyTryMPewJ"],"eipId":{"namespace":"integration","name":"inbound-channel-adapter"},"description":"message incoming"},"LoiC2CFbLP":{"attributes":{"phase":"init"},"children":[],"eipId":{"namespace":"integration","name":"router"},"routerKey":{"name":"expression","attributes":{"expression":"headers.protocol"}}},"H4-ED4F0XT":{"attributes":{},"children":[],"eipId":{"namespace":"http","name":"outbound-channel-adapter"},"description":"send an http message"},"SV43RVeijQ":{"attributes":{"expression":"headers.filename != null"},"children":[],"eipId":{"namespace":"integration","name":"filter"}},"MrMneIdthg":{"attributes":{"directory":"testdir"},"children":["V1ls9ri4szs"],"eipId":{"namespace":"file","name":"outbound-channel-adapter"},"description":"write to file"},"MZ1rIWIK3s":{"attributes":{"level":"WARN"},"children":[],"eipId":{"namespace":"integration","name":"logging-channel-adapter"}},"mcyTryMPewJ":{"eipId":{"name":"poller","namespace":"integration"},"attributes":{"fixed-rate":"2000"},"children":[]},"V1ls9ri4szs":{"eipId":{"name":"transactional","namespace":"file"},"attributes":{},"children":[]}},"version":"1.0"}"`;
+exports[`serialized store includes a subset of AppStore 1`] = `"{"nodes":[{"id":"9KWCqlIyy7","type":"eipNode","position":{"x":0,"y":91.25},"targetPosition":"left","sourcePosition":"right","data":{"label":"inbound"},"width":114,"height":129,"selected":false,"dragging":false},{"id":"LoiC2CFbLP","type":"eipNode","position":{"x":201.02071435884494,"y":121.16003882408089},"targetPosition":"left","sourcePosition":"right","data":{"label":"test-router"},"width":114,"height":71,"selected":false,"dragging":false},{"id":"H4-ED4F0XT","type":"eipNode","position":{"x":402.5684310475592,"y":-19.39612977438884},"targetPosition":"left","sourcePosition":"right","data":{"label":"httpOut"},"width":128,"height":132,"selected":false,"dragging":false},{"id":"SV43RVeijQ","type":"eipNode","position":{"x":378.5346234085371,"y":244.49918423048507},"targetPosition":"left","sourcePosition":"right","data":{"label":"test-filter"},"width":114,"height":77,"selected":false,"dragging":false},{"id":"MrMneIdthg","type":"eipNode","position":{"x":557.741165016793,"y":220.00829459816538},"targetPosition":"left","sourcePosition":"right","data":{"label":"fileOut"},"width":128,"height":140,"selected":false,"dragging":false},{"id":"MZ1rIWIK3s","type":"eipNode","position":{"x":526.8005098062063,"y":400.71416837852064},"targetPosition":"left","sourcePosition":"right","data":{},"width":111,"height":121,"selected":false,"dragging":false}],"edges":[{"source":"9KWCqlIyy7","sourceHandle":"output","target":"LoiC2CFbLP","targetHandle":"input","id":"reactflow__edge-9KWCqlIyy7output-LoiC2CFbLPinput"},{"source":"LoiC2CFbLP","sourceHandle":"output","target":"H4-ED4F0XT","targetHandle":"input","type":"dynamicEdge","data":{"mapping":{"mapperName":"mapping","matcher":{"name":"value","type":"string","description":"A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')","required":false},"isDefaultMapping":true}},"animated":true,"id":"reactflow__edge-LoiC2CFbLPoutput-H4-ED4F0XTinput","selected":false},{"source":"LoiC2CFbLP","sourceHandle":"output","target":"SV43RVeijQ","targetHandle":"input","type":"dynamicEdge","data":{"mapping":{"mapperName":"mapping","matcher":{"name":"value","type":"string","description":"A value of the evaluation token that will be mapped to a channel reference (e.g., mapping value='foo' channel='myChannel')","required":false},"matcherValue":"file"}},"animated":true,"id":"reactflow__edge-LoiC2CFbLPoutput-SV43RVeijQinput","selected":false},{"source":"SV43RVeijQ","sourceHandle":"output","target":"MrMneIdthg","targetHandle":"input","id":"reactflow__edge-SV43RVeijQoutput-MrMneIdthginput"},{"source":"SV43RVeijQ","sourceHandle":"discard","target":"MZ1rIWIK3s","targetHandle":"input","id":"reactflow__edge-SV43RVeijQdiscard-MZ1rIWIK3sinput"}],"eipConfigs":{"9KWCqlIyy7":{"attributes":{},"children":["mcyTryMPewJ"],"eipId":{"namespace":"integration","name":"inbound-channel-adapter"},"description":"message incoming"},"LoiC2CFbLP":{"attributes":{"phase":"init"},"children":[],"eipId":{"namespace":"integration","name":"router"},"routerKey":{"name":"expression","attributes":{"expression":"headers.protocol"}}},"H4-ED4F0XT":{"attributes":{},"children":[],"eipId":{"namespace":"http","name":"outbound-channel-adapter"},"description":"send an http message"},"SV43RVeijQ":{"attributes":{"expression":"headers.filename != null"},"children":[],"eipId":{"namespace":"integration","name":"filter"}},"MrMneIdthg":{"attributes":{"directory":"testdir"},"children":["V1ls9ri4szs"],"eipId":{"namespace":"file","name":"outbound-channel-adapter"},"description":"write to file"},"MZ1rIWIK3s":{"attributes":{"level":"WARN"},"children":[],"eipId":{"namespace":"integration","name":"logging-channel-adapter"}},"mcyTryMPewJ":{"eipId":{"name":"poller","namespace":"integration"},"attributes":{"fixed-rate":"2000"},"children":[]},"V1ls9ri4szs":{"eipId":{"name":"transactional","namespace":"file"},"attributes":{},"children":[]}},"customEntities":{},"version":"1.1"}"`;
diff --git a/ui/src/singletons/store/api.ts b/ui/src/singletons/store/api.ts
index 14f0ce15..4a7dd714 100644
--- a/ui/src/singletons/store/api.ts
+++ b/ui/src/singletons/store/api.ts
@@ -10,11 +10,12 @@ export interface EipConfig {
}
export interface AppStore {
- nodes: CustomNode[]
+ customEntities: Record
edges: CustomEdge[]
eipConfigs: Record
- selectedChildNode: string[] | null
layout: Layout
+ nodes: CustomNode[]
+ selectedChildNode: string[] | null
}
export interface SerializedFlow {
@@ -22,4 +23,6 @@ export interface SerializedFlow {
edges: AppStore["edges"]
eipConfigs: AppStore["eipConfigs"]
version: string
+
+ customEntities?: AppStore["customEntities"]
}
diff --git a/ui/src/singletons/store/appActions.test.ts b/ui/src/singletons/store/appActions.test.ts
index a75b42f2..be0b0dc2 100644
--- a/ui/src/singletons/store/appActions.test.ts
+++ b/ui/src/singletons/store/appActions.test.ts
@@ -14,12 +14,14 @@ import {
} from "../../api/flow"
import {
useGetContentRouterKey,
+ useGetCustomEntityIds,
useGetEipAttribute,
useGetEnabledChildren,
useGetNodeDescription,
useGetSelectedChildNode,
} from "./getterHooks"
import { renderAndUnwrapHook, resetMockStore } from "./storeTestingUtils"
+import customEntitiesFlow from "./testdata/store-initializers/customEntitiesFlow.json"
import nestedChildFlow from "./testdata/store-initializers/nestedChildFlow.json"
import selectedNodeFlow from "./testdata/store-initializers/singleSelectedNodeFlow.json"
import standardFlow from "./testdata/store-initializers/standardFlow.json"
@@ -27,6 +29,7 @@ import unspecifiedRouterFlow from "./testdata/store-initializers/unspecifiedRout
import { EipId } from "../../api/generated/eipFlow"
import {
+ clearAllCustomEntities,
clearDiagramSelections,
clearFlow,
createDroppedNode,
@@ -34,10 +37,12 @@ import {
disableChild,
enableChild,
importFlowFromJson,
+ removeCustomEntity,
reorderEnabledChildren,
switchNodeSelection,
toggleLayoutDensity,
updateContentRouterKey,
+ updateCustomEntity,
updateDynamicEdgeMapping,
updateEipAttribute,
updateLayoutOrientation,
@@ -46,6 +51,7 @@ import {
updateSelectedChildNode,
} from "./appActions"
import {
+ getCustomEntityContent,
getEdgesView,
getEipId,
getLayoutView,
@@ -56,12 +62,14 @@ import validExportedFlow from "./testdata/exported-diagrams/validFlow.json?raw"
vi.mock("zustand")
+// these ids reference objects in the imported flow diagrams
const STANDARD_INBOUND_ADAPTER = "9KWCqlIyy7"
const STANDARD_ROUTER = "LoiC2CFbLP"
const STANDARD_FILTER = "SV43RVeijQ"
const STANDARD_POLLER_CHILD = "mcyTryMPewJ"
const STANDARD_TRANSACTIONAL_CHILD = "V1ls9ri4szs"
const NESTED_CHILD_PARENT_ID = "FL5Tssm8tV"
+const CUSTOM_ENTITY_ID = "customTransform"
beforeEach(() => {
act(() => {
@@ -724,3 +732,127 @@ describe("Switch Node Selection", () => {
expect(selectedNodes[0].id).toEqual(STANDARD_ROUTER)
})
})
+
+describe("update custom entity", () => {
+ test("create a new entity -> success", () => {
+ const id = "e1"
+ const expectedContent = "test"
+ act(() => void updateCustomEntity(null, id, expectedContent))
+
+ const actualContent = getCustomEntityContent(id)
+ expect(expectedContent).toEqual(actualContent)
+
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([id])
+ })
+
+ test("update content for existing entity -> success", () => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+
+ const updatedContent = "test in-place update"
+
+ act(
+ () =>
+ void updateCustomEntity(
+ CUSTOM_ENTITY_ID,
+ CUSTOM_ENTITY_ID,
+ updatedContent
+ )
+ )
+
+ expect(getCustomEntityContent(CUSTOM_ENTITY_ID)).toEqual(updatedContent)
+
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([CUSTOM_ENTITY_ID])
+ })
+
+ test("update an existing entity -> both id and content are updated, old id is removed", () => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+
+ const oldId = CUSTOM_ENTITY_ID
+ const newId = "updated"
+ const updatedContent = "test"
+
+ act(() => void updateCustomEntity(oldId, newId, updatedContent))
+
+ expect(getCustomEntityContent(newId)).toEqual(updatedContent)
+ expect(getCustomEntityContent(oldId)).toBeUndefined()
+
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([newId])
+ })
+
+ test("create a new entity with empty id -> error", () => {
+ const expectedContent = "test"
+ const result = updateCustomEntity(null, "", expectedContent)
+
+ expect(result.success).toEqual(false)
+ result.success === false && expect(result.idError).toBeTruthy()
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toHaveLength(0)
+ })
+
+ test("create a new entity with duplicate id -> error", () => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+
+ const expectedContent = "test"
+ const result = updateCustomEntity(null, CUSTOM_ENTITY_ID, expectedContent)
+
+ expect(result.success).toEqual(false)
+ result.success === false && expect(result.idError).toBeTruthy()
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([CUSTOM_ENTITY_ID])
+ })
+
+ test.each([[""], ["test"], ["test/body>"]])(
+ "create a new entity with invalid content -> error",
+ (content) => {
+ const result = updateCustomEntity(null, "newId", content)
+
+ expect(result.success).toEqual(false)
+ result.success === false && expect(result.contentError).toBeTruthy()
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toHaveLength(0)
+ }
+ )
+})
+
+describe("remove custom entity", () => {
+ beforeEach(() => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+ })
+
+ test("remove an existing entity -> success", () => {
+ act(() => removeCustomEntity(CUSTOM_ENTITY_ID))
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toHaveLength(0)
+ })
+
+ test("remove an non-existing entity -> no error", () => {
+ act(() => removeCustomEntity("noId"))
+ const entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([CUSTOM_ENTITY_ID])
+ })
+})
+
+test("clear all custom entities", () => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+
+ let entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toEqual([CUSTOM_ENTITY_ID])
+
+ act(clearAllCustomEntities)
+
+ entities = renderAndUnwrapHook(useGetCustomEntityIds)
+ expect(entities).toHaveLength(0)
+})
diff --git a/ui/src/singletons/store/appActions.ts b/ui/src/singletons/store/appActions.ts
index 90975c27..f3ebfbe9 100644
--- a/ui/src/singletons/store/appActions.ts
+++ b/ui/src/singletons/store/appActions.ts
@@ -240,6 +240,7 @@ export const importFlowFromObject = (flow: SerializedFlow) => {
nodes: flow.nodes,
edges: flow.edges,
eipConfigs: flow.eipConfigs,
+ customEntities: flow.customEntities ?? {},
}
})
}
@@ -272,6 +273,69 @@ export const toggleLayoutDensity = () =>
}
})
+type EntityUpdateResult =
+ | { success: true }
+ | { success: false; idError?: string; contentError?: string }
+
+export const updateCustomEntity = (
+ oldId: string | null,
+ newId: string,
+ content: string
+): EntityUpdateResult => {
+ const { customEntities } = useAppStore.getState()
+
+ if (!newId) {
+ return { success: false, idError: "An Entity ID is required" }
+ }
+
+ const isInPlaceUpdate = oldId === newId
+ const isDuplicateId = newId in customEntities
+
+ if (!isInPlaceUpdate && isDuplicateId) {
+ return { success: false, idError: "Entity ID must be unique" }
+ }
+
+ if (!isWellFormedXML(content)) {
+ return {
+ success: false,
+ contentError: "Content should be a valid XML snippet",
+ }
+ }
+
+ useAppStore.setState((state) => {
+ const updatedEntities = {
+ ...state.customEntities,
+ [newId]: content,
+ }
+
+ if (!isInPlaceUpdate && oldId) {
+ delete updatedEntities[oldId]
+ }
+
+ return {
+ customEntities: updatedEntities,
+ }
+ })
+
+ return { success: true }
+}
+
+export const removeCustomEntity = (entityId: string) =>
+ useAppStore.setState((state) => {
+ const entities = { ...state.customEntities }
+ delete entities[entityId]
+ return {
+ customEntities: entities,
+ }
+ })
+
+export const clearAllCustomEntities = () =>
+ useAppStore.setState(() => {
+ return {
+ customEntities: {},
+ }
+ })
+
interface NodeDescriptor {
node: CustomNode
eipId: EipId
@@ -376,3 +440,13 @@ const importDeprecatedFlow = (flow: SerializedFlow): Partial => {
eipConfigs,
}
}
+
+const isWellFormedXML = (content: string) => {
+ if (!content) {
+ return false
+ }
+
+ const xmlDoc = new DOMParser().parseFromString(content, "text/xml")
+ const errorNode = xmlDoc.querySelector("parsererror")
+ return errorNode === null
+}
diff --git a/ui/src/singletons/store/appStore.ts b/ui/src/singletons/store/appStore.ts
index 993ffa45..e7cb82a5 100644
--- a/ui/src/singletons/store/appStore.ts
+++ b/ui/src/singletons/store/appStore.ts
@@ -6,7 +6,7 @@ import debounce from "../../utils/debounce"
import { AppStore } from "./api"
-export const EXPORTED_FLOW_VERSION = "1.0"
+export const EXPORTED_FLOW_VERSION = "1.1"
// If app becomes too slow, might need to switch to async persistent storage.
export const useAppStore = create()(
@@ -21,6 +21,7 @@ export const useAppStore = create()(
orientation: "horizontal",
density: "comfortable",
},
+ customEntities: {},
}),
{
limit: 50,
diff --git a/ui/src/singletons/store/diagramToEipFlow.test.ts b/ui/src/singletons/store/diagramToEipFlow.test.ts
index b2a10eca..99b85eb0 100644
--- a/ui/src/singletons/store/diagramToEipFlow.test.ts
+++ b/ui/src/singletons/store/diagramToEipFlow.test.ts
@@ -3,6 +3,7 @@ import { expect, test, vi } from "vitest"
import { useEipFlow } from "./diagramToEipFlow"
import { resetMockStore } from "./storeTestingUtils"
import childBasedRouterFlow from "./testdata/store-initializers/childBasedRouterFlow.json"
+import customEntitiesFlow from "./testdata/store-initializers/customEntitiesFlow.json"
import inboundGatewayFlow from "./testdata/store-initializers/inboundGatewayFlow.json"
import nestedChildFlow from "./testdata/store-initializers/nestedChildFlow.json"
import standardFlow from "./testdata/store-initializers/standardFlow.json"
@@ -44,3 +45,12 @@ test("diagram with inbound-request-reply node", () => {
const { result } = renderHook(() => useEipFlow())
expect(result.current).toMatchSnapshot()
})
+
+test("diagram with custom entities", () => {
+ act(() => {
+ resetMockStore(customEntitiesFlow)
+ })
+
+ const { result } = renderHook(() => useEipFlow())
+ expect(result.current).toMatchSnapshot()
+})
diff --git a/ui/src/singletons/store/diagramToEipFlow.ts b/ui/src/singletons/store/diagramToEipFlow.ts
index f1cdf6b9..37273e60 100644
--- a/ui/src/singletons/store/diagramToEipFlow.ts
+++ b/ui/src/singletons/store/diagramToEipFlow.ts
@@ -100,7 +100,7 @@ const diagramToEipFlow = (state: AppStore): EipFlow => {
}
})
- return { nodes, edges }
+ return { nodes, edges, customEntities: state.customEntities }
}
const getNodeId = (nodeId: string, nodeLookup: Map) => {
diff --git a/ui/src/singletons/store/getterHooks.test.ts b/ui/src/singletons/store/getterHooks.test.ts
index f0d86db1..ebee1b3b 100644
--- a/ui/src/singletons/store/getterHooks.test.ts
+++ b/ui/src/singletons/store/getterHooks.test.ts
@@ -26,7 +26,7 @@ beforeEach(() => {
})
})
-test("serialized store includes nodes, edges, and eipNodeConfigs only", () => {
+test("serialized store includes a subset of AppStore", () => {
const storeJson = renderAndUnwrapHook(useSerializedFlow)
expect(storeJson).toMatchSnapshot()
})
diff --git a/ui/src/singletons/store/getterHooks.ts b/ui/src/singletons/store/getterHooks.ts
index 364c88d9..cc2c7924 100644
--- a/ui/src/singletons/store/getterHooks.ts
+++ b/ui/src/singletons/store/getterHooks.ts
@@ -29,6 +29,7 @@ export const useSerializedFlow = () =>
nodes: state.nodes,
edges: state.edges,
eipConfigs: state.eipConfigs,
+ customEntities: state.customEntities,
version: EXPORTED_FLOW_VERSION,
}
return JSON.stringify(flow)
@@ -62,3 +63,6 @@ export const useGetRouterDefaultEdgeMapping = (routerId: string) =>
edge.data?.mapping.isDefaultMapping
)
)
+
+export const useGetCustomEntityIds = () =>
+ useAppStore(useShallow((state) => Object.keys(state.customEntities)))
diff --git a/ui/src/singletons/store/storeViews.ts b/ui/src/singletons/store/storeViews.ts
index c47ceb4b..3d1f1a22 100644
--- a/ui/src/singletons/store/storeViews.ts
+++ b/ui/src/singletons/store/storeViews.ts
@@ -29,6 +29,11 @@ export const getEipId = (nodeId: string): Readonly | undefined =>
export const getSelectedChildNode = (): readonly string[] | null =>
useAppStore.getState().selectedChildNode
+export const getCustomEntityContent = (
+ entityId: string
+): Readonly | undefined =>
+ useAppStore.getState().customEntities[entityId]
+
export const childrenBreadthTraversal = function* (
rootId: string
): Generator {
diff --git a/ui/src/singletons/store/testdata/exported-diagrams/validFlow.json b/ui/src/singletons/store/testdata/exported-diagrams/validFlow.json
index 3d5a5587..fbe8d786 100644
--- a/ui/src/singletons/store/testdata/exported-diagrams/validFlow.json
+++ b/ui/src/singletons/store/testdata/exported-diagrams/validFlow.json
@@ -76,5 +76,5 @@
"children": []
}
},
- "version": "1.0"
+ "version": "1.1"
}
diff --git a/ui/src/singletons/store/testdata/store-initializers/customEntitiesFlow.json b/ui/src/singletons/store/testdata/store-initializers/customEntitiesFlow.json
new file mode 100644
index 00000000..d9da71fb
--- /dev/null
+++ b/ui/src/singletons/store/testdata/store-initializers/customEntitiesFlow.json
@@ -0,0 +1,77 @@
+{
+ "nodes": [
+ {
+ "id": "ZTh-Hq0oZG",
+ "type": "eipNode",
+ "position": { "x": 0, "y": 0 },
+ "data": { "label": "in1" },
+ "measured": { "width": 114, "height": 124 },
+ "selected": false,
+ "dragging": false,
+ "targetPosition": "left",
+ "sourcePosition": "right"
+ },
+ {
+ "id": "1FrY34Iw0d",
+ "type": "eipNode",
+ "position": { "x": 189, "y": 17 },
+ "data": { "label": "xmlToJson" },
+ "measured": { "width": 99, "height": 90 },
+ "selected": false,
+ "dragging": false,
+ "targetPosition": "left",
+ "sourcePosition": "right"
+ },
+ {
+ "id": "w6VkhYtaKx",
+ "type": "eipNode",
+ "position": { "x": 363, "y": 0 },
+ "data": { "label": "out1" },
+ "measured": { "width": 119, "height": 124 },
+ "selected": false,
+ "targetPosition": "left",
+ "sourcePosition": "right"
+ }
+ ],
+ "edges": [
+ {
+ "source": "ZTh-Hq0oZG",
+ "sourceHandle": "output",
+ "target": "1FrY34Iw0d",
+ "targetHandle": "input",
+ "id": "xy-edge__ZTh-Hq0oZGoutput-1FrY34Iw0dinput"
+ },
+ {
+ "source": "1FrY34Iw0d",
+ "sourceHandle": "output",
+ "target": "w6VkhYtaKx",
+ "targetHandle": "input",
+ "id": "xy-edge__1FrY34Iw0doutput-w6VkhYtaKxinput",
+ "selected": false
+ }
+ ],
+ "eipConfigs": {
+ "ZTh-Hq0oZG": {
+ "attributes": {},
+ "children": [],
+ "eipId": { "namespace": "integration", "name": "inbound-channel-adapter" }
+ },
+ "1FrY34Iw0d": {
+ "attributes": { "ref": "customTransform", "method": "toJson" },
+ "children": [],
+ "eipId": { "namespace": "integration", "name": "transformer" }
+ },
+ "w6VkhYtaKx": {
+ "attributes": {},
+ "children": [],
+ "eipId": {
+ "namespace": "integration",
+ "name": "outbound-channel-adapter"
+ }
+ }
+ },
+ "customEntities": {
+ "customTransform": ""
+ },
+ "version": "1.1"
+}
diff --git a/ui/src/singletons/store/testdata/store-initializers/standardFlow.json b/ui/src/singletons/store/testdata/store-initializers/standardFlow.json
index a159b8b1..acf3a7ee 100644
--- a/ui/src/singletons/store/testdata/store-initializers/standardFlow.json
+++ b/ui/src/singletons/store/testdata/store-initializers/standardFlow.json
@@ -257,5 +257,6 @@
"layout": {
"orientation": "horizontal",
"density": "comfortable"
- }
+ },
+ "customEntities": {}
}
diff --git a/ui/src/styles.scss b/ui/src/styles.scss
index 400a4489..4795960a 100644
--- a/ui/src/styles.scss
+++ b/ui/src/styles.scss
@@ -50,7 +50,7 @@
$options-modal-editor-height: 30vh;
-.options-modal__editor {
+.modal__code-editor {
background-color: themes.$layer-02;
overflow-y: auto;
height: $options-modal-editor-height;
@@ -58,18 +58,28 @@ $options-modal-editor-height: 30vh;
@include type.type-style("code-02");
}
-.options-modal__editor > div {
+.modal__code-editor > div {
min-height: $options-modal-editor-height;
}
-.options-modal__editor:focus-within {
+.modal__code-editor:focus-within {
outline: 2px solid themes.$border-interactive;
}
-.options-modal__editor-textarea:focus {
+.modal__code-editor-textarea:focus {
outline: none;
}
+p.modal__error-message {
+ color: themes.$text-error;
+
+ @include type.type-style("label-02");
+}
+
+p.modal__helper-text {
+ @include type.type-style("helper-text-01");
+}
+
.canvas {
width: 100%;
height: 100%;
@@ -165,11 +175,16 @@ $options-modal-editor-height: 30vh;
// End XML Editor Panel //
}
-.node-chooser-panel {
+.resizable-panel-divider {
+ border: 1px solid themes.$border-strong-01;
+}
+
+.node-palette {
position: static;
inline-size: 100%;
max-inline-size: 18rem;
max-height: 100%;
+ user-select: none;
a.cds--side-nav__link {
padding-left: 1rem;
@@ -206,10 +221,6 @@ $options-modal-editor-height: 30vh;
padding: 1rem;
}
- .eip-namespace-list {
- overflow-y: auto;
- }
-
.eip-item-list {
padding: 0;
}