mirror of
https://github.com/xtr-dev/payload-automation.git
synced 2025-12-07 23:53:24 +00:00
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:
@@ -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'
|
||||
|
||||
|
||||
@@ -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
86
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal file
297
src/components/WorkflowBuilder/StepConfigurationForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal file
255
src/components/WorkflowBuilder/WorkflowBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal file
118
src/components/WorkflowBuilder/WorkflowToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
4
src/components/WorkflowBuilder/index.ts
Normal file
4
src/components/WorkflowBuilder/index.ts
Normal 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'
|
||||
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal file
157
src/components/WorkflowBuilder/nodes/StepNode.tsx
Normal 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'
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
113
src/fields/WorkflowBuilderField.tsx
Normal file
113
src/fields/WorkflowBuilderField.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user