feat(client): move components

This commit is contained in:
2025-07-22 08:05:20 +05:00
parent 8b0659fc89
commit 5966b7d6f1
4 changed files with 408 additions and 144 deletions

View File

@@ -4,46 +4,23 @@ import {
Controls, Controls,
useNodesState, useNodesState,
useEdgesState, useEdgesState,
Handle, Node,
Position, Edge,
} from "@xyflow/react"; } from "@xyflow/react";
import IfElseNode from "./nodes/IfElseNode"; import { useState } from "react";
import { useCallback, useEffect } from "react";
import customEdgeStyle from "./edges/defaultEdgeStyle"; import customEdgeStyle from "./edges/defaultEdgeStyle";
import { message } from "antd"; import { Dropdown, DropdownProps } from "antd";
import AppropriationNode from "./nodes/AppropriationNode";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { edgeTitleGenerator } from "@/utils/edge";
function CustomNode({ data }: { data: any }) { import IfElseNode from "./nodes/IfElseNode";
return ( import AppropriationNode from "./nodes/AppropriationNode";
<div
style={{
padding: 10,
border: "1px solid #ccc",
borderRadius: 5,
backgroundColor: "white",
}}
>
<div>
<div>{data.label}</div>
</div>
<div>
<Handle type="source" position={Position.Right} />
</div>
<div>
<Handle type="source" position={Position.Bottom} />
</div>
</div>
);
}
const nodeTypes = { const nodeTypes = {
custom: CustomNode,
ifElse: IfElseNode, ifElse: IfElseNode,
appropriation: AppropriationNode, appropriation: AppropriationNode,
}; };
const initialNodes = [ const initialNodes: Node[] = [
{ {
id: "1", id: "1",
type: "ifElse", type: "ifElse",
@@ -52,9 +29,9 @@ const initialNodes = [
}, },
{ {
id: "2", id: "2",
type: "default", type: "appropriation",
position: { x: 400, y: 100 }, position: { x: 100, y: 300 },
data: { label: "Приём" }, data: { value: "Выбрать {{account.email}}" },
}, },
{ {
id: "3", id: "3",
@@ -64,14 +41,24 @@ const initialNodes = [
}, },
]; ];
const initialEdges = [ const initialEdges: Edge[] = [
{ // {
id: "e1-2", // id: "e1-3",
source: "1", // source: "1",
target: "2", // sourceHandle: "1",
label: "A1", // target: "3",
...customEdgeStyle, // targetHandle: null,
}, // label: "A1",
// ...customEdgeStyle,
// },
// {
// id: "e1-2",
// source: "1",
// sourceHandle: "2",
// target: "2",
// label: "B1",
// ...customEdgeStyle,
// },
]; ];
interface ReactFlowDrawerProps { interface ReactFlowDrawerProps {
@@ -84,56 +71,117 @@ export default function ReactFlowDrawer({ showDrawer }: ReactFlowDrawerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const onConnect = useCallback( const [menuVisible, setMenuVisible] = useState(false);
(con: { source: any; target: any }) => { const [selectedHandleId, setSelectedHandleId] = useState<string | null>(null);
const exists = edges.some( const [menuPosition, setMenuPosition] = useState({ x: 0, y: 0 });
(edge) => edge.source === con.source && edge.target === con.target
);
if (exists) { const handleOpenChange: DropdownProps["onOpenChange"] = (nextOpen, info) => {
message.warning("Edge already exists"); if (info.source === "trigger" || nextOpen) {
return; setMenuVisible(nextOpen);
}
};
const handleClick = (e: React.MouseEvent, node: Node) => {
e.stopPropagation();
const target = e.target as HTMLElement;
const handleElement = target.closest(".react-flow__handle") as HTMLElement;
if (!handleElement) return;
const handleId = handleElement.getAttribute("data-handleid");
if (!handleId) return;
setSelectedHandleId(`${node.id}-${handleId}`);
const flowWrapper = document.querySelector(".react-flow") as HTMLElement;
if (flowWrapper) {
const wrapperRect = flowWrapper.getBoundingClientRect();
const handleRect = handleElement.getBoundingClientRect();
setMenuPosition({
x: handleRect.right - wrapperRect.left,
y: handleRect.top - wrapperRect.top,
});
} }
const newEdge = { setMenuVisible(true);
...con, };
const handleMenuItemClick = (targetNodeId: string) => {
if (!selectedHandleId) return;
const [sourceNodeId, sourceHandleId] = selectedHandleId.split("-");
const label = edgeTitleGenerator(edges.length + 1);
const newEdge: Edge = {
id: `e${sourceNodeId}-${sourceHandleId}-${targetNodeId}`,
source: sourceNodeId,
sourceHandle: sourceHandleId,
target: targetNodeId,
label,
...customEdgeStyle, ...customEdgeStyle,
id: `e${con.source}-${con.target}`,
label: "A1",
}; };
setEdges((eds) => [...eds, newEdge]); setEdges((eds) => [...eds, newEdge]);
}, setMenuVisible(false);
[edges, setEdges] setSelectedHandleId(null);
); };
useEffect(() => { const menuItems = nodes
console.log("Nodes changed:", nodes); .filter((node) => node.id !== selectedHandleId?.split("-")[0]) // исключаем текущий узел
}, [nodes]); .map((node) => ({
key: node.id,
useEffect(() => { label: t("connectTo", { nodeId: node.id }),
console.log("Edges changed:", edges); onClick: () => handleMenuItemClick(node.id),
}, [edges]); }));
return ( return (
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
// onNodesChange={onNodesChange}
// onEdgesChange={onEdgesChange}
// onConnect={onConnect}
nodesDraggable={false} nodesDraggable={false}
elementsSelectable={false} elementsSelectable={false}
nodesConnectable={false} nodesConnectable={false}
onNodeClick={(e) => { onNodeClick={(event, node) => {
console.log(e, "node clicked"); const target = event.target as HTMLElement;
if (!target.closest(".react-flow__handle")) {
console.log("node clicked");
showDrawer(); showDrawer();
} else {
handleClick(event, node);
}
}} }}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
> >
<Background /> <Background color="#F2F2F2" />
<Controls /> <Controls
position="bottom-center"
orientation="horizontal"
showInteractive={false}
/>
{menuVisible && (
<div
style={{
position: "absolute",
left: `${menuPosition.x}px`,
top: `${menuPosition.y}px`,
zIndex: 9999,
}}
>
<Dropdown
menu={{ items: menuItems }}
open={menuVisible}
onOpenChange={handleOpenChange}
placement="bottom"
>
<div style={{ width: 1, height: 1 }} />
</Dropdown>
</div>
)}
</ReactFlow> </ReactFlow>
); );
} }

View File

@@ -0,0 +1,213 @@
import { User } from "@/types/user";
import { Avatar, Typography } from "antd";
import { TFunction } from "i18next";
interface DrawerTitleProps {
closeDrawer: () => void;
t: TFunction;
}
interface UserEditDrawerTitleProps extends DrawerTitleProps {
login?: string;
name?: string;
email?: string | null;
user: User | null;
}
interface UserCreateDrawerTitleProps extends DrawerTitleProps {}
interface NodeEditDrawerTitleProps extends DrawerTitleProps {}
const UserEditDrawerTitle = ({
closeDrawer,
login,
name,
email,
user,
t,
}: UserEditDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 12, flex: 1 }}>
<Avatar
src={
login ? `https://gamma.heado.ru/go/ava?name=${login}` : undefined
}
size={40}
style={{ flexShrink: 0 }}
/>
<div>
<Typography.Text
strong
style={{ display: "block", fontSize: "20px" }}
>
{name} {login === user?.login ? t("you") : ""}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 14 }}>
{email}
</Typography.Text>
</div>
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
const UserCreateDrawerTitle = ({
closeDrawer,
t,
}: UserCreateDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
flex: 1,
fontSize: "20px",
}}
>
{t("newAccount")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
const NodeEditDrawerTitle = ({ closeDrawer, t }: NodeEditDrawerTitleProps) => {
return (
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
}}
>
<div
onClick={closeDrawer}
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
cursor: "pointer",
}}
>
<img
src="./icons/drawer/arrow_back.svg"
alt="close_drawer"
style={{ height: "16px", width: "16px" }}
/>
</div>
<div
style={{
display: "flex",
alignItems: "center",
gap: "12px",
flex: 1,
fontSize: "20px",
}}
>
{t("editNode")}
</div>
<div
style={{
display: "flex",
alignItems: "center",
height: "24px",
width: "24px",
}}
onClick={closeDrawer}
>
<img
src="./icons/drawer/delete.svg"
alt="delete"
style={{ height: "18px", width: "16px" }}
/>
</div>
</div>
);
};
export { UserEditDrawerTitle, UserCreateDrawerTitle, NodeEditDrawerTitle };

