Skip to content

Commit 3d72aab

Browse files
committed
feat(ui): add support for follower node types
1 parent b1b552f commit 3d72aab

21 files changed

+810
-113
lines changed

ui/genApiFromSchema.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import path from "node:path"
99

1010
// TODO: Use git tags instead of commit hashes
1111
// To use an updated source schema, change this to point to the desired version.
12-
const COMMIT_HASH = "4fe59e1438fad31b79c87568686cf867fd14a41e"
12+
13+
const COMMIT_HASH = "9ccbe6e4f960451e36476572df05aa2bad375d4e"
1314

1415
const SCHEMAS = ["eipComponentDef.schema.json", "eipFlow.schema.json"]
1516

@@ -32,9 +33,9 @@ const generateSources = async (schemaFileName) => {
3233

3334
const outputFileName = `${schemaFileName.split(".")[0]}.ts`
3435
const outputPath = path.join(GENERATED_SOURCE_DIR, outputFileName)
35-
36+
3637
console.log(`generating ${outputPath}`)
37-
38+
3839
await writeFile(outputPath, generatedCode)
3940
}
4041

ui/src/api/flow.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,37 @@ export interface Layout {
77
density: "compact" | "comfortable"
88
}
99

10-
export const EIP_NODE_TYPE = "eipNode"
10+
export enum CustomNodeType {
11+
EipNode = "eipNode",
12+
FollowerNode = "followerNode",
13+
}
1114

1215
// react flow requires using a type rather than an interface for NodeData
1316
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
1417
export type EipNodeData = {
1518
label?: string
19+
20+
// The id of a follower that is linked to this one (i.e. has the same lifecycle)
21+
followerId?: string
1622
}
1723

18-
export type EipFlowNode = Node<EipNodeData, typeof EIP_NODE_TYPE>
24+
export type EipFlowNode = Node<EipNodeData, CustomNodeType.EipNode>
25+
26+
export const isEipNode = (node?: Node): node is EipFlowNode =>
27+
node?.type === CustomNodeType.EipNode
28+
29+
// react flow requires using a type rather than an interface for NodeData
30+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
31+
export type FollowerNodeData = {
32+
leaderId: string
33+
}
34+
35+
export type FollowerNode = Node<FollowerNodeData, CustomNodeType.FollowerNode>
36+
37+
export const isFollowerNode = (node?: Node): node is FollowerNode =>
38+
node?.type === CustomNodeType.FollowerNode
1939

20-
export type CustomNode = EipFlowNode
40+
export type CustomNode = EipFlowNode | FollowerNode
2141

2242
export interface ChannelMapping {
2343
mapperName: string

ui/src/api/generated/eipComponentDef.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,14 @@ export type EipRole = "channel" | "endpoint" | "router" | "transformer";
2929
/**
3030
* Defines a connection pattern for an EIP component
3131
*/
32-
export type ConnectionType = "content_based_router" | "passthru" | "request_reply" | "sink" | "source" | "tee";
32+
export type ConnectionType =
33+
| "content_based_router"
34+
| "inbound_request_reply"
35+
| "passthru"
36+
| "request_reply"
37+
| "sink"
38+
| "source"
39+
| "tee";
3340

3441
/**
3542
* Defines the collection of EIP components available for use

ui/src/api/generated/eipFlow.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,14 @@ export type EipRole = "channel" | "endpoint" | "router" | "transformer";
1212
/**
1313
* Defines a connection pattern for an EIP component
1414
*/
15-
export type ConnectionType = "content_based_router" | "passthru" | "request_reply" | "sink" | "source" | "tee";
15+
export type ConnectionType =
16+
| "content_based_router"
17+
| "inbound_request_reply"
18+
| "passthru"
19+
| "request_reply"
20+
| "sink"
21+
| "source"
22+
| "tee";
1623
/**
1724
* The attribute's value type (attribute keys are always strings)
1825
*/

ui/src/components/canvas/EipNode.tsx

Lines changed: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
import { Tile } from "@carbon/react"
22

3-
import { Handle, NodeProps, Position } from "@xyflow/react"
4-
import { EipFlowNode, Layout } from "../../api/flow"
3+
import { Handle, NodeProps, Position, useNodesData } from "@xyflow/react"
4+
import {
5+
EipFlowNode,
6+
FollowerNode as FollowerNodeType,
7+
Layout,
8+
} from "../../api/flow"
59
import { ConnectionType, EipRole } from "../../api/generated/eipComponentDef"
6-
import { lookupEipComponent } from "../../singletons/eipDefinitions"
10+
import { EipId } from "../../api/generated/eipFlow"
11+
import {
12+
eipIdToString,
13+
lookupEipComponent,
14+
} from "../../singletons/eipDefinitions"
715
import getIconUrl from "../../singletons/eipIconCatalog"
16+
import { describeFollower } from "../../singletons/followerNodeDefs"
817
import { clearSelectedChildNode } from "../../singletons/store/appActions"
918
import {
1019
useGetEnabledChildren,
@@ -15,11 +24,17 @@ import { getNamespacedTitle } from "../../utils/titleTransform"
1524
import { ChildrenNavigationPopover } from "./ChildrenNavigationPopover"
1625
import "./nodes.scss"
1726

27+
interface NodeContentProps {
28+
eipId: EipId
29+
label: string
30+
}
31+
1832
const DEFAULT_NODE_LABEL = "New Node"
1933

2034
const renderHorizontalHandles = (connectionType: ConnectionType) => {
2135
switch (connectionType) {
2236
case "source":
37+
case "inbound_request_reply":
2338
return <Handle id="output" type="source" position={Position.Right} />
2439
case "sink":
2540
return <Handle id="input" type="target" position={Position.Left} />
@@ -46,6 +61,7 @@ const renderHorizontalHandles = (connectionType: ConnectionType) => {
4661
const renderVerticalHandles = (connectionType: ConnectionType) => {
4762
switch (connectionType) {
4863
case "source":
64+
case "inbound_request_reply":
4965
return <Handle id="output" type="source" position={Position.Bottom} />
5066
case "sink":
5167
return <Handle id="input" type="target" position={Position.Top} />
@@ -78,13 +94,23 @@ const renderHandles = (
7894
? renderHorizontalHandles(connectionType)
7995
: renderVerticalHandles(connectionType)
8096

81-
const getClassNames = (props: NodeProps<EipFlowNode>, role: EipRole) => {
97+
const getNodeClassNames = (isSelected: boolean, role: EipRole) => {
8298
const roleClsName =
8399
role === "channel" ? "eip-channel-node" : "eip-endpoint-node"
84-
const selectedClsName = props.selected ? "eip-node-selected" : ""
100+
const selectedClsName = isSelected ? "eip-node-selected" : ""
85101
return ["eip-node", roleClsName, selectedClsName].join(" ")
86102
}
87103

104+
const NodeDisplayContent = ({ eipId, label }: NodeContentProps) => (
105+
<>
106+
<div className="eip-node-title">{getNamespacedTitle(eipId)}</div>
107+
<img className="eip-node-image" src={getIconUrl(eipId)} />
108+
<div className="eip-node-label">
109+
<strong>{label}</strong>
110+
</div>
111+
</>
112+
)
113+
88114
export const EipNode = (props: NodeProps<EipFlowNode>) => {
89115
const layout = useGetLayout()
90116
const children = useGetEnabledChildren(props.id)
@@ -105,16 +131,65 @@ export const EipNode = (props: NodeProps<EipFlowNode>) => {
105131

106132
return (
107133
<Tile
108-
className={getClassNames(props, componentDefinition.role)}
134+
className={getNodeClassNames(props.selected, componentDefinition.role)}
109135
onClick={hasChildren ? () => clearSelectedChildNode() : undefined}
110136
>
111-
<div>{getNamespacedTitle(eipId)}</div>
112-
<img className="eip-node-image" src={getIconUrl(eipId)} />
113-
<div className="eip-node-label">
114-
<strong>{data.label || DEFAULT_NODE_LABEL}</strong>
115-
</div>
137+
<NodeDisplayContent
138+
eipId={eipId}
139+
label={data.label || DEFAULT_NODE_LABEL}
140+
/>
116141
{hasChildren && <ChildrenNavigationPopover />}
117142
{handles}
118143
</Tile>
119144
)
120145
}
146+
147+
export const FollowerNode = (props: NodeProps<FollowerNodeType>) => {
148+
const layout = useGetLayout()
149+
const leaderId = props.data.leaderId
150+
const leaderData = useNodesData<EipFlowNode>(leaderId)
151+
152+
const eipId = getEipId(props.id)
153+
const componentDefinition = eipId && lookupEipComponent(eipId)
154+
155+
const leaderEipId = getEipId(leaderId)
156+
157+
if (!componentDefinition || !leaderEipId) {
158+
console.error(
159+
`Failed to render follower node with eipId: (${eipId && eipIdToString(eipId)}) for leaderId: (${leaderId})`
160+
)
161+
return null
162+
}
163+
164+
const followerDescriptor = describeFollower(leaderEipId)
165+
166+
if (!followerDescriptor) {
167+
console.error(
168+
`Failed to find follower descriptor for node with eipId: (${eipIdToString(eipId)}) for leaderId: (${leaderId})`
169+
)
170+
return null
171+
}
172+
173+
const handles = renderHandles(
174+
followerDescriptor.overrides?.connectionType ??
175+
componentDefinition.connectionType,
176+
layout.orientation
177+
)
178+
179+
return (
180+
<Tile
181+
className={
182+
getNodeClassNames(props.selected, componentDefinition.role) +
183+
" eip-follower-node"
184+
}
185+
>
186+
<NodeDisplayContent
187+
eipId={eipId}
188+
label={followerDescriptor.generateLabel(
189+
leaderData?.data.label ?? leaderId
190+
)}
191+
/>
192+
{handles}
193+
</Tile>
194+
)
195+
}

ui/src/components/canvas/FlowCanvas.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import "@xyflow/react/dist/style.css"
2020
import { KeyboardEvent, useEffect } from "react"
2121
import { DropTargetMonitor, useDrop } from "react-dnd"
2222
import { NativeTypes } from "react-dnd-html5-backend"
23-
import { DYNAMIC_EDGE_TYPE, EIP_NODE_TYPE } from "../../api/flow"
23+
import { CustomNodeType, DYNAMIC_EDGE_TYPE } from "../../api/flow"
2424
import { EipId } from "../../api/generated/eipFlow"
2525
import {
2626
clearFlow,
@@ -42,7 +42,7 @@ import {
4242
} from "../../singletons/store/reactFlowActions"
4343
import { DragTypes } from "../draggable-panel/dragTypes"
4444
import DynamicEdge from "./DynamicEdge"
45-
import { EipNode } from "./EipNode"
45+
import { EipNode, FollowerNode } from "./EipNode"
4646

4747
const FLOW_ERROR_MESSAGE =
4848
"Failed to load the canvas - the stored flow is malformed. Clearing the flow from the state store."
@@ -88,7 +88,8 @@ const getDropPosition = (
8888
}
8989

9090
const nodeTypes = {
91-
[EIP_NODE_TYPE]: EipNode,
91+
[CustomNodeType.EipNode]: EipNode,
92+
[CustomNodeType.FollowerNode]: FollowerNode,
9293
}
9394

9495
const edgeTypes = {

ui/src/components/canvas/nodes.scss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,23 @@ $icon-button-focus-shadow:
6969
height: 3rem;
7070
}
7171

72+
// Follower node style overrides
73+
.eip-follower-node {
74+
border-color: themes.$border-disabled;
75+
76+
.eip-node-title {
77+
color: themes.$text-on-color-disabled;
78+
}
79+
80+
.eip-node-label {
81+
color: themes.$text-on-color-disabled;
82+
}
83+
84+
.eip-node-image {
85+
stroke: themes.$text-on-color-disabled;
86+
}
87+
}
88+
7289
.react-flow {
7390
.react-flow__handle-left,
7491
.react-flow__handle-right {

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import {
66
CustomNode,
77
DynamicEdge,
88
isDynamicEdge,
9+
isEipNode,
10+
isFollowerNode,
911
} from "../../api/flow"
1012
import { Attribute } from "../../api/generated/eipComponentDef"
1113
import { EipId } from "../../api/generated/eipFlow"
@@ -18,7 +20,11 @@ import { clearSelectedChildNode } from "../../singletons/store/appActions"
1820
import { useGetSelectedChildNode } from "../../singletons/store/getterHooks"
1921
import { getEipId } from "../../singletons/store/storeViews"
2022
import DynamicEdgeConfig from "./DynamicEdgeConfig"
21-
import { ChildNodeConfig, RootNodeConfig } from "./NodeConfigPanel"
23+
import {
24+
ChildNodeConfig,
25+
FollowerNodePanel,
26+
RootNodeConfig,
27+
} from "./NodeConfigPanel"
2228
import { findChildDefinition } from "./childDefinitions"
2329

2430
const isDynamicRouterAttribute = (attribute: Attribute, eipId?: EipId) => {
@@ -76,7 +82,7 @@ const EipConfigSidePanel = () => {
7682
attributes={configurableAttrs}
7783
/>
7884
)
79-
} else if (selectedNode) {
85+
} else if (selectedNode && isEipNode(selectedNode)) {
8086
// TODO: Handle error case if eipComponent is undefined
8187
const selectedNodeEipId = selectedNode && getEipId(selectedNode.id)
8288
const eipComponent =
@@ -92,6 +98,8 @@ const EipConfigSidePanel = () => {
9298
attributes={configurableAttrs}
9399
/>
94100
)
101+
} else if (selectedNode && isFollowerNode(selectedNode)) {
102+
sidePanelContent = <FollowerNodePanel node={selectedNode} />
95103
} else if (selectedEdge) {
96104
sidePanelContent = (
97105
<DynamicEdgeConfig key={selectedEdge.id} edge={selectedEdge} />

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

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Button, Stack, TextArea, TextInput } from "@carbon/react"
2-
import { Settings } from "@carbon/react/icons"
2+
import { ParentNode, Settings } from "@carbon/react/icons"
33
import { ChangeEvent, useMemo, useState } from "react"
4-
import { EipFlowNode } from "../../api/flow"
4+
import { EipFlowNode, FollowerNode } from "../../api/flow"
55
import { Attribute } from "../../api/generated/eipComponentDef"
66
import {
7+
switchNodeSelection,
78
updateNodeDescription,
89
updateNodeLabel,
910
} from "../../singletons/store/appActions"
@@ -44,6 +45,10 @@ interface ChildPanelProps {
4445
attributes: Attribute[]
4546
}
4647

48+
interface FollowerPanelProps {
49+
node: FollowerNode
50+
}
51+
4752
const IdDisplay = ({ id }: IdDisplayProps) => (
4853
<TextInput id="nodeId" labelText="NodeId" disabled defaultValue={id} />
4954
)
@@ -182,3 +187,25 @@ export const ChildNodeConfig = ({ childPath, attributes }: ChildPanelProps) => {
182187
/>
183188
)
184189
}
190+
191+
export const FollowerNodePanel = ({ node }: FollowerPanelProps) => {
192+
const eipId = getEipId(node.id)
193+
194+
return (
195+
<div className="cfg-panel__container__top-padding-add">
196+
<Stack gap={6} className="cfg-panel__container__side-padding-add">
197+
{eipId && <h4>{getNamespacedTitle(eipId)}</h4>}
198+
<IdDisplay id={node.id} />
199+
<h5>This node is managed by its parent</h5>
200+
<Button
201+
className="cfg-panel__button"
202+
kind="primary"
203+
onClick={() => switchNodeSelection(node.data.leaderId)}
204+
renderIcon={ParentNode}
205+
>
206+
Go to Parent
207+
</Button>
208+
</Stack>
209+
</div>
210+
)
211+
}

0 commit comments

Comments
 (0)