From ee4051f5230bbd0f36cdcc43190b691aa91c9bbd Mon Sep 17 00:00:00 2001 From: Vladislav Syrochkin Date: Wed, 6 Aug 2025 12:19:55 +0500 Subject: [PATCH] feat(nodes): add start node and create new node function --- client/public/icons/node/home.svg | 3 + client/src/components/ReactFlowDrawer.tsx | 128 +++++++++++++++--- .../components/nodes/AppropriationNode.tsx | 15 +- client/src/components/nodes/IfElseNode.tsx | 60 ++++++-- client/src/components/nodes/StartNode.tsx | 83 ++++++++++++ .../components/nodes/defaultHandleStyle.ts | 31 ++++- client/src/config/i18n.ts | 6 + 7 files changed, 285 insertions(+), 41 deletions(-) create mode 100644 client/public/icons/node/home.svg create mode 100644 client/src/components/nodes/StartNode.tsx diff --git a/client/public/icons/node/home.svg b/client/public/icons/node/home.svg new file mode 100644 index 0000000..876167f --- /dev/null +++ b/client/public/icons/node/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/src/components/ReactFlowDrawer.tsx b/client/src/components/ReactFlowDrawer.tsx index 98b2b90..30a0269 100644 --- a/client/src/components/ReactFlowDrawer.tsx +++ b/client/src/components/ReactFlowDrawer.tsx @@ -7,36 +7,38 @@ import { Node, Edge, } from "@xyflow/react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import customEdgeStyle from "./edges/defaultEdgeStyle"; import { Dropdown, DropdownProps } from "antd"; import { useTranslation } from "react-i18next"; import { edgeTitleGenerator } from "@/utils/edge"; import IfElseNode from "./nodes/IfElseNode"; import AppropriationNode from "./nodes/AppropriationNode"; - -const nodeTypes = { - ifElse: IfElseNode, - appropriation: AppropriationNode, -}; +import StartNode from "./nodes/StartNode"; const initialNodes: Node[] = [ { id: "1", - type: "ifElse", - position: { x: 100, y: 100 }, - data: { condition: "B=2" }, + type: "startNode", + position: { x: 100, y: 0 }, + data: {}, }, { id: "2", - type: "appropriation", - position: { x: 100, y: 300 }, - data: { value: "Выбрать {{account.email}}" }, + type: "ifElse", + position: { x: 100, y: 200 }, + data: { condition: "B=2" }, }, { id: "3", type: "appropriation", - position: { x: 400, y: 300 }, + position: { x: 100, y: 400 }, + data: { value: "Выбрать {{account.email}}" }, + }, + { + id: "4", + type: "appropriation", + position: { x: 400, y: 400 }, data: { value: "Выбрать {{account.role}}" }, }, ]; @@ -92,6 +94,9 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) { const handleId = handleElement.getAttribute("data-handleid"); if (!handleId) return; + const handlePos = handleElement.getAttribute("data-handlepos"); + if (handlePos === "top") return; + setSelectedHandleId(`${node.id}-${handleId}`); const flowWrapper = document.querySelector(".react-flow") as HTMLElement; @@ -100,7 +105,7 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) { const handleRect = handleElement.getBoundingClientRect(); setMenuPosition({ x: handleRect.right - wrapperRect.left, - y: handleRect.top - wrapperRect.top, + y: handleRect.top - wrapperRect.top + 20, }); } @@ -115,7 +120,7 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) { const label = edgeTitleGenerator(edges.length + 1); const newEdge: Edge = { - id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}`, + id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}:${label}`, source: sourceNodeId, sourceHandle: sourceHandleId, target: targetNodeId, @@ -128,7 +133,61 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) { setSelectedHandleId(null); }; - const menuItems = nodes + const handleCreateNode = (type: string) => { + if (!selectedHandleId) return; + const [sourceNodeId, sourceHandleId] = selectedHandleId.split("-"); + const sourceNode = nodes.find((n) => n.id === sourceNodeId); + const newId = ( + Math.max(...nodes.map((n) => Number(n.id) || 0)) + 1 + ).toString(); + let newNode; + if (type === "ifElse") { + newNode = { + id: newId, + type: "ifElse", + position: { + x: (sourceNode?.position.x || 0) + 200, + y: (sourceNode?.position.y || 0) + 100, + }, + data: { condition: "" }, + }; + } else if (type === "appropriation") { + newNode = { + id: newId, + type: "appropriation", + position: { + x: (sourceNode?.position.x || 0) + 200, + y: (sourceNode?.position.y || 0) + 100, + }, + data: { value: "" }, + }; + } + if (newNode) { + setNodes((nds) => [...nds, newNode]); + const label = edgeTitleGenerator(edges.length + 1); + const newEdge = { + id: `e${sourceNodeId}-${sourceHandleId}-${newId}:${label}`, + source: sourceNodeId, + sourceHandle: sourceHandleId, + target: newId, + label, + ...customEdgeStyle, + }; + setEdges((eds) => [...eds, newEdge]); + setMenuVisible(false); + setSelectedHandleId(null); + } + }; + + const newNodeTypes = [ + { key: "ifElse", label: t("ifElseNode") }, + { + key: "appropriation", + label: t("appropriationNode"), + }, + ]; + + const existingNodes = nodes .filter((node) => node.id !== selectedHandleId?.split("-")[0]) .filter((node) => { if (!selectedHandleId) return false; @@ -139,12 +198,35 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) { edge.sourceHandle === sourceHandleId && edge.target === node.id ); - }) - .map((node) => ({ - key: node.id, - label: t("connectTo", { nodeId: node.id }), - onClick: () => handleMenuItemClick(node.id), - })); + }); + + const menuItems = [ + { + key: "connectToExisting", + label: t("connectToExisting"), + children: existingNodes.map((node) => ({ + key: node.id, + label: t("connectTo", { nodeId: node.id }), + onClick: () => handleMenuItemClick(node.id), + })), + }, + ...newNodeTypes.map((nt) => ({ + key: `createNew-${nt.key}`, + label: nt.label, + onClick: () => handleCreateNode(nt.key), + })), + ]; + + const nodeTypes = useMemo( + () => ({ + startNode: (props: any) => , + ifElse: (props: any) => , + appropriation: (props: any) => ( + + ), + }), + [edges] + ); return ( ) { + id, + edges = [], +}: NodeProps & { edges?: Edge[] }) { const { t } = useTranslation(); + return (
) { +export default function IfElseNode({ + id, + data, + edges = [], +}: NodeProps & { edges?: Edge[] }) { const { t } = useTranslation(); + + const isHandle1Connected = edges.some( + (e: Edge) => e.source === id && e.sourceHandle === "1" + ); + const isHandle2Connected = edges.some( + (e: Edge) => e.source === id && e.sourceHandle === "2" + ); return ( <>
) { borderRadius: 8, backgroundColor: "white", width: "248px", - height: "144px", + minHeight: "144px", + position: "relative", }} >
) { style={{ display: "flex", alignItems: "center", - paddingLeft: "12px", + padding: "12px", fontSize: "14px", - height: "48px", + minHeight: "48px", + position: "relative", }} > {t("conditionIf", { condition: data.condition })} +
@@ -64,18 +97,21 @@ export default function IfElseNode({ id, data }: NodeProps) { {t("conditionElse")}
-
diff --git a/client/src/components/nodes/StartNode.tsx b/client/src/components/nodes/StartNode.tsx new file mode 100644 index 0000000..88f96b1 --- /dev/null +++ b/client/src/components/nodes/StartNode.tsx @@ -0,0 +1,83 @@ +import { Edge, Handle, Node, NodeProps, Position } from "@xyflow/react"; +import { useTranslation } from "react-i18next"; +import { + HANDLE_STYLE_CONNECTED, + HANDLE_STYLE_DISCONNECTED, +} from "./defaultHandleStyle"; +import { useState } from "react"; + +interface StartNodeProps extends Node { + edges?: Edge[]; +} + +export default function StartNode({ + id, + edges = [], +}: NodeProps & { edges?: Edge[] }) { + const { t } = useTranslation(); + const isHandleConnected = edges.some( + (e: Edge) => e.source === id && e.sourceHandle === "1" + ); + return ( +
+
+
+ start logo +
+ {t("startNode")} +
+
+
+ {t("startNodeDescription")} +
+ +
+ ); +} diff --git a/client/src/components/nodes/defaultHandleStyle.ts b/client/src/components/nodes/defaultHandleStyle.ts index 0e48908..6793f29 100644 --- a/client/src/components/nodes/defaultHandleStyle.ts +++ b/client/src/components/nodes/defaultHandleStyle.ts @@ -1,7 +1,34 @@ -const DEFAULT_HANDLE_STYLE = { +// horizontal +const HANDLE_STYLE_CONNECTED = { width: 8, height: 8, backgroundColor: "#BDBDBD", }; -export default DEFAULT_HANDLE_STYLE; +// horizontal +const HANDLE_STYLE_DISCONNECTED = { + width: 20, + height: 20, + backgroundColor: "#C7D95A", + borderColor: "#606060", + borderWidth: 2, +}; + +// vertical +const HANDLE_STYLE_CONNECTED_V = { + ...HANDLE_STYLE_CONNECTED, + right: "-4px", +}; + +// vertical +const HANDLE_STYLE_DISCONNECTED_V = { + ...HANDLE_STYLE_DISCONNECTED, + right: "-10px", +}; + +export { + HANDLE_STYLE_CONNECTED, + HANDLE_STYLE_DISCONNECTED, + HANDLE_STYLE_CONNECTED_V, + HANDLE_STYLE_DISCONNECTED_V, +}; diff --git a/client/src/config/i18n.ts b/client/src/config/i18n.ts index 19da706..0a0ebb3 100644 --- a/client/src/config/i18n.ts +++ b/client/src/config/i18n.ts @@ -52,6 +52,9 @@ i18n editAccountMessage: "User successfully updated!", you: "(You)", // nodes + startNode: "Start", + startNodeDescription: "Start", + connectToExisting: "Connect to existing", editNode: "Editing a block", connectTo: "Connect to {{nodeId}}", ifElseNode: "IF - ELSE", @@ -102,6 +105,9 @@ i18n editAccountMessage: "Пользователь успешно обновлен!", you: "(Вы)", // nodes + startNode: "Запуск", + startNodeDescription: "Включение", + connectToExisting: "Подключить к существующей", editNode: "Редактирование блока", connectTo: `Подключить к {{nodeId}}`, ifElseNode: "ЕСЛИ - ТО",