This commit is contained in:
2024-08-16 15:08:06 +07:00
commit 502e4877fc
22 changed files with 4263 additions and 0 deletions

18
.eslintrc.cjs Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parser: '@typescript-eslint/parser',
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2023 webkid GmbH
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

19
README.md Normal file
View File

@@ -0,0 +1,19 @@
## Cài đặt và chạy dữ án
Tải dự án về:
```bash
git clone https://repo.hurasoft.com/tieptk/react-flow
```
Cài đặt file thư viện liên quan:
```bash
npm install
```
Chạy dự án với lệnh:
```bash
npm run dev
```

16
index.html Normal file
View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="./not-image.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Customer signs up for product updates</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3439
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "vite-react-flow-template",
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@xyflow/react": "^12.0.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-icons": "^5.3.0"
},
"license": "MIT",
"devDependencies": {
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^6.20.0",
"@typescript-eslint/parser": "^6.20.0",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.56.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"typescript": "^5.3.3",
"vite": "^5.0.12"
}
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

BIN
public/not-image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

128
src/App.tsx Normal file
View File

@@ -0,0 +1,128 @@
import { useCallback, useState } from 'react';
import {
ReactFlow,
addEdge,
applyEdgeChanges,
applyNodeChanges,
Background
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import TextUpdaterNode from './nodes/TextUpdaterNode.js';
import { FiUser, FiMail, FiCheckCircle,FiList } from 'react-icons/fi';
import {FaShuffle } from "react-icons/fa6";
const initialNodes = [
{
id: '1',
type: 'input',
data: {
label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiUser style={{ marginRight: 5, fontSize: '24px' }} />
Customer signs up for product updates
</div>
)
},
position: { x: 250, y: 0 },
},
{
id: '2',
data: {
label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiMail style={{ marginRight: 5 }} />
Send email
</div>
)
},
position: { x: 250, y: 100 },
},
{
id: '3',
data: {
label:(
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FaShuffle style={{ marginRight: 5, transform:`rotate(90deg)` }} />
<span>Replied to conversation?</span>
</div>
)
},
position: { x: 250, y: 200 },
},
{
id: '4',
type: 'output',
data: {
label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiCheckCircle style={{ marginRight: 5 }} />
Send survey
</div>
)
},
position: { x: 100, y: 300 },
},
{
id: '5',
type: 'output',
data: {
label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiList style={{ marginRight: 5 }} />
Contact Exits
</div>
)
},
position: { x: 400, y: 300 },
},
];
const initialEdges = [
{ id: 'e1-2', source: '1', target: '2' },
{ id: 'e2-3', source: '2', target: '3' },
{ id: 'e3-4', source: '3', target: '4', label: 'No' },
{ id: 'e3-5', source: '3', target: '5', label: 'Yes' },
];
// we define the nodeTypes outside of the component to prevent re-renderings
// you could also use useMemo inside the component
const nodeTypes = { textUpdater: TextUpdaterNode };
function Flow() {
const [nodes, setNodes] = useState(initialNodes);
const [edges, setEdges] = useState(initialEdges);
const onNodesChange = useCallback(
(changes) => setNodes((nds) => applyNodeChanges(changes, nds)),
[setNodes],
);
const onEdgesChange = useCallback(
(changes) => setEdges((eds) => applyEdgeChanges(changes, eds)),
[setEdges],
);
const onConnect = useCallback(
(connection) => setEdges((eds) => addEdge(connection, eds)),
[setEdges],
);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Background />
</ReactFlow>
);
}
export default Flow;

11
src/edges/index.ts Normal file
View File

@@ -0,0 +1,11 @@
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;

122
src/index.css Normal file
View File

@@ -0,0 +1,122 @@
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
.react-flow__node-input,
.react-flow__node-default,
.react-flow__node-output,
.react-flow__node-group {
width: 275px !important;
}
.react-flow__attribution {
display: none;
}
/* Popup Styling */
.popup {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 600px;
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.popup.hidden {
display: none;
}
.popup-content {
background: #fff;
padding: 20px;
border-radius: 5px;
width: 100%;
max-width: 600px;
position: relative;
}
.popup-close {
position: absolute;
top: 10px;
right: 10px;
font-size: 20px;
cursor: pointer;
}
form {
display: flex;
flex-direction: column;
}
form label {
font-weight: bold;
margin-bottom: 5px;
color: #333;
display: block;
}
form input,
form textarea,
form select {
padding: 10px;
margin-bottom: 15px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 16px;
width: 97%;
}
form textarea {
resize: vertical;
}
form button {
padding: 10px 15px;
background-color: #007bff;
border: none;
border-radius: 4px;
color: white;
font-size: 16px;
cursor: pointer;
transition: background-color 0.3s;
}
form button:hover {
background-color: #0056b3;
}
form .form-group {
margin-bottom: 15px;
}
form .form-group:last-child {
margin-bottom: 0;
}
#popup-form-fields {
width: 99%;
padding: 10px 0;
}
.popup-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999;
}

12
src/main.tsx Normal file
View File

@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import NoteFlow from './nodes/NoteFlow';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<NoteFlow />
</React.StrictMode>
);

293
src/nodes/NoteFlow.tsx Normal file
View File

@@ -0,0 +1,293 @@
import { useState } from 'react';
import {ReactFlow, addEdge, Controls, Background, MarkerType } 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 { event } from 'jquery';
const initialNode = {
id: '1',
type: 'input',
data: {
label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiUser style={{ marginRight: 5 }} />
<span>Customer signs up for product updates</span>
</div>
)
},
position: { x: 250, y: 100 },
};
const tabBarOptions = [
{
label: 'Send email',
id: '2',
type: '',
html: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiMail style={{ marginRight: 5 }} />
Send email
</div>
),
position: { x: 250, y: 200 },
},
{
label: 'Replied to conversation?',
id: '3',
type: '',
html: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FaShuffle style={{ marginRight: 5, transform:`rotate(90deg)` }} />
<span>Replied to conversation?</span>
</div>
),
position: { x: 250, y: 290 },
},
{
label: 'Send survey',
id: '4',
type: 'output',
html: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiCheckCircle style={{ marginRight: 5 }} />
Send survey
</div>
),
position: { x: 100, y: 400 },
},
{
label: 'Contact Exists',
id: '5',
type: 'output',
html: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiList style={{ marginRight: 5 }} />
Contact Exits
</div>
),
position: { x: 400, y: 400 },
},
];
// Nội dung cho mỗi node
const nodeContents = {
'2': `
<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>
`,
'1': `
<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">
`,
'3': `
<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>
`,
'5': `
<label for="contact-id">Contact ID:</label>
<input type="text" id="contact-id" name="contact-id" placeholder="Enter contact ID">
`,
'4': `
<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 NoteFlow = () => {
const [nodes, setNodes] = useState([initialNode]);
const [edges, setEdges] = useState([]);
const [showTabBar, setShowTabBar] = useState(false);
const [lastNodeId, setLastNodeId] = useState('1');
const [showAdditionalOptions, setShowAdditionalOptions] = useState(false);
const [selectedNodeContent, setSelectedNodeContent] = useState('');
const [showPopup, setShowPopup] = useState(false);
const handleAddNoteClick = () => {
setShowTabBar(true);
};
const handleTabClick = (option) => {
setShowTabBar(false);
const newNode = {
id: option.id,
data: { label: option.html },
position: { x: option.position.x, y: option.position.y },
};
setNodes((nds) => [...nds, newNode]);
setEdges((eds) => addEdge({ id: `e${lastNodeId}-${option.id}`, source: lastNodeId, target: option.id, animated: false }, eds));
setLastNodeId(option.id);
if (option.id === '3') {
const yesNode = {
id: '4',
type: 'output',
data: { label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiCheckCircle style={{ marginRight: 5 }} />
Send survey
</div>
) },
position: { x: 100, y: 400 },
};
const noNode = {
id: '5',
type: 'output',
data: { label: (
<div style={{display: 'flex', alignItems: 'center', justifyContent: 'center'}}>
<FiList style={{ marginRight: 5 }} />
Contact Exits
</div>
) },
position: { x: 400, y: 400 },
};
setNodes((nds) => [...nds, yesNode, noNode]);
setEdges((eds) => [
...eds,
{ id: 'e3-4', source: '3', target: '4', label: 'yes', animated: false,},
{ id: 'e3-5', source: '3', target: '5', label: 'no', animated: false},
]);
}
};
const handleAdditionalOptionClick = (option) => {
setShowAdditionalOptions(false);
const surveyNode = {
id: option.id,
data: { label: 'Send survey' },
position: { x: 100, y: 150 * (nodes.length + 1) },
};
const contactExistsNode = {
id: '5',
data: { label: 'Contact Exists' },
position: { x: 400, y: 150 * (nodes.length + 1) },
};
setNodes((nds) => [...nds, surveyNode, contactExistsNode]);
setEdges((eds) => [
...eds,
{ id: `e${lastNodeId}-${option.id}`, source: lastNodeId, target: option.id, label: 'yes', animated: true },
{ id: `e${option.id}-5`, source: option.id, target: '5', label: 'no', animated: true },
]);
};
const onNodeClick = (event, node) => {
const content = nodeContents[node.id];
setSelectedNodeContent(content);
setShowPopup(true);
};
const hideenPopup = () => {
setShowPopup(false);
}
const onConnect = (params) => setEdges((eds) => addEdge(params, eds));
return (
<div style={{ height: '100vh', width: '100%' }}>
<ReactFlow nodes={nodes} edges={edges} onConnect={onConnect} onNodeClick={onNodeClick}>
<Controls />
<Background />
</ReactFlow>
<button
onClick={handleAddNoteClick}
style={{
position: 'absolute',
top: 10,
left: 10,
zIndex: 10,
padding: '10px',
}}
>
Add Note
</button>
{showTabBar && (
<div
style={{
position: 'absolute',
top: 50,
left: 10,
zIndex: 10,
background: 'white',
border: '1px solid #ccc',
padding: '10px',
}}
>
{tabBarOptions.map((option) => (
<div
key={option.id}
onClick={() => handleTabClick(option)}
style={{
padding: '10px',
cursor: 'pointer',
borderBottom: '1px solid #ddd',
}}
>
{option.label}
</div>
))}
</div>
)}
{showAdditionalOptions && (
<div className='popup'
>
{additionalOptions.map((option) => (
<div
key={option.id}
onClick={() => handleAdditionalOptionClick(option)}
style={{
padding: '10px',
cursor: 'pointer',
borderBottom: '1px solid #ddd',
}}
>
{option.label}
</div>
))}
</div>
)}
{showPopup && (
<>
<div className="popup-overlay" onClick={hideenPopup}></div>
<div
className={`popup global-popup ${showPopup ? '' : 'hidden'}`}
>
<div className="popup-content">
<div className="conent-form">
<form>
<div dangerouslySetInnerHTML={{ __html: selectedNodeContent }} />
<button onClick={() => setShowPopup(false)}>Close</button>
</form>
</div>
</div>
</div>
</>
)}
</div>
);
};
export default NoteFlow;

View File

@@ -0,0 +1,25 @@
import { Handle, Position, type NodeProps } from '@xyflow/react';
import { type PositionLoggerNode } from './types';
export function PositionLoggerNode({
positionAbsoluteX,
positionAbsoluteY,
data,
}: NodeProps<PositionLoggerNode>) {
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.
<div className="react-flow__node-default">
{data.label && <div>{data.label}</div>}
<div>
{x} {y}
</div>
<Handle type="source" position={Position.Bottom} />
</div>
);
}

View File

@@ -0,0 +1,31 @@
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 (
<div className="text-updater-node">
<div className="customer-sign-up node" data-node="customerSignsUpNode"><p><i className="fa-regular fa-circle-play"></i> Customer signs up for product updates</p></div>
<Handle
type="source"
position={Position.Bottom}
id="a"
style={handleStyle}
isConnectable={isConnectable}
/>
<Handle
type="source"
position={Position.Bottom}
id="b"
isConnectable={isConnectable}
/>
</div>
);
}
export default TextUpdaterNode;

26
src/nodes/index.ts Normal file
View File

@@ -0,0 +1,26 @@
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;

5
src/nodes/types.ts Normal file
View File

@@ -0,0 +1,5 @@
import type { Node, BuiltInNode } from '@xyflow/react';
export type PositionLoggerNode = Node<{ label: string }, 'position-logger'>;
export type AppNode = BuiltInNode | PositionLoggerNode;

1
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

7
vite.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})