feat: Enhance ease of use of workflow editor

This commit is contained in:
Dries Augustyns
2026-04-24 11:56:43 +02:00
parent bdc9b30ef2
commit c910d20bb7
6 changed files with 709 additions and 918 deletions
+6 -1
View File
@@ -219,12 +219,17 @@ export class Workflows {
const auth = res.locals.auth; const auth = res.locals.auth;
const workflowId = req.params.id; const workflowId = req.params.id;
const stepId = req.params.stepId; const stepId = req.params.stepId;
const splice = req.query.splice === 'true';
if (!workflowId || !stepId) { if (!workflowId || !stepId) {
return res.status(400).json({error: 'Workflow ID and Step ID are required'}); return res.status(400).json({error: 'Workflow ID and Step ID are required'});
} }
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId); if (splice) {
await WorkflowService.spliceStep(auth.projectId!, workflowId, stepId);
} else {
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
}
return res.status(204).send(); return res.status(204).send();
} }
+60
View File
@@ -576,6 +576,66 @@ export class WorkflowService {
}); });
} }
/**
* Splice a step out of the flow: re-wire its parent(s) directly to its child,
* then delete only the step itself. Not allowed for CONDITION or TRIGGER steps.
*/
public static async spliceStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
await this.get(projectId, workflowId);
const step = await prisma.workflowStep.findUnique({
where: {id: stepId},
include: {
outgoingTransitions: true,
incomingTransitions: true,
},
});
if (step?.workflowId !== workflowId) {
throw new HttpException(404, 'Workflow step not found');
}
if (step.type === 'TRIGGER') {
throw new HttpException(400, 'Cannot remove the trigger step.');
}
if (step.type === 'CONDITION') {
throw new HttpException(400, 'Cannot splice a condition step out of the flow.');
}
// Check for active executions on this step
const executionsOnStep = await prisma.workflowExecution.count({
where: {
workflowId,
currentStepId: stepId,
status: {in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING]},
},
});
if (executionsOnStep > 0) {
throw new HttpException(
409,
`Cannot remove step "${step.name}" while ${executionsOnStep} execution(s) are currently on it. ` +
'Please disable the workflow first or wait for executions to complete.',
);
}
// Re-wire: for each incoming transition, point it to our child (if we have one)
const child = step.outgoingTransitions[0];
if (child) {
for (const incoming of step.incomingTransitions) {
await prisma.workflowTransition.update({
where: {id: incoming.id},
data: {toStepId: child.toStepId},
});
}
}
// Delete the step — cascades and removes its own transitions
await prisma.workflowStep.delete({where: {id: stepId}});
}
/** /**
* Create a transition between two steps * Create a transition between two steps
*/ */
@@ -2,6 +2,7 @@ import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db'; import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types'; import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui'; import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {ChevronDown} from 'lucide-react';
import {useCallback, useRef, useState} from 'react'; import {useCallback, useRef, useState} from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
@@ -63,7 +64,9 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
onBlur={() => setTimeout(() => setOpen(false), 150)} onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…" placeholder="Search templates…"
autoComplete="off" autoComplete="off"
className="pr-8"
/> />
<ChevronDown className="pointer-events-none absolute right-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-neutral-400" />
{open && ( {open && (
<div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto"> <div className="absolute z-50 w-full mt-1 rounded-md border border-neutral-200 bg-white shadow-md max-h-60 overflow-y-auto">
+141 -127
View File
@@ -12,7 +12,6 @@ import {
ReactFlow, ReactFlow,
useEdgesState, useEdgesState,
useNodesState, useNodesState,
useReactFlow,
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
@@ -24,6 +23,8 @@ import {
Link, Link,
LogOut, LogOut,
Mail, Mail,
Maximize2,
Minimize2,
Plus, Plus,
Settings, Settings,
Timer, Timer,
@@ -35,7 +36,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre'; import dagre from 'dagre';
import {network} from '../lib/network'; import {network} from '../lib/network';
import {toast} from 'sonner'; import {toast} from 'sonner';
import {Button, ConfirmDialog, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui'; import {Button, Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle} from '@plunk/ui';
import {WorkflowSchemas} from '@plunk/shared'; import {WorkflowSchemas} from '@plunk/shared';
interface WorkflowBuilderProps { interface WorkflowBuilderProps {
@@ -256,13 +257,7 @@ function CustomNode({
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
style={{ style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/> />
<div <div
@@ -418,13 +413,7 @@ function CustomNode({
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
style={{ style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/> />
</> </>
); );
@@ -447,13 +436,23 @@ const STEP_TYPE_OPTIONS = [
]; ];
export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) { export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) {
const reactFlowInstance = useReactFlow();
const [addStepContext, setAddStepContext] = useState<{ const [addStepContext, setAddStepContext] = useState<{
fromStepId: string | null; fromStepId: string | null;
branch?: string; branch?: string;
} | null>(null); } | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [stepToDelete, setStepToDelete] = useState<string | null>(null); const [stepToDelete, setStepToDelete] = useState<string | null>(null);
const [deleteMode, setDeleteMode] = useState<'splice' | 'cascade'>('splice');
const [isExpanded, setIsExpanded] = useState(false);
useEffect(() => {
if (!isExpanded) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsExpanded(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isExpanded]);
// Define handlers before they are used in useMemo // Define handlers before they are used in useMemo
const handleEditStep = useCallback( const handleEditStep = useCallback(
@@ -474,10 +473,17 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
[steps], [steps],
); );
const handleDeleteStepClick = useCallback((stepId: string) => { const handleDeleteStepClick = useCallback(
setStepToDelete(stepId); (stepId: string) => {
setShowDeleteDialog(true); const step = steps.find(s => s.id === stepId);
}, []); const isCondition = step?.type === 'CONDITION';
const hasChildren = (step?.outgoingTransitions?.length ?? 0) > 0;
setDeleteMode(isCondition || !hasChildren ? 'cascade' : 'splice');
setStepToDelete(stepId);
setShowDeleteDialog(true);
},
[steps],
);
// Convert workflow steps to React Flow nodes // Convert workflow steps to React Flow nodes
@@ -664,70 +670,6 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
// Custom nodes change handler that updates "+" node positions when parent nodes move
const handleNodesChange = useCallback(
(changes: any[]) => {
onNodesChange(changes);
// Check if any position changes occurred
const positionChanges = changes.filter(change => change.type === 'position' && change.dragging);
if (positionChanges.length > 0) {
setNodes(currentNodes => {
const updatedNodes = [...currentNodes];
const nodeWidth = 280;
positionChanges.forEach((change: any) => {
const movedNode = updatedNodes.find(n => n.id === change.id);
if (movedNode && movedNode.type === 'custom' && change.position) {
// Find all "+" nodes connected to this parent node
const connectedAddNodes = updatedNodes.filter(node => {
if (node.type !== 'addStep') return false;
// Check if this add node is connected to the moved node
return node.id === `${movedNode.id}-add` || node.id.startsWith(`${movedNode.id}-add-`);
});
// Update positions of connected "+" nodes to stay below parent
if (connectedAddNodes.length === 1) {
// Single add node — center below parent
const addNode = connectedAddNodes[0]!;
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
const existingNode = updatedNodes[addNodeIndex];
updatedNodes[addNodeIndex] = {
...existingNode,
position: {
x: change.position.x + nodeWidth / 2 - 32, // Center below parent
y: existingNode.position.y,
},
};
}
} else if (connectedAddNodes.length > 1) {
// Multiple branches — move all add nodes by the same delta as the parent
const oldX = movedNode.position.x;
const deltaX = change.position.x - oldX;
connectedAddNodes.forEach(addNode => {
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
const existingNode = updatedNodes[addNodeIndex];
updatedNodes[addNodeIndex] = {
...existingNode,
position: {
x: existingNode.position.x + deltaX,
y: existingNode.position.y,
},
};
}
});
}
}
});
return updatedNodes;
});
}
},
[onNodesChange, setNodes],
);
// Update nodes/edges when layout changes // Update nodes/edges when layout changes
useEffect(() => { useEffect(() => {
@@ -850,13 +792,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
if (!stepToDelete) return; if (!stepToDelete) return;
try { try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`); const url =
const affectedSteps = getAffectedSteps(stepToDelete); deleteMode === 'splice'
if (affectedSteps.length > 1) { ? `/workflows/${workflowId}/steps/${stepToDelete}?splice=true`
toast.success(`Deleted ${affectedSteps.length} steps`); : `/workflows/${workflowId}/steps/${stepToDelete}`;
await network.fetch('DELETE', url);
if (deleteMode === 'cascade') {
const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
} else {
toast.success('Step deleted');
}
} else { } else {
toast.success('Step deleted'); toast.success('Step removed from flow');
} }
onUpdate(); onUpdate();
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : 'Failed to delete step'); toast.error(error instanceof Error ? error.message : 'Failed to delete step');
@@ -865,17 +818,6 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
} }
}; };
// Auto-layout on demand
const handleAutoLayout = useCallback(() => {
const {nodes: newNodes, edges: newEdges} = getLayoutedElements(nodes, edges);
setNodes(newNodes);
setEdges(newEdges);
// Fit view after layout
setTimeout(() => {
reactFlowInstance?.fitView({padding: 0.3});
}, 10);
}, [nodes, edges, setNodes, setEdges, reactFlowInstance]);
if (steps.length === 0) { if (steps.length === 0) {
return ( return (
@@ -889,11 +831,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
return ( return (
<> <>
<div className="w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative"> {isExpanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setIsExpanded(false)}
/>
)}
<div
className={
isExpanded
? 'fixed inset-[5%] z-50 bg-neutral-50 rounded-xl border border-neutral-200 shadow-2xl'
: 'w-full h-[800px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner relative'
}
>
<ReactFlow <ReactFlow
nodes={nodes} nodes={nodes}
edges={edges} edges={edges}
onNodesChange={handleNodesChange} onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange} onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
@@ -904,7 +858,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
}} }}
minZoom={0.1} minZoom={0.1}
maxZoom={2} maxZoom={2}
nodesDraggable={true} nodesDraggable={false}
nodesConnectable={false} nodesConnectable={false}
elementsSelectable={true} elementsSelectable={true}
defaultEdgeOptions={{ defaultEdgeOptions={{
@@ -941,15 +895,20 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</div> </div>
</div> </div>
</Panel> </Panel>
<Panel position="top-right" className="flex gap-2"> <Panel position="top-right">
<button <button
onClick={handleAutoLayout} onClick={() => setIsExpanded(e => !e)}
className="bg-white px-4 py-2 rounded-lg shadow-md border border-neutral-200 text-sm font-medium text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900 transition-colors" className="bg-white border border-neutral-200 rounded-lg shadow-md p-2 hover:bg-neutral-50 transition-colors"
title={isExpanded ? 'Exit fullscreen' : 'Expand to fullscreen'}
> >
Auto Layout {isExpanded ? (
<Minimize2 className="h-4 w-4 text-neutral-600" />
) : (
<Maximize2 className="h-4 w-4 text-neutral-600" />
)}
</button> </button>
</Panel> </Panel>
{rawEdges.length === 0 && steps.length > 1 && ( {rawEdges.length === 0 && steps.length > 1 && (
<Panel <Panel
position="bottom-center" position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm" className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
@@ -1004,36 +963,91 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const affectedSteps = getAffectedSteps(stepToDelete); const affectedSteps = getAffectedSteps(stepToDelete);
const stepToDeleteData = steps.find(s => s.id === stepToDelete); const stepToDeleteData = steps.find(s => s.id === stepToDelete);
const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete); const downstreamSteps = affectedSteps.filter(s => s.id !== stepToDelete);
const isCondition = stepToDeleteData?.type === 'CONDITION';
const hasChildren = downstreamSteps.length > 0;
const canSplice = !isCondition && hasChildren;
return ( return (
<ConfirmDialog <Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
open={showDeleteDialog} <DialogContent>
onOpenChange={setShowDeleteDialog} <DialogHeader>
onConfirm={handleDeleteStep} <DialogTitle>Remove &quot;{stepToDeleteData?.name}&quot;</DialogTitle>
title="Delete Step" </DialogHeader>
description={
downstreamSteps.length > 0 ? ( {canSplice ? (
<div className="space-y-3"> <div className="space-y-3 py-1">
<p> <p className="text-sm text-neutral-600">How would you like to remove this step?</p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '} <div className="space-y-2">
{downstreamSteps.length === 1 ? 'step' : 'steps'}: <button
onClick={() => setDeleteMode('splice')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
deleteMode === 'splice'
? 'border-neutral-900 bg-neutral-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="text-sm font-medium text-neutral-900">Remove from flow</p>
<p className="text-xs text-neutral-500 mt-0.5">
Delete this step and connect its parent directly to its child. The rest of the workflow is
preserved.
</p>
</button>
<button
onClick={() => setDeleteMode('cascade')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
deleteMode === 'cascade'
? 'border-red-500 bg-red-50'
: 'border-neutral-200 hover:border-neutral-300'
}`}
>
<p className="text-sm font-medium text-neutral-900">
Delete with {downstreamSteps.length} downstream {downstreamSteps.length === 1 ? 'step' : 'steps'}
</p>
<p className="text-xs text-neutral-500 mt-0.5">
Permanently removes this step and everything below it. This cannot be undone.
</p>
</button>
</div>
</div>
) : isCondition && hasChildren ? (
<div className="space-y-3 py-1">
<p className="text-sm text-neutral-600">
Removing a condition step will also delete all {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'} across its branches:
</p> </p>
<ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200"> <ul className="list-disc list-inside text-sm text-neutral-600 max-h-32 overflow-y-auto bg-neutral-50 p-3 rounded border border-neutral-200">
{downstreamSteps.map(step => ( {downstreamSteps.map(step => (
<li key={step.id}> <li key={step.id}>
{step.name} ({step.type}) {step.name} ({STEP_TYPE_LABELS[step.type] ?? step.type})
</li> </li>
))} ))}
</ul> </ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p> <p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div> </div>
) : ( ) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.` <p className="text-sm text-neutral-600 py-1">
) Are you sure you want to delete this step? This action cannot be undone.
} </p>
confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'} )}
variant="destructive"
/> <DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)}>
Cancel
</Button>
<Button
variant={deleteMode === 'cascade' || !canSplice ? 'destructive' : 'default'}
onClick={() => {
setShowDeleteDialog(false);
handleDeleteStep();
}}
>
{canSplice && deleteMode === 'splice'
? 'Remove from flow'
: `Delete ${hasChildren ? `${affectedSteps.length} steps` : 'step'}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
); );
})()} })()}
</> </>
+100 -68
View File
@@ -5,6 +5,7 @@ import {
type Edge, type Edge,
Handle, Handle,
MarkerType, MarkerType,
MiniMap,
type Node, type Node,
Panel, Panel,
Position, Position,
@@ -14,8 +15,8 @@ import {
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db'; import type {WorkflowStep} from '@plunk/db';
import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Timer, UserCog, Webhook} from 'lucide-react'; import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Maximize2, Minimize2, Timer, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo} from 'react'; import {useEffect, useMemo, useState} from 'react';
import dagre from 'dagre'; import dagre from 'dagre';
interface WorkflowVisualizerProps { interface WorkflowVisualizerProps {
@@ -140,17 +141,10 @@ function CustomNode({
return ( return (
<> <>
{/* Target Handle (top) - where edges come IN */}
<Handle <Handle
type="target" type="target"
position={Position.Top} position={Position.Top}
style={{ style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/> />
<div <div
@@ -228,17 +222,10 @@ function CustomNode({
)} )}
</div> </div>
{/* Source Handle (bottom) - where edges go OUT */}
<Handle <Handle
type="source" type="source"
position={Position.Bottom} position={Position.Bottom}
style={{ style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
/> />
</> </>
); );
@@ -460,6 +447,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
const [isExpanded, setIsExpanded] = useState(false);
// Update nodes/edges when layout changes // Update nodes/edges when layout changes
useEffect(() => { useEffect(() => {
@@ -470,6 +458,15 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
setEdges(layoutedEdges); setEdges(layoutedEdges);
}, [layoutedEdges, setEdges]); }, [layoutedEdges, setEdges]);
useEffect(() => {
if (!isExpanded) return;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsExpanded(false);
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isExpanded]);
if (steps.length === 0) { if (steps.length === 0) {
return ( return (
<div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center"> <div className="bg-neutral-50 border-2 border-dashed border-neutral-300 rounded-lg p-12 text-center">
@@ -481,61 +478,96 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
} }
return ( return (
<div className="w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner"> <>
<ReactFlow {isExpanded && (
nodes={nodes} <div
edges={edges} className="fixed inset-0 z-40 bg-black/40"
onNodesChange={onNodesChange} onClick={() => setIsExpanded(false)}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{hideAttribution: true}}
>
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
/> />
<Panel )}
position="top-left" <div
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200" className={
isExpanded
? 'fixed inset-[5%] z-50 bg-neutral-50 rounded-xl border border-neutral-200 shadow-2xl'
: 'w-full h-[700px] bg-neutral-50 rounded-lg border border-neutral-200 shadow-inner'
}
>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
fitViewOptions={{
padding: 0.3,
minZoom: 0.5,
maxZoom: 1.2,
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
type: 'smoothstep',
}}
proOptions={{hideAttribution: true}}
> >
<div className="flex items-center gap-3"> <Background color="#e5e7eb" gap={16} size={1} />
<GitBranch className="h-4 w-4 text-neutral-700" /> <Controls
<div className="text-sm"> showInteractive={false}
<span className="font-semibold text-neutral-900">{steps.length}</span> className="bg-white border border-neutral-200 rounded-lg shadow-md"
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span> />
<span className="text-neutral-400 mx-2">·</span> <MiniMap
<span className="font-semibold text-neutral-900">{rawEdges.length}</span> nodeColor={node => {
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span> const step = steps.find(s => s.id === node.id);
</div> return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
</div> }}
</Panel> className="bg-white border border-neutral-200 rounded-lg shadow-md"
{rawEdges.length === 0 && steps.length > 1 && ( maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel <Panel
position="bottom-center" position="top-left"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm" className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
> >
<div className="flex items-center gap-2 text-sm text-neutral-600"> <div className="flex items-center gap-3">
<AlertTriangle className="h-4 w-4" /> <GitBranch className="h-4 w-4 text-neutral-700" />
<span>No transitions found. Connect your steps to see the flow.</span> <div className="text-sm">
<span className="font-semibold text-neutral-900">{steps.length}</span>
<span className="text-neutral-600"> step{steps.length !== 1 ? 's' : ''}</span>
<span className="text-neutral-400 mx-2">·</span>
<span className="font-semibold text-neutral-900">{rawEdges.length}</span>
<span className="text-neutral-600"> transition{rawEdges.length !== 1 ? 's' : ''}</span>
</div>
</div> </div>
</Panel> </Panel>
)} <Panel position="top-right">
</ReactFlow> <button
</div> onClick={() => setIsExpanded(e => !e)}
className="bg-white border border-neutral-200 rounded-lg shadow-md p-2 hover:bg-neutral-50 transition-colors"
title={isExpanded ? 'Exit fullscreen' : 'Expand to fullscreen'}
>
{isExpanded ? (
<Minimize2 className="h-4 w-4 text-neutral-600" />
) : (
<Maximize2 className="h-4 w-4 text-neutral-600" />
)}
</button>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
>
<div className="flex items-center gap-2 text-sm text-neutral-600">
<AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span>
</div>
</Panel>
)}
</ReactFlow>
</div>
</>
); );
} }
File diff suppressed because it is too large Load Diff