Files
react-flow/src/nodes/NodeFlow.tsx
2024-09-25 17:20:34 +07:00

807 lines
24 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react';
import {
ReactFlow,
addEdge,
Controls,
Background,
BackgroundVariant,
MarkerType,
useReactFlow,
ReactFlowProvider,
useNodesState,
useEdgesState,
useStoreApi,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import '../assets/css/style.css';
import Tabbar from './Tabbar.tsx';
import ShowPopup from './ShowPopup.tsx';
import { nanoid } from 'nanoid';
import CustomEdge from './CustomEdge';
import MenuFlow from './MenuFlow';
import DeleteNodeModal from './popup/DeleteNodeModal.tsx';
// Nội dung cho mỗi node
const nodeContents = {
'send-email': `
<label for="email-subject">Subject:</label>
<input type="text" id="email-subject" name="email-subject" placeholder="Enter subject">
<label for="email-body">Body:</label>
<textarea id="email-body" name="email-body" rows="4" placeholder="Enter email body"></textarea>
`,
'customer-sign': `
<label for="customer-name">Customer Name:</label>
<input type="text" id="customer-name" name="customer-name" placeholder="Enter customer name">
<label for="customer-email">Customer Email:</label>
<input type="email" id="customer-email" name="customer-email" placeholder="Enter customer email">
`,
ifelse: `
<label for="reply-status">Reply Status:</label>
<select id="reply-status" name="reply-status">
<option value="replied">Replied</option>
<option value="not-replied">Not Replied</option>
</select>
`,
'contact-exists': `
<label for="contact-id">Contact ID:</label>
<input type="text" id="contact-id" name="contact-id" placeholder="Enter contact ID">
`,
'send-survey': `
<label for="survey-question">Survey Question:</label>
<input type="text" id="survey-question" name="survey-question" placeholder="Enter survey question">
<label for="survey-options">Options (comma-separated):</label>
<input type="text" id="survey-options" name="survey-options" placeholder="Option 1, Option 2, ...">
`,
};
const NodeFlow = ({ initialNodes, initialEdges }) => {
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [showTabBar, setShowTabBar] = useState(false);
const [lastNodeId, setLastNodeId] = useState(nodes[0].id);
const [showPopup, setShowPopup] = useState(false);
const [selectedNodeContent, setSelectedNodeContent] = useState('');
const [addNodeCallback, setAddNodeCallback] = useState(() => () => {});
const [isTabbarVisible, setIsTabbarVisible] = useState(false);
const { screenToFlowPosition } = useReactFlow();
const [showActions, setShowActions] = useState(null);
const [showPopupSuccess, setShowPopupSuccess] = useState(false);
const store = useStoreApi();
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteNodeId, setDeleteNodeId] = useState(null);
const handleAddNoteClick = () => {
setShowTabBar(true);
setIsTabbarVisible(true);
setAddNodeCallback(() => (selectedNode) => {
addNodeBetween(selectedNode, sourceId, targetId);
});
};
const closeTabbar = () => {
setIsTabbarVisible(false); // Ẩn tabbar khi click close
};
const onNodeClick = (event, node) => {
if (node.key === 'add-node') {
// Check if the clicked node is "Add Node"
setShowTabBar(true);
setIsTabbarVisible(true);
}
};
const getNextYPosition = (xPosition, spacing) => {
const nodesInColumn = nodes.filter((node) => node.position.x === xPosition);
if (nodesInColumn.length === 0) return 0; // Return 0 if there are no nodes
const lastNode = nodesInColumn[nodesInColumn.length - 1];
return lastNode.position.y + spacing;
};
const addNode = (option) => {
const newNodeId = nanoid(8); // Generate a unique ID for the new node
const addNoteNode = nodes.find((node) => node.key === 'add-node');
const initialX = addNoteNode?.position.x;
const nextY =
nodes.length > 2
? getNextYPosition(initialX, 120)
: addNoteNode?.position.y;
// Remove existing edges from addNoteNode
setEdges((eds) =>
eds.filter((edge) => edge.source !== '2' && edge.target !== '2')
);
if (option.id === '4') {
const repliedNode = {
id: newNodeId,
key: option.key,
data: { label: option.htmlNode },
position: { x: initialX, y: nextY },
};
const yesNode = {
id: nanoid(8),
type: 'output',
key: 'contact-exists',
data: {
label: (
<div className="step">
<div className="left">
<img
src="/src/assets/icons/icon-check.svg"
width="24px"
height="24px"
alt=""
/>
</div>
<div className="right">Contact Exists</div>
</div>
),
},
position: { x: initialX - 150, y: nextY + 150 },
};
const noNode = {
id: nanoid(8),
type: '',
key: 'send-survey',
data: {
label: (
<div className="step">
<div className="left">
<img
src="/src/assets/icons/icon-list.svg"
width="24px"
height="24px"
alt=""
/>
</div>
<div className="right">Send survey</div>
</div>
),
},
position: { x: initialX + 150, y: nextY + 150 },
};
// Di chuyển node add-note sang nhánh no
setNodes((nds) =>
nds.map((node) =>
node.id === '2'
? {
...node,
position: {
x: noNode.position.x,
y: noNode.position.y + 150,
},
}
: node
)
);
// Thêm các node mới (replied, yes, no)
setNodes((nds) => [...nds, repliedNode, yesNode, noNode]);
// Kết nối các edges giữa các node
setEdges((eds) => [
...eds,
{
id: nanoid(8),
source: repliedNode.id,
target: yesNode.id,
label: 'yes',
},
{
id: nanoid(8),
source: repliedNode.id,
target: noNode.id,
label: 'no',
},
{ id: nanoid(8), source: lastNodeId, target: repliedNode.id },
{ id: nanoid(8), source: noNode.id, target: addNoteNode.id },
]);
setLastNodeId(noNode.id);
} else {
// Add new node logic
if (addNoteNode) {
const initialX = addNoteNode.position.x;
setNodes((nds) =>
nds.map((node) =>
node.key === 'add-node'
? {
...node,
position: {
x: initialX,
y: nextY + 130,
},
}
: node
)
);
}
const newNode = {
id: newNodeId,
key: option.key,
data: { label: option.htmlNode },
position: {
x: initialX,
y: nodes.length > 2 ? nextY : addNoteNode.position.y,
},
};
const newEdge = {
id: nanoid(8),
source: lastNodeId,
target: newNodeId,
// data: {
// label: <div onClick={handleAddNoteClick}>+</div>,
// onClick: handleAddNoteClick
// }
};
setNodes((nds) => [...nds, newNode]);
setEdges((eds) => [
...eds,
newEdge,
{ id: nanoid(8), source: newNodeId, target: addNoteNode.id },
]);
// setEdges((eds) => [
// ...eds,
// {
// id: nanoid(8),
// source: lastNodeId,
// data: {
// label: <div onClick={handleAddNoteClick}>Add Note</div>, // Use HTML or React component
// onClick: handleAddNoteClick
// },
// target: newNodeId
// },
// { id: nanoid(8), source: newNodeId, target: addNoteNode.id }
// ]);
// Cuộn tới node mới thêm
setLastNodeId(newNodeId);
}
setShowTabBar(false);
};
const hidePopup = () => {
setShowPopup(false);
};
const addPopup = (node) => {
const content = nodeContents[node.id]; // Lấy nội dung từ nodeContents dựa trên ID node
setSelectedNodeContent(content); // Cập nhật nội dung được chọn
};
const onDrop = (event: React.DragEvent) => {
event.preventDefault();
const type = JSON.parse(
event.dataTransfer.getData('application/reactflow')
);
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
const newNodeId = nanoid(8);
const addNodeId = '2';
const addNode = nodes.find((node) => node.id === addNodeId);
const targetEdge = edges.find(
(edge) => edge?.source === '2' || edge?.target === '2'
);
const targetNodeId =
targetEdge?.source === '2' ? targetEdge?.target : targetEdge?.source;
const targetNode = nodes.find((node) => node.id === targetNodeId);
const newNodePosition = targetNode
? { x: targetNode.position.x, y: targetNode.position.y + 110 }
: position;
const createNewNode = (
nodeId: string,
nodePosition: any,
nodeLabel: JSX.Element
) => ({
id: nodeId,
key: type.key,
type: type.type || 'default',
data: { label: nodeLabel },
position: nodePosition,
});
const newNode = createNewNode(
newNodeId,
newNodePosition,
<div className="custom-node">
<div className="step">
<div className="left">
<img src={type.icon} width="24px" height="24px" alt="" />
</div>
<div className="right">{type.label}</div>
</div>
<div className="manageStep">
<button className="btn-show" onClick={() => toggleActions(newNodeId)}>
<img
src="/src/assets/icons/icon-wink.svg"
width="20px"
height="20px"
alt="wink"
/>
</button>
<div className={`actions actions-${newNodeId}`}>
<button
onClick={() => handleEditNode(newNodeId)}
className="btn-edit"
>
Sửa
</button>
<button
onClick={() => handleDeleteNode(newNodeId, type.key)}
className="btn-remove"
>
Xóa
</button>
</div>
</div>
</div>
);
setEdges((eds) =>
eds.filter((edge) => edge.source !== '2' && edge.target !== '2')
);
if (type.key === 'ifelse') {
const yesNodeId = nanoid(8);
const noNodeId = nanoid(8);
const createBranchNode = (id: string, xOffset: number) => ({
id,
key: 'add-node',
data: {
label: (
<div className="add-node">
<i>+</i> <span>Add a journey point</span>
</div>
),
},
position: {
x: newNode.position.x + xOffset,
y: newNode.position.y + 150,
},
});
const yesNode = createBranchNode(yesNodeId, -200);
const noNode = createBranchNode(noNodeId, 200);
const ifelseEdges = [
{ id: nanoid(8), source: targetNodeId, target: newNodeId },
{
id: nanoid(8),
source: newNodeId,
target: yesNodeId,
type: 'custom',
data: { label: 'Yes' },
},
{
id: nanoid(8),
source: newNodeId,
target: noNodeId,
type: 'custom',
data: { label: 'No' },
},
];
setNodes((nds) => [
...nds.filter((node) => node.id !== addNodeId),
newNode,
yesNode,
noNode,
]);
setEdges((eds) => [...eds, ...ifelseEdges]);
} else {
if (targetNode) {
const updatedEdges = [
{ id: nanoid(8), source: targetNodeId, target: newNodeId },
{ id: nanoid(8), source: newNodeId, target: '2' },
];
// Cập nhật nodes
setNodes((nds) => {
const updatedNodes = [...nds, newNode];
if (addNode) {
// Tính toán vị trí cho addNode nằm dưới newNode
const addNodePosition = {
x: newNode.position.x,
y: newNode.position.y + 110, // Đặt add-node dưới newNode
};
// Cập nhật vị trí của addNode
const addNodeUpdated = updatedNodes.map((node) =>
node.id === addNodeId
? { ...node, position: addNodePosition }
: node
);
// Đảm bảo rằng add-node nằm dưới newNode
return addNodeUpdated.sort((a, b) => {
if (a.id === newNodeId) return -1; // Đặt newNode lên trước
if (b.id === newNodeId) return 1; // Đặt addNode xuống dưới
return 0; // Giữ nguyên thứ tự cho các node khác
});
}
return updatedNodes;
});
// Cập nhật edges
setEdges((eds) => [...eds, ...updatedEdges]);
if (addNode && type.key === 'contact-exists') {
// Nếu key là 'contact-exists', xóa addNode
setNodes((nds) => nds.filter((node) => node.id !== addNodeId));
}
} else {
const newNodeNew = createNewNode(
newNodeId,
position,
newNode.data.label
);
let closestNode = null;
let closestDistance = Infinity;
const threshold = 150;
nodes.forEach((node) => {
const distance = getDistance(newNodeNew.position, node.position);
if (distance < closestDistance && distance <= threshold) {
closestDistance = distance;
closestNode = node;
}
});
if (closestNode) {
const updatedEdges = edges.map((edge) => {
if (edge.source === closestNode.id)
return { ...edge, source: newNodeNew.id };
if (edge.target === closestNode.id)
return { ...edge, target: newNodeNew.id };
return edge;
});
if (newNodeNew.type !== 'output') {
const addNodeNew = createNewNode(
nanoid(8),
{ x: closestNode.position.x, y: closestNode.position.y + 100 },
<div className="add-node">
<i>+</i> <span>Add a journey point</span>
</div>
);
setNodes((nds) => [
...nds.map((n) =>
n.id === closestNode.id
? { ...newNodeNew, position: closestNode.position }
: n
),
addNodeNew,
]);
setEdges([
...updatedEdges,
{ id: nanoid(8), source: newNodeNew.id, target: addNodeNew.id },
]);
} else {
setNodes((nds) => [
...nds.map((n) =>
n.id === closestNode.id
? { ...newNodeNew, position: closestNode.position }
: n
),
]);
setEdges(updatedEdges);
}
} else {
setNodes((nds) => [...nds, newNodeNew]);
}
}
}
};
const onDragOver = useCallback((event) => {
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
}, []);
const defaultEdgeOptions = {
style: { strokeWidth: 1.5, stroke: 'black' },
type: 'floating',
markerEnd: {
type: MarkerType.ArrowClosed,
color: 'black',
},
};
const onConnect = useCallback(
(params) => setEdges((eds) => addEdge(params, eds)),
[setEdges]
);
// Hàm tính khoảng cách giữa hai node
const getDistance = (pos1, pos2) => {
const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
return Math.sqrt(dx * dx + dy * dy);
};
const toggleActions = (nodeId: string) => {
setShowActions((prev) => (prev === nodeId ? null : nodeId));
const actionsElement = document.querySelector(`.actions-${nodeId}`);
if (actionsElement) {
// Toggle class 'show' cho phần tử actions
actionsElement.classList.toggle('staticNode');
}
};
// Hàm xóa node
const handleDeleteNode = useCallback(
(nodeId, nodeKey) => {
// Tìm tất cả các edges có nodeId là source hoặc target
const allNode = store.getState();
const edgeNode = allNode.edges;
const Node = allNode.nodes;
if (nodeKey === 'ifelse') {
setDeleteNodeId(nodeId); // Lưu lại ID của node muốn xóa
// Hiển thị modal để người dùng chọn nhánh xóa
setIsDeleteModalOpen(true);
document
.querySelector('.actions-' + nodeId)
?.classList.remove('staticNode');
} else {
const isConfirmed = window.confirm(
'Bạn có chắc chắn muốn xóa node này không?'
);
if (!isConfirmed) return;
// Tìm tất cả các edges có nodeId là source hoặc target
const connectedEdges = edgeNode.filter(
(edge) => edge.source === nodeId || edge.target === nodeId
);
console.log('connectedEdges', connectedEdges);
const sourceNode = connectedEdges.find(
(edge) => edge.target === nodeId
)?.source;
const targetNode = connectedEdges.find(
(edge) => edge.source === nodeId
)?.target;
// Xóa node và các node con
setNodes((nds) => {
const updatedNodes = nds.filter((node) => node.id !== nodeId);
// Cập nhật vị trí cho các node còn lại
const sortedNodes = updatedNodes.sort(
(a, b) => a.position.y - b.position.y
);
return sortedNodes.map((node, index) => ({
...node,
position: {
...node.position,
y: index * 110, // Khoảng cách giữa các node
},
}));
});
// Xóa các cạnh kết nối với node bị xóa và các node con
setEdges((eds) =>
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
);
setEdges((eds) => [
...eds,
{
id: `new-edge-${sourceNode}-${targetNode}`,
source: sourceNode,
target: targetNode,
},
]);
}
},
[edges, setNodes, setEdges]
);
// Hàm xác định và xóa nhánh dựa trên node đã chọn
const handleConfirmDeleteBranchNode = (branch) => {
const allNode = store.getState();
const Node = allNode.nodes;
const edgeNode = allNode.edges;
// Tìm node ifelse
const ifelseNode = allNode.nodes.find((node) => node.id === deleteNodeId);
// Tìm edges của nhánh được chọn (Yes hoặc No)
const branchEdges = edgeNode.filter((edge) => edge.data?.label === branch);
// Lấy ID của tất cả các node trong nhánh dựa trên các edges
const branchNodeIds = branchEdges.map((edge) => edge.target);
// Lấy danh sách các node trong nhánh dựa trên cùng tọa độ x với node được chọn
const selectedNode = allNode.nodes.find(
(node) => node.id === branchNodeIds[0]
);
const nodesToDelete = allNode.nodes
.filter((node) => node.position.x === selectedNode.position.x) // Lọc các node có cùng tọa độ x với node được chọn
.map((node) => node.id); // Lấy ID của các node trong nhánh
// Bao gồm cả node ifelse trong danh sách node cần xóa
nodesToDelete.push(ifelseNode.id);
// Tìm edges nối đến nhánh còn lại
const remainingEdges = edgeNode.filter(
(edge) => edge.source === ifelseNode.id && edge.data?.label !== branch
);
const TargetIfelse = edgeNode.filter(
(edge) => edge.target === ifelseNode.id
);
const NodeRemaining = allNode.nodes.find(
(node) => node.id === remainingEdges[0].target
);
// Xóa tất cả các node trong nhánh
setNodes((nds) => {
const updatedNodes = nds.filter(
(node) => !nodesToDelete.includes(node.id)
);
// Nếu còn node sau khi xóa
if (updatedNodes.length > 0) {
// Lấy tất cả các node của nhánh còn lại
const nodesToRemaining = allNode.nodes
.filter((node) => node.position.x === NodeRemaining.position.x) // Lọc các node có cùng tọa độ x với node được chọn
.map((node) => node); // Lấy các node trong nhánh
// Đặt lại vị trí của tất cả các node còn lại theo thứ tự và khoảng cách đều
const updatedRemainingNodes = nodesToRemaining.map((node, index) => ({
...node,
position: {
x: ifelseNode.position.x,
y: ifelseNode.position.y + index * 110,
},
}));
// Kết hợp lại với các node khác (nếu có)
return updatedNodes.map((node) => {
const updatedNode = updatedRemainingNodes.find(
(n) => n.id === node.id
);
return updatedNode ? updatedNode : node;
});
}
return updatedNodes; // Trả về danh sách nodes đã được cập nhật
});
// Xóa các edges liên quan đến nhánh được chọn
setEdges((eds) => {
const updatedEdges = [
...eds.filter(
(edge) =>
!nodesToDelete.includes(edge.source) &&
!nodesToDelete.includes(edge.target)
),
{
id: `edge-${NodeRemaining.id}-${TargetIfelse[0].source}`,
source: NodeRemaining.id,
target: TargetIfelse[0].source,
},
];
return updatedEdges;
});
setIsDeleteModalOpen(false); // Đóng modal
};
// Hàm xử lý xác nhận xóa
const handleConfirmDelete = (choice) => {
if (choice === 'yes') {
handleConfirmDeleteBranchNode('Yes');
} else if (choice === 'no') {
handleConfirmDeleteBranchNode('No');
} else if (choice === 'both') {
handleConfirmDeleteBranchNode('Yes');
handleConfirmDeleteBranchNode('No');
}
};
// Hàm sửa node
const handleEditNode = (nodeId: string) => {
// Logic chỉnh sửa node
alert(`Sửa node: ${nodeId}`);
};
const handleSave = () => {
setShowPopupSuccess(true);
setTimeout(() => {
setShowPopupSuccess(false);
}, 2000);
};
return (
<div style={{ overflowY: 'auto', position: 'relative' }}>
<div className="flex-flow">
<MenuFlow />
<div className="panelContainer">
<ReactFlow
nodes={nodes}
edges={edges}
edgeTypes={{ custom: CustomEdge }}
onNodeClick={onNodeClick}
onDrop={onDrop}
onDragOver={onDragOver}
panOnScroll
defaultEdgeOptions={defaultEdgeOptions}
onConnect={onConnect}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
>
<Controls position="right-bottom" />
<Background variant={BackgroundVariant.Lines} color="#ccccc" />
</ReactFlow>
<button className="btn-save" onClick={() => handleSave()}>
Save
</button>
</div>
</div>
{isTabbarVisible && (
<Tabbar addNode={addNode} closeTabbar={closeTabbar} />
)}
{showPopup && (
<ShowPopup
selectedNodeContent={selectedNodeContent}
hidePopup={() => setShowPopup(false)}
/>
)}
{showPopupSuccess && (
<div className="success-save">
<div className="content-container">
<div className="success-checkmark">
<div className="check-icon">
<span className="icon-line line-tip"></span>
<span className="icon-line line-long"></span>
<div className="icon-circle"></div>
<div className="icon-fix"></div>
</div>
</div>
<div className="text-center content-text text-30">
Đã lưu lại tiến trình thành công !
</div>
</div>
</div>
)}
{isDeleteModalOpen && (
<DeleteNodeModal
onConfirm={handleConfirmDelete}
onClose={() => setIsDeleteModalOpen(false)}
/>
)}
</div>
);
};
export default NodeFlow;