diff --git a/client/src/components/ActionsFlow.tsx b/client/src/components/ActionsFlow.tsx index 9de99cb..d8b7964 100644 --- a/client/src/components/ActionsFlow.tsx +++ b/client/src/components/ActionsFlow.tsx @@ -1,56 +1,42 @@ import Dagre from '@dagrejs/dagre' import '@xyflow/react/dist/style.css' -import { useEffect, useMemo } from 'react' +import { useEffect, useState } from 'react' import { useTheme } from '@mui/material/styles' import { Controls, ReactFlow, - ReactFlowProvider, useNodesState, useEdgesState, - type Node, - type Edge, - Position, Background, BackgroundVariant, + ReactFlowProvider, + type Node, + type Edge, + type ReactFlowInstance, } from '@xyflow/react' -import { sentenceCase } from "../utils/helpers"; import { getCustomisation } from '../utils/page-customisation' import { GetListResponse } from '@refinedev/core' import { FC } from 'react' -import { getRootNodes } from '../utils/react-flow-builder' +import { getAllNodesAndEdges } from '../utils/react-flow-builder' import RootDirectory from './flow/RootDirectory' +import ActionNode from './flow/ActionNode' const nodeTypes = { rootDirectory: RootDirectory, + action: ActionNode, } const nameText = getCustomisation()?.plasmactl_web_ui_platform_name ?? 'Platform' -const initialRoots: Node[] = [ - { - id: 'start', - data: { label: sentenceCase(nameText) }, - position: { x: 0, y: 0 }, - sourcePosition: Position.Right, - targetPosition: Position.Right, - width: 300, - height: 60, - className: 'flow-action flow-action--start', - draggable: false, - }, -] - const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({})) - g.setGraph({ rankdir: 'LR' }) + g.setGraph({ rankdir: 'TB' }) nodes.forEach((node) => { g.setNode(node.id, { - ...node, - width: node.measured?.width ?? 0, - height: node.measured?.height ?? 0, + width: node.measured?.width ?? 300, + height: node.measured?.height ?? 80, }) }) @@ -60,83 +46,67 @@ const getLayoutedElements = (nodes: Node[], edges: Edge[]) => { return { nodes: nodes.map((node) => { - const position = g.node(node.id) - const x = - node?.type === 'rootDirectory' - ? 400 - : position.x - (node.measured?.width ?? 0) / 2 - const y = position.y - (node.measured?.height ?? 0) / 2 - return { ...node, position: { x, y } } + const pos = g.node(node.id) + return { + ...node, + position: { + x: node.type === 'rootDirectory' ? 400 : pos.x - 100, + y: pos.y - 40, + }, + } }), edges, } } -const LayoutFlow = ({ rootNodes }: { rootNodes: Node[] | undefined }) => { - const { palette } = useTheme() - const [nodes, setNodes, onNodesChange] = useNodesState([ - ...initialRoots, - ...(rootNodes || []), - ]) - const edgesDependencies = useMemo(() => [rootNodes], [rootNodes]) - const [edges, setEdges, onEdgesChange] = useEdgesState( - useMemo( - () => - rootNodes?.map((node) => ({ - id: `${node.id}-start`, - source: 'start', - target: node.id, - type: 'smoothstep', - style: { strokeWidth: 2 }, - pathOptions: { borderRadius: 20 }, - })) || [], - edgesDependencies - ) - ) +export const LayoutFlow = ({ + startNodes, + startEdges, +}: { + startNodes?: Node[] + startEdges?: Edge[] +}) => { + const theme = useTheme() + const [nodes, setNodes, onNodesChange] = useNodesState([]) + const [edges, setEdges, onEdgesChange] = useEdgesState([]) + const [rfInstance, setRfInstance] = useState(null) useEffect(() => { - if (!rootNodes) return - const layouted = getLayoutedElements(nodes, edges) + if (!startNodes || !startEdges) return + const layouted = getLayoutedElements(startNodes, startEdges) setNodes(layouted.nodes) setEdges(layouted.edges) - }, [rootNodes]) + }, [startNodes, startEdges]) + + useEffect(() => { + if (rfInstance && nodes.length > 0) { + rfInstance.fitView({ padding: 0.3, duration: 0 }) // 🔥 no animation, just centering + } + }, [rfInstance, nodes.length, edges.length]) - const backgroundCrossColor = palette?.mode === 'dark' ? '#272727' : '#C8C9CD' + const backgroundCrossColor = theme.palette.mode === 'dark' ? '#272727' : '#C8C9CD' return ( - + - + ) @@ -145,11 +115,11 @@ const LayoutFlow = ({ rootNodes }: { rootNodes: Node[] | undefined }) => { export const ActionsFlow: FC<{ actions: GetListResponse | undefined }> = ({ actions }) => { - const rootNodes = getRootNodes(actions) + const { nodes, edges } = getAllNodesAndEdges(actions, nameText) return ( - + ) } diff --git a/client/src/components/flow/ActionNode.tsx b/client/src/components/flow/ActionNode.tsx new file mode 100644 index 0000000..4ad5927 --- /dev/null +++ b/client/src/components/flow/ActionNode.tsx @@ -0,0 +1,23 @@ +import { memo } from 'react' +import { Handle, Position } from '@xyflow/react' +import type { Node, NodeProps } from '@xyflow/react' +import ActionButton from './ActionButton' +import { components } from '../../../openapi' + +type ActionNodeType = Node< + { + action: components['schemas']['ActionShort'] + }, + 'action' +> + +function ActionNode({ data }: NodeProps) { + return ( +
+ + +
+ ) +} + +export default memo(ActionNode) diff --git a/client/src/components/flow/RootDirectory.tsx b/client/src/components/flow/RootDirectory.tsx index aa011f6..5024c6c 100644 --- a/client/src/components/flow/RootDirectory.tsx +++ b/client/src/components/flow/RootDirectory.tsx @@ -1,7 +1,7 @@ import { memo } from 'react' import { Handle, Position } from '@xyflow/react' import type { Node, NodeProps } from '@xyflow/react' -import Directory from './Directory' + import { components } from '../../../openapi' type ActionsNode = Node< @@ -17,8 +17,9 @@ type ActionsNode = Node< function RootDirectory({ data }: NodeProps) { return (
- - + +
{data.id}
+
) } diff --git a/client/src/styles.css b/client/src/styles.css index 8fef4dd..3a38d3f 100644 --- a/client/src/styles.css +++ b/client/src/styles.css @@ -16,12 +16,12 @@ div.flow-directory__action { } div.flow-directory__action:hover { - color: white; + /* color: white; */ background-color: rgb(var(--primary-color)); } div.flow-directory__action--active { - color: white; + /* color: white; */ background-color: rgb(var(--primary-color)); } diff --git a/client/src/utils/react-flow-builder.ts b/client/src/utils/react-flow-builder.ts index 5d7f8c4..97cffac 100644 --- a/client/src/utils/react-flow-builder.ts +++ b/client/src/utils/react-flow-builder.ts @@ -1,36 +1,121 @@ import { GetListResponse } from '@refinedev/core' import { sentenceCase, splitActionId } from './helpers' -import { Node } from '@xyflow/react' +import { Edge, Node, Position } from '@xyflow/react' -export const getRootNodes = (actions: GetListResponse | undefined) => { - if (!actions) { - return [] +export const getAllNodesAndEdges = ( + actions: GetListResponse | undefined, + nameText: string +): { nodes: Node[]; edges: Edge[] } => { + if (!actions?.data) return { nodes: [], edges: [] } + + const nodesMap = new Map() + const edgesMap = new Map() + const rootLevelSet = new Set() + + // Start node + const initialNode: Node = { + id: 'start', + data: { label: sentenceCase(nameText) }, + position: { x: 0, y: 0 }, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + width: 300, + height: 60, + className: 'flow-action flow-action--start', + draggable: false, } - const roots = new Set() + nodesMap.set(initialNode.id, initialNode) + + let yOffset = 1 for (const item of actions.data) { - if (!item.id || typeof item.id !== 'string') { - continue + if (!item.id || typeof item.id !== 'string') continue + + const { levels, id: actionId, isRoot, isAction } = splitActionId(item.id) + + let path = '' + levels.forEach((level, i) => { + path = path ? `${path}.${level}` : level + + if (!nodesMap.has(path)) { + nodesMap.set(path, { + id: path, + data: { label: sentenceCase(level), action: item }, + position: { x: i * 300 + 300, y: yOffset * 80 }, + type: 'default', + }) + yOffset++ + } + + if (i > 0) { + const prev = levels.slice(0, i).join('.') + const edgeId = `e${prev}-${path}` + if (!edgesMap.has(edgeId)) { + edgesMap.set(edgeId, { + id: edgeId, + source: prev, + target: path, + type: 'smoothstep', + className: 'flow-edge', + style: { strokeWidth: 2 }, + }) + } + } + }) + + // Track root-level nodes for connection from 'start' + if (!isRoot && levels.length > 0 && levels[0]) { + rootLevelSet.add(levels[0]) } - const { levels } = splitActionId(item.id) - if (levels.length < 1) { - roots.add(item.id) - continue + + if (isAction) { + const actionNodeId = isRoot ? actionId : `${levels.join('.')}:${actionId}` + const parentId = isRoot ? 'start' : levels.join('.') + + if (typeof actionNodeId === 'string' && !nodesMap.has(actionNodeId)) { + nodesMap.set(actionNodeId, { + id: actionNodeId, + data: { action: item }, + position: { x: (levels.length + 1) * 300 + 300, y: yOffset * 80 }, + type: 'action', + className: isRoot ? 'flow-action flow-action--root' : 'flow-action', + draggable: true, + }) + yOffset++ + } + + const edgeId = `e${parentId}-${actionNodeId}` + if (!edgesMap.has(edgeId)) { + edgesMap.set(edgeId, { + id: edgeId, + source: parentId, + target: actionNodeId ?? '', + type: 'smoothstep', + className: 'flow-edge', + style: { strokeWidth: 2 }, + }) + } } - roots.add(levels[0]) } + // Add edges from start → all unique root-level nodes (non-isRoot) + for (const rootId of rootLevelSet) { + const edgeId = `e_start-${rootId}` + if (!edgesMap.has(edgeId) && nodesMap.has(rootId)) { + edgesMap.set(edgeId, { + id: edgeId, + source: 'start', + target: rootId, + type: 'smoothstep', + className: 'flow-edge', + style: { strokeWidth: 2 }, + }) + } + } - const newRoots: Node[] = Array.from(roots).map((root, i) => ({ - id: root as string, - type: 'rootDirectory', - data: { label: sentenceCase(root as string), actions, id: root as string }, - position: { x: 500, y: (i + 1) * 100 }, - className: 'flow-directory', - draggable: false, - zIndex: 1000, - selectable: true, - })) - return [...newRoots] + return { + nodes: Array.from(nodesMap.values()), + edges: Array.from(edgesMap.values()), + } }