Add WorkflowBuilder component and related modules

- Introduce `WorkflowBuilder` for visual workflow configuration
- Add child components: `WorkflowToolbar`, `StepConfigurationForm`, and `StepNode`
- Implement `WorkflowBuilderField` for integration with PayloadCMS
- Provide dynamic step type handling and JSON-based configuration editing
- Enhance UI with drag-and-drop functionality and step dependencies management
This commit is contained in:
2025-09-11 21:32:55 +02:00
parent 243bff2de3
commit 9c75b28cd7
11 changed files with 1142 additions and 164 deletions

View File

@@ -0,0 +1,297 @@
'use client'
import React, { useState, useCallback, useEffect } from 'react'
import type { Node } from '@xyflow/react'
import { Button } from '@payloadcms/ui'
interface StepField {
name: string
type: string
label?: string
admin?: {
description?: string
condition?: (data: any, siblingData: any) => boolean
}
options?: Array<{ label: string; value: string }>
defaultValue?: any
required?: boolean
hasMany?: boolean
fields?: StepField[] // For group fields
}
interface StepType {
slug: string
label?: string
inputSchema?: StepField[]
outputSchema?: StepField[]
}
interface StepConfigurationFormProps {
selectedNode: Node | null
availableStepTypes: StepType[]
availableSteps: string[] // For dependency selection
onNodeUpdate: (nodeId: string, data: Partial<Node['data']>) => void
onClose: () => void
}
export const StepConfigurationForm: React.FC<StepConfigurationFormProps> = ({
selectedNode,
availableStepTypes,
availableSteps,
onNodeUpdate,
onClose
}) => {
const [formData, setFormData] = useState<Record<string, any>>(
selectedNode?.data.configuration || {}
)
const [jsonText, setJsonText] = useState<string>(() =>
JSON.stringify(selectedNode?.data.configuration || {}, null, 2)
)
if (!selectedNode) return null
const stepType = availableStepTypes.find(type => type.slug === selectedNode.data.stepType)
const inputSchema = stepType?.inputSchema || []
// Update form data when selected node changes
useEffect(() => {
const config = selectedNode?.data.configuration || {}
setFormData(config)
setJsonText(JSON.stringify(config, null, 2))
}, [selectedNode])
const handleSave = useCallback(() => {
// Update the node with form data
onNodeUpdate(selectedNode.id, {
...selectedNode.data,
configuration: formData
})
onClose()
}, [selectedNode, formData, onNodeUpdate, onClose])
const renderStepConfiguration = () => {
if (!inputSchema.length) {
return (
<div style={{
padding: '20px',
textAlign: 'center',
color: 'var(--theme-text-400)',
fontStyle: 'italic'
}}>
This step type has no configuration parameters.
</div>
)
}
return (
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500',
color: 'var(--theme-text)'
}}>
Step Configuration
</label>
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
Configure this step's parameters in JSON format. Use JSONPath expressions like <code>$.trigger.doc.id</code> to reference dynamic data.
</div>
{/* Schema Reference */}
<details style={{ marginBottom: '12px' }}>
<summary style={{
fontSize: '11px',
color: 'var(--theme-text-400)',
cursor: 'pointer',
marginBottom: '8px'
}}>
📖 Available Fields (click to expand)
</summary>
<div style={{
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
padding: '12px',
fontSize: '11px',
fontFamily: 'monospace'
}}>
{inputSchema.map((field, index) => (
<div key={field.name} style={{ marginBottom: index < inputSchema.length - 1 ? '8px' : '0' }}>
<strong>{field.name}</strong> ({field.type})
{field.required && <span style={{ color: 'var(--theme-error-500)' }}> *required</span>}
{field.admin?.description && (
<div style={{ color: 'var(--theme-text-400)', marginTop: '2px' }}>
{field.admin.description}
</div>
)}
</div>
))}
</div>
</details>
<textarea
value={jsonText}
onChange={(e) => {
const text = e.target.value
setJsonText(text)
try {
const parsed = JSON.parse(text)
setFormData(parsed)
} catch {
// Keep invalid JSON, user is still typing
// Don't update formData until JSON is valid
}
}}
rows={Math.min(Math.max(inputSchema.length * 2, 6), 15)}
style={{
width: '100%',
padding: '12px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
fontSize: '13px',
fontFamily: 'monospace',
lineHeight: '1.4',
background: 'var(--theme-input-bg)',
color: 'var(--theme-text)',
resize: 'vertical'
}}
placeholder='{\n "field1": "value1",\n "field2": "$.trigger.doc.id"\n}'
/>
</div>
)
}
return (
<div style={{
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}>
{/* Header */}
<div style={{
padding: '16px',
borderBottom: '1px solid var(--theme-elevation-100)',
background: 'var(--theme-elevation-50)'
}}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<h4 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
Configure Step
</h4>
<Button
buttonStyle="none"
onClick={onClose}
size="small"
>
×
</Button>
</div>
<div style={{ fontSize: '12px', color: 'var(--theme-text-400)', marginTop: '4px' }}>
{stepType?.label || (selectedNode.data.stepType as string)}
</div>
</div>
{/* Form */}
<div style={{
flex: 1,
overflow: 'auto',
padding: '16px'
}}>
{/* Basic step info */}
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
Step Name *
</label>
<input
type="text"
value={(selectedNode.data.label as string) || ''}
onChange={(e) => onNodeUpdate(selectedNode.id, {
...selectedNode.data,
label: e.target.value
})}
style={{
width: '100%',
padding: '8px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
fontSize: '14px'
}}
required
/>
</div>
{/* Dependencies */}
<div style={{ marginBottom: '16px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '12px',
fontWeight: '500'
}}>
Dependencies
</label>
<div style={{ fontSize: '11px', color: 'var(--theme-text-400)', marginBottom: '8px' }}>
Steps that must complete before this step can run
</div>
{availableSteps
.filter(step => step !== selectedNode.id)
.map(stepId => (
<label key={stepId} style={{
display: 'block',
fontSize: '12px',
marginBottom: '4px'
}}>
<input
type="checkbox"
checked={((selectedNode.data.dependencies as string[]) || []).includes(stepId)}
onChange={(e) => {
const currentDeps = (selectedNode.data.dependencies as string[]) || []
const newDeps = e.target.checked
? [...currentDeps, stepId]
: currentDeps.filter((dep: string) => dep !== stepId)
onNodeUpdate(selectedNode.id, {
...selectedNode.data,
dependencies: newDeps
})
}}
style={{ marginRight: '8px' }}
/>
{stepId}
</label>
))}
</div>
{/* Step-specific configuration */}
{renderStepConfiguration()}
{/* Submit button */}
<div style={{
borderTop: '1px solid var(--theme-elevation-100)',
paddingTop: '16px',
marginTop: '16px'
}}>
<Button
buttonStyle="primary"
onClick={handleSave}
>
Save Configuration
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,255 @@
'use client'
import React, { useCallback, useMemo, useState } from 'react'
import {
ReactFlow,
Node,
Edge,
addEdge,
Connection,
useNodesState,
useEdgesState,
Controls,
Background,
BackgroundVariant,
MiniMap,
Panel
} from '@xyflow/react'
import '@xyflow/react/dist/style.css'
// Import custom node types
import { StepNode } from './nodes/StepNode.js'
import { WorkflowToolbar } from './WorkflowToolbar.js'
import { StepConfigurationForm } from './StepConfigurationForm.js'
// Define node types for React Flow
const nodeTypes = {
stepNode: StepNode,
}
interface WorkflowData {
id: string
name: string
steps?: Array<{
name: string
type: string
position?: { x: number; y: number }
visual?: { color?: string; icon?: string }
dependencies?: string[]
}>
layout?: {
viewport?: { x: number; y: number; zoom: number }
}
}
interface StepType {
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}
interface WorkflowBuilderProps {
workflow?: WorkflowData
availableStepTypes?: StepType[]
onSave?: (workflow: WorkflowData) => void
readonly?: boolean
}
export const WorkflowBuilder: React.FC<WorkflowBuilderProps> = ({
workflow,
availableStepTypes = [],
onSave,
readonly = false
}) => {
const [selectedNode, setSelectedNode] = useState<Node | null>(null)
// Convert workflow steps to React Flow nodes
const initialNodes: Node[] = useMemo(() => {
if (!workflow?.steps) return []
return workflow.steps.map((step, index) => ({
id: step.name || `step-${index}`,
type: 'stepNode',
position: step.position || { x: 100 + index * 200, y: 100 },
data: {
label: step.name || 'Unnamed Step',
stepType: step.type,
color: step.visual?.color || '#3b82f6',
icon: step.visual?.icon,
dependencies: step.dependencies || []
}
}))
}, [workflow?.steps])
// Convert dependencies to React Flow edges
const initialEdges: Edge[] = useMemo(() => {
if (!workflow?.steps) return []
const edges: Edge[] = []
workflow.steps.forEach((step, index) => {
const targetId = step.name || `step-${index}`
if (step.dependencies) {
step.dependencies.forEach((depName) => {
// Find the source step
const sourceStep = workflow.steps?.find(s => s.name === depName)
if (sourceStep) {
const sourceId = sourceStep.name || `step-${workflow.steps?.indexOf(sourceStep)}`
edges.push({
id: `${sourceId}-${targetId}`,
source: sourceId,
target: targetId,
type: 'smoothstep'
})
}
})
}
})
return edges
}, [workflow?.steps])
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges)
// Handle new connections
const onConnect = useCallback((params: Connection) => {
if (readonly) return
setEdges((eds) => addEdge({
...params,
type: 'smoothstep'
}, eds))
}, [setEdges, readonly])
// Handle node selection
const onNodeClick = useCallback((_event: React.MouseEvent, node: Node) => {
console.log('Node clicked:', node.id, node.data.label)
setSelectedNode(node)
}, [])
// Handle adding new step
const onAddStep = useCallback((stepType: string) => {
if (readonly) return
const newStep: Node = {
id: `step-${Date.now()}`,
type: 'stepNode',
position: { x: 100, y: 100 },
data: {
label: 'New Step',
stepType,
color: '#3b82f6',
dependencies: []
}
}
setNodes((nds) => [...nds, newStep])
}, [setNodes, readonly])
// Handle updating a node's data
const handleNodeUpdate = useCallback((nodeId: string, newData: Partial<Node['data']>) => {
setNodes((nds) =>
nds.map((node) =>
node.id === nodeId
? { ...node, data: { ...node.data, ...newData } }
: node
)
)
}, [setNodes])
// Handle saving workflow
const handleSave = useCallback(() => {
if (!workflow || !onSave) return
// Convert nodes and edges back to workflow format
const updatedSteps = nodes.map((node) => {
// Find dependencies from edges
const dependencies = edges
.filter(edge => edge.target === node.id)
.map(edge => edge.source)
return {
name: node.id,
type: node.data.stepType as string,
position: node.position,
visual: {
color: node.data.color as string,
icon: node.data.icon as string
},
dependencies: dependencies.length > 0 ? dependencies : undefined
}
})
const updatedWorkflow: WorkflowData = {
...workflow,
steps: updatedSteps
}
onSave(updatedWorkflow)
}, [workflow, nodes, edges, onSave])
return (
<div style={{
width: '100%',
height: '600px',
display: 'flex',
background: 'var(--theme-bg)',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-100)'
}}>
{/* Main canvas area */}
<div style={{
flex: selectedNode ? '1 1 70%' : '1 1 100%',
transition: 'flex 0.3s ease'
}}>
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
attributionPosition="top-right"
>
<Controls />
<MiniMap />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
{!readonly && (
<Panel position="top-left">
<WorkflowToolbar
availableStepTypes={availableStepTypes}
onAddStep={onAddStep}
onSave={handleSave}
/>
</Panel>
)}
</ReactFlow>
</div>
{/* Side panel for step configuration */}
{selectedNode && !readonly && (
<div style={{
flex: '0 0 30%',
borderLeft: '1px solid var(--theme-elevation-100)',
background: 'var(--theme-elevation-0)',
display: 'flex',
flexDirection: 'column'
}}>
<StepConfigurationForm
selectedNode={selectedNode}
availableStepTypes={availableStepTypes}
availableSteps={nodes.map(node => node.id)}
onNodeUpdate={handleNodeUpdate}
onClose={() => setSelectedNode(null)}
/>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,118 @@
'use client'
import React from 'react'
interface AvailableStepType {
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}
interface WorkflowToolbarProps {
availableStepTypes: AvailableStepType[]
onAddStep: (stepType: string) => void
onSave: () => void
}
export const WorkflowToolbar: React.FC<WorkflowToolbarProps> = ({
availableStepTypes,
onAddStep,
onSave
}) => {
const getStepTypeLabel = (stepType: AvailableStepType) => {
return stepType.label || stepType.slug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
const getStepTypeIcon = (stepType: AvailableStepType) => {
// Simple icon mapping based on step type
switch (stepType.slug) {
case 'http-request-step':
return '🌐'
case 'create-document-step':
return '📄'
case 'read-document-step':
return '👁️'
case 'update-document-step':
return '✏️'
case 'delete-document-step':
return '🗑️'
case 'send-email-step':
return '📧'
default:
return '⚡'
}
}
return (
<div style={{
background: 'var(--theme-elevation-0)',
padding: '12px',
borderRadius: '4px',
border: '1px solid var(--theme-elevation-150)',
minWidth: '200px'
}}>
<h4 style={{
margin: '0 0 12px 0',
fontSize: '14px',
fontWeight: '600',
color: 'var(--theme-text)'
}}>
Add Step
</h4>
<div style={{ marginBottom: '16px' }}>
{availableStepTypes.map((stepType) => (
<button
key={stepType.slug}
onClick={() => onAddStep(stepType.slug)}
style={{
display: 'block',
width: '100%',
padding: '8px 12px',
margin: '4px 0',
background: 'var(--theme-elevation-50)',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
cursor: 'pointer',
textAlign: 'left',
fontSize: '12px',
color: 'var(--theme-text)',
transition: 'background-color 0.2s'
}}
onMouseEnter={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-100)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.backgroundColor = 'var(--theme-elevation-50)'
}}
>
<span style={{ marginRight: '8px' }}>
{getStepTypeIcon(stepType)}
</span>
{getStepTypeLabel(stepType)}
</button>
))}
</div>
<div style={{ borderTop: '1px solid var(--theme-elevation-100)', paddingTop: '12px' }}>
<button
onClick={onSave}
style={{
width: '100%',
padding: '8px 16px',
background: 'var(--theme-success-500)',
color: 'var(--theme-base-0)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontSize: '12px',
fontWeight: '500'
}}
>
💾 Save Workflow
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,4 @@
export { WorkflowBuilder } from './WorkflowBuilder.js'
export { WorkflowToolbar } from './WorkflowToolbar.js'
export { StepConfigurationForm } from './StepConfigurationForm.js'
export { StepNode } from './nodes/StepNode.js'

View File

@@ -0,0 +1,157 @@
'use client'
import React, { memo } from 'react'
import { Handle, Position, NodeProps } from '@xyflow/react'
interface StepNodeData {
label: string
stepType: string
color?: string
icon?: string
dependencies?: string[]
}
export const StepNode: React.FC<NodeProps> = memo(({ data, selected }) => {
const { label, stepType, color = '#3b82f6', icon, dependencies = [] } = data as unknown as StepNodeData
const getStepTypeIcon = (type: string) => {
// Return icon from data or default based on type
if (icon) return icon
switch (type) {
case 'http-request-step':
return '🌐'
case 'create-document-step':
return '📄'
case 'read-document-step':
return '👁️'
case 'update-document-step':
return '✏️'
case 'delete-document-step':
return '🗑️'
case 'send-email-step':
return '📧'
default:
return '⚡'
}
}
const getStepTypeLabel = (type: string) => {
return type.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase())
}
return (
<div
style={{
background: color,
color: 'white',
borderRadius: '8px',
padding: '12px 16px',
minWidth: '150px',
border: selected ? '2px solid #1e40af' : '1px solid rgba(255, 255, 255, 0.2)',
boxShadow: selected
? '0 8px 25px rgba(0, 0, 0, 0.15)'
: '0 4px 15px rgba(0, 0, 0, 0.1)',
transition: 'all 0.2s ease',
cursor: 'pointer',
position: 'relative'
}}
title="Click to configure this step"
>
{/* Input Handle - only show if this step has dependencies */}
{dependencies.length > 0 && (
<Handle
type="target"
position={Position.Top}
style={{
background: '#fff',
border: '2px solid #3b82f6',
width: '10px',
height: '10px'
}}
/>
)}
<div style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px'
}}>
<span style={{ fontSize: '16px' }}>
{getStepTypeIcon(stepType)}
</span>
<div>
<div style={{
fontWeight: '600',
fontSize: '14px',
lineHeight: '1.2'
}}>
{label}
</div>
</div>
</div>
<div style={{
fontSize: '11px',
opacity: 0.9,
fontWeight: '400'
}}>
{getStepTypeLabel(stepType)}
</div>
{/* Status indicator for dependencies */}
{dependencies.length > 0 && (
<div style={{
position: 'absolute',
top: '4px',
right: '20px',
background: 'rgba(255, 255, 255, 0.2)',
borderRadius: '50%',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '8px',
fontWeight: 'bold',
pointerEvents: 'none' // Allow clicks to pass through to parent
}}>
{dependencies.length}
</div>
)}
{/* Configuration indicator */}
<div style={{
position: 'absolute',
top: '4px',
right: '4px',
background: 'rgba(255, 255, 255, 0.3)',
borderRadius: '50%',
width: '16px',
height: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
pointerEvents: 'none' // Allow clicks to pass through to parent
}}>
</div>
{/* Output Handle - always show for potential connections */}
<Handle
type="source"
position={Position.Bottom}
style={{
background: '#fff',
border: '2px solid #3b82f6',
width: '10px',
height: '10px'
}}
/>
</div>
)
})
StepNode.displayName = 'StepNode'