diff --git a/src/assets/css/style.css b/src/assets/css/style.css new file mode 100644 index 0000000..a75a91c --- /dev/null +++ b/src/assets/css/style.css @@ -0,0 +1,107 @@ +.PopupTabBar { + position: absolute; + bottom: 50px; + right: 10px; + z-index: 10; + background: white; + border: 1px solid #ccc; + padding: 0 10px 10px 10px; + width: 340px; + height: 460px; + max-height: 70%; + overflow-y: auto; + border-radius: 8px; + box-shadow: 0px 12px 24px rgba(36, 28, 21, 0.16); +} +.PopupTabBar .tittle { + margin: 0 0 10px 0; +} + +.itemTab { + padding: 8px 20px; +} + +.itemTab.grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + grid-gap: 8px; +} + +.PopupTabBar .itemTab .items { + position: relative; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: center; + -ms-flex-pack: center; + justify-content: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + height: 48px; + border: 1px solid rgba(36, 28, 21, 0.15); + border-radius: 4px; + padding: 16px 8px; + background: #fff; +} + +.itemTab.grid .items { + -webkit-box-orient: vertical; + -webkit-box-direction: normal; + -ms-flex-direction: column; + flex-direction: column; +} + +.PopupTabBar .itemTab .items:hover { + box-shadow: 0px 4px 12px rgba(36, 28, 21, 0.12); + border: 1px solid #8c8c8c; +} +.PopupTabBar .itemTab span { + margin-top: 5px; + font-size: 13px; + text-align: center; +} + +.header-tab { + text-align: center; + position: sticky; + top: 0; + padding: 10px 0; + z-index: 2; + background: #fff; +} + +.header-tab .close-tabbar { + text-align: right; + font-size: 20px; +} + +.step { + display: flex; + align-items: center; + height: 30px; +} + +.step .left { + width: 40px; + margin-right: 10px; + border-right: 1px solid #241c156e; +} + +.step .right { + width: calc(100% - 30px); + text-align: left; + font-size: 14px; +} + +.step .left img { + margin-top: 5px; + height: 20px; + width: 20px; +} + +.react-flow__edge-text { + font-size: 13px; + font-weight: 700; + text-transform: capitalize; +} diff --git a/src/assets/icons/icon-check.svg b/src/assets/icons/icon-check.svg new file mode 100644 index 0000000..cd27eeb --- /dev/null +++ b/src/assets/icons/icon-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-email.svg b/src/assets/icons/icon-email.svg new file mode 100644 index 0000000..5d3349e --- /dev/null +++ b/src/assets/icons/icon-email.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-if.svg b/src/assets/icons/icon-if.svg new file mode 100644 index 0000000..bbda400 --- /dev/null +++ b/src/assets/icons/icon-if.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-list.svg b/src/assets/icons/icon-list.svg new file mode 100644 index 0000000..29d2777 --- /dev/null +++ b/src/assets/icons/icon-list.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-time.svg b/src/assets/icons/icon-time.svg new file mode 100644 index 0000000..4a6520f --- /dev/null +++ b/src/assets/icons/icon-time.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-user.svg b/src/assets/icons/icon-user.svg new file mode 100644 index 0000000..f366874 --- /dev/null +++ b/src/assets/icons/icon-user.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/assets/icons/icon-wait.svg b/src/assets/icons/icon-wait.svg new file mode 100644 index 0000000..c3d511e --- /dev/null +++ b/src/assets/icons/icon-wait.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/edges/index.ts b/src/edges/index.ts deleted file mode 100644 index 1f2b021..0000000 --- a/src/edges/index.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { Edge, EdgeTypes } from '@xyflow/react'; - -export const initialEdges: Edge[] = [ - { id: 'a->c', source: 'a', target: 'c', animated: true }, - { id: 'b->d', source: 'b', target: 'd' }, - { id: 'c->d', source: 'c', target: 'd', animated: true }, -]; - -export const edgeTypes = { - // Add your custom edge types here! -} satisfies EdgeTypes; diff --git a/src/main.tsx b/src/main.tsx index 8d624a4..553536f 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -10,4 +10,4 @@ ReactDOM.createRoot(document.getElementById('root')!).render( -); \ No newline at end of file +); diff --git a/src/nodes/NoteFlow.tsx b/src/nodes/NoteFlow.tsx index 3f40606..2e04727 100644 --- a/src/nodes/NoteFlow.tsx +++ b/src/nodes/NoteFlow.tsx @@ -1,108 +1,75 @@ -import { useState,useCallback } from 'react'; -import { ReactFlow, addEdge, Controls, Background } from '@xyflow/react'; +import { useState, useCallback, useEffect } from 'react'; +import { + ReactFlow, + addEdge, + Controls, + Background, + BackgroundVariant, + SelectionMode +} from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import { FiUser, FiMail, FiCheckCircle, FiList } from 'react-icons/fi'; import { FaShuffle } from 'react-icons/fa6'; +import '../assets/css/style.css'; +import Tabbar from './Tabbar.tsx'; +import ShowPopup from './ShowPopup.tsx'; +import { v4 as uuidv4 } from 'uuid'; const initialNodes = [ { + key: 'customer-sign', id: '1', type: 'input', data: { label: ( -
- - Customer signs up for product updates +
+
+ +
+
Customer signs up to
- ), + ) }, - position: { x: 250, y: 100 }, + position: { x: 250, y: 100 } }, { id: '2', data: { label: 'Add Note' }, - position: { x: 250, y: 250 }, - }, -]; - -// nội dung tabbar -const tabBarOptions = [ - { - label: 'Send email', - id: '3', - type: '', - html: ( -
- - Send email -
- ), - position: { x: 250, y: 200 }, - }, - { - label: 'Replied to conversation?', - id: '4', - type: '', - html: ( -
- - Replied to conversation? -
- ), - position: { x: 250, y: 290 }, - }, - { - label: 'Send survey', - id: '5', - type: 'output', - html: ( -
- - Send survey -
- ), - position: { x: 100, y: 400 }, - }, - { - label: 'Contact Exists', - id: '6', - type: 'output', - html: ( -
- - Contact Exists -
- ), - position: { x: 400, y: 400 }, - }, + position: { x: 250, y: 250 } + } ]; // Nội dung cho mỗi node const nodeContents = { - '2': ` + 'send-email': ` `, - '1': ` + 'customer-sign': ` `, - '3': ` + ifelse: ` `, - '5': ` + 'contact-exists': ` `, - '4': ` + 'send-survey': ` @@ -110,127 +77,35 @@ const nodeContents = { ` }; - const NoteFlow = () => { const [nodes, setNodes] = useState(initialNodes); const [edges, setEdges] = useState([ - { id: 'e1-2', source: '1', target: '2' }, // Kết nối node "Add Note" với node mặc định ban đầu + { id: 'e1-2', source: '1', target: '2' } // Kết nối node "Add Note" với node mặc định ban đầu ]); const [showTabBar, setShowTabBar] = useState(false); const [lastNodeId, setLastNodeId] = useState('1'); - const [selectedNodeId, setSelectedNodeId] = useState(null); // Theo dõi node đã chọn const [nodeToDelete, setNodeToDelete] = useState(null); const [showPopup, setShowPopup] = useState(false); const [selectedNodeContent, setSelectedNodeContent] = useState(''); - - const handleAddNoteClick = () => { setShowTabBar(true); - }; - - const handleTabClick = (option) => { - setShowTabBar(false); - - - if (selectedNodeId === option.id) { - alert('Node này đã được thêm. Vui lòng chọn một node khác.'); - return; - } - - const newNode = { - id: option.id, - data: { label: option.html }, - position: { x: option.position.x, y: option.position.y }, + const handleNodeSelect = (selectedNode) => { + addNodeBetween(selectedNode, sourceId, targetId); }; - - const addNoteNode = nodes.find((node) => node.id === '2'); - - // Xóa node "Add Note" nếu chọn "Replied to conversation?" - if (option.id === '4') { - setNodes((nds) => nds.filter((node) => node.id !== '2')); - - // Thêm node "Replied to conversation?" - const repliedNode = { - id: '4', - data: { label: option.html }, - position: { x: option.position.x, y: option.position.y }, - }; - - // Thêm các nhánh "Send survey" và "Contact Exists" - const yesNode = { - id: '5', - type: 'output', - data: { - label: ( -
- - Send survey -
- ), - }, - position: { x: option.position.x - 150, y: option.position.y + 150 }, - }; - - const noNode = { - id: '6', - type: 'output', - data: { - label: ( -
- - Contact Exists -
- ), - }, - position: { x: option.position.x + 150, y: option.position.y + 150 }, - }; - - setNodes((nds) => [...nds, repliedNode, yesNode, noNode]); - - - setEdges((eds) => [ - ...eds, - { id: 'e4-5', source: '4', target: '5', label: 'yes', animated: false }, - { id: 'e4-6', source: '4', target: '6', label: 'no', animated: false }, - { id: `e${lastNodeId}-4`, source: lastNodeId, target: '4' }, // Kết nối node hiện tại với "Replied to conversation?" - ]); - } else { - if (addNoteNode) { - // Di chuyển node "Add Note" xuống dưới node mới - setNodes((nds) => - nds.map((node) => - node.id === '2' - ? { ...node, position: { x: option.position.x, y: option.position.y + 150 } } - : node - ) - ); - } - - // Thêm node mới vào danh sách node - setNodes((nds) => [...nds, newNode]); - - // Kết nối node mới với node hiện tại - setEdges((eds) => addEdge({ id: `e${lastNodeId}-${option.id}`, source: lastNodeId, target: option.id }, eds)); - - // Kết nối node "Add Note" cũ với node mới - if (addNoteNode) { - setEdges((eds) => addEdge({ id: `e${option.id}-2`, source: option.id, target: '2' }, eds)); - } - - // Cập nhật lastNodeId - setLastNodeId(option.id); - } - - // Cập nhật selectedNodeId với ID của node được chọn - setSelectedNodeId(option.id); + setAddNodeCallback(() => addNodeBetweenWithIds); }; const onNodeClick = (event, node) => { - if (node.id === '2') { // Check if the clicked node is "Add Note" + if (node.id === '2') { + // Check if the clicked node is "Add Note" setShowTabBar(true); + } else { + // Hiển thị popup khi click vào bất kỳ node nào + const content = nodeContents[node.key]; // Lấy nội dung tương ứng của node + setSelectedNodeContent(content); // Cập nhật nội dung cho popup + setShowPopup(true); // Mở popup } - }; const onNodeContextMenu = useCallback((event, node) => { @@ -241,65 +116,203 @@ const NoteFlow = () => { const handleNodeDelete = () => { if (nodeToDelete) { setNodes((nds) => nds.filter((node) => node.id !== nodeToDelete.id)); - setEdges((eds) => eds.filter((edge) => edge.source !== nodeToDelete.id && edge.target !== nodeToDelete.id)); + setEdges((eds) => + eds.filter( + (edge) => + edge.source !== nodeToDelete.id && edge.target !== nodeToDelete.id + ) + ); setNodeToDelete(null); } }; + // Function to get a unique y position + const getUniqueYPosition = (xPosition, baseY, spacing) => { + const existingYPositions = nodes + .filter((node) => node.position.x === xPosition) + .map((node) => node.position.y); - const onNodeDoubleClick = (event, node) => { - const content = nodeContents[node.id]; - setSelectedNodeContent(content); - setShowPopup(true); - } + let newYPosition = baseY; - const hideenPopup = () => { + while (existingYPositions.includes(newYPosition)) { + newYPosition += spacing; + } + + return newYPosition; + }; + + 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 = uuidv4(); // Generate a unique ID for the new node + const addNoteNode = nodes.find((node) => node.id === '2'); + const initialX = addNoteNode.position.x; + const nextY = + nodes.length > 2 + ? getNextYPosition(initialX, 130) + : addNoteNode.position.y; + + if (option.id === '4') { + // Handle specific option case + setNodes((nds) => nds.filter((node) => node.id !== '2')); + + const repliedNode = { + id: newNodeId, + key: option.key, + data: { label: option.htmlNode }, + position: { x: option.position.x, y: nextY } + }; + + const yesNode = { + id: uuidv4(), + type: 'output', + key: 'contact-exists', + data: { + label: ( +
+
+ +
+
Contact Exists
+
+ ) + }, + position: { x: option.position.x - 150, y: nextY + 150 } + }; + + const noNode = { + id: uuidv4(), + type: 'output', + key: 'send-survey', + data: { + label: ( +
+
+ +
+
Send survey
+
+ ) + }, + position: { x: option.position.x + 150, y: nextY + 150 } + }; + + setNodes((nds) => [...nds, repliedNode, yesNode, noNode]); + + setEdges((eds) => [ + ...eds, + { + id: uuidv4(), + source: repliedNode.id, + target: yesNode.id, + label: 'yes', + animated: false + }, + { + id: uuidv4(), + source: repliedNode.id, + target: noNode.id, + label: 'no', + animated: false + }, + { id: uuidv4(), source: lastNodeId, target: repliedNode.id } + ]); + } else { + // Add new node logic + if (addNoteNode) { + const initialX = addNoteNode.position.x; + setNodes((nds) => + nds.map((node) => + node.id === '2' + ? { + ...node, + position: { + x: initialX, + y: nextY + 130 + } + } + : node + ) + ); + } + const newNode = { + id: newNodeId, + key: option.key, + data: { label: option.htmlNode }, + position: { + x: option.position.x, + y: nodes.length > 2 ? nextY : addNoteNode.position.y + } + }; + + console.log(getNextYPosition(option.position.x, 130)); + + setNodes((nds) => [...nds, newNode]); + + setEdges((eds) => [ + ...eds, + { + id: uuidv4(), + source: lastNodeId, + target: newNodeId + } + ]); + + 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 + }; return (
- + - + - {showTabBar && ( -
- {tabBarOptions.map((option) => ( -
handleTabClick(option)} - - style={{ - padding: '10px', - cursor: selectedNodeId === option.id ? 'not-allowed' : 'pointer', - opacity: selectedNodeId === option.id ? 0.5 : 1, // Làm mờ các node đã chọn - borderBottom: '1px solid #ddd', - }} - > - {option.label} -
- ))} -
- )} + + {showTabBar && } + {nodeToDelete && (
)} {showPopup && ( - <> -
-
-
-
-
-
- - -
-
-
- + setShowPopup(false)} + /> )}
); }; -export default NoteFlow; \ No newline at end of file +export default NoteFlow; diff --git a/src/nodes/PositionLoggerNode.tsx b/src/nodes/PositionLoggerNode.tsx deleted file mode 100644 index 2b93cd3..0000000 --- a/src/nodes/PositionLoggerNode.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { Handle, Position, type NodeProps } from '@xyflow/react'; - -import { type PositionLoggerNode } from './types'; - -export function PositionLoggerNode({ - positionAbsoluteX, - positionAbsoluteY, - data, -}: NodeProps) { - const x = `${Math.round(positionAbsoluteX)}px`; - const y = `${Math.round(positionAbsoluteY)}px`; - - return ( - // We add this class to use the same styles as React Flow's default nodes. -
- {data.label &&
{data.label}
} - -
- {x} {y} -
- - -
- ); -} diff --git a/src/nodes/ShowPopup.tsx b/src/nodes/ShowPopup.tsx new file mode 100644 index 0000000..beee81e --- /dev/null +++ b/src/nodes/ShowPopup.tsx @@ -0,0 +1,34 @@ +import { useState, useEffect } from 'react'; + +const ShowPopup = ({ selectedNodeContent, hidePopup }) => { + const [showPopup, setShowPopup] = useState(true); + + const handleOverlayClick = () => { + setShowPopup(false); + hidePopup(); // Gọi hàm từ props để ẩn popup ở component cha + }; + + useEffect(() => { + if (!showPopup) { + hidePopup(); // Đảm bảo khi popup bị ẩn, nó được xử lý ở component cha + } + }, [showPopup, hidePopup]); + + return ( + <> +
+
+
+
+
+
+ + +
+
+
+ + ); +}; + +export default ShowPopup; diff --git a/src/nodes/Tabbar.tsx b/src/nodes/Tabbar.tsx new file mode 100644 index 0000000..4d5df60 --- /dev/null +++ b/src/nodes/Tabbar.tsx @@ -0,0 +1,192 @@ +import { useState, useCallback } from 'react'; +import { FiUser, FiMail, FiCheckCircle, FiList } from 'react-icons/fi'; +import { addEdge } from '@xyflow/react'; + +const initialNodes = [ + { + id: '1', + type: 'input', + key: 'customer-signs', + data: { + label: ( +
+ + Customer signs up for product updates +
+ ) + }, + position: { x: 250, y: 100 } + }, + { + id: '2', + data: { label: 'Add Note' }, + position: { x: 250, y: 250 } + } +]; +// nội dung tabbar +const tabBarOptions = [ + { + key: 'customer-sign', + label: 'Send email', + id: '3', + type: '', + html: ( + <> + + Email + + ), + htmlNode: ( +
+
+ +
+
Send email
+
+ ), + position: { x: 250, y: 0 } + }, + { + key: 'ifelse', + label: 'Replied to conversation?', + id: '4', + type: '', + html: ( + <> + + Replied to conversation? + + ), + htmlNode: ( +
+
+ +
+
Replied to conversation?
+
+ ), + position: { x: 250, y: 290 } + }, + { + key: 'send-survey', + label: 'Send survey', + id: '5', + type: 'output', + html: ( + <> + + Send survey + + ), + htmlNode: ( +
+
+ +
+
Send survey
+
+ ), + position: { x: 250, y: 400 } + }, + { + key: 'contact-exists', + label: 'Contact Exists', + id: '6', + type: 'output', + html: ( + <> + + Contact Exists + + ), + htmlNode: ( +
+
+ +
+
Contact Exists
+
+ ), + position: { x: 250, y: 400 } + } +]; + +const Tabbar = ({ addNode }) => { + const [selectedNodeId, setSelectedNodeId] = useState(null); + + const handleTabClick = (option) => { + addNode(option); + // Cập nhật selectedNodeId với ID của node được chọn + setSelectedNodeId(option.id); + }; + + return ( + <> +
+
+
+
+

Rules

+
+ {tabBarOptions.map((option) => ( +
handleTabClick(option)} + > + {option.html} +
+ ))} +
+
+ + ); +}; + +export default Tabbar; diff --git a/src/nodes/TextUpdaterNode.tsx b/src/nodes/TextUpdaterNode.tsx deleted file mode 100644 index 61d1f03..0000000 --- a/src/nodes/TextUpdaterNode.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useCallback } from 'react'; -import { Handle, Position } from '@xyflow/react'; - -const handleStyle = { left: 10 }; - -function TextUpdaterNode({ data, isConnectable }) { - const onChange = useCallback((evt) => { - console.log(evt.target.value); - }, []); - - return ( -
-

Customer signs up for product updates

- - -
- ); -} - -export default TextUpdaterNode; diff --git a/src/nodes/index.ts b/src/nodes/index.ts deleted file mode 100644 index 3307f46..0000000 --- a/src/nodes/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { NodeTypes } from '@xyflow/react'; - -import { PositionLoggerNode } from './PositionLoggerNode'; -import { AppNode } from './types'; - -export const initialNodes: AppNode[] = [ - { id: 'a', type: 'input', position: { x: 0, y: 0 }, data: { label: 'wire' } }, - { - id: 'b', - type: 'position-logger', - position: { x: -100, y: 100 }, - data: { label: 'drag me!' }, - }, - { id: 'c', position: { x: 100, y: 100 }, data: { label: 'your ideas' } }, - { - id: 'd', - type: 'output', - position: { x: 0, y: 200 }, - data: { label: 'with React Flow' }, - }, -]; - -export const nodeTypes = { - 'position-logger': PositionLoggerNode, - // Add any of your custom nodes here! -} satisfies NodeTypes;