View File

@@ -1,11 +1,13 @@
import { Handle, Node, NodeProps, Position } from "@xyflow/react"; import { Handle, Node, NodeProps, Position } from "@xyflow/react";
import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle"; import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle";
import { useTranslation } from "react-i18next";
type AppropriationNodeData = { value: string }; type AppropriationNodeData = { value: string };
export default function AppropriationNode({ export default function AppropriationNode({
data, data,
}: NodeProps<Node & AppropriationNodeData>) { }: NodeProps<Node & AppropriationNodeData>) {
const { t } = useTranslation();
return ( return (
<div <div
style={{ style={{
@@ -30,8 +32,8 @@ export default function AppropriationNode({
style={{ height: "24px", width: "24px" }} style={{ height: "24px", width: "24px" }}
src="/icons/node/calculate.svg" src="/icons/node/calculate.svg"
alt="appropriation logo" alt="appropriation logo"
/>{" "} />
ПРИСВОЕНИЕ {t("appropriationNode")}
</div> </div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div> <div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
<div <div

View File

@@ -1,12 +1,15 @@
import { Handle, NodeProps, Position, Node } from "@xyflow/react"; import { Handle, NodeProps, Position, Node } from "@xyflow/react";
import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle"; import DEFAULT_HANDLE_STYLE from "./defaultHandleStyle";
import { useTranslation } from "react-i18next";
type IfElseNodeData = { interface IfElseNodeProps extends Node {
condition: string; condition: string;
}; }
export default function IfElseNode({ data }: NodeProps<Node & IfElseNodeData>) { export default function IfElseNode({ id, data }: NodeProps<IfElseNodeProps>) {
const { t } = useTranslation();
return ( return (
<>
<div <div
style={{ style={{
border: "0px solid", border: "0px solid",
@@ -31,8 +34,8 @@ export default function IfElseNode({ data }: NodeProps<Node & IfElseNodeData>) {
style={{ height: "24px", width: "24px" }} style={{ height: "24px", width: "24px" }}
src="/icons/node/ifElse.svg" src="/icons/node/ifElse.svg"
alt="if else logo" alt="if else logo"
/>{" "} />
ЕСЛИ - ТО {t("ifElseNode")}
</div> </div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div> <div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
@@ -45,7 +48,7 @@ export default function IfElseNode({ data }: NodeProps<Node & IfElseNodeData>) {
height: "48px", height: "48px",
}} }}
> >
Если {data.condition as string}, то {t("conditionIf", { condition: data.condition })}
</div> </div>
<div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div> <div style={{ height: "1px", backgroundColor: "#E2E2E2" }}></div>
@@ -58,25 +61,23 @@ export default function IfElseNode({ data }: NodeProps<Node & IfElseNodeData>) {
height: "48px", height: "48px",
}} }}
> >
Иначе {t("conditionElse")}
</div> </div>
<Handle type="target" position={Position.Top} id="input" /> <Handle type="target" position={Position.Top} id="input" />
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
id="true" id="1"
style={{ ...DEFAULT_HANDLE_STYLE }} style={{ ...DEFAULT_HANDLE_STYLE }}
/> />
<Handle <Handle
onClick={() => {
console.log("click");
}}
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
id="false" id="2"
style={{ ...DEFAULT_HANDLE_STYLE }} style={{ ...DEFAULT_HANDLE_STYLE }}
/> />
</div> </div>
</>
); );
} }