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
@@ -2,6 +2,7 @@ import {Input} from '@plunk/ui';
import type {Template} from '@plunk/db';
import type {PaginatedResponse} from '@plunk/types';
import {Command, CommandGroup, CommandItem, CommandList} from '@plunk/ui';
import {ChevronDown} from 'lucide-react';
import {useCallback, useRef, useState} from 'react';
import useSWR from 'swr';
@@ -63,7 +64,9 @@ export function TemplateSearchPicker({value, initialName, onChange}: TemplateSea
onBlur={() => setTimeout(() => setOpen(false), 150)}
placeholder="Search templates…"
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 && (
<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,
useEdgesState,
useNodesState,
useReactFlow,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
@@ -24,6 +23,8 @@ import {
Link,
LogOut,
Mail,
Maximize2,
Minimize2,
Plus,
Settings,
Timer,
@@ -35,7 +36,7 @@ import {useCallback, useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
import {network} from '../lib/network';
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';
interface WorkflowBuilderProps {
@@ -256,13 +257,7 @@ function CustomNode({
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
<div
@@ -418,13 +413,7 @@ function CustomNode({
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 14,
height: 14,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
</>
);
@@ -447,13 +436,23 @@ const STEP_TYPE_OPTIONS = [
];
export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderProps) {
const reactFlowInstance = useReactFlow();
const [addStepContext, setAddStepContext] = useState<{
fromStepId: string | null;
branch?: string;
} | null>(null);
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
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
const handleEditStep = useCallback(
@@ -474,10 +473,17 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
[steps],
);
const handleDeleteStepClick = useCallback((stepId: string) => {
setStepToDelete(stepId);
setShowDeleteDialog(true);
}, []);
const handleDeleteStepClick = useCallback(
(stepId: string) => {
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
@@ -664,70 +670,6 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
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
useEffect(() => {
@@ -850,13 +792,24 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
if (!stepToDelete) return;
try {
await network.fetch('DELETE', `/workflows/${workflowId}/steps/${stepToDelete}`);
const affectedSteps = getAffectedSteps(stepToDelete);
if (affectedSteps.length > 1) {
toast.success(`Deleted ${affectedSteps.length} steps`);
const url =
deleteMode === 'splice'
? `/workflows/${workflowId}/steps/${stepToDelete}?splice=true`
: `/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 {
toast.success('Step deleted');
toast.success('Step removed from flow');
}
onUpdate();
} catch (error) {
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) {
return (
@@ -889,11 +831,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
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
nodes={nodes}
edges={edges}
onNodesChange={handleNodesChange}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
@@ -904,7 +858,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
}}
minZoom={0.1}
maxZoom={2}
nodesDraggable={true}
nodesDraggable={false}
nodesConnectable={false}
elementsSelectable={true}
defaultEdgeOptions={{
@@ -941,15 +895,20 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
</div>
</div>
</Panel>
<Panel position="top-right" className="flex gap-2">
<Panel position="top-right">
<button
onClick={handleAutoLayout}
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"
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'}
>
Auto Layout
{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 && (
{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"
@@ -1004,36 +963,91 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const affectedSteps = getAffectedSteps(stepToDelete);
const stepToDeleteData = steps.find(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 (
<ConfirmDialog
open={showDeleteDialog}
onOpenChange={setShowDeleteDialog}
onConfirm={handleDeleteStep}
title="Delete Step"
description={
downstreamSteps.length > 0 ? (
<div className="space-y-3">
<p>
Deleting &quot;{stepToDeleteData?.name}&quot; will also delete {downstreamSteps.length} downstream{' '}
{downstreamSteps.length === 1 ? 'step' : 'steps'}:
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Remove &quot;{stepToDeleteData?.name}&quot;</DialogTitle>
</DialogHeader>
{canSplice ? (
<div className="space-y-3 py-1">
<p className="text-sm text-neutral-600">How would you like to remove this step?</p>
<div className="space-y-2">
<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>
<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 => (
<li key={step.id}>
{step.name} ({step.type})
{step.name} ({STEP_TYPE_LABELS[step.type] ?? step.type})
</li>
))}
</ul>
<p className="text-sm font-medium text-red-600">This action cannot be undone.</p>
</div>
) : (
`Are you sure you want to delete "${stepToDeleteData?.name}"? This action cannot be undone.`
)
}
confirmText={downstreamSteps.length > 0 ? `Delete ${affectedSteps.length} Steps` : 'Delete'}
variant="destructive"
/>
<p className="text-sm text-neutral-600 py-1">
Are you sure you want to delete this step? This action cannot be undone.
</p>
)}
<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,
Handle,
MarkerType,
MiniMap,
type Node,
Panel,
Position,
@@ -14,8 +15,8 @@ import {
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type {WorkflowStep} from '@plunk/db';
import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Timer, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo} from 'react';
import {AlertTriangle, Clock, GitBranch, Hourglass, Link, LogOut, Mail, Maximize2, Minimize2, Timer, UserCog, Webhook} from 'lucide-react';
import {useEffect, useMemo, useState} from 'react';
import dagre from 'dagre';
interface WorkflowVisualizerProps {
@@ -140,17 +141,10 @@ function CustomNode({
return (
<>
{/* Target Handle (top) - where edges come IN */}
<Handle
type="target"
position={Position.Top}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
<div
@@ -228,17 +222,10 @@ function CustomNode({
)}
</div>
{/* Source Handle (bottom) - where edges go OUT */}
<Handle
type="source"
position={Position.Bottom}
style={{
background: color,
width: 12,
height: 12,
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
}}
style={{opacity: 0, cursor: 'default', pointerEvents: 'none'}}
/>
</>
);
@@ -460,6 +447,7 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
const [isExpanded, setIsExpanded] = useState(false);
// Update nodes/edges when layout changes
useEffect(() => {
@@ -470,6 +458,15 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) {
setEdges(layoutedEdges);
}, [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) {
return (
<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 (
<div className="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}}
>
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
<>
{isExpanded && (
<div
className="fixed inset-0 z-40 bg-black/40"
onClick={() => setIsExpanded(false)}
/>
<Panel
position="top-left"
className="bg-white px-4 py-2.5 rounded-lg shadow-md border border-neutral-200"
)}
<div
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">
<GitBranch className="h-4 w-4 text-neutral-700" />
<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>
</Panel>
{rawEdges.length === 0 && steps.length > 1 && (
<Background color="#e5e7eb" gap={16} size={1} />
<Controls
showInteractive={false}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
/>
<MiniMap
nodeColor={node => {
const step = steps.find(s => s.id === node.id);
return step ? STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] : '#6b7280';
}}
className="bg-white border border-neutral-200 rounded-lg shadow-md"
maskColor="rgba(0, 0, 0, 0.05)"
/>
<Panel
position="bottom-center"
className="bg-white border border-neutral-200 px-4 py-2.5 rounded-lg shadow-sm"
position="top-left"
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">
<AlertTriangle className="h-4 w-4" />
<span>No transitions found. Connect your steps to see the flow.</span>
<div className="flex items-center gap-3">
<GitBranch className="h-4 w-4 text-neutral-700" />
<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>
</Panel>
)}
</ReactFlow>
</div>
<Panel position="top-right">
<button
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>
</>
);
}