feat(frontend): integrate drag-and-drop functionality in new builder (#11341)

In this PR, I’ve added drag-and-drop functionality to the new builder
using built-in HTML drag-and-drop.



https://github.com/user-attachments/assets/b27c281e-6216-4131-9a89-e10b0dd56a8f

### Changes

- Added ReactFlowProvider to manage flow state in BuilderPage and Flow
components.
- Implemented drag-and-drop support for blocks in the NewControlPanel,
allowing users to drag blocks from the menu and drop them onto the
canvas.
- Enhanced the Block component to handle drag events and provide visual
feedback during dragging.
- Updated useFlow hook to include onDragOver and onDrop handlers for
managing block placement.
- Adjusted nodeStore to accept position parameters for added blocks,
improving placement accuracy.

### Checklist 📋

#### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
- [x] I’ve tried dragging and dropping multiple blocks, and it works
perfectly as shown in the video.
This commit is contained in:
Abhimanyu Yadav
2025-11-11 11:35:06 +05:30
committed by GitHub
parent 43638defa2
commit c3a6235cee
11 changed files with 106 additions and 10 deletions

View File

@@ -23,7 +23,7 @@ export const Flow = () => {
const { edges, onConnect, onEdgesChange } = useCustomEdge();
// We use this hook to load the graph and convert them into custom nodes and edges.
useFlow();
const { onDragOver, onDrop } = useFlow();
// This hook is used for websocket realtime updates.
useFlowRealtime();
@@ -45,6 +45,8 @@ export const Flow = () => {
edgeTypes={{ custom: CustomEdge }}
maxZoom={2}
minZoom={0.1}
onDragOver={onDragOver}
onDrop={onDrop}
>
<Background />
<Controls />

View File

@@ -1,3 +1,4 @@
import { useCallback, useEffect, useMemo } from "react";
import { useGetV2GetSpecificBlocks } from "@/app/api/__generated__/endpoints/default/default";
import {
useGetV1GetExecutionDetails,
@@ -8,12 +9,13 @@ import { GraphModel } from "@/app/api/__generated__/models/graphModel";
import { parseAsInteger, parseAsString, useQueryStates } from "nuqs";
import { useNodeStore } from "../../../stores/nodeStore";
import { useShallow } from "zustand/react/shallow";
import { useEffect, useMemo } from "react";
import { convertNodesPlusBlockInfoIntoCustomNodes } from "../../helper";
import { useEdgeStore } from "../../../stores/edgeStore";
import { GetV1GetExecutionDetails200 } from "@/app/api/__generated__/models/getV1GetExecutionDetails200";
import { useGraphStore } from "../../../stores/graphStore";
import { AgentExecutionStatus } from "@/app/api/__generated__/models/agentExecutionStatus";
import { useReactFlow } from "@xyflow/react";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
export const useFlow = () => {
const addNodes = useNodeStore(useShallow((state) => state.addNodes));
@@ -30,6 +32,11 @@ export const useFlow = () => {
const setGraphSchemas = useGraphStore(
useShallow((state) => state.setGraphSchemas),
);
const { screenToFlowPosition } = useReactFlow();
const addBlock = useNodeStore(useShallow((state) => state.addBlock));
const setBlockMenuOpen = useControlPanelStore(
useShallow((state) => state.setBlockMenuOpen),
);
const [{ flowID, flowVersion, flowExecutionID }] = useQueryStates({
flowID: parseAsString,
flowVersion: parseAsInteger,
@@ -144,5 +151,36 @@ export const useFlow = () => {
};
}, []);
return { isFlowContentLoading: isGraphLoading || isBlocksLoading };
// Drag and drop block from block menu
const onDragOver = useCallback((event: React.DragEvent) => {
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}, []);
const onDrop = async (event: React.DragEvent) => {
event.preventDefault();
const blockDataString = event.dataTransfer.getData("application/reactflow");
if (!blockDataString) return;
try {
const blockData = JSON.parse(blockDataString) as BlockInfo;
const position = screenToFlowPosition({
x: event.clientX,
y: event.clientY,
});
addBlock(blockData, position);
await new Promise((resolve) => setTimeout(resolve, 200));
setBlockMenuOpen(true);
} catch (error) {
console.error("Failed to drop block:", error);
setBlockMenuOpen(true);
}
};
return {
isFlowContentLoading: isGraphLoading || isBlocksLoading,
onDragOver,
onDrop,
};
};

View File

@@ -75,6 +75,7 @@ export const AllBlocksContent = () => {
title={block.name as string}
description={block.name as string}
onClick={() => addBlock(block)}
blockData={block}
/>
))}

View File

@@ -4,10 +4,14 @@ import { beautifyString, cn } from "@/lib/utils";
import React, { ButtonHTMLAttributes } from "react";
import { highlightText } from "./helpers";
import { PlusIcon } from "@phosphor-icons/react";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { useControlPanelStore } from "../../../stores/controlPanelStore";
import { blockDragPreviewStyle } from "./style";
interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
title?: string;
description?: string;
highlightedText?: string;
blockData: BlockInfo;
}
interface BlockComponent extends React.FC<Props> {
@@ -19,15 +23,38 @@ export const Block: BlockComponent = ({
description,
highlightedText,
className,
blockData,
...rest
}) => {
const setBlockMenuOpen = useControlPanelStore(
(state) => state.setBlockMenuOpen,
);
const handleDragStart = (e: React.DragEvent<HTMLButtonElement>) => {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("application/reactflow", JSON.stringify(blockData));
setBlockMenuOpen(false);
// preview when user drags it
const dragPreview = document.createElement("div");
dragPreview.style.cssText = blockDragPreviewStyle;
dragPreview.textContent = beautifyString(title || "");
document.body.appendChild(dragPreview);
e.dataTransfer.setDragImage(dragPreview, 0, 0);
setTimeout(() => document.body.removeChild(dragPreview), 0);
};
return (
<Button
draggable={true}
className={cn(
"group flex h-16 w-full min-w-[7.5rem] items-center justify-start space-x-3 whitespace-normal rounded-[0.75rem] bg-zinc-50 px-[0.875rem] py-[0.625rem] text-start shadow-none",
"hover:cursor-default hover:bg-zinc-100 focus:ring-0 active:bg-zinc-100 active:ring-1 active:ring-zinc-300 disabled:cursor-not-allowed",
className,
)}
onDragStart={handleDragStart}
{...rest}
>
<div className="flex flex-1 flex-col items-start gap-0.5">

View File

@@ -29,6 +29,7 @@ export const BlocksList: React.FC<BlocksListProps> = ({
title={block.name}
description={block.description}
onClick={() => addBlock(block)}
blockData={block}
/>
));
};

View File

@@ -18,7 +18,7 @@ export const BlockMenu = () => {
const { blockMenuOpen, setBlockMenuOpen } = useControlPanelStore();
return (
// pinBlocksPopover ? true : open
<Popover onOpenChange={setBlockMenuOpen}>
<Popover onOpenChange={setBlockMenuOpen} open={blockMenuOpen}>
<Tooltip delayDuration={100}>
<TooltipTrigger asChild>
<PopoverTrigger className="hover:cursor-pointer">

View File

@@ -76,6 +76,7 @@ export const BlockMenuSearch = () => {
highlightedText={searchQuery}
description={data.description}
onClick={() => addBlock(data)}
blockData={data}
/>
);

View File

@@ -77,6 +77,7 @@ export const SuggestionContent = () => {
title={block.name}
description={block.description}
onClick={() => addBlock(block)}
blockData={block}
/>
))
: Array(3)

View File

@@ -1,2 +1,14 @@
export const blockMenuContainerStyle =
"scrollbar-thin scrollbar-thumb-zinc-300 scrollbar-track-transparent w-full px-4 pb-4 space-y-3 h-full overflow-y-auto pt-4 transition-all duration-200";
export const blockDragPreviewStyle = `
padding: 0.625rem 0.875rem;
background: #f4f4f5;
border-radius: 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: #27272a;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
position: absolute;
top: -9999px;
`;

View File

@@ -9,6 +9,7 @@ import { useEffect } from "react";
import { Flow } from "./components/FlowEditor/Flow/Flow";
import { BuilderViewTabs } from "./components/BuilderViewTabs/BuilderViewTabs";
import { useBuilderView } from "./components/BuilderViewTabs/useBuilderViewTabs";
import { ReactFlowProvider } from "@xyflow/react";
function BuilderContent() {
const query = useSearchParams();
@@ -42,10 +43,22 @@ export default function BuilderPage() {
return (
<div className="relative h-full w-full">
<BuilderViewTabs value={selectedView} onChange={setSelectedView} />
{selectedView === "new" ? <Flow /> : <BuilderContent />}
{selectedView === "new" ? (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
) : (
<BuilderContent />
)}
</div>
);
}
return isNewFlowEditorEnabled ? <Flow /> : <BuilderContent />;
return isNewFlowEditorEnabled ? (
<ReactFlowProvider>
<Flow />
</ReactFlowProvider>
) : (
<BuilderContent />
);
}

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import { NodeChange, applyNodeChanges } from "@xyflow/react";
import { NodeChange, XYPosition, applyNodeChanges } from "@xyflow/react";
import { CustomNode } from "../components/FlowEditor/nodes/CustomNode/CustomNode";
import { BlockInfo } from "@/app/api/__generated__/models/blockInfo";
import { convertBlockInfoIntoCustomNodeData } from "../components/helper";
@@ -16,7 +16,7 @@ type NodeStore = {
setNodes: (nodes: CustomNode[]) => void;
onNodesChange: (changes: NodeChange<CustomNode>[]) => void;
addNode: (node: CustomNode) => void;
addBlock: (block: BlockInfo) => void;
addBlock: (block: BlockInfo, position?: XYPosition) => void;
incrementNodeCounter: () => void;
updateNodeData: (nodeId: string, data: Partial<CustomNode["data"]>) => void;
toggleAdvanced: (nodeId: string) => void;
@@ -71,7 +71,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
nodes: [...state.nodes, node],
}));
},
addBlock: (block: BlockInfo) => {
addBlock: (block: BlockInfo, position?: XYPosition) => {
const customNodeData = convertBlockInfoIntoCustomNodeData(block);
get().incrementNodeCounter();
const nodeNumber = get().nodeCounter;
@@ -79,7 +79,7 @@ export const useNodeStore = create<NodeStore>((set, get) => ({
id: nodeNumber.toString(),
data: customNodeData,
type: "custom",
position: { x: 0, y: 0 },
position: position || ({ x: 0, y: 0 } as XYPosition),
};
set((state) => ({
nodes: [...state.nodes, customNode],