From c910d20bb71ff5de4abf95f3f96be9c435342cb4 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Fri, 24 Apr 2026 11:56:43 +0200 Subject: [PATCH] feat: Enhance ease of use of workflow editor --- apps/api/src/controllers/Workflows.ts | 7 +- apps/api/src/services/WorkflowService.ts | 60 + .../src/components/TemplateSearchPicker.tsx | 3 + apps/web/src/components/WorkflowBuilder.tsx | 268 ++-- .../web/src/components/WorkflowVisualizer.tsx | 168 ++- apps/web/src/pages/workflows/[id].tsx | 1121 ++++++----------- 6 files changed, 709 insertions(+), 918 deletions(-) diff --git a/apps/api/src/controllers/Workflows.ts b/apps/api/src/controllers/Workflows.ts index 64afe64..2b8acb4 100644 --- a/apps/api/src/controllers/Workflows.ts +++ b/apps/api/src/controllers/Workflows.ts @@ -219,12 +219,17 @@ export class Workflows { const auth = res.locals.auth; const workflowId = req.params.id; const stepId = req.params.stepId; + const splice = req.query.splice === 'true'; if (!workflowId || !stepId) { 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(); } diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 5253312..157cc6e 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -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 { + 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 */ diff --git a/apps/web/src/components/TemplateSearchPicker.tsx b/apps/web/src/components/TemplateSearchPicker.tsx index c283858..ebde87e 100644 --- a/apps/web/src/components/TemplateSearchPicker.tsx +++ b/apps/web/src/components/TemplateSearchPicker.tsx @@ -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" /> + {open && (
diff --git a/apps/web/src/components/WorkflowBuilder.tsx b/apps/web/src/components/WorkflowBuilder.tsx index dbbc8a1..efde17f 100644 --- a/apps/web/src/components/WorkflowBuilder.tsx +++ b/apps/web/src/components/WorkflowBuilder.tsx @@ -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({
); @@ -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(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 ( <> -
+ {isExpanded && ( +
setIsExpanded(false)} + /> + )} +
- + - {rawEdges.length === 0 && steps.length > 1 && ( +{rawEdges.length === 0 && steps.length > 1 && ( 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 ( - 0 ? ( -
-

- Deleting "{stepToDeleteData?.name}" will also delete {downstreamSteps.length} downstream{' '} - {downstreamSteps.length === 1 ? 'step' : 'steps'}: +

+ + + Remove "{stepToDeleteData?.name}" + + + {canSplice ? ( +
+

How would you like to remove this step?

+
+ + +
+
+ ) : isCondition && hasChildren ? ( +
+

+ Removing a condition step will also delete all {downstreamSteps.length} downstream{' '} + {downstreamSteps.length === 1 ? 'step' : 'steps'} across its branches:

    {downstreamSteps.map(step => (
  • - {step.name} ({step.type}) + {step.name} ({STEP_TYPE_LABELS[step.type] ?? step.type})
  • ))}

This action cannot be undone.

) : ( - `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" - /> +

+ Are you sure you want to delete this step? This action cannot be undone. +

+ )} + + + + + +
+
); })()} diff --git a/apps/web/src/components/WorkflowVisualizer.tsx b/apps/web/src/components/WorkflowVisualizer.tsx index 9b554a4..d795a6e 100644 --- a/apps/web/src/components/WorkflowVisualizer.tsx +++ b/apps/web/src/components/WorkflowVisualizer.tsx @@ -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 */}
- {/* Source Handle (bottom) - where edges go OUT */} ); @@ -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 (
@@ -481,61 +478,96 @@ export function WorkflowVisualizer({steps}: WorkflowVisualizerProps) { } return ( -
- - - + {isExpanded && ( +
setIsExpanded(false)} /> - + -
- -
- {steps.length} - step{steps.length !== 1 ? 's' : ''} - · - {rawEdges.length} - transition{rawEdges.length !== 1 ? 's' : ''} -
-
-
- {rawEdges.length === 0 && steps.length > 1 && ( + + + { + 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)" + /> -
- - No transitions found. Connect your steps to see the flow. +
+ +
+ {steps.length} + step{steps.length !== 1 ? 's' : ''} + · + {rawEdges.length} + transition{rawEdges.length !== 1 ? 's' : ''} +
- )} - -
+ + + + {rawEdges.length === 0 && steps.length > 1 && ( + +
+ + No transitions found. Connect your steps to see the flow. +
+
+ )} + +
+ ); } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index 65bfb53..8fed6e9 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -62,10 +62,12 @@ import {useEffect, useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; import {WorkflowBuilder} from '../../components/WorkflowBuilder'; +import {KeyValueEditor} from '../../components/KeyValueEditor'; import {TemplateSearchPicker} from '../../components/TemplateSearchPicker'; import {ReactFlowProvider} from '@xyflow/react'; import {WorkflowSchemas} from '@plunk/shared'; import dayjs from 'dayjs'; +import {WIKI_URI} from '../../lib/constants'; interface WorkflowWithDetails extends Workflow { steps: (WorkflowStep & { @@ -87,6 +89,28 @@ interface PaginatedExecutions { } // Step type styling (matching WorkflowBuilder) +const STEP_TYPE_LABELS: Record = { + TRIGGER: 'Trigger', + SEND_EMAIL: 'Send Email', + DELAY: 'Delay', + WAIT_FOR_EVENT: 'Wait for Event', + CONDITION: 'Condition', + EXIT: 'Exit', + WEBHOOK: 'Webhook', + UPDATE_CONTACT: 'Update Contact', +}; + +const STEP_TYPE_DESCRIPTIONS: Record = { + TRIGGER: 'Starts the workflow when a specific event is received.', + SEND_EMAIL: 'Sends an email to the contact using a template you choose.', + DELAY: 'Pauses the workflow for a set amount of time before continuing.', + WAIT_FOR_EVENT: 'Waits until the contact triggers a specific event, then continues.', + CONDITION: 'Splits the flow based on contact data — each path leads to different steps.', + EXIT: 'Ends the workflow for the contact.', + WEBHOOK: 'Makes an HTTP request to an external URL with the contact\'s data.', + UPDATE_CONTACT: 'Sets or updates fields on the contact\'s profile.', +}; + const STEP_TYPE_ICONS = { TRIGGER: GitBranch, SEND_EMAIL: Mail, @@ -983,7 +1007,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo const [webhookHeaders, setWebhookHeaders] = useState(''); // UPDATE_CONTACT fields - const [contactUpdates, setContactUpdates] = useState(''); + const [contactUpdateData, setContactUpdateData] = useState | null>(null); // EXIT fields const [exitReason, setExitReason] = useState('completed'); @@ -1140,20 +1164,13 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo headers, }; } else if (type === 'UPDATE_CONTACT') { - if (!contactUpdates.trim()) { - toast.error('Contact updates are required'); + if (!contactUpdateData || Object.keys(contactUpdateData).length === 0) { + toast.error('At least one field to update is required'); setIsSubmitting(false); return; } - try { - const updates = JSON.parse(contactUpdates); - config = {updates}; - } catch { - toast.error('Invalid JSON in contact updates'); - setIsSubmitting(false); - return; - } + config = {updates: contactUpdateData}; } else if (type === 'WAIT_FOR_EVENT') { if (!eventName) { toast.error('Event name is required'); @@ -1218,7 +1235,7 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo setWebhookUrl(''); setWebhookMethod('POST'); setWebhookHeaders(''); - setContactUpdates(''); + setContactUpdateData(null); setExitReason('completed'); onOpenChange(false); @@ -1234,20 +1251,15 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo - Add Workflow Step -

Configure a new step to add to your workflow

+ Add Step + {STEP_TYPE_DESCRIPTIONS[type] && ( +

{STEP_TYPE_DESCRIPTIONS[type]}

+ )}
-
- {/* Basic Information Section */} -
-
-

Basic Information

-
- + +
- + -

Choose the type of action this step will perform

- + -

- A descriptive name to identify this step in the workflow -

{/* SEND_EMAIL Configuration */} {type === 'SEND_EMAIL' && (
-
-

Email Configuration

+
+ +
-
-
- - -

The email template to use for this step

-
- -
- - -

Choose who should receive this email

-
- - {recipientType === 'CUSTOM' && ( -
- - setCustomEmail(e.target.value)} - required - placeholder="e.g., admin@example.com" - className="mt-1.5" +
+ +
+ + {recipientType === 'CUSTOM' && ( +
+ + setCustomEmail(e.target.value)} + required + placeholder="e.g., admin@example.com" + className="mt-1.5" + /> +
+ )}
)} {/* DELAY Configuration */} {type === 'DELAY' && ( -
-
-

Delay Configuration

-
- -
-
- +
+
+
-
-

Maximum delay: 365 days

)} {/* CONDITION Configuration */} {type === 'CONDITION' && (
-
-

Condition Configuration

-
-

- Define the condition that determines which path contacts will follow -

- -
-
- +
+ {loadingFields ? (
@@ -1483,31 +1453,22 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo ))} -

- {availableFields.length} field{availableFields.length !== 1 ? 's' : ''} available from your - contacts -

) : ( - <> - setConditionField(e.target.value)} - required - placeholder="e.g., contact.subscribed or contact.data.plan" - className="mt-1.5" - /> -

Enter a field path (e.g., contact.data.plan)

- + setConditionField(e.target.value)} + required + placeholder="e.g., contact.subscribed or contact.data.plan" + className="mt-1.5" + /> )} -
+
-
- +
+ - {currentFieldType && ( -

- Operators for{' '} - {currentFieldType} type - fields -

- )} -
+
- {needsValue && ( -
- + {needsValue && ( +
+ {currentFieldType === 'boolean' ? ( )}
-

- Enter the event name to wait for, or select from previously tracked events -

-
+
-
- +
+

- Continue the workflow after this time even if the event hasn't occurred + If not received, the workflow continues after this time

-
)} @@ -1691,122 +1628,77 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo {/* WEBHOOK Configuration */} {type === 'WEBHOOK' && (
-
-

Webhook Configuration

+
+ + setWebhookUrl(e.target.value)} + required + placeholder="https://api.example.com/webhook" + className="font-mono mt-1.5" + />
-
-
- - setWebhookUrl(e.target.value)} - required - placeholder="https://api.example.com/webhook" - className="mt-1.5" - /> -

The endpoint that will receive the webhook request

-
+
+ + +
-
- - -
- -
- -