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

@@ -1,16 +1,13 @@
import type {CollectionSlug, TypedJobs} from 'payload';
import type {CollectionSlug} from 'payload';
import {sqliteAdapter} from "@payloadcms/db-sqlite"
import { lexicalEditor } from '@payloadcms/richtext-lexical'
import { MongoMemoryReplSet } from 'mongodb-memory-server'
import path from 'path'
import {buildConfig} from 'payload'
import sharp from 'sharp'
import { fileURLToPath } from 'url'
import {workflowsPlugin} from "../src/plugin/index.js"
import {HttpRequestStepTask} from "../src/steps/http-request.js"
import {CreateDocumentStepTask} from "../src/steps/index.js"
import {CreateDocumentStepTask,HttpRequestStepTask} from "../src/steps/index.js"
import { testEmailAdapter } from './helpers/testEmailAdapter.js'
import { seed } from './seed.js'

View File

@@ -75,6 +75,7 @@
"@payloadcms/ui": "3.45.0",
"@playwright/test": "^1.52.0",
"@swc/cli": "0.6.0",
"@types/handlebars": "^4.1.0",
"@types/nock": "^11.1.0",
"@types/node": "^22.5.4",
"@types/node-cron": "^3.0.11",
@@ -135,7 +136,7 @@
"registry": "https://registry.npmjs.org/",
"packageManager": "pnpm@10.12.4+sha512.5ea8b0deed94ed68691c9bad4c955492705c5eeb8a87ef86bc62c74a26b037b08ff9570f108b2e4dbd1dd1a9186fea925e527f141c648e85af45631074680184",
"dependencies": {
"jsonpath-plus": "^10.3.0",
"handlebars": "^4.7.8",
"node-cron": "^4.2.1",
"pino": "^9.9.0"
}

86
pnpm-lock.yaml generated
View File

