Merge pull request #169 from johndoe6345789/codex/refactor-workfloweditor-into-separate-modules

Refactor workflow editor into modular components
This commit is contained in:
2025-12-27 17:39:27 +00:00
committed by GitHub
10 changed files with 703 additions and 471 deletions

View File

@@ -1,243 +1,56 @@
import { useState } from 'react'
import {
Box,
Card,
CardContent,
CardHeader,
TextField,
Typography,
Chip,
MenuItem,
IconButton,
} from '@mui/material'
import {
Add as AddIcon,
Delete as DeleteIcon,
FlashOn as LightningIcon,
Code as CodeIcon,
AccountTree as GitBranchIcon,
ArrowForward as ArrowRightIcon,
PlayArrow as PlayIcon,
CheckCircle as CheckCircleIcon,
Cancel as XCircleIcon,
} from '@mui/icons-material'
import { toast } from 'sonner'
import { createWorkflowEngine, type WorkflowExecutionResult as WFExecResult } from '@/lib/workflow/engine/workflow-engine'
import type { Workflow, WorkflowNode, WorkflowEdge, LuaScript } from '@/lib/level-types'
import { Input, Label, Badge, Button, Textarea, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
import { CardTitle, CardDescription } from '@/components/ui'
interface WorkflowEditorProps {
workflows: Workflow[]
onWorkflowsChange: (workflows: Workflow[]) => void
scripts?: LuaScript[]
}
import { Card, CardContent, CardHeader } from '@mui/material'
import { PlayArrow as PlayIcon } from '@mui/icons-material'
import { CardDescription, CardTitle } from '@/components/ui'
import { WorkflowSidebar } from './editor/WorkflowSidebar'
import { WorkflowDetailsPanel } from './editor/WorkflowDetailsPanel'
import { WorkflowNodesPanel } from './editor/WorkflowNodesPanel'
import { WorkflowTester } from './editor/WorkflowTester'
import { useWorkflowState } from './editor/useWorkflowState'
import { createActionHandlers } from './editor/createActionHandlers'
import type { WorkflowEditorProps } from './editor/types'
export function WorkflowEditor({ workflows, onWorkflowsChange, scripts = [] }: WorkflowEditorProps) {
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(
workflows.length > 0 ? workflows[0].id : null
)
const [testData, setTestData] = useState<string>('{"example": "data"}')
const [testOutput, setTestOutput] = useState<WFExecResult | null>(null)
const [isExecuting, setIsExecuting] = useState(false)
const state = useWorkflowState(workflows)
const {
currentWorkflow,
selectedWorkflowId,
setSelectedWorkflowId,
testData,
setTestData,
testOutput,
setTestOutput,
isExecuting,
setIsExecuting,
} = state
const currentWorkflow = workflows.find(w => w.id === selectedWorkflow)
const handleAddWorkflow = () => {
const newWorkflow: Workflow = {
id: `workflow_${Date.now()}`,
name: 'New Workflow',
nodes: [],
edges: [],
enabled: true,
}
onWorkflowsChange([...workflows, newWorkflow])
setSelectedWorkflow(newWorkflow.id)
toast.success('Workflow created')
}
const handleDeleteWorkflow = (workflowId: string) => {
onWorkflowsChange(workflows.filter(w => w.id !== workflowId))
if (selectedWorkflow === workflowId) {
setSelectedWorkflow(workflows.length > 1 ? workflows[0].id : null)
}
toast.success('Workflow deleted')
}
const handleUpdateWorkflow = (updates: Partial<Workflow>) => {
if (!currentWorkflow) return
onWorkflowsChange(
workflows.map(w => w.id === selectedWorkflow ? { ...w, ...updates } : w)
)
}
const handleAddNode = (type: WorkflowNode['type']) => {
if (!currentWorkflow) return
const newNode: WorkflowNode = {
id: `node_${Date.now()}`,
type,
label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node`,
config: {},
position: { x: 100, y: currentWorkflow.nodes.length * 100 + 100 },
}
handleUpdateWorkflow({
nodes: [...currentWorkflow.nodes, newNode],
})
toast.success('Node added')
}
const handleDeleteNode = (nodeId: string) => {
if (!currentWorkflow) return
handleUpdateWorkflow({
nodes: currentWorkflow.nodes.filter(n => n.id !== nodeId),
edges: currentWorkflow.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
})
toast.success('Node deleted')
}
const handleUpdateNode = (nodeId: string, updates: Partial<WorkflowNode>) => {
if (!currentWorkflow) return
handleUpdateWorkflow({
nodes: currentWorkflow.nodes.map(n => n.id === nodeId ? { ...n, ...updates } : n),
})
}
const handleTestWorkflow = async () => {
if (!currentWorkflow) return
setIsExecuting(true)
setTestOutput(null)
try {
let parsedData: any
try {
parsedData = JSON.parse(testData)
} catch {
parsedData = testData
}
const engine = createWorkflowEngine()
const result = await engine.executeWorkflow(currentWorkflow, {
data: parsedData,
user: { username: 'test_user', role: 'god' },
scripts,
})
setTestOutput(result)
if (result.success) {
toast.success('Workflow executed successfully')
} else {
toast.error('Workflow execution failed')
}
} catch (error) {
toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error)))
setTestOutput({
success: false,
outputs: {},
logs: [],
error: error instanceof Error ? error.message : String(error),
})
} finally {
setIsExecuting(false)
}
}
const getNodeIcon = (type: WorkflowNode['type']) => {
switch (type) {
case 'trigger':
return <LightningIcon fontSize="small" />
case 'action':
return <ArrowRightIcon fontSize="small" />
case 'condition':
return <GitBranchIcon fontSize="small" />
case 'lua':
return <CodeIcon fontSize="small" />
case 'transform':
return <ArrowRightIcon fontSize="small" />
default:
return <ArrowRightIcon fontSize="small" />
}
}
const getNodeColor = (type: WorkflowNode['type']): string => {
switch (type) {
case 'trigger':
return 'success.main'
case 'action':
return 'primary.main'
case 'condition':
return 'warning.main'
case 'lua':
return 'secondary.main'
case 'transform':
return 'info.main'
default:
return 'grey.500'
}
}
const {
handleAddWorkflow,
handleDeleteWorkflow,
handleUpdateWorkflow,
handleAddNode,
handleDeleteNode,
handleUpdateNode,
handleTestWorkflow,
} = createActionHandlers({
workflows,
currentWorkflow,
onWorkflowsChange,
setSelectedWorkflowId,
setTestOutput,
setIsExecuting,
scripts,
testData,
})
return (
<div className="grid md:grid-cols-3 gap-6 h-full">
<Card className="md:col-span-1">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Workflows</CardTitle>
<Button size="sm" onClick={handleAddWorkflow}>
<AddIcon sx={{ fontSize: 16 }} />
</Button>
</div>
<CardDescription>Automation workflows</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{workflows.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No workflows yet. Create one to start.
</p>
) : (
workflows.map((workflow) => (
<div
key={workflow.id}
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
selectedWorkflow === workflow.id
? 'bg-accent border-accent-foreground'
: 'hover:bg-muted border-border'
}`}
onClick={() => setSelectedWorkflow(workflow.id)}
>
<div className="flex-1">
<div className="font-medium text-sm">{workflow.name}</div>
<div className="text-xs text-muted-foreground">
{workflow.nodes.length} nodes
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={workflow.enabled ? 'default' : 'secondary'} className="text-xs">
{workflow.enabled ? 'On' : 'Off'}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
handleDeleteWorkflow(workflow.id)
}}
>
<DeleteIcon sx={{ fontSize: 14 }} />
</Button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
<WorkflowSidebar
workflows={workflows}
selectedWorkflowId={selectedWorkflowId}
onSelectWorkflow={setSelectedWorkflowId}
onAddWorkflow={handleAddWorkflow}
onDeleteWorkflow={handleDeleteWorkflow}
/>
<Card className="md:col-span-2">
{!currentWorkflow ? (
@@ -254,250 +67,34 @@ export function WorkflowEditor({ workflows, onWorkflowsChange, scripts = [] }: W
<CardTitle>Edit Workflow: {currentWorkflow.name}</CardTitle>
<CardDescription>Configure workflow nodes and connections</CardDescription>
</div>
<Button onClick={handleTestWorkflow} disabled={isExecuting}>
<PlayIcon sx={{ fontSize: 16, mr: 1 }} />
<button
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm"
onClick={handleTestWorkflow}
disabled={isExecuting}
>
<PlayIcon sx={{ fontSize: 16 }} />
{isExecuting ? 'Running...' : 'Test Workflow'}
</Button>
</button>
</div>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Workflow Name</Label>
<Input
value={currentWorkflow.name}
onChange={(e) => handleUpdateWorkflow({ name: e.target.value })}
placeholder="My Workflow"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={currentWorkflow.description || ''}
onChange={(e) => handleUpdateWorkflow({ description: e.target.value })}
placeholder="What this workflow does..."
/>
</div>
</div>
<WorkflowDetailsPanel workflow={currentWorkflow} onUpdate={handleUpdateWorkflow} />
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base">Nodes</Label>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => handleAddNode('trigger')}>
<LightningIcon sx={{ fontSize: 14, mr: 1 }} />
Trigger
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddNode('action')}>
<ArrowRightIcon sx={{ fontSize: 14, mr: 1 }} />
Action
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddNode('condition')}>
<GitBranchIcon sx={{ fontSize: 14, mr: 1 }} />
Condition
</Button>
<Button size="sm" variant="outline" onClick={() => handleAddNode('lua')}>
<CodeIcon sx={{ fontSize: 14, mr: 1 }} />
Lua
</Button>
</div>
</div>
<div className="space-y-3">
{currentWorkflow.nodes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
No nodes yet. Add nodes to build your workflow.
</p>
) : (
currentWorkflow.nodes.map((node, index) => (
<Card key={node.id} className="border-2">
<CardContent className="pt-4">
<div className="flex items-start gap-4">
<div className={`w-10 h-10 rounded-lg ${getNodeColor(node.type)} flex items-center justify-center text-white shrink-0`}>
{getNodeIcon(node.type)}
</div>
<div className="flex-1 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Node Label</Label>
<Input
value={node.label}
onChange={(e) =>
handleUpdateNode(node.id, { label: e.target.value })
}
placeholder="Node name"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Node Type</Label>
<Select
value={node.type}
onValueChange={(value) =>
handleUpdateNode(node.id, { type: value as WorkflowNode['type'] })
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="trigger">Trigger</SelectItem>
<SelectItem value="action">Action</SelectItem>
<SelectItem value="condition">Condition</SelectItem>
<SelectItem value="lua">Lua Script</SelectItem>
<SelectItem value="transform">Transform</SelectItem>
</SelectContent>
</Select>
</div>
</div>
{node.type === 'lua' && scripts.length > 0 && (
<div className="space-y-2">
<Label className="text-xs">Lua Script</Label>
<Select
value={node.config.scriptId || ''}
onValueChange={(value) =>
handleUpdateNode(node.id, {
config: { ...node.config, scriptId: value }
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a script" />
</SelectTrigger>
<SelectContent>
{scripts.map((script) => (
<SelectItem key={script.id} value={script.id}>
{script.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{node.type === 'condition' && (
<div className="space-y-2">
<Label className="text-xs">Condition Expression</Label>
<Input
value={node.config.condition || ''}
onChange={(e) =>
handleUpdateNode(node.id, {
config: { ...node.config, condition: e.target.value }
})
}
placeholder="data.value > 10"
className="font-mono text-xs"
/>
</div>
)}
{node.type === 'transform' && (
<div className="space-y-2">
<Label className="text-xs">Transform Expression</Label>
<Input
value={node.config.transform || ''}
onChange={(e) =>
handleUpdateNode(node.id, {
config: { ...node.config, transform: e.target.value }
})
}
placeholder="{ result: data.value * 2 }"
className="font-mono text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Step {index + 1}
</Badge>
{index < currentWorkflow.nodes.length - 1 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRightIcon sx={{ fontSize: 12 }} />
<span>Next</span>
</div>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteNode(node.id)}
>
<DeleteIcon sx={{ fontSize: 16 }} />
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</div>
<WorkflowNodesPanel
workflow={currentWorkflow}
scripts={scripts}
onAddNode={handleAddNode}
onDeleteNode={handleDeleteNode}
onUpdateNode={handleUpdateNode}
/>
{currentWorkflow.nodes.length > 0 && (
<>
<div className="space-y-2">
<Label>Test Input Data (JSON)</Label>
<Textarea
value={testData}
onChange={(e) => setTestData(e.target.value)}
className="font-mono text-sm min-h-[100px]"
placeholder='{"example": "data"}'
/>
</div>
{testOutput && (
<Card className={testOutput.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}>
<CardHeader>
<div className="flex items-center gap-2">
{testOutput.success ? (
<CheckCircleIcon sx={{ fontSize: 20, color: 'success.main' }} />
) : (
<XCircleIcon sx={{ fontSize: 20, color: 'error.main' }} />
)}
<CardTitle className="text-sm">
{testOutput.success ? 'Workflow Execution Successful' : 'Workflow Execution Failed'}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
{testOutput.error && (
<div>
<Label className="text-xs text-red-600 mb-1">Error</Label>
<pre className="text-xs font-mono whitespace-pre-wrap text-red-700 bg-red-100 p-2 rounded">
{testOutput.error}
</pre>
</div>
)}
{testOutput.logs.length > 0 && (
<div>
<Label className="text-xs mb-1">Execution Logs</Label>
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded max-h-[200px] overflow-y-auto">
{testOutput.logs.join('\n')}
</pre>
</div>
)}
{Object.keys(testOutput.outputs).length > 0 && (
<div>
<Label className="text-xs mb-1">Node Outputs</Label>
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded max-h-[200px] overflow-y-auto">
{JSON.stringify(testOutput.outputs, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
)}
<div className="bg-muted/50 rounded-lg p-4 border border-dashed">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LightningIcon sx={{ fontSize: 16 }} />
<span>Workflow execution: {currentWorkflow.nodes.map(n => n.label).join(' → ')}</span>
</div>
</div>
</>
<WorkflowTester
workflow={currentWorkflow}
testData={testData}
testOutput={testOutput}
onTestDataChange={setTestData}
/>
)}
</CardContent>
</>

View File

@@ -0,0 +1,23 @@
import { Input, Label } from '@/components/ui'
import type { WorkflowDetailsPanelProps } from './types'
export const WorkflowDetailsPanel = ({ workflow, onUpdate }: WorkflowDetailsPanelProps) => (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Workflow Name</Label>
<Input
value={workflow.name}
onChange={(e) => onUpdate({ name: e.target.value })}
placeholder="My Workflow"
/>
</div>
<div className="space-y-2">
<Label>Description</Label>
<Input
value={workflow.description || ''}
onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="What this workflow does..."
/>
</div>
</div>
)

View File

@@ -0,0 +1,138 @@
import { Badge, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
import { Card, CardContent } from '@mui/material'
import { ArrowForward as ArrowRightIcon, Delete as DeleteIcon } from '@mui/icons-material'
import type { WorkflowNode, LuaScript } from '@/lib/level-types'
import { NODE_TYPE_COLORS, NODE_TYPE_ICONS, NODE_TYPE_OPTIONS } from './constants'
interface WorkflowNodeCardProps {
node: WorkflowNode
index: number
totalNodes: number
scripts: LuaScript[]
onDeleteNode: (nodeId: string) => void
onUpdateNode: (nodeId: string, updates: Partial<WorkflowNode>) => void
}
const getNodeIcon = (type: WorkflowNode['type']) => NODE_TYPE_ICONS[type] || <ArrowRightIcon fontSize="small" />
const getNodeColor = (type: WorkflowNode['type']) => NODE_TYPE_COLORS[type] || 'grey.500'
export const WorkflowNodeCard = ({
node,
index,
totalNodes,
scripts,
onDeleteNode,
onUpdateNode,
}: WorkflowNodeCardProps) => (
<Card className="border-2">
<CardContent className="pt-4">
<div className="flex items-start gap-4">
<div className={`w-10 h-10 rounded-lg ${getNodeColor(node.type)} flex items-center justify-center text-white shrink-0`}>
{getNodeIcon(node.type)}
</div>
<div className="flex-1 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-xs">Node Label</Label>
<Input
value={node.label}
onChange={(e) => onUpdateNode(node.id, { label: e.target.value })}
placeholder="Node name"
/>
</div>
<div className="space-y-2">
<Label className="text-xs">Node Type</Label>
<Select
value={node.type}
onValueChange={(value) => onUpdateNode(node.id, { type: value as WorkflowNode['type'] })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{NODE_TYPE_OPTIONS.map(({ type, label }) => (
<SelectItem key={type} value={type}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
{node.type === 'lua' && scripts.length > 0 && (
<div className="space-y-2">
<Label className="text-xs">Lua Script</Label>
<Select
value={node.config.scriptId || ''}
onValueChange={(value) =>
onUpdateNode(node.id, {
config: { ...node.config, scriptId: value },
})
}
>
<SelectTrigger>
<SelectValue placeholder="Select a script" />
</SelectTrigger>
<SelectContent>
{scripts.map((script) => (
<SelectItem key={script.id} value={script.id}>
{script.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{node.type === 'condition' && (
<div className="space-y-2">
<Label className="text-xs">Condition Expression</Label>
<Input
value={node.config.condition || ''}
onChange={(e) =>
onUpdateNode(node.id, {
config: { ...node.config, condition: e.target.value },
})
}
placeholder="data.value > 10"
className="font-mono text-xs"
/>
</div>
)}
{node.type === 'transform' && (
<div className="space-y-2">
<Label className="text-xs">Transform Expression</Label>
<Input
value={node.config.transform || ''}
onChange={(e) =>
onUpdateNode(node.id, {
config: { ...node.config, transform: e.target.value },
})
}
placeholder="{ result: data.value * 2 }"
className="font-mono text-xs"
/>
</div>
)}
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
Step {index + 1}
</Badge>
{index < totalNodes - 1 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<ArrowRightIcon sx={{ fontSize: 12 }} />
<span>Next</span>
</div>
)}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => onDeleteNode(node.id)}>
<DeleteIcon sx={{ fontSize: 16 }} />
</Button>
</div>
</CardContent>
</Card>
)

View File

@@ -0,0 +1,61 @@
import { Button, Label } from '@/components/ui'
import {
AccountTree as GitBranchIcon,
ArrowForward as ArrowRightIcon,
Code as CodeIcon,
FlashOn as LightningIcon,
} from '@mui/icons-material'
import type { WorkflowNodesPanelProps } from './types'
import { WorkflowNodeCard } from './WorkflowNodeCard'
export const WorkflowNodesPanel = ({
workflow,
scripts,
onAddNode,
onDeleteNode,
onUpdateNode,
}: WorkflowNodesPanelProps) => (
<div>
<div className="flex items-center justify-between mb-4">
<Label className="text-base">Nodes</Label>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={() => onAddNode('trigger')}>
<LightningIcon sx={{ fontSize: 14, mr: 1 }} />
Trigger
</Button>
<Button size="sm" variant="outline" onClick={() => onAddNode('action')}>
<ArrowRightIcon sx={{ fontSize: 14, mr: 1 }} />
Action
</Button>
<Button size="sm" variant="outline" onClick={() => onAddNode('condition')}>
<GitBranchIcon sx={{ fontSize: 14, mr: 1 }} />
Condition
</Button>
<Button size="sm" variant="outline" onClick={() => onAddNode('lua')}>
<CodeIcon sx={{ fontSize: 14, mr: 1 }} />
Lua
</Button>
</div>
</div>
<div className="space-y-3">
{workflow.nodes.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-8 border border-dashed rounded-lg">
No nodes yet. Add nodes to build your workflow.
</p>
) : (
workflow.nodes.map((node, index) => (
<WorkflowNodeCard
key={node.id}
node={node}
index={index}
totalNodes={workflow.nodes.length}
scripts={scripts}
onDeleteNode={onDeleteNode}
onUpdateNode={onUpdateNode}
/>
))
)}
</div>
</div>
)

View File

@@ -0,0 +1,67 @@
import { Badge, Button } from '@/components/ui'
import { Card, CardContent, CardHeader } from '@mui/material'
import { Add as AddIcon, Delete as DeleteIcon } from '@mui/icons-material'
import type { WorkflowSidebarProps } from './types'
export const WorkflowSidebar = ({
workflows,
selectedWorkflowId,
onSelectWorkflow,
onAddWorkflow,
onDeleteWorkflow,
}: WorkflowSidebarProps) => (
<Card className="md:col-span-1">
<CardHeader>
<div className="flex items-center justify-between">
<div className="text-lg font-semibold">Workflows</div>
<Button size="sm" onClick={onAddWorkflow}>
<AddIcon sx={{ fontSize: 16 }} />
</Button>
</div>
<p className="text-sm text-muted-foreground">Automation workflows</p>
</CardHeader>
<CardContent>
<div className="space-y-2">
{workflows.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No workflows yet. Create one to start.
</p>
) : (
workflows.map((workflow) => (
<div
key={workflow.id}
className={`flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-colors ${
selectedWorkflowId === workflow.id
? 'bg-accent border-accent-foreground'
: 'hover:bg-muted border-border'
}`}
onClick={() => onSelectWorkflow(workflow.id)}
>
<div className="flex-1">
<div className="font-medium text-sm">{workflow.name}</div>
<div className="text-xs text-muted-foreground">
{workflow.nodes.length} nodes
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={workflow.enabled ? 'default' : 'secondary'} className="text-xs">
{workflow.enabled ? 'On' : 'Off'}
</Badge>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation()
onDeleteWorkflow(workflow.id)
}}
>
<DeleteIcon sx={{ fontSize: 14 }} />
</Button>
</div>
</div>
))
)}
</div>
</CardContent>
</Card>
)

View File

@@ -0,0 +1,76 @@
import { Card, CardContent, CardHeader } from '@mui/material'
import { CardTitle } from '@/components/ui'
import { CheckCircle as CheckCircleIcon, Cancel as XCircleIcon, FlashOn as LightningIcon } from '@mui/icons-material'
import { Label, Textarea } from '@/components/ui'
import type { WorkflowTesterProps } from './types'
export const WorkflowTester = ({
workflow,
testData,
testOutput,
onTestDataChange,
}: WorkflowTesterProps) => (
<>
<div className="space-y-2">
<Label>Test Input Data (JSON)</Label>
<Textarea
value={testData}
onChange={(e) => onTestDataChange(e.target.value)}
className="font-mono text-sm min-h-[100px]"
placeholder='{"example": "data"}'
/>
</div>
{testOutput && (
<Card className={testOutput.success ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}>
<CardHeader>
<div className="flex items-center gap-2">
{testOutput.success ? (
<CheckCircleIcon sx={{ fontSize: 20, color: 'success.main' }} />
) : (
<XCircleIcon sx={{ fontSize: 20, color: 'error.main' }} />
)}
<CardTitle className="text-sm">
{testOutput.success ? 'Workflow Execution Successful' : 'Workflow Execution Failed'}
</CardTitle>
</div>
</CardHeader>
<CardContent className="space-y-3">
{testOutput.error && (
<div>
<Label className="text-xs text-red-600 mb-1">Error</Label>
<pre className="text-xs font-mono whitespace-pre-wrap text-red-700 bg-red-100 p-2 rounded">
{testOutput.error}
</pre>
</div>
)}
{testOutput.logs.length > 0 && (
<div>
<Label className="text-xs mb-1">Execution Logs</Label>
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded max-h-[200px] overflow-y-auto">
{testOutput.logs.join('\n')}
</pre>
</div>
)}
{Object.keys(testOutput.outputs).length > 0 && (
<div>
<Label className="text-xs mb-1">Node Outputs</Label>
<pre className="text-xs font-mono whitespace-pre-wrap bg-muted p-2 rounded max-h-[200px] overflow-y-auto">
{JSON.stringify(testOutput.outputs, null, 2)}
</pre>
</div>
)}
</CardContent>
</Card>
)}
<div className="bg-muted/50 rounded-lg p-4 border border-dashed">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<LightningIcon sx={{ fontSize: 16 }} />
<span>Workflow execution: {workflow.nodes.map(n => n.label).join(' → ')}</span>
</div>
</div>
</>
)

View File

@@ -0,0 +1,35 @@
import type { JSX } from 'react'
import {
AccountTree as GitBranchIcon,
ArrowForward as ArrowRightIcon,
Code as CodeIcon,
FlashOn as LightningIcon,
} from '@mui/icons-material'
import type { NodeTypeOption } from './types'
import type { WorkflowNode } from '@/lib/level-types'
export const DEFAULT_TEST_DATA = '{"example": "data"}'
export const NODE_TYPE_OPTIONS: NodeTypeOption[] = [
{ type: 'trigger', label: 'Trigger' },
{ type: 'action', label: 'Action' },
{ type: 'condition', label: 'Condition' },
{ type: 'lua', label: 'Lua Script' },
{ type: 'transform', label: 'Transform' },
]
export const NODE_TYPE_ICONS: Record<WorkflowNode['type'], JSX.Element> = {
trigger: <LightningIcon fontSize="small" />,
action: <ArrowRightIcon fontSize="small" />,
condition: <GitBranchIcon fontSize="small" />,
lua: <CodeIcon fontSize="small" />,
transform: <ArrowRightIcon fontSize="small" />,
}
export const NODE_TYPE_COLORS: Record<WorkflowNode['type'], string> = {
trigger: 'success.main',
action: 'primary.main',
condition: 'warning.main',
lua: 'secondary.main',
transform: 'info.main',
}

View File

@@ -0,0 +1,133 @@
import { toast } from 'sonner'
import { createWorkflowEngine } from '@/lib/workflow/engine/workflow-engine'
import type { LuaScript, Workflow, WorkflowNode } from '@/lib/level-types'
import type { WorkflowActionHandlers, WorkflowStateSetters } from './types'
import type { WorkflowExecutionResult } from '@/lib/workflow/engine/workflow-engine'
interface WorkflowActionDependencies extends WorkflowStateSetters {
workflows: Workflow[]
currentWorkflow: Workflow | undefined
onWorkflowsChange: (workflows: Workflow[]) => void
scripts: LuaScript[]
testData: string
}
export const createActionHandlers = ({
workflows,
currentWorkflow,
onWorkflowsChange,
setSelectedWorkflowId,
setTestOutput,
setIsExecuting,
scripts,
testData,
}: WorkflowActionDependencies): WorkflowActionHandlers => {
const handleAddWorkflow = () => {
const newWorkflow: Workflow = {
id: `workflow_${Date.now()}`,
name: 'New Workflow',
nodes: [],
edges: [],
enabled: true,
}
onWorkflowsChange([...workflows, newWorkflow])
setSelectedWorkflowId(newWorkflow.id)
toast.success('Workflow created')
}
const handleDeleteWorkflow = (workflowId: string) => {
onWorkflowsChange(workflows.filter(w => w.id !== workflowId))
if (currentWorkflow?.id === workflowId) {
setSelectedWorkflowId(workflows.length > 1 ? workflows[0].id : null)
}
toast.success('Workflow deleted')
}
const handleUpdateWorkflow = (updates: Partial<Workflow>) => {
if (!currentWorkflow) return
onWorkflowsChange(workflows.map(w => w.id === currentWorkflow.id ? { ...w, ...updates } : w))
}
const handleAddNode = (type: WorkflowNode['type']) => {
if (!currentWorkflow) return
const newNode: WorkflowNode = {
id: `node_${Date.now()}`,
type,
label: `${type.charAt(0).toUpperCase() + type.slice(1)} Node`,
config: {},
position: { x: 100, y: currentWorkflow.nodes.length * 100 + 100 },
}
handleUpdateWorkflow({ nodes: [...currentWorkflow.nodes, newNode] })
toast.success('Node added')
}
const handleDeleteNode = (nodeId: string) => {
if (!currentWorkflow) return
handleUpdateWorkflow({
nodes: currentWorkflow.nodes.filter(n => n.id !== nodeId),
edges: currentWorkflow.edges.filter(e => e.source !== nodeId && e.target !== nodeId),
})
toast.success('Node deleted')
}
const handleUpdateNode = (nodeId: string, updates: Partial<WorkflowNode>) => {
if (!currentWorkflow) return
handleUpdateWorkflow({
nodes: currentWorkflow.nodes.map(n => n.id === nodeId ? { ...n, ...updates } : n),
})
}
const handleTestWorkflow = async () => {
if (!currentWorkflow) return
setIsExecuting(true)
setTestOutput(null)
try {
let parsedData: any
try {
parsedData = JSON.parse(testData)
} catch {
parsedData = testData
}
const engine = createWorkflowEngine()
const result = await engine.executeWorkflow(currentWorkflow, {
data: parsedData,
user: { username: 'test_user', role: 'god' },
scripts,
}) as WorkflowExecutionResult
setTestOutput(result)
if (result.success) {
toast.success('Workflow executed successfully')
} else {
toast.error('Workflow execution failed')
}
} catch (error) {
toast.error('Execution error: ' + (error instanceof Error ? error.message : String(error)))
setTestOutput({
success: false,
outputs: {},
logs: [],
error: error instanceof Error ? error.message : String(error),
})
} finally {
setIsExecuting(false)
}
}
return {
handleAddWorkflow,
handleDeleteWorkflow,
handleUpdateWorkflow,
handleAddNode,
handleDeleteNode,
handleUpdateNode,
handleTestWorkflow,
}
}

View File

@@ -0,0 +1,69 @@
import type { WorkflowExecutionResult } from '@/lib/workflow/engine/workflow-engine'
import type { LuaScript, Workflow, WorkflowNode } from '@/lib/level-types'
export interface WorkflowEditorProps {
workflows: Workflow[]
onWorkflowsChange: (workflows: Workflow[]) => void
scripts?: LuaScript[]
}
export interface WorkflowState {
selectedWorkflowId: string | null
testData: string
testOutput: WorkflowExecutionResult | null
isExecuting: boolean
}
export interface WorkflowStateSetters {
setSelectedWorkflowId: (id: string | null) => void
setTestData: (data: string) => void
setTestOutput: (result: WorkflowExecutionResult | null) => void
setIsExecuting: (isExecuting: boolean) => void
}
export interface WorkflowSelection {
currentWorkflow: Workflow | undefined
}
export interface WorkflowActionHandlers {
handleAddWorkflow: () => void
handleDeleteWorkflow: (workflowId: string) => void
handleUpdateWorkflow: (updates: Partial<Workflow>) => void
handleAddNode: (type: WorkflowNode['type']) => void
handleDeleteNode: (nodeId: string) => void
handleUpdateNode: (nodeId: string, updates: Partial<WorkflowNode>) => void
handleTestWorkflow: () => Promise<void>
}
export interface WorkflowSidebarProps {
workflows: Workflow[]
selectedWorkflowId: string | null
onSelectWorkflow: (id: string) => void
onAddWorkflow: () => void
onDeleteWorkflow: (id: string) => void
}
export interface WorkflowDetailsPanelProps {
workflow: Workflow
onUpdate: (updates: Partial<Workflow>) => void
}
export interface WorkflowNodesPanelProps {
workflow: Workflow
scripts: LuaScript[]
onAddNode: (type: WorkflowNode['type']) => void
onDeleteNode: (nodeId: string) => void
onUpdateNode: (nodeId: string, updates: Partial<WorkflowNode>) => void
}
export interface WorkflowTesterProps {
workflow: Workflow
testData: string
testOutput: WorkflowExecutionResult | null
onTestDataChange: (value: string) => void
}
export interface NodeTypeOption {
type: WorkflowNode['type']
label: string
}

View File

@@ -0,0 +1,33 @@
import { useMemo, useState } from 'react'
import type { Workflow } from '@/lib/level-types'
import type { WorkflowSelection, WorkflowState, WorkflowStateSetters } from './types'
import { DEFAULT_TEST_DATA } from './constants'
import type { WorkflowExecutionResult } from '@/lib/workflow/engine/workflow-engine'
export const useWorkflowState = (
workflows: Workflow[]
): WorkflowState & WorkflowStateSetters & WorkflowSelection => {
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
workflows.length > 0 ? workflows[0].id : null
)
const [testData, setTestData] = useState<string>(DEFAULT_TEST_DATA)
const [testOutput, setTestOutput] = useState<WorkflowExecutionResult | null>(null)
const [isExecuting, setIsExecuting] = useState(false)
const currentWorkflow = useMemo(
() => workflows.find(({ id }) => id === selectedWorkflowId),
[workflows, selectedWorkflowId]
)
return {
selectedWorkflowId,
setSelectedWorkflowId,
testData,
setTestData,
testOutput,
setTestOutput,
isExecuting,
setIsExecuting,
currentWorkflow,
}
}