mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-10 08:05:22 +02:00
chore: improve ux of workflow editor
This commit is contained in:
parent
5c1fe2c6af
commit
be1699aafc
17 changed files with 417 additions and 93 deletions
8
ui/package-lock.json
generated
8
ui/package-lock.json
generated
|
|
@ -31,7 +31,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"livekit-client": "^2.9.9",
|
||||
"lucide-react": "^0.487.0",
|
||||
"lucide-react": "^0.505.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^9.9.2",
|
||||
|
|
@ -14231,9 +14231,9 @@
|
|||
"license": "ISC"
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.487.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.487.0.tgz",
|
||||
"integrity": "sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==",
|
||||
"version": "0.505.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.505.0.tgz",
|
||||
"integrity": "sha512-CblOqNBI1aIJqTIBx42CbBf7omVukYtYEy43eZLkm0CTrOO1tgumeuL/RrjwzXRaWonlcJYYTtBE70STDH3pvg==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"livekit-client": "^2.9.9",
|
||||
"lucide-react": "^0.487.0",
|
||||
"lucide-react": "^0.505.0",
|
||||
"next": "^15.3.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"pino": "^9.9.2",
|
||||
|
|
|
|||
|
|
@ -6,13 +6,21 @@ interface WorkflowLayoutProps {
|
|||
children: ReactNode,
|
||||
headerActions?: ReactNode,
|
||||
backButton?: ReactNode,
|
||||
showFeaturesNav?: boolean
|
||||
showFeaturesNav?: boolean,
|
||||
stickyTabs?: ReactNode
|
||||
}
|
||||
|
||||
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true }) => {
|
||||
const WorkflowLayout: React.FC<WorkflowLayoutProps> = ({ children, headerActions, backButton, showFeaturesNav = true, stickyTabs }) => {
|
||||
return (
|
||||
<>
|
||||
<BaseHeader headerActions={headerActions} backButton={backButton} showFeaturesNav={showFeaturesNav} />
|
||||
{stickyTabs && (
|
||||
<div className="sticky top-0 z-50 bg-white border-b">
|
||||
<div className="flex justify-center relative">
|
||||
{stickyTabs}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,19 +2,28 @@ import '@xyflow/react/dist/style.css';
|
|||
|
||||
import {
|
||||
Background,
|
||||
BackgroundVariant,
|
||||
MiniMap,
|
||||
Panel,
|
||||
ReactFlow,
|
||||
} from "@xyflow/react";
|
||||
import { BrushCleaning, Maximize2, Minus, Plus, Settings, Variable } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import WorkflowLayout from '@/app/workflow/WorkflowLayout';
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import AddNodePanel from "../../../components/flow/AddNodePanel";
|
||||
import CustomEdge from "../../../components/flow/edges/CustomEdge";
|
||||
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
|
||||
import WorkflowControls from "./components/WorkflowControls";
|
||||
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
|
||||
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
|
||||
import { layoutNodes } from './components/WorkflowControls';
|
||||
import WorkflowHeader from "./components/WorkflowHeader";
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
|
||||
|
|
@ -30,6 +39,22 @@ const edgeTypes = {
|
|||
custom: CustomEdge,
|
||||
};
|
||||
|
||||
// Helper function for MiniMap node colors
|
||||
const getNodeColor = (node: FlowNode) => {
|
||||
switch (node.type) {
|
||||
case NodeType.START_CALL:
|
||||
return '#10B981'; // green-500
|
||||
case NodeType.AGENT_NODE:
|
||||
return '#3B82F6'; // blue-500
|
||||
case NodeType.END_CALL:
|
||||
return '#EF4444'; // red-500
|
||||
case NodeType.GLOBAL_NODE:
|
||||
return '#F59E0B'; // orange-500
|
||||
default:
|
||||
return '#6B7280'; // gray-500
|
||||
}
|
||||
};
|
||||
|
||||
interface RenderWorkflowProps {
|
||||
initialWorkflowName: string;
|
||||
workflowId: number;
|
||||
|
|
@ -47,22 +72,22 @@ interface RenderWorkflowProps {
|
|||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
|
||||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
rfInstance,
|
||||
nodes,
|
||||
edges,
|
||||
isAddNodePanelOpen,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setIsAddNodePanelOpen,
|
||||
setIsEditingName,
|
||||
handleNodeSelect,
|
||||
handleNameChange,
|
||||
saveWorkflow,
|
||||
onConnect,
|
||||
onEdgesChange,
|
||||
|
|
@ -72,6 +97,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
saveWorkflowConfigurations
|
||||
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
|
||||
|
||||
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
animated: true,
|
||||
type: "custom"
|
||||
}), []);
|
||||
|
||||
const headerActions = (
|
||||
<WorkflowHeader
|
||||
workflowValidationErrors={workflowValidationErrors}
|
||||
|
|
@ -84,10 +115,12 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
/>
|
||||
);
|
||||
|
||||
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={{ saveWorkflow }}>
|
||||
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false}>
|
||||
<div className="h-[calc(100vh-80px)]">
|
||||
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
|
||||
<div className="h-[calc(100vh-80px)] relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
|
|
@ -99,29 +132,149 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onInit={(instance) => {
|
||||
rfInstance.current = instance;
|
||||
}}
|
||||
defaultEdgeOptions={{ animated: true, type: "custom" }}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
>
|
||||
<Background />
|
||||
<Panel position="top-left">
|
||||
<WorkflowControls
|
||||
workflowId={workflowId}
|
||||
workflowName={workflowName}
|
||||
isEditingName={isEditingName}
|
||||
setIsEditingName={setIsEditingName}
|
||||
handleNameChange={handleNameChange}
|
||||
setIsAddNodePanelOpen={setIsAddNodePanelOpen}
|
||||
saveWorkflow={saveWorkflow}
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
setNodes={setNodes}
|
||||
rfInstance={rfInstance}
|
||||
templateContextVariables={templateContextVariables}
|
||||
saveTemplateContextVariables={saveTemplateContextVariables}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
saveWorkflowConfigurations={saveWorkflowConfigurations}
|
||||
/>
|
||||
<Background
|
||||
variant={BackgroundVariant.Dots}
|
||||
gap={16}
|
||||
size={1}
|
||||
color="#94a3b8"
|
||||
/>
|
||||
<MiniMap
|
||||
nodeColor={getNodeColor}
|
||||
position="bottom-right"
|
||||
className="bg-white/90 border rounded shadow-lg"
|
||||
maskColor="rgb(0, 0, 0, 0.1)"
|
||||
/>
|
||||
|
||||
{/* Top-right controls - vertical layout */}
|
||||
<Panel position="top-right">
|
||||
<TooltipProvider>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
size="icon"
|
||||
onClick={() => setIsAddNodePanelOpen(true)}
|
||||
className="shadow-md hover:shadow-lg"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Add node</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsConfigurationsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Configurations</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setIsContextVarsDialogOpen(true)}
|
||||
className="bg-white shadow-sm hover:shadow-md"
|
||||
>
|
||||
<Variable className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left">
|
||||
<p>Template Context Variables</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{/* Bottom-left controls - horizontal layout with custom buttons */}
|
||||
<div className="absolute bottom-12 left-8 z-[1000] flex gap-2">
|
||||
<TooltipProvider>
|
||||
{/* Zoom In */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => rfInstance.current?.zoomIn()}
|
||||
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Zoom in</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Zoom Out */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => rfInstance.current?.zoomOut()}
|
||||
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
|
||||
>
|
||||
<Minus className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Zoom out</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Fit View */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => rfInstance.current?.fitView()}
|
||||
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
|
||||
>
|
||||
<Maximize2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Fit view</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
{/* Tidy/Arrange Nodes */}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}
|
||||
className="bg-white shadow-sm hover:shadow-md h-8 w-8"
|
||||
>
|
||||
<BrushCleaning className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
<p>Tidy Up</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AddNodePanel
|
||||
|
|
@ -129,6 +282,20 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onNodeSelect={handleNodeSelect}
|
||||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigurationsDialog
|
||||
open={isConfigurationsDialogOpen}
|
||||
onOpenChange={setIsConfigurationsDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<TemplateContextVariablesDialog
|
||||
open={isContextVarsDialogOpen}
|
||||
onOpenChange={setIsContextVarsDialogOpen}
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Trash2Icon } from "lucide-react";
|
|||
import { useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
|
||||
|
|
@ -63,6 +63,10 @@ export const TemplateContextVariablesDialog = ({
|
|||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Template Context Variables</DialogTitle>
|
||||
<DialogDescription>
|
||||
Add or remove template context variables that will be available to your workflow. You can use
|
||||
these variables within your workflow nodes within double curly braces. Example: {`{{variable_name}}`}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
{/* Existing Variables */}
|
||||
|
|
|
|||
47
ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx
Normal file
47
ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface WorkflowTabsProps {
|
||||
workflowId: number;
|
||||
currentTab: 'editor' | 'executions';
|
||||
}
|
||||
|
||||
export const WorkflowTabs = ({ workflowId, currentTab }: WorkflowTabsProps) => {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => router.push(`/workflow/${workflowId}`)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors relative cursor-pointer",
|
||||
currentTab === 'editor'
|
||||
? "text-gray-900"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
Editor
|
||||
{currentTab === 'editor' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
|
||||
className={cn(
|
||||
"px-4 py-2 text-sm font-medium transition-colors relative cursor-pointer",
|
||||
currentTab === 'executions'
|
||||
? "text-gray-900"
|
||||
: "text-gray-500 hover:text-gray-700"
|
||||
)}
|
||||
>
|
||||
Executions
|
||||
{currentTab === 'executions' && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -321,22 +321,19 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
(changes) => setEdges((eds) => {
|
||||
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
|
||||
setIsDirty(true);
|
||||
// Trigger validation after edge changes
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
console.log("in onEdgesChange", changes, eds, newEdges);
|
||||
return newEdges;
|
||||
}),
|
||||
[setEdges, validateWorkflow],
|
||||
[setEdges],
|
||||
);
|
||||
|
||||
const onNodesChange: OnNodesChange = useCallback(
|
||||
(changes) => setNodes((nds) => {
|
||||
const newNodes = applyNodeChanges(changes, nds) as FlowNode[];
|
||||
setIsDirty(true);
|
||||
// Trigger validation after node changes
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
return newNodes;
|
||||
}),
|
||||
[setNodes, validateWorkflow],
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const onRun = async (mode: string) => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
|
|
@ -27,6 +26,7 @@ import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
|||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||
|
||||
import WorkflowLayout from '../../WorkflowLayout';
|
||||
import { WorkflowTabs } from '../components/WorkflowTabs';
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { workflowId } = useParams();
|
||||
|
|
@ -89,7 +89,7 @@ export default function WorkflowRunsPage() {
|
|||
};
|
||||
|
||||
loadDispositionCodes();
|
||||
}, [workflowId, accessToken, configuredAttributes]);
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
|
|
@ -177,17 +177,10 @@ export default function WorkflowRunsPage() {
|
|||
setIsExecutingFilters(false);
|
||||
}, [fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
const backButton = (
|
||||
<Link href={`/workflow/${workflowId}`}>
|
||||
<Button variant="outline" size="sm" className="flex items-center gap-1">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Workflow
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
const stickyTabs = <WorkflowTabs workflowId={Number(workflowId)} currentTab="executions" />;
|
||||
|
||||
return (
|
||||
<WorkflowLayout backButton={backButton}>
|
||||
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getSmoothStepPath, useReactFlow } from '@xyflow/react';
|
||||
import { BaseEdge, type Edge, EdgeLabelRenderer, type EdgeProps, getBezierPath, useReactFlow } from '@xyflow/react';
|
||||
import { AlertCircle, Pencil } from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useWorkflow } from "@/app/workflow/[workflowId]/contexts/WorkflowContext";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -85,10 +85,18 @@ interface CustomEdgeProps extends EdgeProps {
|
|||
}
|
||||
|
||||
export default function CustomEdge(props: CustomEdgeProps) {
|
||||
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data } = props;
|
||||
const { id, source, target, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, data, style, selected } = props;
|
||||
|
||||
const { getEdges, setEdges } = useReactFlow<FlowNode, FlowEdge>();
|
||||
const { getEdges, setEdges, setNodes } = useReactFlow<FlowNode, FlowEdge>();
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
// Edge is highlighted when either selected or hovered
|
||||
const isHighlighted = selected || isHovered;
|
||||
|
||||
console.log("in CustomEdge", id, selected, isHovered, isHighlighted);
|
||||
|
||||
const parallel = getEdges().filter(
|
||||
(e) =>
|
||||
(e.source === source && e.target === target) ||
|
||||
|
|
@ -113,8 +121,8 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
}
|
||||
}
|
||||
|
||||
// 3) draw the straight path + get label coords
|
||||
const [edgePath, labelX, labelY] = getSmoothStepPath({
|
||||
// 3) draw the bezier path + get label coords
|
||||
const [edgePath, labelX, labelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
|
|
@ -123,7 +131,34 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
targetPosition,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
// Highlight connected nodes when edge is highlighted (selected or hovered)
|
||||
useEffect(() => {
|
||||
if (isHighlighted) {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === source || node.id === target) {
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, highlighted: true }
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
} else {
|
||||
setNodes((nodes) =>
|
||||
nodes.map((node) => {
|
||||
if (node.id === source || node.id === target) {
|
||||
return {
|
||||
...node,
|
||||
data: { ...node.data, highlighted: false }
|
||||
};
|
||||
}
|
||||
return node;
|
||||
})
|
||||
);
|
||||
}
|
||||
}, [isHighlighted, source, target, setNodes, selected]);
|
||||
|
||||
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
|
||||
// Update the node data in the ReactFlow nodes state
|
||||
|
|
@ -144,39 +179,72 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
/>
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
<g
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'all',
|
||||
transformOrigin: 'center',
|
||||
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
|
||||
...style,
|
||||
stroke: isHighlighted
|
||||
? '#3B82F6' // blue-500 when highlighted (selected or hovered)
|
||||
: data?.invalid ? '#EF4444' : '#94A3B8',
|
||||
strokeWidth: isHighlighted ? 4 : 2.5,
|
||||
filter: isHighlighted ? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))' : 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
>
|
||||
<div className={cn(
|
||||
"flex items-center gap-2 bg-white pl-3 pr-1 py-1 rounded-md border shadow-sm",
|
||||
data?.invalid ? "border-red-500/30 shadow-[0_0_10px_rgba(239,68,68,0.5)]" : "border-gray-200"
|
||||
)}>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm">{data?.label || data?.condition || 'Set Condition'}</span>
|
||||
|
||||
interactionWidth={20}
|
||||
/>
|
||||
</g>
|
||||
{/* Show label when highlighted (selected or hovered), positioned at edge center */}
|
||||
{isHighlighted && (
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
pointerEvents: 'all',
|
||||
transformOrigin: 'center',
|
||||
transform: `translate(-50%, -50%) translate(${labelX + offsetX}px, ${labelY + offsetY}px)`,
|
||||
zIndex: 1000,
|
||||
}}
|
||||
className="nodrag nopan"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className={cn(
|
||||
"flex flex-col gap-2 bg-white rounded-lg border-2 shadow-xl min-w-[200px]",
|
||||
"animate-in fade-in zoom-in duration-200",
|
||||
data?.invalid ? "border-red-500 shadow-[0_0_15px_rgba(239,68,68,0.5)]" : "border-gray-300"
|
||||
)}>
|
||||
{/* Header with label */}
|
||||
<div className={cn(
|
||||
"flex items-center justify-between px-3 py-2 border-b",
|
||||
data?.invalid ? "bg-red-50 border-red-200" : "bg-gray-50 border-gray-200"
|
||||
)}>
|
||||
<span className="text-xs font-semibold text-gray-600 uppercase tracking-wide">
|
||||
Condition
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0 hover:bg-gray-200"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="text-sm font-medium text-gray-900 break-words">
|
||||
{data?.label || data?.condition || 'Click to set condition'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
</EdgeLabelRenderer>
|
||||
)}
|
||||
<EdgeDetailsDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
|
|
|
|||
|
|
@ -88,11 +88,14 @@ export const AgentNode = memo(({ data, selected, id }: AgentNodeProps) => {
|
|||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
highlighted={data.highlighted}
|
||||
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,9 @@ export const BaseNode = forwardRef<
|
|||
HTMLAttributes<HTMLDivElement> & {
|
||||
selected?: boolean;
|
||||
invalid?: boolean;
|
||||
highlighted?: boolean;
|
||||
}
|
||||
>(({ className, selected, invalid, ...props }, ref) => (
|
||||
>(({ className, selected, invalid, highlighted, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
|
|
@ -16,6 +17,7 @@ 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)]" : "",
|
||||
highlighted ? "ring-2 ring-blue-400 shadow-[0_0_15px_rgba(59,130,246,0.5)]" : "",
|
||||
"hover:ring-1",
|
||||
)}
|
||||
tabIndex={0}
|
||||
|
|
|
|||
|
|
@ -92,10 +92,13 @@ export const EndCall = memo(({ data, selected, id }: EndCallNodeProps) => {
|
|||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
highlighted={data.highlighted}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -61,9 +61,12 @@ export const GlobalNode = memo(({ data, selected, id }: GlobalNodeProps) => {
|
|||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
highlighted={data.highlighted}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -100,10 +100,13 @@ export const StartCall = memo(({ data, selected, id }: StartCallNodeProps) => {
|
|||
<NodeContent
|
||||
selected={selected}
|
||||
invalid={data.invalid}
|
||||
highlighted={data.highlighted}
|
||||
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,7 @@ import { NodeHeader, NodeHeaderIcon, NodeHeaderTitle } from "@/components/flow/n
|
|||
interface NodeContentProps {
|
||||
selected: boolean;
|
||||
invalid?: boolean;
|
||||
highlighted?: boolean;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
bgColor: string;
|
||||
|
|
@ -15,11 +16,14 @@ interface NodeContentProps {
|
|||
hasTargetHandle?: boolean;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
onDoubleClick?: () => void;
|
||||
nodeId?: string;
|
||||
}
|
||||
|
||||
export const NodeContent = ({
|
||||
selected,
|
||||
invalid,
|
||||
highlighted,
|
||||
title,
|
||||
icon,
|
||||
bgColor,
|
||||
|
|
@ -27,13 +31,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}
|
||||
highlighted={highlighted}
|
||||
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>
|
||||
<p>{nodeId}</p>
|
||||
</NodeHeader>
|
||||
<div className="p-3">
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export type FlowNodeData = {
|
|||
is_end?: boolean;
|
||||
invalid?: boolean;
|
||||
validationMessage?: string | null;
|
||||
highlighted?: boolean;
|
||||
allow_interrupt?: boolean;
|
||||
extraction_enabled?: boolean;
|
||||
extraction_prompt?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue