mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-07 07:55:16 +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
99
ui/package-lock.json
generated
99
ui/package-lock.json
generated
|
|
@ -26,12 +26,12 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.28",
|
||||
"@xyflow/react": "^12.5.5",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"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",
|
||||
|
|
@ -49,7 +49,9 @@
|
|||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"zundo": "^2.3.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
|
@ -10107,12 +10109,12 @@
|
|||
"peer": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.5.5",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.5.5.tgz",
|
||||
"integrity": "sha512-mAtHuS4ktYBL1ph5AJt7X/VmpzzlmQBN3+OXxyT/1PzxwrVto6AKc3caerfxzwBsg3cA4J8lB63F3WLAuPMmHw==",
|
||||
"version": "12.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.9.2.tgz",
|
||||
"integrity": "sha512-Xr+LFcysHCCoc5KRHaw+FwbqbWYxp9tWtk1mshNcqy25OAPuaKzXSdqIMNOA82TIXF/gFKo0Wgpa6PU7wUUVqw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@xyflow/system": "0.0.55",
|
||||
"@xyflow/system": "0.0.72",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.0"
|
||||
},
|
||||
|
|
@ -10121,17 +10123,47 @@
|
|||
"react-dom": ">=17"
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"immer": {
|
||||
"optional": true
|
||||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@xyflow/system": {
|
||||
"version": "0.0.55",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.55.tgz",
|
||||
"integrity": "sha512-6cngWlE4oMXm+zrsbJxerP3wUNUFJcv/cE5kDfu0qO55OWK3fAeSOLW9td3xEVQlomjIW5knds1MzeMnBeCfqw==",
|
||||
"version": "0.0.72",
|
||||
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.72.tgz",
|
||||
"integrity": "sha512-WBI5Aau0fXTXwxHPzceLNS6QdXggSWnGjDtj/gG669crApN8+SCmEtkBth1m7r6pStNo/5fI9McEi7Dk0ymCLA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-drag": "^3.0.7",
|
||||
"@types/d3-interpolate": "^3.0.4",
|
||||
"@types/d3-selection": "^3.0.10",
|
||||
"@types/d3-transition": "^3.0.8",
|
||||
"@types/d3-zoom": "^3.0.8",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-selection": "^3.0.0",
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
|
|
@ -14231,9 +14263,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"
|
||||
|
|
@ -17894,21 +17926,37 @@
|
|||
"type-fest": "^2.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.6",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.6.tgz",
|
||||
"integrity": "sha512-ibr/n1hBzLLj5Y+yUcU7dYw8p6WnIVzdJbnX+1YpaScvZVF2ziugqHs+LAmHw4lWO9c/zRj+K1ncgWDQuthEdQ==",
|
||||
"node_modules/zundo": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz",
|
||||
"integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"use-sync-external-store": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.7.0"
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/charkour"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=16.8",
|
||||
"zustand": "^4.3.0 || ^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"zustand": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": ">=18.0.0",
|
||||
"immer": ">=9.0.6",
|
||||
"react": ">=16.8"
|
||||
"react": ">=18.0.0",
|
||||
"use-sync-external-store": ">=1.2.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
|
|
@ -17919,6 +17967,9 @@
|
|||
},
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"use-sync-external-store": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,12 +29,12 @@
|
|||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@sentry/nextjs": "^9.28.1",
|
||||
"@stackframe/stack": "^2.8.28",
|
||||
"@xyflow/react": "^12.5.5",
|
||||
"@xyflow/react": "^12.9.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"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",
|
||||
|
|
@ -52,7 +52,9 @@
|
|||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.2.5"
|
||||
"tw-animate-css": "^1.2.5",
|
||||
"zundo": "^2.3.0",
|
||||
"zustand": "^5.0.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
|
|
|
|||
|
|
@ -6,13 +6,23 @@ 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-[73px] z-40 bg-[#2a2e39] border-b border-gray-700">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-center py-2">
|
||||
{stickyTabs}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,21 +2,30 @@ 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 React, { 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 WorkflowHeader from "./components/WorkflowHeader";
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
import { WorkflowProvider } from "./contexts/WorkflowContext";
|
||||
import { useWorkflowState } from "./hooks/useWorkflowState";
|
||||
import { layoutNodes } from './utils/layoutNodes';
|
||||
|
||||
// Define the node types dynamically based on the onSave prop
|
||||
const nodeTypes = {
|
||||
|
|
@ -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;
|
||||
|
|
@ -44,25 +69,27 @@ interface RenderWorkflowProps {
|
|||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
user: { id: string; email?: string };
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: RenderWorkflowProps) {
|
||||
function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations, user, getAccessToken }: 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,
|
||||
|
|
@ -70,7 +97,21 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onRun,
|
||||
saveTemplateContextVariables,
|
||||
saveWorkflowConfigurations
|
||||
} = useWorkflowState({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations });
|
||||
} = useWorkflowState({
|
||||
initialWorkflowName,
|
||||
workflowId,
|
||||
initialFlow,
|
||||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations,
|
||||
user,
|
||||
getAccessToken
|
||||
});
|
||||
|
||||
// Memoize defaultEdgeOptions to prevent unnecessary re-renders
|
||||
const defaultEdgeOptions = useMemo(() => ({
|
||||
animated: true,
|
||||
type: "custom"
|
||||
}), []);
|
||||
|
||||
const headerActions = (
|
||||
<WorkflowHeader
|
||||
|
|
@ -81,13 +122,20 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onRun={onRun}
|
||||
workflowId={workflowId}
|
||||
saveWorkflow={saveWorkflow}
|
||||
user={user}
|
||||
getAccessToken={getAccessToken}
|
||||
/>
|
||||
);
|
||||
|
||||
const stickyTabs = <WorkflowTabs workflowId={workflowId} currentTab="editor" />;
|
||||
|
||||
// Memoize the context value to prevent unnecessary re-renders
|
||||
const workflowContextValue = useMemo(() => ({ saveWorkflow }), [saveWorkflow]);
|
||||
|
||||
return (
|
||||
<WorkflowProvider value={{ saveWorkflow }}>
|
||||
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false}>
|
||||
<div className="h-[calc(100vh-80px)]">
|
||||
<WorkflowProvider value={workflowContextValue}>
|
||||
<WorkflowLayout headerActions={headerActions} showFeaturesNav={false} stickyTabs={stickyTabs}>
|
||||
<div className="h-[calc(100vh-128px)] relative">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
|
|
@ -98,30 +146,155 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onConnect={onConnect}
|
||||
onInit={(instance) => {
|
||||
rfInstance.current = instance;
|
||||
// Center the workflow on load
|
||||
setTimeout(() => {
|
||||
instance.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
|
||||
}, 0);
|
||||
}}
|
||||
defaultEdgeOptions={{ animated: true, type: "custom" }}
|
||||
defaultEdgeOptions={defaultEdgeOptions}
|
||||
defaultViewport={initialFlow?.viewport}
|
||||
>
|
||||
<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,9 +302,35 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
|
|||
onNodeSelect={handleNodeSelect}
|
||||
onClose={() => setIsAddNodePanelOpen(false)}
|
||||
/>
|
||||
|
||||
<ConfigurationsDialog
|
||||
open={isConfigurationsDialogOpen}
|
||||
onOpenChange={setIsConfigurationsDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<TemplateContextVariablesDialog
|
||||
open={isContextVarsDialogOpen}
|
||||
onOpenChange={setIsContextVarsDialogOpen}
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
</WorkflowLayout>
|
||||
</WorkflowProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default RenderWorkflow;
|
||||
// Memoize the component to prevent unnecessary re-renders when parent re-renders
|
||||
export default React.memo(RenderWorkflow, (prevProps, nextProps) => {
|
||||
// Only re-render if these specific props change
|
||||
return (
|
||||
prevProps.workflowId === nextProps.workflowId &&
|
||||
prevProps.initialWorkflowName === nextProps.initialWorkflowName &&
|
||||
prevProps.user.id === nextProps.user.id &&
|
||||
prevProps.getAccessToken === nextProps.getAccessToken
|
||||
// Note: We intentionally don't compare initialFlow, initialTemplateContextVariables,
|
||||
// or initialWorkflowConfigurations because they're only used for initialization
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
|
@ -11,7 +11,8 @@ interface ConfigurationsDialogProps {
|
|||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
onSave: (configurations: WorkflowConfigurations) => Promise<void>;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const DEFAULT_VAD_CONFIG: VADConfiguration = {
|
||||
|
|
@ -30,8 +31,10 @@ export const ConfigurationsDialog = ({
|
|||
open,
|
||||
onOpenChange,
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave
|
||||
}: ConfigurationsDialogProps) => {
|
||||
const [name, setName] = useState<string>(workflowName);
|
||||
const [vadConfig, setVadConfig] = useState<VADConfiguration>(
|
||||
workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG
|
||||
);
|
||||
|
|
@ -54,7 +57,7 @@ export const ConfigurationsDialog = ({
|
|||
ambient_noise_configuration: ambientNoiseConfig,
|
||||
max_call_duration: maxCallDuration,
|
||||
max_user_idle_timeout: maxUserIdleTimeout
|
||||
});
|
||||
}, name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to save configurations:", error);
|
||||
|
|
@ -63,15 +66,16 @@ export const ConfigurationsDialog = ({
|
|||
}
|
||||
};
|
||||
|
||||
const handleDialogOpenChange = (isOpen: boolean) => {
|
||||
onOpenChange(isOpen);
|
||||
if (isOpen) {
|
||||
// Sync state with props when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(workflowName);
|
||||
setVadConfig(workflowConfigurations?.vad_configuration || DEFAULT_VAD_CONFIG);
|
||||
setAmbientNoiseConfig(workflowConfigurations?.ambient_noise_configuration || DEFAULT_AMBIENT_NOISE_CONFIG);
|
||||
setMaxCallDuration(workflowConfigurations?.max_call_duration || 600);
|
||||
setMaxUserIdleTimeout(workflowConfigurations?.max_user_idle_timeout || 10);
|
||||
}
|
||||
};
|
||||
}, [open, workflowName, workflowConfigurations]);
|
||||
|
||||
const handleVadChange = (field: keyof VADConfiguration, value: string) => {
|
||||
const numValue = parseFloat(value);
|
||||
|
|
@ -84,13 +88,35 @@ export const ConfigurationsDialog = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Configurations</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Workflow Name Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-1">Workflow Name</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
The name of your workflow
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="workflow_name" className="text-xs">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="workflow_name"
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Enter workflow name"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Voice Activity Detection Section */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -1,178 +0,0 @@
|
|||
import dagre from '@dagrejs/dagre';
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
import { Check, Pencil } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
import { ConfigurationsDialog } from "./ConfigurationsDialog";
|
||||
import { TemplateContextVariablesDialog } from "./TemplateContextVariablesDialog";
|
||||
|
||||
interface WorkflowControlsProps {
|
||||
workflowId: number;
|
||||
workflowName: string;
|
||||
isEditingName: boolean;
|
||||
setIsEditingName: (isEditing: boolean) => void;
|
||||
handleNameChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
setIsAddNodePanelOpen: (isOpen: boolean) => void;
|
||||
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>;
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
setNodes: (nodes: FlowNode[] | ((nds: FlowNode[]) => FlowNode[])) => void;
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>;
|
||||
templateContextVariables?: Record<string, string>;
|
||||
saveTemplateContextVariables: (variables: Record<string, string>) => Promise<void>;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
saveWorkflowConfigurations: (configurations: WorkflowConfigurations) => Promise<void>;
|
||||
}
|
||||
|
||||
export const layoutNodes = (
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
rankdir: 'TB' | 'LR',
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>,
|
||||
saveWorkflow: (updateWorkflowDefinition: boolean) => Promise<void>
|
||||
) => {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Sort nodes so startCall nodes come first and endCall nodes come last
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
if (a.type === 'startCall') return -1;
|
||||
if (b.type === 'startCall') return 1;
|
||||
if (a.type === 'endCall') return 1;
|
||||
if (b.type === 'endCall') return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
sortedNodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const newNodes = sortedNodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
|
||||
};
|
||||
});
|
||||
|
||||
// Fit view to the new layout and save the viewport position
|
||||
setTimeout(() => {
|
||||
rfInstance.current?.fitView();
|
||||
saveWorkflow(true);
|
||||
}, 0);
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
||||
const WorkflowControls = ({
|
||||
workflowId,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
setIsEditingName,
|
||||
handleNameChange,
|
||||
setIsAddNodePanelOpen,
|
||||
saveWorkflow,
|
||||
nodes,
|
||||
edges,
|
||||
setNodes,
|
||||
rfInstance,
|
||||
templateContextVariables = {},
|
||||
saveTemplateContextVariables,
|
||||
workflowConfigurations,
|
||||
saveWorkflowConfigurations
|
||||
}: WorkflowControlsProps) => {
|
||||
const router = useRouter();
|
||||
const [isContextVarsDialogOpen, setIsContextVarsDialogOpen] = useState(false);
|
||||
const [isConfigurationsDialogOpen, setIsConfigurationsDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center relative bg-white border border-gray-200 rounded-md px-3 py-1 shadow-sm group hover:border-gray-300 transition-colors w-45">
|
||||
{isEditingName ? (
|
||||
<input
|
||||
type="text"
|
||||
value={workflowName}
|
||||
onChange={handleNameChange}
|
||||
className="pr-8 bg-transparent focus:outline-none w-full text-lg"
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === 'Enter' && (setIsEditingName(false), saveWorkflow(false))}
|
||||
/>
|
||||
) : (
|
||||
<h1 className="text-lg font-medium pr-8 truncate">{workflowName}</h1>
|
||||
)}
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
if (isEditingName) {
|
||||
setIsEditingName(false);
|
||||
saveWorkflow(false);
|
||||
} else {
|
||||
setIsEditingName(true);
|
||||
}
|
||||
}}
|
||||
className="h-7 w-7 absolute right-2 top-1/2 transform -translate-y-1/2"
|
||||
>
|
||||
{isEditingName ? (
|
||||
<Check className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Pencil className="h-4 w-4 opacity-50 group-hover:opacity-100 transition-opacity" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button onClick={() => setIsAddNodePanelOpen(true)}>Add New Node</Button>
|
||||
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'TB', rfInstance, saveWorkflow))}>Vertical Layout</Button>
|
||||
<Button onClick={() => setNodes(layoutNodes(nodes, edges, 'LR', rfInstance, saveWorkflow))}>Horizontal Layout</Button>
|
||||
<Button
|
||||
onClick={() => setIsConfigurationsDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Configurations
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setIsContextVarsDialogOpen(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
Template Context Variables
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => router.push(`/workflow/${workflowId}/runs`)}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
View Run History
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ConfigurationsDialog
|
||||
open={isConfigurationsDialogOpen}
|
||||
onOpenChange={setIsConfigurationsDialogOpen}
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
<TemplateContextVariablesDialog
|
||||
open={isContextVarsDialogOpen}
|
||||
onOpenChange={setIsContextVarsDialogOpen}
|
||||
templateContextVariables={templateContextVariables}
|
||||
onSave={saveTemplateContextVariables}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkflowControls;
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet, getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
||||
import { downloadFile } from "@/lib/files";
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||
|
||||
interface WorkflowExecutionsProps {
|
||||
workflowId: number;
|
||||
searchParams: URLSearchParams;
|
||||
}
|
||||
|
||||
export function WorkflowExecutions({ workflowId, searchParams }: WorkflowExecutionsProps) {
|
||||
const router = useRouter();
|
||||
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRunResponseSchema[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
||||
|
||||
const { accessToken } = useUserConfig();
|
||||
|
||||
// Initialize filters from URL
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, availableAttributes);
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||
|
||||
// Load disposition codes from workflow configuration
|
||||
useEffect(() => {
|
||||
const loadDispositionCodes = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
const updatedAttributes = configuredAttributes.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
});
|
||||
setConfiguredAttributes(updatedAttributes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
}
|
||||
};
|
||||
|
||||
loadDispositionCodes();
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Prepare filter data for API
|
||||
let filterParam = undefined;
|
||||
if (filters && filters.length > 0) {
|
||||
const filterData = filters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
|
||||
const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
query: {
|
||||
page: page,
|
||||
limit: 50,
|
||||
...(filterParam && { filters: filterParam })
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch workflow runs");
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setWorkflowRuns(response.data.runs || []);
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalCount(response.data.total_count || 0);
|
||||
setCurrentPage(response.data.page || 1);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error fetching workflow runs:", err);
|
||||
setError("Failed to load workflow runs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('tab', 'executions');
|
||||
params.set('page', page.toString());
|
||||
|
||||
// Add filters to URL if present
|
||||
if (filters && filters.length > 0) {
|
||||
const filterString = encodeFiltersToURL(filters);
|
||||
if (filterString) {
|
||||
const filterParams = new URLSearchParams(filterString);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/workflow/${workflowId}?${params.toString()}`, { scroll: false });
|
||||
}, [router, workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflowRuns(currentPage, activeFilters);
|
||||
}, [currentPage, activeFilters, fetchWorkflowRuns]);
|
||||
|
||||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
updatePageInUrl(1, activeFilters);
|
||||
await fetchWorkflowRuns(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||
setActiveFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
updatePageInUrl(1, []); // Clear filters from URL
|
||||
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
|
||||
setIsExecutingFilters(false);
|
||||
}, [fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
|
||||
<FilterBuilder
|
||||
availableAttributes={configuredAttributes}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-pulse">Loading workflow runs...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
) : workflowRuns.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No workflow runs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
Showing {workflowRuns.length} of {totalCount} total runs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold">Duration</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflowRuns.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank')}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.is_completed ? "default" : "secondary"}>
|
||||
{run.is_completed ? "Completed" : "In Progress"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.call_duration_seconds === 'number'
|
||||
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.gathered_context?.mapped_call_disposition ? (
|
||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
||||
{run.gathered_context.mapped_call_disposition as string}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.dograh_token_usage === 'number'
|
||||
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
{run.transcript_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.transcript_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Transcript
|
||||
</Button>
|
||||
)}
|
||||
{run.recording_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.recording_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Recording
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
window.open(`/workflow/${workflowId}/run/${run.id}`, '_blank');
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage - 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage + 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -16,7 +16,6 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||
import { WORKFLOW_RUN_MODES } from '@/constants/workflowRunModes';
|
||||
import { useOnboarding } from '@/context/OnboardingContext';
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
interface WorkflowHeaderProps {
|
||||
isDirty: boolean;
|
||||
|
|
@ -26,6 +25,8 @@ interface WorkflowHeaderProps {
|
|||
workflowId: number;
|
||||
workflowValidationErrors: WorkflowError[];
|
||||
saveWorkflow: (updateWorkflowDefinition?: boolean) => Promise<void>;
|
||||
user: { id: string; email?: string };
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonObject<FlowNode, FlowEdge> | undefined) => {
|
||||
|
|
@ -57,7 +58,7 @@ const handleExport = (workflow_name: string, workflow_definition: ReactFlowJsonO
|
|||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow }: WorkflowHeaderProps) => {
|
||||
const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId, workflowValidationErrors, saveWorkflow, user, getAccessToken }: WorkflowHeaderProps) => {
|
||||
const router = useRouter();
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const { hasSeenTooltip, markTooltipSeen } = useOnboarding();
|
||||
|
|
@ -70,7 +71,6 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [validationDialogOpen, setValidationDialogOpen] = useState(false);
|
||||
const [configureDialogOpen, setConfigureDialogOpen] = useState(false);
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const webCallButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const hasValidationErrors = workflowValidationErrors.length > 0;
|
||||
|
|
@ -211,39 +211,73 @@ const WorkflowHeader = ({ isDirty, workflowName, rfInstance, onRun, workflowId,
|
|||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Pathway
|
||||
</Button>
|
||||
<Button
|
||||
ref={webCallButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Mark the tooltip as seen when the button is clicked
|
||||
if (!hasSeenTooltip('web_call')) {
|
||||
markTooltipSeen('web_call');
|
||||
}
|
||||
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
|
||||
}}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Web Call
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePhoneCallClick}
|
||||
disabled={hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleExport(workflowName, rfInstance.current?.toObject())}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Download className="mr-2 h-4 w-4" />
|
||||
Export Pathway
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before exporting' : 'Fix validation errors before exporting'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
ref={webCallButtonRef}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Mark the tooltip as seen when the button is clicked
|
||||
if (!hasSeenTooltip('web_call')) {
|
||||
markTooltipSeen('web_call');
|
||||
}
|
||||
onRun(WORKFLOW_RUN_MODES.SMALL_WEBRTC);
|
||||
}}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Web Call
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before testing' : 'Fix validation errors before testing'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePhoneCallClick}
|
||||
disabled={isDirty || hasValidationErrors}
|
||||
>
|
||||
<Phone className="mr-2 h-4 w-4" />
|
||||
Phone Call
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{(isDirty || hasValidationErrors) && (
|
||||
<TooltipContent>
|
||||
{isDirty ? 'Save the workflow before making a call' : 'Fix validation errors before making a call'}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{isDirty ? (
|
||||
<Button
|
||||
|
|
|
|||
45
ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx
Normal file
45
ui/src/app/workflow/[workflowId]/components/WorkflowTabs.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
"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();
|
||||
|
||||
const handleTabChange = (tab: 'editor' | 'executions') => {
|
||||
router.push(`/workflow/${workflowId}?tab=${tab}`, { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleTabChange('editor')}
|
||||
className={cn(
|
||||
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
|
||||
currentTab === 'editor'
|
||||
? "text-white bg-[#3d4451]"
|
||||
: "text-gray-300 hover:text-white hover:bg-[#343842]"
|
||||
)}
|
||||
>
|
||||
Editor
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTabChange('executions')}
|
||||
className={cn(
|
||||
"px-6 py-2.5 text-sm font-medium transition-all relative cursor-pointer rounded-md",
|
||||
currentTab === 'executions'
|
||||
? "text-white bg-[#3d4451]"
|
||||
: "text-gray-300 hover:text-white hover:bg-[#343842]"
|
||||
)}
|
||||
>
|
||||
Executions
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,2 +1 @@
|
|||
export * from './WorkflowControls';
|
||||
export * from './WorkflowHeader';
|
||||
|
|
|
|||
|
|
@ -5,13 +5,11 @@ import {
|
|||
OnEdgesChange,
|
||||
OnNodesChange,
|
||||
ReactFlowInstance,
|
||||
useEdgesState,
|
||||
useNodesState
|
||||
} from "@xyflow/react";
|
||||
import { addEdge } from "@xyflow/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
|
||||
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
|
||||
import {
|
||||
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
|
||||
updateWorkflowApiV1WorkflowWorkflowIdPut,
|
||||
|
|
@ -19,10 +17,9 @@ import {
|
|||
} from "@/client";
|
||||
import { WorkflowError } from "@/client/types.gen";
|
||||
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
|
||||
import { useAuth } from '@/lib/auth';
|
||||
import logger from '@/lib/logger';
|
||||
import { getRandomId } from "@/lib/utils";
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
import { getNextNodeId, getRandomId } from "@/lib/utils";
|
||||
import { WorkflowConfigurations } from "@/types/workflow-configurations";
|
||||
|
||||
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
|
||||
switch (type) {
|
||||
|
|
@ -49,9 +46,9 @@ const defaultNodes: FlowNode[] = [
|
|||
},
|
||||
];
|
||||
|
||||
const getNewNode = (type: string, position: { x: number, y: number }) => {
|
||||
const getNewNode = (type: string, position: { x: number, y: number }, existingNodes: FlowNode[]) => {
|
||||
return {
|
||||
id: `${getRandomId()}`,
|
||||
id: getNextNodeId(existingNodes),
|
||||
type,
|
||||
position,
|
||||
data: {
|
||||
|
|
@ -82,14 +79,56 @@ interface UseWorkflowStateProps {
|
|||
};
|
||||
initialTemplateContextVariables?: Record<string, string>;
|
||||
initialWorkflowConfigurations?: WorkflowConfigurations;
|
||||
user: { id: string; email?: string }; // Minimal user type needed
|
||||
getAccessToken: () => Promise<string>;
|
||||
}
|
||||
|
||||
export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow, initialTemplateContextVariables, initialWorkflowConfigurations }: UseWorkflowStateProps) => {
|
||||
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
export const useWorkflowState = ({
|
||||
initialWorkflowName,
|
||||
workflowId,
|
||||
initialFlow,
|
||||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations,
|
||||
user,
|
||||
getAccessToken
|
||||
}: UseWorkflowStateProps) => {
|
||||
const router = useRouter();
|
||||
const { user, getAccessToken } = useAuth();
|
||||
const [nodes, setNodes] = useNodesState(
|
||||
initialFlow?.nodes?.length
|
||||
const rfInstance = useRef<ReactFlowInstance<FlowNode, FlowEdge> | null>(null);
|
||||
|
||||
// Get state and actions from the store
|
||||
const {
|
||||
nodes,
|
||||
edges,
|
||||
workflowName,
|
||||
isDirty,
|
||||
isAddNodePanelOpen,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
initializeWorkflow,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setWorkflowName,
|
||||
setIsDirty,
|
||||
setIsAddNodePanelOpen,
|
||||
setWorkflowValidationErrors,
|
||||
setTemplateContextVariables,
|
||||
setWorkflowConfigurations,
|
||||
clearValidationErrors,
|
||||
markNodeAsInvalid,
|
||||
markEdgeAsInvalid,
|
||||
setRfInstance,
|
||||
} = useWorkflowStore();
|
||||
|
||||
// Get undo/redo functions from the store
|
||||
const undo = useWorkflowStore((state) => state.undo);
|
||||
const redo = useWorkflowStore((state) => state.redo);
|
||||
const canUndo = useWorkflowStore((state) => state.canUndo());
|
||||
const canRedo = useWorkflowStore((state) => state.canRedo());
|
||||
|
||||
// Initialize workflow on mount
|
||||
useEffect(() => {
|
||||
const initialNodes = initialFlow?.nodes?.length
|
||||
? initialFlow.nodes.map(node => ({
|
||||
...node,
|
||||
data: {
|
||||
|
|
@ -100,43 +139,74 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
: getDefaultAllowInterrupt(node.type),
|
||||
}
|
||||
}))
|
||||
: defaultNodes
|
||||
);
|
||||
const [edges, setEdges] = useEdgesState(initialFlow?.edges ?? []);
|
||||
const [isAddNodePanelOpen, setIsAddNodePanelOpen] = useState(false);
|
||||
const [workflowName, setWorkflowName] = useState(initialWorkflowName);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [isDirty, setIsDirty] = useState(false);
|
||||
const [workflowValidationErrors, setWorkflowValidationErrors] = useState<WorkflowError[]>([]);
|
||||
const [templateContextVariables, setTemplateContextVariables] = useState<Record<string, string>>(
|
||||
initialTemplateContextVariables || {}
|
||||
);
|
||||
const [workflowConfigurations, setWorkflowConfigurations] = useState<WorkflowConfigurations | null>(
|
||||
initialWorkflowConfigurations || DEFAULT_WORKFLOW_CONFIGURATIONS
|
||||
);
|
||||
: defaultNodes;
|
||||
|
||||
initializeWorkflow(
|
||||
workflowId,
|
||||
initialWorkflowName,
|
||||
initialNodes,
|
||||
initialFlow?.edges ?? [],
|
||||
initialTemplateContextVariables,
|
||||
initialWorkflowConfigurations
|
||||
);
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Set up keyboard shortcuts for undo/redo
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Check if we're in an input field
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo: Cmd/Ctrl + Z
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (canUndo) {
|
||||
undo();
|
||||
}
|
||||
}
|
||||
// Redo: Cmd/Ctrl + Shift + Z or Cmd/Ctrl + Y
|
||||
else if (
|
||||
((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === 'z') ||
|
||||
((e.metaKey || e.ctrlKey) && e.key === 'y')
|
||||
) {
|
||||
e.preventDefault();
|
||||
if (canRedo) {
|
||||
redo();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [undo, redo, canUndo, canRedo]);
|
||||
|
||||
const handleNodeSelect = useCallback((nodeType: string) => {
|
||||
/*
|
||||
Used to add new node to the workflow. Receives nodeType as param.
|
||||
Example: nodeType can be agentNode/ startNode etc. as defined by NodeType in
|
||||
types.ts
|
||||
if (!rfInstance.current) return;
|
||||
|
||||
We then pass nodeTypes which contais the NodeType keyword and the component.
|
||||
Those components then contain all the component speecific functioanlity like edit
|
||||
button etc.
|
||||
const position = rfInstance.current.screenToFlowPosition({
|
||||
x: window.innerWidth / 2,
|
||||
y: window.innerHeight / 2,
|
||||
});
|
||||
|
||||
*/
|
||||
const newNode = getNewNode(nodeType, { x: 150, y: 150 });
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
const newNode = {
|
||||
...getNewNode(nodeType, position, nodes),
|
||||
selected: true, // Mark the new node as selected
|
||||
};
|
||||
|
||||
// Use addNodes from ReactFlow instance
|
||||
rfInstance.current.addNodes([newNode]);
|
||||
setIsAddNodePanelOpen(false);
|
||||
}, [setNodes, setIsAddNodePanelOpen]);
|
||||
}, [nodes, setIsAddNodePanelOpen]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setWorkflowName(e.target.value);
|
||||
setIsDirty(true);
|
||||
};
|
||||
|
||||
// Validate workflow function (without saving)
|
||||
// Validate workflow function
|
||||
const validateWorkflow = useCallback(async () => {
|
||||
if (!user) return;
|
||||
try {
|
||||
|
|
@ -150,106 +220,48 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
},
|
||||
});
|
||||
|
||||
// Reset validation state for all nodes and edges
|
||||
setNodes((nds) => nds.map(node => ({ ...node, data: { ...node.data, invalid: false, validationMessage: null } })));
|
||||
setEdges((eds) => eds.map(edge => ({ ...edge, data: { ...edge.data, invalid: false, validationMessage: null } })));
|
||||
setWorkflowValidationErrors([]);
|
||||
// Clear validation errors first
|
||||
clearValidationErrors();
|
||||
|
||||
// Check if we have a 422 error with validation errors
|
||||
// Check if we have validation errors
|
||||
if (response.error) {
|
||||
// The error could be in different formats depending on the status code
|
||||
let errors: WorkflowError[] = [];
|
||||
|
||||
// Type assertion for validation response structure
|
||||
const errorResponse = response.error as {
|
||||
is_valid?: boolean;
|
||||
errors?: WorkflowError[];
|
||||
detail?: { errors: WorkflowError[] };
|
||||
};
|
||||
|
||||
// For 422 responses, the error contains the validation response
|
||||
if (errorResponse.is_valid === false && errorResponse.errors) {
|
||||
errors = errorResponse.errors;
|
||||
}
|
||||
// Also check for detail.errors format
|
||||
else if (errorResponse.detail?.errors) {
|
||||
} else if (errorResponse.detail?.errors) {
|
||||
errors = errorResponse.detail.errors;
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
// Update nodes with validation state
|
||||
setNodes((nds) => nds.map(node => {
|
||||
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
|
||||
if (nodeErrors.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
invalid: true,
|
||||
validationMessage: nodeErrors.map(err => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
errors.forEach((error) => {
|
||||
if (error.kind === 'node' && error.id) {
|
||||
markNodeAsInvalid(error.id, error.message);
|
||||
} else if (error.kind === 'edge' && error.id) {
|
||||
markEdgeAsInvalid(error.id, error.message);
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
});
|
||||
|
||||
// Update edges with validation state
|
||||
setEdges((eds) => eds.map(edge => {
|
||||
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
|
||||
if (edgeErrors.length > 0) {
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
invalid: true,
|
||||
validationMessage: edgeErrors.map(err => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
}));
|
||||
|
||||
// Set workflow validation errors (all types of errors)
|
||||
setWorkflowValidationErrors(errors);
|
||||
}
|
||||
} else if (response.data) {
|
||||
// If we get a 200 response with data, check if it's valid
|
||||
if (response.data.is_valid === false && response.data.errors) {
|
||||
const errors = response.data.errors;
|
||||
|
||||
// Update nodes with validation state
|
||||
setNodes((nds) => nds.map(node => {
|
||||
const nodeErrors = errors.filter((err) => err.kind === 'node' && err.id === node.id);
|
||||
if (nodeErrors.length > 0) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
invalid: true,
|
||||
validationMessage: nodeErrors.map((err) => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
errors.forEach((error) => {
|
||||
if (error.kind === 'node' && error.id) {
|
||||
markNodeAsInvalid(error.id, error.message);
|
||||
} else if (error.kind === 'edge' && error.id) {
|
||||
markEdgeAsInvalid(error.id, error.message);
|
||||
}
|
||||
return node;
|
||||
}));
|
||||
});
|
||||
|
||||
// Update edges with validation state
|
||||
setEdges((eds) => eds.map(edge => {
|
||||
const edgeErrors = errors.filter((err) => err.kind === 'edge' && err.id === edge.id);
|
||||
if (edgeErrors.length > 0) {
|
||||
return {
|
||||
...edge,
|
||||
data: {
|
||||
...edge.data,
|
||||
invalid: true,
|
||||
validationMessage: edgeErrors.map((err) => err.message).join(', ')
|
||||
}
|
||||
};
|
||||
}
|
||||
return edge;
|
||||
}));
|
||||
|
||||
// Set workflow validation errors (all types of errors)
|
||||
setWorkflowValidationErrors(errors);
|
||||
} else {
|
||||
logger.info('Workflow is valid');
|
||||
|
|
@ -258,13 +270,10 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
} catch (error) {
|
||||
logger.error(`Unexpected validation error: ${error}`);
|
||||
}
|
||||
}, [workflowId, user, getAccessToken, setNodes, setEdges]);
|
||||
}, [workflowId, user, getAccessToken, clearValidationErrors, markNodeAsInvalid, markEdgeAsInvalid, setWorkflowValidationErrors]);
|
||||
|
||||
// Save function
|
||||
// Save workflow function
|
||||
const saveWorkflow = useCallback(async (updateWorkflowDefinition: boolean = true) => {
|
||||
/*
|
||||
validates and saves workflow
|
||||
*/
|
||||
if (!user || !rfInstance.current) return;
|
||||
const flow = rfInstance.current.toObject();
|
||||
const accessToken = await getAccessToken();
|
||||
|
|
@ -283,60 +292,43 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
});
|
||||
setIsDirty(false);
|
||||
} catch (error) {
|
||||
logger.error(`Error auto-saving workflow: ${error}`);
|
||||
logger.error(`Error saving workflow: ${error}`);
|
||||
}
|
||||
|
||||
// Validate after saving
|
||||
await validateWorkflow();
|
||||
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, rfInstance, validateWorkflow]);
|
||||
|
||||
// Handle debounced save - REMOVED AUTOSAVE FUNCTIONALITY
|
||||
// const debouncedSave = useCallback(() => {
|
||||
// // Clear any existing timeout
|
||||
// if (saveTimeoutRef.current) {
|
||||
// clearTimeout(saveTimeoutRef.current);
|
||||
// }
|
||||
|
||||
// // Set a new timeout
|
||||
// saveTimeoutRef.current = setTimeout(() => {
|
||||
// saveWorkflow();
|
||||
// saveTimeoutRef.current = null;
|
||||
// }, 2000);
|
||||
// }, [saveWorkflow]);
|
||||
}, [workflowId, workflowName, setIsDirty, user, getAccessToken, validateWorkflow]);
|
||||
|
||||
const onConnect: OnConnect = useCallback((connection) => {
|
||||
setEdges((eds) => addEdge({
|
||||
if (!rfInstance.current) return;
|
||||
|
||||
// Use addEdges from ReactFlow instance
|
||||
rfInstance.current.addEdges([{
|
||||
...connection,
|
||||
id: `${connection.source}-${connection.target}`,
|
||||
data: {
|
||||
label: '',
|
||||
condition: ''
|
||||
}
|
||||
}, eds));
|
||||
setIsDirty(true);
|
||||
// Trigger validation after connection
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
}, [setEdges, validateWorkflow]);
|
||||
}]);
|
||||
}, []);
|
||||
|
||||
const onEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => setEdges((eds) => {
|
||||
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
|
||||
setIsDirty(true);
|
||||
// Trigger validation after edge changes
|
||||
setTimeout(() => validateWorkflow(), 100);
|
||||
return newEdges;
|
||||
}),
|
||||
[setEdges, validateWorkflow],
|
||||
(changes) => {
|
||||
const currentEdges = useWorkflowStore.getState().edges;
|
||||
const newEdges = applyEdgeChanges(changes, currentEdges) as FlowEdge[];
|
||||
setEdges(newEdges, changes);
|
||||
},
|
||||
[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],
|
||||
(changes) => {
|
||||
const currentNodes = useWorkflowStore.getState().nodes;
|
||||
const newNodes = applyNodeChanges(changes, currentNodes) as FlowNode[];
|
||||
setNodes(newNodes, changes);
|
||||
},
|
||||
[setNodes],
|
||||
);
|
||||
|
||||
const onRun = async (mode: string) => {
|
||||
|
|
@ -358,7 +350,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
router.push(`/workflow/${workflowId}/run/${response.data?.id}`);
|
||||
};
|
||||
|
||||
// Save template context variables function
|
||||
// Save template context variables
|
||||
const saveTemplateContextVariables = useCallback(async (variables: Record<string, string>) => {
|
||||
if (!user) return;
|
||||
const accessToken = await getAccessToken();
|
||||
|
|
@ -382,10 +374,10 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
logger.error(`Error saving template context variables: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}, [workflowId, workflowName, user, getAccessToken]);
|
||||
}, [workflowId, workflowName, user, getAccessToken, setTemplateContextVariables]);
|
||||
|
||||
// Save workflow configurations function
|
||||
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => {
|
||||
// Save workflow configurations
|
||||
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
|
||||
if (!user) return;
|
||||
const accessToken = await getAccessToken();
|
||||
try {
|
||||
|
|
@ -394,7 +386,7 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
workflow_id: workflowId,
|
||||
},
|
||||
body: {
|
||||
name: workflowName,
|
||||
name: newWorkflowName,
|
||||
workflow_definition: null,
|
||||
workflow_configurations: configurations as Record<string, unknown>,
|
||||
},
|
||||
|
|
@ -403,36 +395,38 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
},
|
||||
});
|
||||
setWorkflowConfigurations(configurations);
|
||||
setWorkflowName(newWorkflowName);
|
||||
logger.info('Workflow configurations saved successfully');
|
||||
} catch (error) {
|
||||
logger.error(`Error saving workflow configurations: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}, [workflowId, workflowName, user, getAccessToken]);
|
||||
}, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]);
|
||||
|
||||
// Update rfInstance when it changes
|
||||
useEffect(() => {
|
||||
if (rfInstance.current) {
|
||||
setRfInstance(rfInstance.current);
|
||||
}
|
||||
}, [setRfInstance]);
|
||||
|
||||
// Validate workflow on mount
|
||||
useEffect(() => {
|
||||
validateWorkflow();
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Removed useEffect for clearing auto-save timeout as autosave is disabled
|
||||
|
||||
return {
|
||||
rfInstance,
|
||||
nodes,
|
||||
edges,
|
||||
isAddNodePanelOpen,
|
||||
workflowName,
|
||||
isEditingName,
|
||||
isDirty,
|
||||
workflowValidationErrors,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setIsAddNodePanelOpen,
|
||||
setWorkflowName,
|
||||
setIsEditingName,
|
||||
handleNodeSelect,
|
||||
handleNameChange,
|
||||
saveWorkflow,
|
||||
|
|
@ -441,6 +435,11 @@ export const useWorkflowState = ({ initialWorkflowName, workflowId, initialFlow,
|
|||
onNodesChange,
|
||||
onRun,
|
||||
saveTemplateContextVariables,
|
||||
saveWorkflowConfigurations
|
||||
saveWorkflowConfigurations,
|
||||
// Export undo/redo state
|
||||
undo,
|
||||
redo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import RenderWorkflow from '@/app/workflow/[workflowId]/RenderWorkflow';
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet } from '@/client/sdk.gen';
|
||||
|
|
@ -13,14 +13,20 @@ import logger from '@/lib/logger';
|
|||
import { DEFAULT_WORKFLOW_CONFIGURATIONS,WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
import WorkflowLayout from '../WorkflowLayout';
|
||||
import { WorkflowExecutions } from './components/WorkflowExecutions';
|
||||
import { WorkflowTabs } from './components/WorkflowTabs';
|
||||
|
||||
export default function WorkflowDetailPage() {
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
const [workflow, setWorkflow] = useState<WorkflowResponse | undefined>(undefined);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { user, getAccessToken, redirectToLogin, loading: authLoading } = useAuth();
|
||||
|
||||
// Get current tab from URL, default to 'editor'
|
||||
const currentTab = (searchParams.get('tab') as 'editor' | 'executions') || 'editor';
|
||||
|
||||
// Redirect if not authenticated
|
||||
useEffect(() => {
|
||||
if (!authLoading && !user) {
|
||||
|
|
@ -56,16 +62,22 @@ export default function WorkflowDetailPage() {
|
|||
}
|
||||
}, [params.workflowId, user, getAccessToken]);
|
||||
|
||||
const stickyTabs = workflow ? <WorkflowTabs workflowId={workflow.id} currentTab={currentTab} /> : null;
|
||||
|
||||
// Memoize user and getAccessToken to prevent unnecessary re-renders
|
||||
const stableUser = useMemo(() => user, [user?.id]);
|
||||
const stableGetAccessToken = useMemo(() => getAccessToken, [getAccessToken]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<WorkflowLayout>
|
||||
<WorkflowLayout stickyTabs={stickyTabs}>
|
||||
<SpinLoader />
|
||||
</WorkflowLayout>
|
||||
);
|
||||
}
|
||||
else if (error || !workflow) {
|
||||
return (
|
||||
<WorkflowLayout showFeaturesNav={false}>
|
||||
<WorkflowLayout showFeaturesNav={false} stickyTabs={stickyTabs}>
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="text-lg text-red-500">{error || 'Workflow not found'}</div>
|
||||
</div>
|
||||
|
|
@ -73,19 +85,36 @@ export default function WorkflowDetailPage() {
|
|||
);
|
||||
}
|
||||
else {
|
||||
// Render both views but hide the inactive one using absolute positioning
|
||||
// This preserves state when switching tabs
|
||||
return (
|
||||
// We are sending custom header actions to WorkflowLayout from RenderWorkflow component
|
||||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 1 }
|
||||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
/>
|
||||
<>
|
||||
{/* Editor view */}
|
||||
<div className={currentTab === 'editor' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'editor'}>
|
||||
{stableUser && (
|
||||
<RenderWorkflow
|
||||
initialWorkflowName={workflow.name}
|
||||
workflowId={workflow.id}
|
||||
initialFlow={{
|
||||
nodes: workflow.workflow_definition.nodes as FlowNode[],
|
||||
edges: workflow.workflow_definition.edges as FlowEdge[],
|
||||
viewport: { x: 0, y: 0, zoom: 0 }
|
||||
}}
|
||||
initialTemplateContextVariables={workflow.template_context_variables as Record<string, string> || {}}
|
||||
initialWorkflowConfigurations={(workflow.workflow_configurations as WorkflowConfigurations) || DEFAULT_WORKFLOW_CONFIGURATIONS}
|
||||
user={stableUser}
|
||||
getAccessToken={stableGetAccessToken}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Executions view */}
|
||||
<div className={currentTab === 'executions' ? 'block' : 'hidden'} aria-hidden={currentTab !== 'executions'}>
|
||||
<WorkflowLayout stickyTabs={stickyTabs} showFeaturesNav={false}>
|
||||
<WorkflowExecutions workflowId={workflow.id} searchParams={searchParams} />
|
||||
</WorkflowLayout>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,359 +1,19 @@
|
|||
"use client";
|
||||
|
||||
import { ArrowLeft, ChevronLeft, ChevronRight, Download, ExternalLink } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import { getWorkflowApiV1WorkflowFetchWorkflowIdGet,getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet } from "@/client/sdk.gen";
|
||||
import { WorkflowRunResponseSchema } from "@/client/types.gen";
|
||||
import { FilterBuilder } from "@/components/filters/FilterBuilder";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { DISPOSITION_CODES } from "@/constants/dispositionCodes";
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { getDispositionBadgeVariant } from '@/lib/dispositionBadgeVariant';
|
||||
import { downloadFile } from "@/lib/files";
|
||||
import { decodeFiltersFromURL, encodeFiltersToURL } from "@/lib/filters";
|
||||
import { ActiveFilter, availableAttributes, FilterAttribute } from "@/types/filters";
|
||||
|
||||
import WorkflowLayout from '../../WorkflowLayout';
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function WorkflowRunsPage() {
|
||||
const { workflowId } = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const [workflowRuns, setWorkflowRuns] = useState<WorkflowRunResponseSchema[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(() => {
|
||||
const pageParam = searchParams.get('page');
|
||||
return pageParam ? parseInt(pageParam, 10) : 1;
|
||||
});
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [isExecutingFilters, setIsExecutingFilters] = useState(false);
|
||||
const [configuredAttributes, setConfiguredAttributes] = useState<FilterAttribute[]>(availableAttributes);
|
||||
|
||||
const { accessToken } = useUserConfig();
|
||||
|
||||
// Initialize filters from URL
|
||||
const [activeFilters, setActiveFilters] = useState<ActiveFilter[]>(() => {
|
||||
return decodeFiltersFromURL(searchParams, availableAttributes);
|
||||
});
|
||||
|
||||
const formatDate = (dateString: string) => new Date(dateString).toLocaleString();
|
||||
|
||||
|
||||
// Load disposition codes from workflow configuration
|
||||
// Redirect to main workflow page with executions tab
|
||||
useEffect(() => {
|
||||
const loadDispositionCodes = async () => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
const response = await getWorkflowApiV1WorkflowFetchWorkflowIdGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` }
|
||||
});
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('tab', 'executions');
|
||||
router.replace(`/workflow/${workflowId}?${params.toString()}`);
|
||||
}, [workflowId, router, searchParams]);
|
||||
|
||||
const workflow = response.data;
|
||||
if (workflow?.call_disposition_codes) {
|
||||
// Update the disposition code attribute with actual options
|
||||
const updatedAttributes = configuredAttributes.map(attr => {
|
||||
if (attr.id === 'dispositionCode') {
|
||||
return {
|
||||
...attr,
|
||||
config: {
|
||||
...attr.config,
|
||||
options: Object.keys(workflow.call_disposition_codes || {}).length > 0
|
||||
? Object.keys(workflow.call_disposition_codes || {})
|
||||
: [...DISPOSITION_CODES]
|
||||
}
|
||||
};
|
||||
}
|
||||
return attr;
|
||||
});
|
||||
setConfiguredAttributes(updatedAttributes);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load disposition codes:", err);
|
||||
}
|
||||
};
|
||||
|
||||
loadDispositionCodes();
|
||||
}, [workflowId, accessToken, configuredAttributes]);
|
||||
|
||||
const fetchWorkflowRuns = useCallback(async (page: number, filters?: ActiveFilter[]) => {
|
||||
if (!accessToken) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
// Prepare filter data for API
|
||||
let filterParam = undefined;
|
||||
if (filters && filters.length > 0) {
|
||||
const filterData = filters.map(filter => ({
|
||||
attribute: filter.attribute.id,
|
||||
type: filter.attribute.type,
|
||||
value: filter.value
|
||||
}));
|
||||
filterParam = JSON.stringify(filterData);
|
||||
}
|
||||
|
||||
const response = await getWorkflowRunsApiV1WorkflowWorkflowIdRunsGet({
|
||||
path: { workflow_id: Number(workflowId) },
|
||||
query: {
|
||||
page: page,
|
||||
limit: 50,
|
||||
...(filterParam && { filters: filterParam })
|
||||
},
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
}
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
throw new Error("Failed to fetch workflow runs");
|
||||
}
|
||||
|
||||
if (response.data) {
|
||||
setWorkflowRuns(response.data.runs || []);
|
||||
setTotalPages(response.data.total_pages || 1);
|
||||
setTotalCount(response.data.total_count || 0);
|
||||
setCurrentPage(response.data.page || 1);
|
||||
}
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Error fetching workflow runs:", err);
|
||||
setError("Failed to load workflow runs");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [workflowId, accessToken]);
|
||||
|
||||
const updatePageInUrl = useCallback((page: number, filters?: ActiveFilter[]) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', page.toString());
|
||||
|
||||
// Add filters to URL if present
|
||||
if (filters && filters.length > 0) {
|
||||
const filterString = encodeFiltersToURL(filters);
|
||||
if (filterString) {
|
||||
const filterParams = new URLSearchParams(filterString);
|
||||
filterParams.forEach((value, key) => params.set(key, value));
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/workflow/${workflowId}/runs?${params.toString()}`);
|
||||
}, [router, workflowId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchWorkflowRuns(currentPage, activeFilters);
|
||||
}, [currentPage, activeFilters, fetchWorkflowRuns]);
|
||||
|
||||
const handleApplyFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1); // Reset to first page when applying filters
|
||||
updatePageInUrl(1, activeFilters);
|
||||
await fetchWorkflowRuns(1, activeFilters);
|
||||
setIsExecutingFilters(false);
|
||||
}, [activeFilters, fetchWorkflowRuns, updatePageInUrl]);
|
||||
|
||||
const handleFiltersChange = useCallback((filters: ActiveFilter[]) => {
|
||||
setActiveFilters(filters);
|
||||
}, []);
|
||||
|
||||
const handleClearFilters = useCallback(async () => {
|
||||
setIsExecutingFilters(true);
|
||||
setCurrentPage(1);
|
||||
updatePageInUrl(1, []); // Clear filters from URL
|
||||
await fetchWorkflowRuns(1, []); // Fetch all workflows without filters
|
||||
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>
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowLayout backButton={backButton}>
|
||||
<div className="container mx-auto py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold mb-4">Workflow Run History</h1>
|
||||
<FilterBuilder
|
||||
availableAttributes={configuredAttributes}
|
||||
activeFilters={activeFilters}
|
||||
onFiltersChange={handleFiltersChange}
|
||||
onApplyFilters={handleApplyFilters}
|
||||
onClearFilters={handleClearFilters}
|
||||
isExecuting={isExecutingFilters}
|
||||
/>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="flex justify-center">
|
||||
<div className="animate-pulse">Loading workflow runs...</div>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
|
||||
{error}
|
||||
</div>
|
||||
) : workflowRuns.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-gray-500">No workflow runs found</p>
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Workflow Runs</CardTitle>
|
||||
<CardDescription>
|
||||
Showing {workflowRuns.length} of {totalCount} total runs
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="bg-white border rounded-lg overflow-hidden shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-50">
|
||||
<TableHead className="font-semibold">ID</TableHead>
|
||||
<TableHead className="font-semibold">Status</TableHead>
|
||||
<TableHead className="font-semibold">Created At</TableHead>
|
||||
<TableHead className="font-semibold">Duration</TableHead>
|
||||
<TableHead className="font-semibold">Disposition</TableHead>
|
||||
<TableHead className="font-semibold">Dograh Token</TableHead>
|
||||
<TableHead className="font-semibold">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{workflowRuns.map((run) => (
|
||||
<TableRow
|
||||
key={run.id}
|
||||
className="cursor-pointer"
|
||||
onClick={() => router.push(`/workflow/${workflowId}/run/${run.id}`)}
|
||||
>
|
||||
<TableCell className="font-mono text-sm">#{run.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={run.is_completed ? "default" : "secondary"}>
|
||||
{run.is_completed ? "Completed" : "In Progress"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{formatDate(run.created_at)}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.call_duration_seconds === 'number'
|
||||
? `${run.cost_info.call_duration_seconds.toFixed(1)}s`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{run.gathered_context?.mapped_call_disposition ? (
|
||||
<Badge variant={getDispositionBadgeVariant(run.gathered_context.mapped_call_disposition as string)}>
|
||||
{run.gathered_context.mapped_call_disposition as string}
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{typeof run.cost_info?.dograh_token_usage === 'number'
|
||||
? `${run.cost_info.dograh_token_usage.toFixed(2)}`
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex space-x-2">
|
||||
{run.transcript_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.transcript_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Transcript
|
||||
</Button>
|
||||
)}
|
||||
{run.recording_url && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (accessToken) downloadFile(run.recording_url, accessToken);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
Recording
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/workflow/${workflowId}/run/${run.id}`);
|
||||
}}
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6">
|
||||
<p className="text-sm text-gray-600">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage - 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const newPage = currentPage + 1;
|
||||
setCurrentPage(newPage);
|
||||
updatePageInUrl(newPage, activeFilters);
|
||||
}}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</WorkflowLayout>
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
399
ui/src/app/workflow/[workflowId]/stores/workflowStore.ts
Normal file
399
ui/src/app/workflow/[workflowId]/stores/workflowStore.ts
Normal file
|
|
@ -0,0 +1,399 @@
|
|||
import { ReactFlowInstance } from '@xyflow/react';
|
||||
import { EdgeChange,NodeChange } from '@xyflow/system';
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { WorkflowError } from '@/client/types.gen';
|
||||
import { FlowEdge, FlowNode } from '@/components/flow/types';
|
||||
import { DEFAULT_WORKFLOW_CONFIGURATIONS, WorkflowConfigurations } from '@/types/workflow-configurations';
|
||||
|
||||
interface HistoryState {
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
workflowName: string;
|
||||
}
|
||||
|
||||
interface WorkflowState {
|
||||
// Workflow identification
|
||||
workflowId: number | null;
|
||||
workflowName: string;
|
||||
|
||||
// Flow state
|
||||
nodes: FlowNode[];
|
||||
edges: FlowEdge[];
|
||||
|
||||
// History for undo/redo
|
||||
history: HistoryState[];
|
||||
historyIndex: number;
|
||||
|
||||
// UI state (not tracked in history)
|
||||
isDirty: boolean;
|
||||
isAddNodePanelOpen: boolean;
|
||||
|
||||
// Validation state
|
||||
workflowValidationErrors: WorkflowError[];
|
||||
|
||||
// Configuration
|
||||
templateContextVariables: Record<string, string>;
|
||||
workflowConfigurations: WorkflowConfigurations | null;
|
||||
|
||||
// ReactFlow instance reference
|
||||
rfInstance: ReactFlowInstance<FlowNode, FlowEdge> | null;
|
||||
}
|
||||
|
||||
interface WorkflowActions {
|
||||
// Initialization
|
||||
initializeWorkflow: (
|
||||
workflowId: number,
|
||||
workflowName: string,
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
templateContextVariables?: Record<string, string>,
|
||||
workflowConfigurations?: WorkflowConfigurations | null
|
||||
) => void;
|
||||
|
||||
// History management
|
||||
pushToHistory: () => void;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
canUndo: () => boolean;
|
||||
canRedo: () => boolean;
|
||||
|
||||
// Node operations
|
||||
setNodes: (nodes: FlowNode[], changes?: NodeChange<FlowNode>[]) => void;
|
||||
addNode: (node: FlowNode) => void;
|
||||
updateNode: (nodeId: string, updates: Partial<FlowNode>) => void;
|
||||
deleteNode: (nodeId: string) => void;
|
||||
|
||||
// Edge operations
|
||||
setEdges: (edges: FlowEdge[], changes?: EdgeChange<FlowEdge>[]) => void;
|
||||
addEdge: (edge: FlowEdge) => void;
|
||||
updateEdge: (edgeId: string, updates: Partial<FlowEdge>) => void;
|
||||
deleteEdge: (edgeId: string) => void;
|
||||
|
||||
// Workflow metadata
|
||||
setWorkflowName: (name: string) => void;
|
||||
setTemplateContextVariables: (variables: Record<string, string>) => void;
|
||||
setWorkflowConfigurations: (configurations: WorkflowConfigurations) => void;
|
||||
|
||||
// UI state
|
||||
setIsDirty: (isDirty: boolean) => void;
|
||||
setIsAddNodePanelOpen: (isOpen: boolean) => void;
|
||||
|
||||
// Validation
|
||||
setWorkflowValidationErrors: (errors: WorkflowError[]) => void;
|
||||
markNodeAsInvalid: (nodeId: string, message: string) => void;
|
||||
markEdgeAsInvalid: (edgeId: string, message: string) => void;
|
||||
clearValidationErrors: () => void;
|
||||
|
||||
// ReactFlow instance
|
||||
setRfInstance: (instance: ReactFlowInstance<FlowNode, FlowEdge> | null) => void;
|
||||
|
||||
// Clear store (for cleanup)
|
||||
clearStore: () => void;
|
||||
}
|
||||
|
||||
type WorkflowStore = WorkflowState & WorkflowActions;
|
||||
|
||||
const MAX_HISTORY_SIZE = 50;
|
||||
|
||||
// Create the store
|
||||
export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
|
||||
// Initial state
|
||||
workflowId: null,
|
||||
workflowName: '',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
isDirty: false,
|
||||
isAddNodePanelOpen: false,
|
||||
workflowValidationErrors: [],
|
||||
templateContextVariables: {},
|
||||
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
|
||||
rfInstance: null,
|
||||
|
||||
// Actions
|
||||
initializeWorkflow: (workflowId, workflowName, nodes, edges, templateContextVariables = {}, workflowConfigurations = DEFAULT_WORKFLOW_CONFIGURATIONS) => {
|
||||
const initialHistory: HistoryState = { nodes, edges, workflowName };
|
||||
set({
|
||||
workflowId,
|
||||
workflowName,
|
||||
nodes,
|
||||
edges,
|
||||
templateContextVariables,
|
||||
workflowConfigurations,
|
||||
isDirty: false,
|
||||
workflowValidationErrors: [],
|
||||
history: [initialHistory],
|
||||
historyIndex: 0,
|
||||
});
|
||||
},
|
||||
|
||||
pushToHistory: () => {
|
||||
const state = get();
|
||||
const currentState: HistoryState = {
|
||||
nodes: state.nodes,
|
||||
edges: state.edges,
|
||||
workflowName: state.workflowName,
|
||||
};
|
||||
|
||||
// Remove any forward history if we're not at the end
|
||||
const newHistory = state.history.slice(0, state.historyIndex + 1);
|
||||
newHistory.push(currentState);
|
||||
|
||||
// Limit history size
|
||||
if (newHistory.length > MAX_HISTORY_SIZE) {
|
||||
newHistory.shift();
|
||||
}
|
||||
|
||||
set({
|
||||
history: newHistory,
|
||||
historyIndex: newHistory.length - 1,
|
||||
});
|
||||
},
|
||||
|
||||
undo: () => {
|
||||
const state = get();
|
||||
if (state.historyIndex > 0) {
|
||||
const newIndex = state.historyIndex - 1;
|
||||
const historicState = state.history[newIndex];
|
||||
set({
|
||||
nodes: historicState.nodes,
|
||||
edges: historicState.edges,
|
||||
workflowName: historicState.workflowName,
|
||||
historyIndex: newIndex,
|
||||
isDirty: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
redo: () => {
|
||||
const state = get();
|
||||
if (state.historyIndex < state.history.length - 1) {
|
||||
const newIndex = state.historyIndex + 1;
|
||||
const historicState = state.history[newIndex];
|
||||
set({
|
||||
nodes: historicState.nodes,
|
||||
edges: historicState.edges,
|
||||
workflowName: historicState.workflowName,
|
||||
historyIndex: newIndex,
|
||||
isDirty: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
canUndo: () => {
|
||||
const state = get();
|
||||
return state.historyIndex > 0;
|
||||
},
|
||||
|
||||
canRedo: () => {
|
||||
const state = get();
|
||||
return state.historyIndex < state.history.length - 1;
|
||||
},
|
||||
|
||||
setNodes: (nodes, changes) => {
|
||||
// Determine whether to push to history and set isDirty based on change types
|
||||
if (changes && changes.length > 0) {
|
||||
// Check if any changes are user-initiated (not just selections or dimensions)
|
||||
const hasDirtyChanges = changes.some(change =>
|
||||
change.type === 'add' ||
|
||||
change.type === 'remove' ||
|
||||
(change.type === 'position' && change.dragging)
|
||||
);
|
||||
|
||||
if (hasDirtyChanges) {
|
||||
get().pushToHistory();
|
||||
set({ nodes, isDirty: true });
|
||||
} else {
|
||||
// For selection changes or dimension updates, don't push to history
|
||||
set({ nodes });
|
||||
}
|
||||
} else {
|
||||
// No changes provided, just update nodes without history
|
||||
set({ nodes });
|
||||
}
|
||||
},
|
||||
|
||||
addNode: (node) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
nodes: [...state.nodes, node],
|
||||
isDirty: true
|
||||
});
|
||||
},
|
||||
|
||||
updateNode: (nodeId, updates) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === nodeId ? { ...node, ...updates } : node
|
||||
),
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
deleteNode: (nodeId) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
nodes: state.nodes.filter((node) => node.id !== nodeId),
|
||||
edges: state.edges.filter(
|
||||
(edge) => edge.source !== nodeId && edge.target !== nodeId
|
||||
),
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
setEdges: (edges, changes) => {
|
||||
// Determine whether to push to history and set isDirty based on change types
|
||||
if (changes && changes.length > 0) {
|
||||
// Check if any changes are user-initiated (not just selections)
|
||||
const hasDirtyChanges = changes.some(change =>
|
||||
change.type === 'add' ||
|
||||
change.type === 'remove' ||
|
||||
change.type === 'replace'
|
||||
);
|
||||
|
||||
if (hasDirtyChanges) {
|
||||
get().pushToHistory();
|
||||
set({ edges, isDirty: true });
|
||||
} else {
|
||||
// For selection changes, don't push to history
|
||||
set({ edges });
|
||||
}
|
||||
} else {
|
||||
// No changes provided, just update edges without history
|
||||
set({ edges });
|
||||
}
|
||||
},
|
||||
|
||||
addEdge: (edge) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
edges: [...state.edges, edge],
|
||||
isDirty: true
|
||||
});
|
||||
},
|
||||
|
||||
updateEdge: (edgeId, updates) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === edgeId ? { ...edge, ...updates } : edge
|
||||
),
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
deleteEdge: (edgeId) => {
|
||||
const state = get();
|
||||
get().pushToHistory();
|
||||
set({
|
||||
edges: state.edges.filter((edge) => edge.id !== edgeId),
|
||||
isDirty: true,
|
||||
});
|
||||
},
|
||||
|
||||
setWorkflowName: (workflowName) => {
|
||||
get().pushToHistory();
|
||||
set({ workflowName, isDirty: true });
|
||||
},
|
||||
|
||||
setTemplateContextVariables: (templateContextVariables) => {
|
||||
set({ templateContextVariables });
|
||||
},
|
||||
|
||||
setWorkflowConfigurations: (workflowConfigurations) => {
|
||||
set({ workflowConfigurations });
|
||||
},
|
||||
|
||||
setIsDirty: (isDirty) => {
|
||||
set({ isDirty });
|
||||
},
|
||||
|
||||
setIsAddNodePanelOpen: (isAddNodePanelOpen) => {
|
||||
set({ isAddNodePanelOpen });
|
||||
},
|
||||
|
||||
setWorkflowValidationErrors: (workflowValidationErrors) => {
|
||||
set({ workflowValidationErrors });
|
||||
},
|
||||
|
||||
markNodeAsInvalid: (nodeId, message) => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) =>
|
||||
node.id === nodeId
|
||||
? { ...node, data: { ...node.data, invalid: true, validationMessage: message } }
|
||||
: node
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
markEdgeAsInvalid: (edgeId, message) => {
|
||||
set((state) => ({
|
||||
edges: state.edges.map((edge) =>
|
||||
edge.id === edgeId
|
||||
? { ...edge, data: { ...edge.data, invalid: true, validationMessage: message } }
|
||||
: edge
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
clearValidationErrors: () => {
|
||||
set((state) => ({
|
||||
nodes: state.nodes.map((node) => ({
|
||||
...node,
|
||||
data: { ...node.data, invalid: false, validationMessage: null },
|
||||
})),
|
||||
edges: state.edges.map((edge) => ({
|
||||
...edge,
|
||||
data: { ...edge.data, invalid: false, validationMessage: null },
|
||||
})),
|
||||
workflowValidationErrors: [],
|
||||
}));
|
||||
},
|
||||
|
||||
setRfInstance: (rfInstance) => {
|
||||
set({ rfInstance });
|
||||
},
|
||||
|
||||
clearStore: () => {
|
||||
set({
|
||||
workflowId: null,
|
||||
workflowName: '',
|
||||
nodes: [],
|
||||
edges: [],
|
||||
history: [],
|
||||
historyIndex: -1,
|
||||
isDirty: false,
|
||||
isAddNodePanelOpen: false,
|
||||
workflowValidationErrors: [],
|
||||
templateContextVariables: {},
|
||||
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
|
||||
rfInstance: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
// Selectors for common use cases
|
||||
export const useWorkflowNodes = () => useWorkflowStore((state) => state.nodes);
|
||||
export const useWorkflowEdges = () => useWorkflowStore((state) => state.edges);
|
||||
export const useWorkflowName = () => useWorkflowStore((state) => state.workflowName);
|
||||
export const useWorkflowId = () => useWorkflowStore((state) => state.workflowId);
|
||||
export const useWorkflowDirtyState = () => useWorkflowStore((state) => state.isDirty);
|
||||
export const useWorkflowValidationErrors = () => useWorkflowStore((state) => state.workflowValidationErrors);
|
||||
|
||||
// Selector for undo/redo state
|
||||
export const useUndoRedo = () => {
|
||||
const undo = useWorkflowStore((state) => state.undo);
|
||||
const redo = useWorkflowStore((state) => state.redo);
|
||||
const canUndo = useWorkflowStore((state) => state.canUndo());
|
||||
const canRedo = useWorkflowStore((state) => state.canRedo());
|
||||
|
||||
return { undo, redo, canUndo, canRedo };
|
||||
};
|
||||
49
ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts
Normal file
49
ui/src/app/workflow/[workflowId]/utils/layoutNodes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import dagre from '@dagrejs/dagre';
|
||||
import { ReactFlowInstance } from "@xyflow/react";
|
||||
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
|
||||
export const layoutNodes = (
|
||||
nodes: FlowNode[],
|
||||
edges: FlowEdge[],
|
||||
rankdir: 'TB' | 'LR',
|
||||
rfInstance: React.RefObject<ReactFlowInstance<FlowNode, FlowEdge> | null>
|
||||
) => {
|
||||
const g = new dagre.graphlib.Graph();
|
||||
g.setGraph({ rankdir, nodesep: 250, ranksep: 250 });
|
||||
g.setDefaultEdgeLabel(() => ({}));
|
||||
|
||||
// Sort nodes so startCall nodes come first and endCall nodes come last
|
||||
const sortedNodes = [...nodes].sort((a, b) => {
|
||||
if (a.type === 'startCall') return -1;
|
||||
if (b.type === 'startCall') return 1;
|
||||
if (a.type === 'endCall') return 1;
|
||||
if (b.type === 'endCall') return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
sortedNodes.forEach((node) => {
|
||||
g.setNode(node.id, { width: 180, height: 60 });
|
||||
});
|
||||
|
||||
edges.forEach((edge) => {
|
||||
g.setEdge(edge.source, edge.target);
|
||||
});
|
||||
|
||||
dagre.layout(g);
|
||||
|
||||
const newNodes = sortedNodes.map((node) => {
|
||||
const nodeWithPosition = g.node(node.id);
|
||||
return {
|
||||
...node,
|
||||
position: { x: nodeWithPosition.x, y: nodeWithPosition.y }
|
||||
};
|
||||
});
|
||||
|
||||
// Fit view to the new layout and save the viewport position
|
||||
setTimeout(() => {
|
||||
rfInstance.current?.fitView({ padding: 0.2, duration: 200, maxZoom: 0.75 });
|
||||
}, 0);
|
||||
|
||||
return newNodes;
|
||||
};
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
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 { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -25,6 +26,14 @@ const EdgeDetailsDialog = ({ open, onOpenChange, data, onSave }: EdgeDetailsDial
|
|||
const [condition, setCondition] = useState(data?.condition ?? '');
|
||||
const [label, setLabel] = useState(data?.label ?? '');
|
||||
|
||||
// Update form state when data changes (e.g., from undo/redo)
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCondition(data?.condition ?? '');
|
||||
setLabel(data?.label ?? '');
|
||||
}
|
||||
}, [data, open]);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave({ condition: condition, label: label });
|
||||
onOpenChange(false);
|
||||
|
|
@ -85,10 +94,14 @@ 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, setNodes } = useReactFlow<FlowNode, FlowEdge>();
|
||||
const { saveWorkflow } = useWorkflow();
|
||||
const updateEdge = useWorkflowStore((state) => state.updateEdge);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
const parallel = getEdges().filter(
|
||||
(e) =>
|
||||
(e.source === source && e.target === target) ||
|
||||
|
|
@ -113,8 +126,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,31 +136,74 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
targetPosition,
|
||||
});
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
// Update connected nodes when edge is selected or hovered
|
||||
useEffect(() => {
|
||||
setNodes((nodes) => {
|
||||
return nodes.map((node) => {
|
||||
if (node.id === source || node.id === target) {
|
||||
// Update both properties based on edge state
|
||||
const shouldSelectThroughEdge = selected || false;
|
||||
const shouldHoverThroughEdge = isHovered || false;
|
||||
|
||||
// Only update if state actually changed
|
||||
if (
|
||||
node.data.selected_through_edge !== shouldSelectThroughEdge ||
|
||||
node.data.hovered_through_edge !== shouldHoverThroughEdge
|
||||
) {
|
||||
return {
|
||||
...node,
|
||||
data: {
|
||||
...node.data,
|
||||
selected_through_edge: shouldSelectThroughEdge,
|
||||
hovered_through_edge: shouldHoverThroughEdge
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
return node;
|
||||
});
|
||||
});
|
||||
}, [selected, isHovered, source, target, setNodes]);
|
||||
|
||||
const handleSaveEdgeData = useCallback(async (updatedData: FlowEdgeData) => {
|
||||
// Update the node data in the ReactFlow nodes state
|
||||
setEdges((edges) => {
|
||||
const updatedEdges = edges.map((edge) =>
|
||||
edge.id === id
|
||||
? { ...edge, data: updatedData }
|
||||
: edge
|
||||
)
|
||||
return updatedEdges;
|
||||
}
|
||||
);
|
||||
// Use the workflow store's updateEdge method to properly track history
|
||||
updateEdge(id, { data: updatedData });
|
||||
|
||||
// Save the workflow after updating edge data with a small delay to ensure state is updated
|
||||
setTimeout(async () => {
|
||||
await saveWorkflow();
|
||||
}, 100);
|
||||
}, [id, setEdges, saveWorkflow]);
|
||||
}, [id, updateEdge, saveWorkflow]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
/>
|
||||
<g
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
>
|
||||
<BaseEdge
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
...style,
|
||||
stroke: selected
|
||||
? '#3B82F6' // blue-500 when selected
|
||||
: isHovered
|
||||
? '#60A5FA' // blue-400 when hovered
|
||||
: data?.invalid ? '#EF4444' : '#94A3B8',
|
||||
strokeWidth: selected ? 4 : isHovered ? 3 : 2.5,
|
||||
filter: selected
|
||||
? 'drop-shadow(0 0 8px rgba(59, 130, 246, 0.6))'
|
||||
: isHovered
|
||||
? 'drop-shadow(0 0 6px rgba(96, 165, 250, 0.4))'
|
||||
: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
interactionWidth={20}
|
||||
/>
|
||||
</g>
|
||||
{/* Always show label, expand on select/hover */}
|
||||
<EdgeLabelRenderer>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -155,26 +211,55 @@ export default function CustomEdge(props: CustomEdgeProps) {
|
|||
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)}
|
||||
onDoubleClick={() => setOpen(true)}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* Show full EdgeLabel when selected or hovered, otherwise show simple label */}
|
||||
{(selected || isHovered) ? (
|
||||
<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 - EdgeID: {id}
|
||||
</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>
|
||||
) : (
|
||||
/* Simple label shown by default */
|
||||
<div className={cn(
|
||||
"px-2 py-1 bg-white rounded border shadow-sm",
|
||||
data?.invalid ? "border-red-400 text-red-600" : "border-gray-300 text-gray-700"
|
||||
)}>
|
||||
<div className="text-xs font-medium">
|
||||
{data?.label || data?.condition || 'No condition'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</EdgeLabelRenderer>
|
||||
<EdgeDetailsDialog
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,8 @@ export type FlowNodeData = {
|
|||
is_end?: boolean;
|
||||
invalid?: boolean;
|
||||
validationMessage?: string | null;
|
||||
selected_through_edge?: boolean;
|
||||
hovered_through_edge?: boolean;
|
||||
allow_interrupt?: boolean;
|
||||
extraction_enabled?: boolean;
|
||||
extraction_prompt?: string;
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ export function getRandomId() {
|
|||
return Math.floor(Math.random() * 10_000);
|
||||
}
|
||||
|
||||
export function getNextNodeId(existingNodes: { id: string }[]): string {
|
||||
const numericIds = existingNodes
|
||||
.map(node => parseInt(node.id, 10))
|
||||
.filter(id => !isNaN(id));
|
||||
|
||||
const maxId = numericIds.length > 0 ? Math.max(...numericIds) : 0;
|
||||
return String(maxId + 1);
|
||||
}
|
||||
|
||||
export function debounce<T extends (...args: unknown[]) => unknown>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||
let timeout: NodeJS.Timeout | null = null;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue