flow 3
import React, { useState, useCallback, useEffect, useRef } from "react";
import Layout from "@/components/layout";
import { Combobox } from "@/components/ui/combobox";
import {
ReactFlow,
MiniMap,
Controls,
Background,
useNodesState,
useEdgesState,
addEdge,
Handle,
Position,
ReactFlowProvider,
getBezierPath,
} from "@xyflow/react";
import Spinner from "@/components/spinner";
import { getCookie } from "cookies-next";
import "@xyflow/react/dist/style.css";
import toast, { Toaster } from "react-hot-toast";
import { X, Image, List, Type, Database } from "lucide-react";
import axios from "../../../utils/axiosInstance";
import { useRouter } from "next/router";
import {
TriggerNode,
TextButtonNode,
MediaNode,
ListNode,
AttributeNode,
} from "../../../components/automation/nodes";
import { FlowHeader } from "@/components/automation/FlowHeader";
const CustomEdge = ({
id,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
data,
markerEnd,
}) => {
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const [isHovered, setIsHovered] = useState(false);
// Ensure we're using the onDelete callback from props.data
const handleEdgeDelete = () => {
if (data?.onDelete) {
data.onDelete(id);
}
};
return (
<>
<path
id={id}
style={{
...style,
strokeWidth: 2,
stroke: "#6366f1",
}}
className="react-flow__edge-path hover:stroke-indigo-400"
d={edgePath}
markerEnd={markerEnd}
/>
<g
transform={`translate(${labelX} ${labelY})`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Make the entire circle clickable */}
<circle
r="18"
className={`
cursor-pointer
transition-all duration-200
${
isHovered
? "fill-red-50 stroke-red-300"
: "fill-white stroke-gray-200"
}
`}
strokeWidth="1.5"
onClick={handleEdgeDelete} // Make sure this is called
style={{ pointerEvents: "all" }} // Ensure clicks are captured
/>
{/* X icon */}
<g
className={`
transform transition-transform duration-200
${isHovered ? "scale-110" : "scale-100"}
`}
style={{ pointerEvents: "none" }} // Prevent X from capturing clicks
>
<path
d="M-6 -6 L6 6 M-6 6 L6 -6"
className={`
transition-all duration-200
${isHovered ? "stroke-red-500" : "stroke-gray-400"}
`}
strokeWidth="2.5"
strokeLinecap="round"
/>
</g>
{/* Ripple effect on hover */}
{isHovered && (
<circle
r="18"
className="fill-none stroke-red-200 animate-ping"
strokeWidth="2"
opacity="0.5"
style={{ pointerEvents: "none" }} // Prevent ripple from capturing clicks
/>
)}
</g>
</>
);
};
const NodeTypeDropdown = ({ options, onSelect, onCancel, position }) => {
const nodeIcons = {
"Text + Button": Type,
Media: Image,
List: List,
Attribute: Database,
};
return (
<div
className="absolute bg-white rounded-xl shadow-2xl border border-gray-200 p-4 z-50 w-[300px]"
style={{
top: position.y,
left: position.x,
transform: "translate(-50%, -50%)",
}}
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold text-gray-800">Select Action</h3>
<button
onClick={onCancel}
className="text-gray-500 hover:text-gray-700 transition-colors"
title="Cancel"
>
<X className="w-6 h-6" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
{options.map((option) => {
const Icon = nodeIcons[option];
return (
<button
key={option}
onClick={() => onSelect(option)}
className="
flex flex-col items-center justify-center
p-4 rounded-lg
hover:bg-indigo-50
border border-transparent
hover:border-indigo-200
transition-all
group
"
>
<Icon
className="
w-8 h-8 mb-2
text-gray-500
group-hover:text-indigo-600
transition-colors
"
/>
<span
className="
text-sm font-medium
text-gray-700
group-hover:text-indigo-700
"
>
{option}
</span>
</button>
);
})}
</div>
</div>
);
};
const nodeTypes = {
triggerNode: TriggerNode,
textButtonNode: TextButtonNode,
mediaNode: MediaNode,
listNode: ListNode,
attributeNode: AttributeNode,
};
const edgeTypes = {
custom: CustomEdge,
};
// In your Flow component
const Flow = ({ setGenerateFlowData, initialFlowData, flowId }) => {
// Add event handlers to the initial nodes
const onEdgeDelete = useCallback((edgeId) => {
setEdges((eds) => eds.filter((edge) => edge.id !== edgeId));
}, []);
// const handleTriggerUpdate = useCallback((nodeId, newData) => {
// setEdges(
// (prevEdges) => prevEdges.filter((edge) => edge.source !== nodeId) // Remove edges connected to trigger node
// );
// setNodes((nds) =>
// nds.map((node) => {
// if (node.id === nodeId) {
// return {
// ...node,
// data: {
// ...node.data,
// triggerType: newData.triggerType || node.data.triggerType,
// keywords:
// newData.triggerType === "keywords"
// ? newData.keywords || []
// : [],
// template:
// newData.triggerType === "template"
// ? newData.template || null
// : null,
// onTriggerUpdate: handleTriggerUpdate,
// },
// };
// }
// return node;
// })
// );
// }, []);
const handleTriggerUpdate = useCallback((nodeId, newData) => {
setEdges(
(prevEdges) => prevEdges.filter((edge) => edge.source !== nodeId) // Remove edges connected to trigger node
);
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
triggerType: newData.triggerType || node.data.triggerType,
keywords: newData.keywords || [],
template: newData.template || null,
onTriggerUpdate: handleTriggerUpdate,
},
};
}
return node;
})
);
}, []);
// Initialize nodes
const defaultTriggerNode = {
id: "1",
type: "triggerNode",
position: { x: 400, y: 200 },
data: {
triggerType: "keywords",
keywords: ["Hi"],
template: null,
onTriggerUpdate: handleTriggerUpdate,
},
};
const [nodes, setNodes, onNodesChange] = useNodesState(
initialFlowData?.nodes && initialFlowData.nodes.length > 0
? initialFlowData.nodes
: [defaultTriggerNode]
);
// Initialize edges with the ref function
const [edges, setEdges, onEdgesChange] = useEdgesState(
initialFlowData?.edges?.map((edge) => ({
id: edge.sourceHandle
? `edge-${edge.source}-${edge.sourceHandle}-${edge.target}`
: `edge-${edge.source}-${edge.target}`,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
type: "custom",
data: {
sourceButtonText: edge.data?.sourceButtonText,
onDelete: onEdgeDelete,
},
})) || []
);
const [isDropdownVisible, setDropdownVisible] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ x: 0, y: 0 });
const [pendingConnection, setPendingConnection] = useState(null);
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
useEffect(() => {
if (initialFlowData?.nodes) {
setNodes((nodes) =>
nodes.map((node) => ({
...node,
data: {
...node.data,
onTriggerUpdate: handleTriggerUpdate,
onKeywordsUpdate: handleKeywordsUpdate,
onButtonUpdate: handleButtonsUpdate,
onContentUpdate: handleContentUpdate,
onButtonsUpdate: handleButtonsUpdate,
onMediaUpdate: handleMediaUpdate,
onItemsUpdate: handleItemsUpdate,
onAttributeUpdate: handleAttributeUpdate,
onButtonTextUpdate: handleButtonTextUpdate,
onSectionsUpdate: handleSectionsUpdate,
onDelete: () => handleNodeDelete(node.id),
},
}))
);
}
}, [initialFlowData]);
// Handle node deletion
const onNodesDelete = useCallback(
(deleted) => {
// Filter out the trigger node from deletion
const nodesToDelete = deleted.filter(
(node) => node.type !== "triggerNode"
);
// Remove associated edges
const edgesToDelete = edges.filter((edge) =>
nodesToDelete.find(
(node) => node.id === edge.source || node.id === edge.target
)
);
setEdges((eds) =>
eds.filter((edge) => !edgesToDelete.find((del) => del.id === edge.id))
);
},
[edges, setEdges]
);
function handleButtonsUpdate(nodeId, newButtons) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
buttons: newButtons,
},
};
}
return node;
})
);
}
function handleContentUpdate(nodeId, newContent) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
content: newContent,
},
};
}
return node;
})
);
}
function handleMediaUpdate(
nodeId,
mediaUrl,
mediaType,
uploadedId,
fileName
) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
mediaUrl,
mediaType,
uploadedId: uploadedId, // Make sure this is set
fileName: fileName, // Make sure this is set
},
};
}
return node;
})
);
}
function handleItemsUpdate(nodeId, newItems) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
items: newItems,
},
};
}
return node;
})
);
}
function handleAttributeUpdate(nodeId, field, value) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
[field]: value,
},
};
}
return node;
})
);
}
function handleKeywordsUpdate(nodeId, newKeywords) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
keywords: newKeywords,
onKeywordsUpdate: handleKeywordsUpdate,
},
};
}
return node;
})
);
}
function handleButtonTextUpdate(nodeId, newButtonText) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
buttonText: newButtonText,
},
};
}
return node;
})
);
}
function handleSectionsUpdate(nodeId, newSections) {
setNodes((nds) =>
nds.map((node) => {
if (node.id === nodeId) {
return {
...node,
data: {
...node.data,
sections: newSections,
},
};
}
return node;
})
);
}
const onConnectStart = useCallback((_, { nodeId, handleId }) => {
setPendingConnection({ sourceId: nodeId, sourceHandleId: handleId });
setDropdownVisible(false);
}, []);
const onConnectEnd = useCallback((event) => {
const targetNode = event.target.closest(".react-flow__node");
if (!targetNode) {
const bounds = document
.querySelector(".react-flow")
.getBoundingClientRect();
// Calculate position relative to the viewport
const position = {
x: event.clientX - bounds.left,
y: event.clientY - bounds.top,
};
setDropdownPosition(position);
setMousePosition(position);
setDropdownVisible(true);
}
}, []);
/// Modify the onConnect function to ensure onDelete is passed
const onConnect = useCallback(
(params) => {
const sourceNode = nodes.find((node) => node.id === params.source);
const sourceButtonText = sourceNode?.data?.buttons?.find(
(btn) => btn.handleId === params.sourceHandle
)?.label;
const newEdge = {
...params,
id: params.sourceHandle
? `edge-${params.source}-${params.sourceHandle}-${params.target}`
: `edge-${params.source}-${params.target}`,
type: "custom",
data: {
onDelete: onEdgeDelete,
sourceButtonText: sourceButtonText,
},
};
setEdges((eds) => addEdge(newEdge, eds));
},
[nodes, onEdgeDelete]
);
const isConnectionAllowed = useCallback(() => {
return true; // Allow all connections
}, []);
// Add this state to track the flow viewport
const [viewport, setViewport] = useState({ x: 0, y: 0, zoom: 1 });
// Update viewport state when it changes
const onViewportChange = useCallback((viewport) => {
setViewport(viewport);
}, []);
const handleDropdownSelect = useCallback(
(option) => {
if (!pendingConnection) return;
const newNodeId = `node-${Date.now()}`;
const messageNodes = nodes.filter((node) =>
["textButtonNode", "mediaNode", "listNode"].includes(node.type)
);
const messageNumber = messageNodes.length + 1;
// Calculate the actual position considering viewport transform
const position = {
x: (dropdownPosition.x - viewport.x) / viewport.zoom,
y: (dropdownPosition.y - viewport.y) / viewport.zoom,
};
let nodeType;
switch (option) {
case "Text + Button":
nodeType = "textButtonNode";
break;
case "Media":
nodeType = "mediaNode";
break;
case "List":
nodeType = "listNode";
break;
case "Attribute":
nodeType = "attributeNode";
break;
default:
nodeType = "textButtonNode";
}
// Create new node with corrected position
const newNode = {
id: newNodeId,
type: nodeType,
position: position, // Use the corrected position
data: {
type: option,
content: "",
buttons: [],
messageNumber: nodeType !== "attributeNode" ? messageNumber : null,
onButtonUpdate: handleButtonsUpdate,
onContentUpdate: handleContentUpdate,
onButtonsUpdate: handleButtonsUpdate,
onMediaUpdate: handleMediaUpdate,
onItemsUpdate: handleItemsUpdate,
onAttributeUpdate: handleAttributeUpdate,
onButtonTextUpdate: handleButtonTextUpdate,
onSectionsUpdate: handleSectionsUpdate,
onDelete: () => handleNodeDelete(newNodeId),
},
};
// Create new edge if there's a pending connection
if (pendingConnection.sourceId) {
const newEdge = {
id: pendingConnection.sourceHandleId
? `edge-${pendingConnection.sourceId}-${pendingConnection.sourceHandleId}-${newNodeId}`
: `edge-${pendingConnection.sourceId}-${newNodeId}`,
source: pendingConnection.sourceId,
sourceHandle: pendingConnection.sourceHandleId,
target: newNodeId,
type: "custom",
data: {
onDelete: onEdgeDelete,
},
};
setEdges((eds) => [...eds, newEdge]);
}
setNodes((nds) => [...nds, newNode]);
setDropdownVisible(false);
setPendingConnection(null);
},
[pendingConnection, dropdownPosition, viewport, nodes, setNodes, setEdges]
);
// Handle node deletion
const handleNodeDelete = useCallback(
(nodeId) => {
setNodes((nds) => {
const filteredNodes = nds.filter((node) => node.id !== nodeId);
// Recalculate message numbers
return filteredNodes.map((node) => {
if (["textButtonNode", "mediaNode", "listNode"].includes(node.type)) {
const newMessageNumber =
filteredNodes.filter(
(n) =>
["textButtonNode", "mediaNode", "listNode"].includes(
n.type
) && n.id < node.id
).length + 1;
return {
...node,
data: {
...node.data,
messageNumber: newMessageNumber,
},
};
}
return node;
});
});
setEdges((eds) =>
eds.filter((edge) => edge.source !== nodeId && edge.target !== nodeId)
);
},
[setNodes, setEdges]
);
const generateFlowData = useCallback(() => {
const mappedNodes = nodes.map((node) => {
const baseNodeData = {
id: node.id,
type: node.type,
position: node.position,
};
switch (node.type) {
case "triggerNode":
return {
...baseNodeData,
data: {
triggerType: node.data.triggerType || "keywords",
keywords:
node.data.triggerType === "keywords"
? node.data.keywords || []
: [],
template:
node.data.triggerType === "template"
? {
name: node.data.template?.name,
buttons: node.data.template?.buttons || [],
}
: null,
},
};
case "textButtonNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
buttons:
node.data.buttons?.map((button) => ({
label: button.label,
handleId: button.handleId,
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "mediaNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
mediaUrl: node.data.mediaUrl || "",
mediaType: node.data.mediaType || "",
uploadedId: node.data.uploadedId || "",
fileName: node.data.fileName || "",
buttons:
node.data.buttons?.map((button) => ({
label: button.label,
handleId: button.handleId,
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "listNode":
return {
...baseNodeData,
data: {
content: node.data.content || "",
buttonText: node.data.buttonText || "Options",
sections:
node.data.sections?.map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({
id: row.id,
title: row.title,
description: row.description,
})),
})) || [],
messageNumber: node.data.messageNumber,
},
};
case "attributeNode":
return {
...baseNodeData,
data: {
attribute1: node.data.attribute1 || "",
attribute2: node.data.attribute2 || "",
},
};
default:
return baseNodeData;
}
});
// Modify how edges are mapped to include all necessary data
const mappedEdges = edges.map((edge) => ({
id: edge.sourceHandle
? `edge-${edge.source}-${edge.sourceHandle}-${edge.target}`
: `edge-${edge.source}-${edge.target}`,
source: edge.source,
sourceHandle: edge.sourceHandle,
target: edge.target,
type: edge.type,
data: {
sourceButtonText: edge.data?.sourceButtonText,
},
}));
return {
nodes: mappedNodes,
edges: mappedEdges,
};
}, [nodes, edges]);
// Update the parent component with the generation function
useEffect(() => {
setGenerateFlowData(() => generateFlowData);
}, [generateFlowData, setGenerateFlowData]);
const [reactFlowInstance, setReactFlowInstance] = useState(null);
// Function to handle quick navigation
const handleQuickNav = (direction) => {
if (!reactFlowInstance) return;
const nodes = reactFlowInstance.getNodes();
if (!nodes.length) return;
// Calculate bounds
let targetY;
if (direction === "top") {
targetY = Math.min(...nodes.map((node) => node.position.y)) - 100;
} else {
targetY = Math.max(...nodes.map((node) => node.position.y)) + 100;
}
// Smooth scroll to position
reactFlowInstance.setViewport(
{
x: reactFlowInstance.getViewport().x,
y: -targetY,
zoom: reactFlowInstance.getViewport().zoom,
},
{ duration: 800 }
);
};
// Add new state for tracking save status
const [lastSaved, setLastSaved] = useState(null);
const [saveStatus, setSaveStatus] = useState("All changes saved");
// Function to format current date-time in UTC
const formatDateTime = () => {
const now = new Date();
return now.toLocaleTimeString("en-IN", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: true,
});
};
// Function to save flow data
const saveFlowData = useCallback(async () => {
try {
const flowData = generateFlowData();
const response = await axios.post(
`${process.env.NEXT_PUBLIC_BASE_URL}/automation/${flowId}/save/`,
{
flowData: flowData,
}
);
if (response.status !== 200) {
throw new Error("Failed to save");
}
setLastSaved(formatDateTime());
setSaveStatus("All changes saved");
console.log("all change saved");
// Optional: Show success toast
// toast.success('Flow autosaved successfully');
} catch (error) {
console.error("Autosave failed:", error);
setSaveStatus("Failed to save");
toast.error("Failed to autosave flow");
}
}, [generateFlowData, flowId]);
// Set up autosave interval
useEffect(() => {
if (!flowId) return; // Don't set up autosave if no flowId
// Save every 5 minutes
const autoSaveInterval = setInterval(() => {
setSaveStatus("Saving...");
saveFlowData();
}, 5 * 60 * 1000); // 5 minutes
// Save when user leaves the page
// const handleBeforeUnload = (e) => {
// saveFlowData();
// e.preventDefault();
// e.returnValue =
// "You have unsaved changes. Are you sure you want to leave?";
// };
// window.addEventListener("beforeunload", handleBeforeUnload);
// Cleanup
return () => {
clearInterval(autoSaveInterval);
// window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [saveFlowData, flowId]);
return (
<div className="relative w-full h-[calc(100vh-64px)] overflow-hidden">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
onConnect={onConnect}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
defaultEdgeOptions={{
type: "custom",
data: { onDelete: onEdgeDelete },
}}
connectionMode="loose"
fitView
onInit={setReactFlowInstance}
onViewportChange={onViewportChange}
>
<Controls
position="bottom-left"
style={{ position: "fixed", left: 20, bottom: 20 }}
showInteractive={false}
/>
<MiniMap
position="bottom-right"
style={{ position: "fixed", right: 20, bottom: 100 }}
zoomable
pannable
/>
<Background gap={12} size={1} />
</ReactFlow>
<div className="absolute right-8 top-4 flex items-center gap-2 bg-white px-4 py-2 rounded-md shadow-sm border border-gray-200 z-[1000]">
<div className="flex items-center gap-2">
{saveStatus === "Saving..." ? (
<svg
className="animate-spin h-4 w-4 text-gray-500"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
></circle>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
) : saveStatus === "Failed to save" ? (
<svg
className="h-4 w-4 text-red-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
></path>
</svg>
) : (
<svg
className="h-4 w-4 text-green-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="2"
d="M5 13l4 4L19 7"
></path>
</svg>
)}
<span className="text-sm text-gray-600">{saveStatus}</span>
</div>
{lastSaved && (
<span className="text-xs text-gray-400">Last saved: {lastSaved}</span>
)}
</div>
{isDropdownVisible && (
<div className="z-[1000]">
<NodeTypeDropdown
options={["Text + Button", "Media", "List", "Attribute"]}
onSelect={handleDropdownSelect}
onCancel={() => {
setDropdownVisible(false);
setPendingConnection(null);
}}
position={dropdownPosition}
/>
</div>
)}
</div>
);
};
// Add this function to handle publishing
export default function WhatsAppFlow() {
const router = useRouter();
const { flowId } = router.query;
const [flowName, setFlowName] = useState("");
const [generateFlowData, setGenerateFlowData] = useState(() => () => ({}));
const [initialFlowData, setInitialFlowData] = useState(null);
useEffect(() => {
if (flowId) {
const fetchFlowDetails = async () => {
try {
const response = await axios.get(
`${process.env.NEXT_PUBLIC_BASE_URL}/automation/${flowId}/`
);
setFlowName(response.data.name);
// If there's no flowData or it's empty, create default structure
const flowData = response.data.flowData || {
nodes: [],
edges: [],
};
setInitialFlowData(flowData);
console.log(response.data.flowData, "!!!!!!!!!!");
} catch (error) {
// If it fails to fetch (new flow), initialize with empty data
setInitialFlowData({
nodes: [],
edges: [],
});
toast.error("Failed to load flow details");
console.error("Error fetching flow details:", error);
}
};
fetchFlowDetails();
}
}, [flowId]);
if (!flowId || !initialFlowData) return null;
return (
<Layout>
<FlowHeader
flowName={flowName}
flowId={flowId}
generateFlowData={generateFlowData}
/>
<ReactFlowProvider>
<Flow
setGenerateFlowData={setGenerateFlowData}
initialFlowData={initialFlowData}
flowId={flowId}
/>
</ReactFlowProvider>
<Toaster position="top-center" />
</Layout>
);
}
Comments
Post a Comment