@@ -8,9 +8,9 @@ importers:
.:
dependencies:
jsonpath-plus:
specifier: ^10.3.0
version: 10.3.0
handlebars:
specifier: ^4.7.8
version: 4.7.8
node-cron:
specifier: ^4.2.1
version: 4.2.1
@@ -45,6 +45,9 @@ importers:
'@swc/cli':
specifier: 0.6.0
version: 0.6.0(@swc/core@1.13.4)
'@types/handlebars':
specifier: ^4.1.0
version: 4.1.0
'@types/nock':
specifier: ^11.1.0
version: 11.1.0
@@ -962,18 +965,6 @@ packages:
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@jsep-plugin/assignment@1.3.0':
resolution: {integrity: sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@jsep-plugin/regex@1.0.4':
resolution: {integrity: sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==}
engines: {node: '>= 10.16.0'}
peerDependencies:
jsep: ^0.4.0||^1.0.0
'@lexical/clipboard@0.28.0':
resolution: {integrity: sha512-LYqion+kAwFQJStA37JAEMxTL/m1WlZbotDfM/2WuONmlO0yWxiyRDI18oeCwhBD6LQQd9c3Ccxp9HFwUG1AVw==}
@@ -1600,6 +1591,10 @@ packages:
'@types/estree@1.0.8':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/handlebars@4.1.0':
resolution: {integrity: sha512-gq9YweFKNNB1uFK71eRqsd4niVkXrxHugqWFQkeLRJvGjnxsLr16bYtcsG4tOFwmYi0Bax+wCkbf1reUfdl4kA==}
deprecated: This is a stub types definition. handlebars provides its own type definitions, so you do not need this installed.
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
@@ -2886,6 +2881,11 @@ packages:
resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
has-bigints@1.1.0:
resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==}
engines: {node: '>= 0.4'}
@@ -3172,10 +3172,6 @@ packages:
resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==}
engines: {node: '>=12.0.0'}
jsep@1.4.0:
resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==}
engines: {node: '>= 10.16.0'}
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -3204,11 +3200,6 @@ packages:
json-stringify-safe@5.0.1:
resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==}
jsonpath-plus@10.3.0:
resolution: {integrity: sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==}
engines: {node: '>=18.0.0'}
hasBin: true
jsox@1.2.121:
resolution: {integrity: sha512-9Ag50tKhpTwS6r5wh3MJSAvpSof0UBr39Pto8OnzFT32Z/pAbxAsKHzyvsyMEHVslELvHyO/4/jaQELHk8wDcw==}
hasBin: true
@@ -3534,6 +3525,9 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
new-find-package-json@2.0.0:
resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==}
engines: {node: '>=12.22.0'}
@@ -4472,6 +4466,11 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
uglify-js@3.19.3:
resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==}
engines: {node: '>=0.8.0'}
hasBin: true
uint8array-extras@1.5.0:
resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==}
engines: {node: '>=18'}
@@ -4663,6 +4662,9 @@ packages:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -5447,14 +5449,6 @@ snapshots:
'@jsdevtools/ono@7.1.3': {}
'@jsep-plugin/assignment@1.3.0(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@jsep-plugin/regex@1.0.4(jsep@1.4.0)':
dependencies:
jsep: 1.4.0
'@lexical/clipboard@0.28.0':
dependencies:
'@lexical/html': 0.28.0
@@ -6316,6 +6310,10 @@ snapshots:
'@types/estree@1.0.8': {}
'@types/handlebars@4.1.0':
dependencies:
handlebars: 4.7.8
'@types/hast@3.0.4':
dependencies:
'@types/unist': 3.0.3
@@ -7966,6 +7964,15 @@ snapshots:
graphql@16.11.0: {}
handlebars@4.7.8:
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.19.3
has-bigints@1.1.0: {}
has-flag@4.0.0: {}
@@ -8224,8 +8231,6 @@ snapshots:
jsdoc-type-pratt-parser@4.8.0: {}
jsep@1.4.0: {}
jsesc@3.1.0: {}
json-buffer@3.0.1: {}
@@ -8252,12 +8257,6 @@ snapshots:
json-stringify-safe@5.0.1: {}
jsonpath-plus@10.3.0:
dependencies:
'@jsep-plugin/assignment': 1.3.0(jsep@1.4.0)
'@jsep-plugin/regex': 1.0.4(jsep@1.4.0)
jsep: 1.4.0
jsox@1.2.121: {}
jsx-ast-utils@3.3.5:
@@ -8698,6 +8697,8 @@ snapshots:
natural-compare@1.4.0: {}
neo-async@2.6.2: {}
new-find-package-json@2.0.0:
dependencies:
debug: 4.4.1
@@ -9761,6 +9762,9 @@ snapshots:
typescript@5.7.3: {}
uglify-js@3.19.3:
optional: true
uint8array-extras@1.5.0: {}
unbox-primitive@1.1.0:
@@ -9973,6 +9977,8 @@ snapshots:
word-wrap@1.2.5: {}
wordwrap@1.0.0: {}
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@@ -84,15 +84,13 @@ export const createWorkflowCollection: <T extends string>(options: WorkflowsPlug
options: steps.map(t => t.slug)
},
{
name: 'parameters',
name: 'input',
type: 'json',
admin: {
hidden: true,
description: 'Step input configuration. Use JSONPath expressions to reference dynamic data (e.g., {"url": "$.trigger.doc.webhookUrl", "data": "$.steps.previousStep.output.result"})'
},
defaultValue: {}
},
// Virtual fields for custom triggers
...steps.flatMap(step => (step.inputSchema || []).map(s => parameter(step.slug, s as any))),
{
name: 'dependencies',
type: 'text',

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'

View File

@@ -19,7 +19,7 @@ export type PayloadWorkflow = {
[key: string]: unknown
}> | null
steps?: Array<{
step?: null | string
type?: null | string
name?: null | string
input?: unknown
dependencies?: null | string[]
@@ -29,7 +29,7 @@ export type PayloadWorkflow = {
[key: string]: unknown
}
import { JSONPath } from 'jsonpath-plus'
import Handlebars from 'handlebars'
// Helper type to extract workflow step data from the generated types
export type WorkflowStep = {
@@ -52,6 +52,63 @@ export class WorkflowExecutor {
private logger: Payload['logger']
) {}
/**
* Convert string values to appropriate types based on common patterns
*/
private convertValueType(value: unknown, key: string): unknown {
if (typeof value !== 'string') {
return value
}
// Type conversion patterns based on field names and values
const numericFields = ['timeout', 'retries', 'delay', 'port', 'limit', 'offset', 'count', 'max', 'min']
const booleanFields = ['enabled', 'required', 'active', 'success', 'failed', 'complete']
// Convert numeric fields
if (numericFields.some(field => key.toLowerCase().includes(field))) {
const numValue = Number(value)
if (!isNaN(numValue)) {
this.logger.debug({
key,
originalValue: value,
convertedValue: numValue
}, 'Auto-converted field to number')
return numValue
}
}
// Convert boolean fields
if (booleanFields.some(field => key.toLowerCase().includes(field))) {
if (value === 'true') return true
if (value === 'false') return false
}
// Try to parse as number if it looks numeric
if (/^\d+$/.test(value)) {
const numValue = parseInt(value, 10)
this.logger.debug({
key,
originalValue: value,
convertedValue: numValue
}, 'Auto-converted numeric string to number')
return numValue
}
// Try to parse as float if it looks like a decimal
if (/^\d+\.\d+$/.test(value)) {
const floatValue = parseFloat(value)
this.logger.debug({
key,
originalValue: value,
convertedValue: floatValue
}, 'Auto-converted decimal string to number')
return floatValue
}
// Return as string if no conversion applies
return value
}
/**
* Classifies error types based on error messages
*/
@@ -165,31 +222,11 @@ export class WorkflowExecutor {
const taskSlug = step.type as string
try {
// Extract input data from step - PayloadCMS flattens inputSchema fields to step level
const inputFields: Record<string, unknown> = {}
// Get input configuration from the step
const inputConfig = (step.input as Record<string, unknown>) || {}
// Get all fields except the core step fields
const coreFields = ['step', 'name', 'dependencies', 'condition', 'type', 'id', 'parameters']
for (const [key, value] of Object.entries(step)) {
if (!coreFields.includes(key)) {
// Handle flattened parameters (remove 'parameter' prefix)
if (key.startsWith('parameter')) {
const cleanKey = key.replace('parameter', '')
const properKey = cleanKey.charAt(0).toLowerCase() + cleanKey.slice(1)
inputFields[properKey] = value
} else {
inputFields[key] = value
}
}
}
// Also extract from nested parameters object if it exists
if (step.parameters && typeof step.parameters === 'object') {
Object.assign(inputFields, step.parameters)
}
// Resolve input data using JSONPath
const resolvedInput = this.resolveStepInput(inputFields, context)
// Resolve input data using Handlebars templates
const resolvedInput = this.resolveStepInput(inputConfig, context, taskSlug)
context.steps[stepName].input = resolvedInput
if (!taskSlug) {
@@ -447,32 +484,6 @@ export class WorkflowExecutor {
}
}
/**
* Parse a condition value (string literal, number, boolean, or JSONPath)
*/
private parseConditionValue(expr: string, context: ExecutionContext): any {
// Handle string literals
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1) // Remove quotes
}
// Handle boolean literals
if (expr === 'true') {return true}
if (expr === 'false') {return false}
// Handle number literals
if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
return Number(expr)
}
// Handle JSONPath expressions
if (expr.startsWith('$')) {
return this.resolveJSONPathValue(expr, context)
}
// Return as string if nothing else matches
return expr
}
/**
* Resolve step execution order based on dependencies
@@ -531,68 +542,56 @@ export class WorkflowExecutor {
return executionBatches
}
/**
* Resolve a JSONPath value from the context
*/
private resolveJSONPathValue(expr: string, context: ExecutionContext): any {
if (expr.startsWith('$')) {
const result = JSONPath({
json: context,
path: expr,
wrap: false
})
// Return first result if array, otherwise the result itself
return Array.isArray(result) && result.length > 0 ? result[0] : result
}
return expr
}
/**
* Resolve step input using JSONPath expressions
* Resolve step input using Handlebars templates with automatic type conversion
*/
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext): Record<string, unknown> {
private resolveStepInput(config: Record<string, unknown>, context: ExecutionContext, stepType?: string): Record<string, unknown> {
const resolved: Record<string, unknown> = {}
this.logger.debug({
configKeys: Object.keys(config),
contextSteps: Object.keys(context.steps),
triggerType: context.trigger?.type
}, 'Starting step input resolution')
triggerType: context.trigger?.type,
stepType
}, 'Starting step input resolution with Handlebars')
for (const [key, value] of Object.entries(config)) {
if (typeof value === 'string' && value.startsWith('$')) {
// This is a JSONPath expression
this.logger.debug({
key,
jsonPath: value,
availableSteps: Object.keys(context.steps),
hasTriggerData: !!context.trigger?.data,
hasTriggerDoc: !!context.trigger?.doc
}, 'Resolving JSONPath expression')
try {
const result = JSONPath({
json: context,
path: value,
wrap: false
})
if (typeof value === 'string') {
// Check if the string contains Handlebars templates
if (value.includes('{{') && value.includes('}}')) {
this.logger.debug({
key,
jsonPath: value,
result: JSON.stringify(result).substring(0, 200),
resultType: Array.isArray(result) ? 'array' : typeof result
}, 'JSONPath resolved successfully')
template: value,
availableSteps: Object.keys(context.steps),
hasTriggerData: !!context.trigger?.data,
hasTriggerDoc: !!context.trigger?.doc
}, 'Processing Handlebars template')
resolved[key] = result
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
key,
path: value,
contextSnapshot: JSON.stringify(context).substring(0, 500)
}, 'Failed to resolve JSONPath')
resolved[key] = value // Keep original value if resolution fails
try {
const template = Handlebars.compile(value)
const result = template(context)
this.logger.debug({
key,
template: value,
result: JSON.stringify(result).substring(0, 200),
resultType: typeof result
}, 'Handlebars template resolved successfully')
resolved[key] = this.convertValueType(result, key)
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
key,
template: value,
contextSnapshot: JSON.stringify(context).substring(0, 500)
}, 'Failed to resolve Handlebars template')
resolved[key] = value // Keep original value if resolution fails
}
} else {
// Regular string, apply type conversion
resolved[key] = this.convertValueType(value, key)
}
} else if (typeof value === 'object' && value !== null) {
// Recursively resolve nested objects
@@ -601,7 +600,7 @@ export class WorkflowExecutor {
nestedKeys: Object.keys(value as Record<string, unknown>)
}, 'Recursively resolving nested object')
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context)
resolved[key] = this.resolveStepInput(value as Record<string, unknown>, context, stepType)
} else {
// Keep literal values as-is
resolved[key] = value
@@ -690,7 +689,7 @@ export class WorkflowExecutor {
}
/**
* Evaluate a condition using JSONPath and comparison operators
* Evaluate a condition using Handlebars templates and comparison operators
*/
public evaluateCondition(condition: string, context: ExecutionContext): boolean {
this.logger.debug({
@@ -708,11 +707,11 @@ export class WorkflowExecutor {
if (comparisonMatch) {
const [, leftExpr, operator, rightExpr] = comparisonMatch
// Evaluate left side (should be JSONPath)
const leftValue = this.resolveJSONPathValue(leftExpr.trim(), context)
// Evaluate left side (could be Handlebars template or JSONPath)
const leftValue = this.resolveConditionValue(leftExpr.trim(), context)
// Parse right side (could be string, number, boolean, or JSONPath)
const rightValue = this.parseConditionValue(rightExpr.trim(), context)
// Evaluate right side (could be Handlebars template, JSONPath, or literal)
const rightValue = this.resolveConditionValue(rightExpr.trim(), context)
this.logger.debug({
condition,
@@ -760,19 +759,15 @@ export class WorkflowExecutor {
return result
} else {
// Treat as simple JSONPath boolean evaluation
const result = JSONPath({
json: context,
path: condition,
wrap: false
})
// Treat as template or JSONPath boolean evaluation
const result = this.resolveConditionValue(condition, context)
this.logger.debug({
condition,
result,
resultType: Array.isArray(result) ? 'array' : typeof result,
resultLength: Array.isArray(result) ? result.length : undefined
}, 'JSONPath boolean evaluation result')
}, 'Boolean evaluation result')
// Handle different result types
let finalResult: boolean
@@ -802,6 +797,43 @@ export class WorkflowExecutor {
}
}
/**
* Resolve a condition value using Handlebars templates or JSONPath
*/
private resolveConditionValue(expr: string, context: ExecutionContext): any {
// Handle string literals
if ((expr.startsWith('"') && expr.endsWith('"')) || (expr.startsWith("'") && expr.endsWith("'"))) {
return expr.slice(1, -1) // Remove quotes
}
// Handle boolean literals
if (expr === 'true') {return true}
if (expr === 'false') {return false}
// Handle number literals
if (/^-?\d+(?:\.\d+)?$/.test(expr)) {
return Number(expr)
}
// Handle Handlebars templates
if (expr.includes('{{') && expr.includes('}}')) {
try {
const template = Handlebars.compile(expr)
return template(context)
} catch (error) {
this.logger.warn({
error: error instanceof Error ? error.message : 'Unknown error',
expr
}, 'Failed to resolve Handlebars condition')
return false
}
}
// Return as string if nothing else matches
return expr
}
/**
* Execute a workflow with the given context
*/

View File

@@ -0,0 +1,113 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useField, useFormFields } from '@payloadcms/ui'
import { WorkflowBuilder } from '../components/WorkflowBuilder/index.js'
// Import the step types from the steps module
import * as stepTasks from '../steps/index.js'
// Extract available step types from imported tasks
const getAvailableStepTypes = () => {
const stepTypes: Array<{
slug: string
label?: string
inputSchema?: any[]
outputSchema?: any[]
}> = []
// Get all exported step tasks
const tasks = [
stepTasks.HttpRequestStepTask,
stepTasks.CreateDocumentStepTask,
stepTasks.ReadDocumentStepTask,
stepTasks.UpdateDocumentStepTask,
stepTasks.DeleteDocumentStepTask,
stepTasks.SendEmailStepTask
]
tasks.forEach(task => {
if (task && task.slug) {
stepTypes.push({
slug: task.slug,
label: undefined, // Tasks don't have labels, will use slug
inputSchema: task.inputSchema,
outputSchema: task.outputSchema
})
}
})
return stepTypes
}
interface WorkflowBuilderFieldProps {
name?: string
path?: string
}
export const WorkflowBuilderField: React.FC<WorkflowBuilderFieldProps> = ({
name,
path
}) => {
const availableStepTypes = getAvailableStepTypes()
const { value: steps, setValue: setSteps } = useField<any>({ path: 'steps' })
const { value: layout, setValue: setLayout } = useField<any>({ path: 'layout' })
const { value: workflowName } = useField<string>({ path: 'name' })
const [workflowData, setWorkflowData] = useState<any>({
id: 'temp',
name: workflowName || 'Workflow',
steps: steps || [],
layout: layout || {}
})
// Update local state when form fields change
useEffect(() => {
setWorkflowData({
id: 'temp',
name: workflowName || 'Workflow',
steps: steps || [],
layout: layout || {}
})
}, [steps, layout, workflowName])
const handleSave = useCallback((updatedWorkflow: any) => {
// Update the form fields
if (updatedWorkflow.steps) {
setSteps(updatedWorkflow.steps)
}
if (updatedWorkflow.layout) {
setLayout(updatedWorkflow.layout)
}
}, [setSteps, setLayout])
return (
<div style={{
marginTop: '20px',
marginBottom: '20px',
border: '1px solid var(--theme-elevation-100)',
borderRadius: '4px',
overflow: 'hidden'
}}>
<div style={{
background: 'var(--theme-elevation-50)',
padding: '12px 16px',
borderBottom: '1px solid var(--theme-elevation-100)'
}}>
<h3 style={{ margin: 0, fontSize: '16px', fontWeight: '600', color: 'var(--theme-text)' }}>
Visual Workflow Builder
</h3>
<p style={{ margin: '4px 0 0', fontSize: '12px', color: 'var(--theme-text-400)' }}>
Drag and drop steps to build your workflow visually. Click on any step to configure its parameters.
</p>
</div>
<WorkflowBuilder
workflow={workflowData}
availableStepTypes={availableStepTypes}
onSave={handleSave}
readonly={false}
/>
</div>
)
}