feat(nodes): add start node and create new node function

This commit is contained in:
2025-08-06 12:19:55 +05:00
parent 65ed6b9561
commit ee4051f523
7 changed files with 285 additions and 41 deletions

View File

@@ -0,0 +1,3 @@
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 16H5V10H11V16H14V7L8 2.5L2 7V16ZM0 18V6L8 0L16 6V18H9V12H7V18H0Z" fill="#606060"/>
</svg>

After

Width:  |  Height:  |  Size: 198 B

View File

@@ -7,36 +7,38 @@ import {
Node, Node,
Edge, Edge,
} from "@xyflow/react"; } from "@xyflow/react";
import { useState } from "react"; import { useMemo, useState } from "react";
import customEdgeStyle from "./edges/defaultEdgeStyle"; import customEdgeStyle from "./edges/defaultEdgeStyle";
import { Dropdown, DropdownProps } from "antd"; import { Dropdown, DropdownProps } from "antd";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { edgeTitleGenerator } from "@/utils/edge"; import { edgeTitleGenerator } from "@/utils/edge";
import IfElseNode from "./nodes/IfElseNode"; import IfElseNode from "./nodes/IfElseNode";
import AppropriationNode from "./nodes/AppropriationNode"; import AppropriationNode from "./nodes/AppropriationNode";
import StartNode from "./nodes/StartNode";
const nodeTypes = {
ifElse: IfElseNode,
appropriation: AppropriationNode,
};
const initialNodes: Node[] = [ const initialNodes: Node[] = [
{ {
id: "1", id: "1",
type: "ifElse", type: "startNode",
position: { x: 100, y: 100 }, position: { x: 100, y: 0 },
data: { condition: "B=2" }, data: {},
}, },
{ {
id: "2", id: "2",
type: "appropriation", type: "ifElse",
position: { x: 100, y: 300 }, position: { x: 100, y: 200 },
data: { value: "Выбрать {{account.email}}" }, data: { condition: "B=2" },
}, },
{ {
id: "3", id: "3",
type: "appropriation", 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}}" }, data: { value: "Выбрать {{account.role}}" },
}, },
]; ];
@@ -92,6 +94,9 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
const handleId = handleElement.getAttribute("data-handleid"); const handleId = handleElement.getAttribute("data-handleid");
if (!handleId) return; if (!handleId) return;
const handlePos = handleElement.getAttribute("data-handlepos");
if (handlePos === "top") return;
setSelectedHandleId(`${node.id}-${handleId}`); setSelectedHandleId(`${node.id}-${handleId}`);
const flowWrapper = document.querySelector(".react-flow") as HTMLElement; const flowWrapper = document.querySelector(".react-flow") as HTMLElement;
@@ -100,7 +105,7 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
const handleRect = handleElement.getBoundingClientRect(); const handleRect = handleElement.getBoundingClientRect();
setMenuPosition({ setMenuPosition({
x: handleRect.right - wrapperRect.left, 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 label = edgeTitleGenerator(edges.length + 1);
const newEdge: Edge = { const newEdge: Edge = {
id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}`, id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}:${label}`,
source: sourceNodeId, source: sourceNodeId,
sourceHandle: sourceHandleId, sourceHandle: sourceHandleId,
target: targetNodeId, target: targetNodeId,
@@ -128,7 +133,61 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
setSelectedHandleId(null); 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) => node.id !== selectedHandleId?.split("-")[0])
.filter((node) => { .filter((node) => {
if (!selectedHandleId) return false; if (!selectedHandleId) return false;
@@ -139,12 +198,35 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
edge.sourceHandle === sourceHandleId && edge.sourceHandle === sourceHandleId &&
edge.target === node.id edge.target === node.id
); );
}) });
.map((node) => ({
const menuItems = [
{
key: "connectToExisting",
label: t("connectToExisting"),
children: existingNodes.map((node) => ({
key: node.id, key: node.id,
label: t("connectTo", { nodeId: node.id }), label: t("connectTo", { nodeId: node.id }),
onClick: () => handleMenuItemClick(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) => <StartNode {...props} edges={edges} />,
ifElse: (props: any) => <IfElseNode {...props} edges={edges} />,
appropriation: (props: any) => (
<AppropriationNode {...props} edges={edges} />
),
}),
[edges]
);
return ( return (
<ReactFlow <ReactFlow
@@ -165,6 +247,8 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
}} }}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
minZoom={0.5}
maxZoom={1.0}
> >
<Background color="#F2F2F2" /> <Background color="#F2F2F2" />
<Controls <Controls

View File

@@ -1,13 +1,16 @@
import { Handle, Node, NodeProps, Position } from "@xyflow/react"; import { Handle, Node, NodeProps, Position, Edge } from "@xyflow/react";
import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle"; import { HANDLE_STYLE_CONNECTED } from "./defaultHandleStyle";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
type AppropriationNodeData = { value: string }; type AppropriationNodeData = { value: string; edges?: Edge[] };
export default function AppropriationNode({ export default function AppropriationNode({
data, data,
}: NodeProps<Node & AppropriationNodeData>) { id,
edges = [],
}: NodeProps<Node & AppropriationNodeData> & { edges?: Edge[] }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
style={{ style={{
@@ -48,7 +51,9 @@ export default function AppropriationNode({
{data.value as string} {data.value as string}
</div> </div>
<Handle <Handle
style={{ ...DEFAULT_HANDLE_STYLE }} style={{
...HANDLE_STYLE_CONNECTED,
}}
type="target" type="target"
position={Position.Top} position={Position.Top}
id="input" id="input"

View File

@@ -1,13 +1,31 @@
import { Handle, NodeProps, Position, Node } from "@xyflow/react"; import { Handle, NodeProps, Position, Node, Edge } from "@xyflow/react";
import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle"; import {
HANDLE_STYLE_CONNECTED,
HANDLE_STYLE_CONNECTED_V,
HANDLE_STYLE_DISCONNECTED,
HANDLE_STYLE_DISCONNECTED_V,
} from "./defaultHandleStyle";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useState } from "react";
interface IfElseNodeProps extends Node { interface IfElseNodeProps extends Node {
condition: string; condition: string;
edges?: Edge[];
} }
export default function IfElseNode({ id, data }: NodeProps<IfElseNodeProps>) { export default function IfElseNode({
id,
data,
edges = [],
}: NodeProps<IfElseNodeProps> & { edges?: Edge[] }) {
const { t } = useTranslation(); 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 ( return (
<> <>
<div <div
@@ -16,7 +34,8 @@ export default function IfElseNode({ id, data }: NodeProps<IfElseNodeProps>) {
borderRadius: 8, borderRadius: 8,
backgroundColor: "white", backgroundColor: "white",
width: "248px", width: "248px",
height: "144px", minHeight: "144px",
position: "relative",
}} }}
> >
<div <div
@@ -43,12 +62,26 @@ export default function IfElseNode({ id, data }: NodeProps<IfElseNodeProps>) {
style={{ style={{
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
paddingLeft: "12px", padding: "12px",
fontSize: "14px", fontSize: "14px",
height: "48px", minHeight: "48px",
position: "relative",
}} }}
> >
{t("conditionIf", { condition: data.condition })} {t("conditionIf", { condition: data.condition })}
<Handle
type="source"
position={Position.Right}
id="1"
style={{
...(isHandle1Connected
? { ...HANDLE_STYLE_CONNECTED_V }
: { ...HANDLE_STYLE_DISCONNECTED_V }),
position: "absolute",
top: "50%",
transform: "translateY(-50%)",
}}
/>
</div> </div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div> <div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
@@ -64,18 +97,21 @@ export default function IfElseNode({ id, data }: NodeProps<IfElseNodeProps>) {
{t("conditionElse")} {t("conditionElse")}
</div> </div>
<Handle type="target" position={Position.Top} id="input" />
<Handle <Handle
type="source" type="target"
position={Position.Right} position={Position.Top}
id="1" id="input"
style={{ ...DEFAULT_HANDLE_STYLE }} style={{ ...HANDLE_STYLE_CONNECTED }}
/> />
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
id="2" id="2"
style={{ ...DEFAULT_HANDLE_STYLE }} style={
isHandle2Connected
? { ...HANDLE_STYLE_CONNECTED }
: { ...HANDLE_STYLE_DISCONNECTED }
}
/> />
</div> </div>
</> </>

View File

@@ -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<StartNodeProps> & { edges?: Edge[] }) {
const { t } = useTranslation();
const isHandleConnected = edges.some(
(e: Edge) => e.source === id && e.sourceHandle === "1"
);
return (
<div
style={{
border: "0px solid",
borderRadius: 8,
backgroundColor: "white",
width: "248px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
paddingLeft: "12px",
height: "40px",
fontWeight: "600px",
fontSize: "16px",
gap: "12px",
backgroundColor: "#D4E0BD",
borderRadius: "8px 8px 0 0",
}}
>
<div
style={{
height: "24px",
width: "24px",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<img
style={{ height: "16px", width: "18px" }}
src="/icons/node/home.svg"
alt="start logo"
/>
</div>
{t("startNode")}
</div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div
style={{
display: "flex",
alignItems: "center",
padding: "12px",
fontSize: "14px",
}}
>
{t("startNodeDescription")}
</div>
<Handle
type="source"
position={Position.Bottom}
id="1"
style={
isHandleConnected
? { ...HANDLE_STYLE_CONNECTED }
: { ...HANDLE_STYLE_DISCONNECTED }
}
/>
</div>
);
}

View File

@@ -1,7 +1,34 @@
const DEFAULT_HANDLE_STYLE = { // horizontal
const HANDLE_STYLE_CONNECTED = {
width: 8, width: 8,
height: 8, height: 8,
backgroundColor: "#BDBDBD", 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,
};

View File

@@ -52,6 +52,9 @@ i18n
editAccountMessage: "User successfully updated!", editAccountMessage: "User successfully updated!",
you: "(You)", you: "(You)",
// nodes // nodes
startNode: "Start",
startNodeDescription: "Start",
connectToExisting: "Connect to existing",
editNode: "Editing a block", editNode: "Editing a block",
connectTo: "Connect to {{nodeId}}", connectTo: "Connect to {{nodeId}}",
ifElseNode: "IF - ELSE", ifElseNode: "IF - ELSE",
@@ -102,6 +105,9 @@ i18n
editAccountMessage: "Пользователь успешно обновлен!", editAccountMessage: "Пользователь успешно обновлен!",
you: "(Вы)", you: "(Вы)",
// nodes // nodes
startNode: "Запуск",
startNodeDescription: "Включение",
connectToExisting: "Подключить к существующей",
editNode: "Редактирование блока", editNode: "Редактирование блока",
connectTo: `Подключить к {{nodeId}}`, connectTo: `Подключить к {{nodeId}}`,
ifElseNode: "ЕСЛИ - ТО", ifElseNode: "ЕСЛИ - ТО",