mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-07-13 11:22:14 +02:00
feat: improve workflow builder UX (#41)
* chore: improve ux of workflow editor * Improve workflow UX * Add option to edit workflow name * Fix undo/ redo for node editing
This commit is contained in:
parent
6ab23d4066
commit
1a0a18a435
27 changed files with 1749 additions and 879 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Headset, PlusIcon,Trash2Icon } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { ExtractionVariable,FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -83,16 +83,33 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPrompt(data.prompt);
|
||||
setName(data.name);
|
||||
setAllowInterrupt(data.allow_interrupt ?? true);
|
||||
setExtractionEnabled(data.extraction_enabled ?? false);
|
||||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
title={data.name || 'Agent'}
|
||||
icon={<Headset />}
|
||||
bgColor="bg-blue-300"
|
||||
hasSourceHandle={true}
|
||||
hasTargetHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
|
|
|
|||
|
|
@ -6,16 +6,28 @@ import { cn } from "@/lib/utils";
|
|||
export type BaseHandleProps = HandleProps;
|
||||
|
||||
export const BaseHandle = forwardRef<HTMLDivElement, BaseHandleProps>(
|
||||
({ className, children, ...props }, ref) => {
|
||||
({ className, children, type, ...props }, ref) => {
|
||||
const isSource = type === 'source';
|
||||
const isTarget = type === 'target';
|
||||
|
||||
return (
|
||||
<Handle
|
||||
ref={ref}
|
||||
type={type}
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-[11px] w-[11px] rounded-full border border-slate-300 bg-slate-100 transition dark:border-secondary dark:bg-secondary",
|
||||
"transition-all hover:!bg-blue-500",
|
||||
// Source (outgoing) has larger visible handle for easier connection
|
||||
isSource && "!h-[16px] !w-[16px] rounded-full",
|
||||
// Target (incoming) smaller rectangle
|
||||
isTarget && "!h-[10px] !w-[14px] rounded-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
style={{
|
||||
border: 'none',
|
||||
background: '#94A3B8', // slate-400
|
||||
...props.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Handle>
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ export const BaseNode = forwardRef<
|
|||
HTMLAttributes<HTMLDivElement> & {
|
||||
selected?: boolean;
|
||||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
}
|
||||
>(({ className, selected, invalid, ...props }, ref) => (
|
||||
>(({ className, selected, invalid, selected_through_edge, hovered_through_edge, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
@ -16,7 +18,10 @@ export const BaseNode = forwardRef<
|
|||
className,
|
||||
selected ? "border-muted-foreground shadow-lg" : "",
|
||||
invalid ? "border-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "",
|
||||
"hover:ring-1",
|
||||
// Hovered through edge takes precedence over selected through edge
|
||||
hovered_through_edge ? "ring-2 ring-blue-400 shadow-[0_0_12px_rgba(96,165,250,0.5)]" : "",
|
||||
!hovered_through_edge && selected_through_edge ? "ring-1 ring-blue-500 shadow-[0_0_8px_rgba(59,130,246,0.4)]" : "",
|
||||
!selected_through_edge && !hovered_through_edge && "hover:ring-1 hover:ring-gray-300",
|
||||
)}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, OctagonX, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { ExtractionVariable, FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -87,15 +87,32 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
|
|||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPrompt(data.prompt);
|
||||
setIsStatic(data.is_static ?? true);
|
||||
setName(data.name);
|
||||
setExtractionEnabled(data.extraction_enabled ?? false);
|
||||
setExtractionPrompt(data.extraction_prompt ?? "");
|
||||
setVariables(data.extraction_variables ?? []);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
title="End Call"
|
||||
icon={<OctagonX />}
|
||||
bgColor="bg-red-300"
|
||||
hasTargetHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Headset, Trash2Icon } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -56,14 +56,26 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
|
|||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPrompt(data.prompt);
|
||||
setName(data.name);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
title={data.name || 'Global'}
|
||||
icon={<Headset />}
|
||||
bgColor="bg-orange-300"
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { NodeProps, NodeToolbar, Position } from "@xyflow/react";
|
||||
import { Edit, Play } from "lucide-react";
|
||||
import { memo, useState } from "react";
|
||||
import { memo, useEffect, useState } from "react";
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
|
|
@ -95,15 +95,34 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
setOpen(newOpen);
|
||||
};
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setPrompt(data.prompt ?? "");
|
||||
setIsStatic(data.is_static ?? true);
|
||||
setName(data.name);
|
||||
setAllowInterrupt(data.allow_interrupt ?? true);
|
||||
setAddGlobalPrompt(data.add_global_prompt ?? true);
|
||||
setWaitForUserResponse(data.wait_for_user_response ?? false);
|
||||
setDetectVoicemail(data.detect_voicemail ?? true);
|
||||
setDelayedStart(data.delayed_start ?? false);
|
||||
setDelayedStartDuration(data.delayed_start_duration ?? 3);
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
selected_through_edge={data.selected_through_edge}
|
||||
hovered_through_edge={data.hovered_through_edge}
|
||||
title="Start Call"
|
||||
icon={<Play />}
|
||||
bgColor="bg-green-300"
|
||||
hasSourceHandle={true}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
nodeId={id}
|
||||
>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{data.prompt?.length > 30 ? `${data.prompt.substring(0, 30)}...` : data.prompt}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@ import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/n
|
|||
interface NodeContentProps {
|
||||
selected: boolean;
|
||||
invalid?: boolean;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
bgColor: string;
|
||||
|
|
@ -15,11 +17,15 @@ interface NodeContentProps {
|
|||
hasTargetHandle?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onDoubleClick?: () => void;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export const NodeContent = ({
|
||||
selected,
|
||||
invalid,
|
||||
selected_through_edge,
|
||||
hovered_through_edge,
|
||||
title,
|
||||
icon,
|
||||
bgColor,
|
||||
|
|
@ -27,13 +33,22 @@ export const NodeContent = ({
|
|||
hasTargetHandle = false,
|
||||
children,
|
||||
className = "",
|
||||
onDoubleClick,
|
||||
nodeId,
|
||||
}: NodeContentProps) => {
|
||||
return (
|
||||
<BaseNode selected={selected} invalid={invalid} className={`p-0 overflow-hidden ${className}`}>
|
||||
<BaseNode
|
||||
selected={selected}
|
||||
invalid={invalid}
|
||||
selected_through_edge={selected_through_edge}
|
||||
hovered_through_edge={hovered_through_edge}
|
||||
className={`p-0 overflow-hidden ${className}`}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{hasTargetHandle && <BaseHandle type="target" position={Position.Top} />}
|
||||
<NodeHeader className={`px-3 py-2 border-b ${bgColor}`}>
|
||||
<NodeHeaderIcon>{icon}</NodeHeaderIcon>
|
||||
<NodeHeaderTitle>{title}</NodeHeaderTitle>
|
||||
<NodeHeaderTitle>{title} - NodeID: {nodeId}</NodeHeaderTitle>
|
||||
</NodeHeader>
|
||||
<div className="p-3">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useReactFlow } from "@xyflow/react";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { FlowEdge, FlowNode, FlowNodeData } from "@/components/flow/types";
|
||||
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
|
||||
import { FlowNodeData } from "@/components/flow/types";
|
||||
|
||||
interface UseNodeHandlersProps {
|
||||
id: string;
|
||||
|
|
@ -10,25 +10,26 @@ interface UseNodeHandlersProps {
|
|||
|
||||
export const useNodeHandlers = ({ id, additionalData = {} }: UseNodeHandlersProps) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { setNodes } = useReactFlow<FlowNode, FlowEdge>();
|
||||
const updateNode = useWorkflowStore((state) => state.updateNode);
|
||||
const deleteNode = useWorkflowStore((state) => state.deleteNode);
|
||||
const nodes = useWorkflowStore((state) => state.nodes);
|
||||
|
||||
const handleSaveNodeData = useCallback(
|
||||
(updatedData: FlowNodeData) => {
|
||||
setNodes((nodes) => {
|
||||
const updatedNodes = nodes.map((node) =>
|
||||
node.id === id
|
||||
? { ...node, data: { ...node.data, ...updatedData, ...additionalData } }
|
||||
: node
|
||||
);
|
||||
return updatedNodes;
|
||||
});
|
||||
// Find the current node to merge data properly
|
||||
const currentNode = nodes.find(node => node.id === id);
|
||||
if (currentNode) {
|
||||
updateNode(id, {
|
||||
data: { ...currentNode.data, ...updatedData, ...additionalData }
|
||||
});
|
||||
}
|
||||
},
|
||||
[id, setNodes, additionalData]
|
||||
[id, updateNode, additionalData, nodes]
|
||||
);
|
||||
|
||||
const handleDeleteNode = useCallback(() => {
|
||||
setNodes((nodes) => nodes.filter((node) => node.id !== id));
|
||||
}, [id, setNodes]);
|
||||
deleteNode(id);
|
||||
}, [id, deleteNode]);
|
||||
|
||||
return {
|
||||
open,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue