Add option to edit workflow name

This commit is contained in:
Abhishek Kumar 2025-11-06 14:59:16 +05:30
parent 0d9dc1fcf8
commit add12ab53c
7 changed files with 139 additions and 235 deletions

View file

@ -21,11 +21,11 @@ import CustomEdge from "../../../components/flow/edges/CustomEdge";
import { AgentNode, EndCall, GlobalNode, StartCall } from "../../../components/flow/nodes";
import { ConfigurationsDialog } from './components/ConfigurationsDialog';
import { TemplateContextVariablesDialog } from './components/TemplateContextVariablesDialog';
import { layoutNodes } from './components/WorkflowControls';
import WorkflowHeader from "./components/WorkflowHeader";
import { WorkflowTabs } from './components/WorkflowTabs';
import { WorkflowProvider } from "./contexts/WorkflowContext";
import { useWorkflowState } from "./hooks/useWorkflowState";
import { layoutNodes } from './utils/layoutNodes';
// Define the node types dynamically based on the onSave prop
const nodeTypes = {
@ -307,6 +307,7 @@ function RenderWorkflow({ initialWorkflowName, workflowId, initialFlow, initialT
open={isConfigurationsDialogOpen}
onOpenChange={setIsConfigurationsDialogOpen}
workflowConfigurations={workflowConfigurations}
workflowName={workflowName}
onSave={saveWorkflowConfigurations}
/>

View file

@ -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>

View file

@ -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;

View file

@ -1,2 +1 @@
export * from './WorkflowControls';
export * from './WorkflowHeader';

View file

@ -9,6 +9,7 @@ import {
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef } from "react";
import { useWorkflowStore } from "@/app/workflow/[workflowId]/stores/workflowStore";
import {
createWorkflowRunApiV1WorkflowWorkflowIdRunsPost,
updateWorkflowApiV1WorkflowWorkflowIdPut,
@ -18,7 +19,6 @@ import { WorkflowError } from "@/client/types.gen";
import { FlowEdge, FlowNode, NodeType } from "@/components/flow/types";
import logger from '@/lib/logger';
import { getNextNodeId, getRandomId } from "@/lib/utils";
import { useWorkflowStore } from "@/stores/workflowStore";
import { WorkflowConfigurations } from "@/types/workflow-configurations";
export function getDefaultAllowInterrupt(type: string = NodeType.START_CALL): boolean {
@ -102,7 +102,6 @@ export const useWorkflowState = ({
workflowName,
isDirty,
isAddNodePanelOpen,
isEditingName,
workflowValidationErrors,
templateContextVariables,
workflowConfigurations,
@ -316,20 +315,18 @@ export const useWorkflowState = ({
const onEdgesChange: OnEdgesChange = useCallback(
(changes) => {
setEdges((eds) => {
const newEdges = applyEdgeChanges(changes, eds) as FlowEdge[];
return newEdges;
});
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[];
return newNodes;
});
const currentNodes = useWorkflowStore.getState().nodes;
const newNodes = applyNodeChanges(changes, currentNodes) as FlowNode[];
setNodes(newNodes, changes);
},
[setNodes],
);
@ -380,7 +377,7 @@ export const useWorkflowState = ({
}, [workflowId, workflowName, user, getAccessToken, setTemplateContextVariables]);
// Save workflow configurations
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations) => {
const saveWorkflowConfigurations = useCallback(async (configurations: WorkflowConfigurations, newWorkflowName: string) => {
if (!user) return;
const accessToken = await getAccessToken();
try {
@ -389,7 +386,7 @@ export const useWorkflowState = ({
workflow_id: workflowId,
},
body: {
name: workflowName,
name: newWorkflowName,
workflow_definition: null,
workflow_configurations: configurations as Record<string, unknown>,
},
@ -398,12 +395,13 @@ export const useWorkflowState = ({
},
});
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, setWorkflowConfigurations]);
}, [workflowId, user, getAccessToken, setWorkflowConfigurations, setWorkflowName]);
// Update rfInstance when it changes
useEffect(() => {
@ -423,7 +421,6 @@ export const useWorkflowState = ({
edges,
isAddNodePanelOpen,
workflowName,
isEditingName,
isDirty,
workflowValidationErrors,
templateContextVariables,

View file

@ -1,4 +1,5 @@
import { ReactFlowInstance } from '@xyflow/react';
import { NodeChange, EdgeChange } from '@xyflow/system';
import { create } from 'zustand';
import { WorkflowError } from '@/client/types.gen';
@ -27,7 +28,6 @@ interface WorkflowState {
// UI state (not tracked in history)
isDirty: boolean;
isAddNodePanelOpen: boolean;
isEditingName: boolean;
// Validation state
workflowValidationErrors: WorkflowError[];
@ -59,13 +59,13 @@ interface WorkflowActions {
canRedo: () => boolean;
// Node operations
setNodes: (nodes: FlowNode[] | ((nodes: FlowNode[]) => FlowNode[])) => void;
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[] | ((edges: FlowEdge[]) => FlowEdge[])) => void;
setEdges: (edges: FlowEdge[], changes?: EdgeChange<FlowEdge>[]) => void;
addEdge: (edge: FlowEdge) => void;
updateEdge: (edgeId: string, updates: Partial<FlowEdge>) => void;
deleteEdge: (edgeId: string) => void;
@ -78,7 +78,6 @@ interface WorkflowActions {
// UI state
setIsDirty: (isDirty: boolean) => void;
setIsAddNodePanelOpen: (isOpen: boolean) => void;
setIsEditingName: (isEditing: boolean) => void;
// Validation
setWorkflowValidationErrors: (errors: WorkflowError[]) => void;
@ -108,7 +107,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
historyIndex: -1,
isDirty: false,
isAddNodePanelOpen: false,
isEditingName: false,
workflowValidationErrors: [],
templateContextVariables: {},
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
@ -194,19 +192,27 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return state.historyIndex < state.history.length - 1;
},
setNodes: (nodes) => {
const state = get();
let newNodes: FlowNode[];
if (typeof nodes === 'function') {
newNodes = nodes(state.nodes);
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 {
newNodes = nodes;
// No changes provided, just update nodes without history
set({ nodes });
}
// Push current state to history before making changes
get().pushToHistory();
set({ nodes: newNodes, isDirty: true });
},
addNode: (node) => {
@ -241,19 +247,27 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
});
},
setEdges: (edges) => {
const state = get();
let newEdges: FlowEdge[];
if (typeof edges === 'function') {
newEdges = edges(state.edges);
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 {
newEdges = edges;
// No changes provided, just update edges without history
set({ edges });
}
// Push current state to history before making changes
get().pushToHistory();
set({ edges: newEdges, isDirty: true });
},
addEdge: (edge) => {
@ -306,10 +320,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ isAddNodePanelOpen });
},
setIsEditingName: (isEditingName) => {
set({ isEditingName });
},
setWorkflowValidationErrors: (workflowValidationErrors) => {
set({ workflowValidationErrors });
},
@ -362,7 +372,6 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
historyIndex: -1,
isDirty: false,
isAddNodePanelOpen: false,
isEditingName: false,
workflowValidationErrors: [],
templateContextVariables: {},
workflowConfigurations: DEFAULT_WORKFLOW_CONFIGURATIONS,
@ -387,4 +396,4 @@ export const useUndoRedo = () => {
const canRedo = useWorkflowStore((state) => state.canRedo());
return { undo, redo, canUndo, canRedo };
};
};

View file

@ -0,0 +1,50 @@
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>,
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({ padding: 0.2, duration: 200, maxZoom: 0.75 });
}, 0);
return newNodes;
};