mirror of
https://github.com/johndoe6345789/low-code-react-app-b.git
synced 2026-04-24 13:44:54 +00:00
Generated by Spark: Can have multiple component trees, add a n8n style workflow system and lambdas with Monaco editor
This commit is contained in:
21
PRD.md
21
PRD.md
@@ -40,6 +40,27 @@ This is a full-featured low-code IDE with multiple integrated tools (code editor
|
||||
- **Progression**: Select component type or describe to AI → Add to tree → Configure props → Nest children → View generated JSX → Export component
|
||||
- **Success criteria**: Can add/remove/reorder components; AI-generated components are well-structured; props are editable; generates valid React code
|
||||
|
||||
### Component Tree Manager
|
||||
- **Functionality**: Manage multiple named component trees, each with its own hierarchy and component structure
|
||||
- **Purpose**: Organize different parts of the application (Main App, Dashboard, Admin Panel) into separate, manageable trees
|
||||
- **Trigger**: Opening the Component Trees tab
|
||||
- **Progression**: Create tree → Name and describe purpose → Build component hierarchy → Switch between trees → Duplicate or delete trees → Export all trees
|
||||
- **Success criteria**: Can create unlimited trees; each tree maintains independent state; trees can be duplicated; generates organized component structure per tree
|
||||
|
||||
### n8n-Style Workflow Designer
|
||||
- **Functionality**: Visual workflow builder with draggable nodes (triggers, actions, conditions, transforms, lambdas, APIs, database queries) connected by visual connections
|
||||
- **Purpose**: Design automation workflows and business logic visually without coding complex orchestration
|
||||
- **Trigger**: Opening the Workflows tab
|
||||
- **Progression**: Create workflow → Add nodes (drag/position on canvas) → Connect nodes → Configure each node (API endpoints, conditions, lambda code) → Activate workflow → Export workflow definition
|
||||
- **Success criteria**: Nodes can be positioned freely; connections show data flow; Monaco editor for lambda/transform nodes; supports HTTP methods, database queries, conditional branching; workflows persist and can be activated/deactivated
|
||||
|
||||
### Lambda Function Designer
|
||||
- **Functionality**: Full-featured serverless function editor with Monaco code editor, multiple runtime support (Node.js, Python), environment variables, and trigger configuration
|
||||
- **Purpose**: Create and manage serverless functions with professional IDE features
|
||||
- **Trigger**: Opening the Lambdas tab
|
||||
- **Progression**: Create lambda → Select language (TypeScript/JavaScript/Python) → Write code in Monaco editor → Configure runtime, timeout, memory → Add triggers (HTTP, schedule, event, queue) → Set environment variables → Export lambda definitions
|
||||
- **Success criteria**: Monaco editor with syntax highlighting; supports multiple languages; configuration matches AWS Lambda standards; triggers are configurable; environment variables are managed securely; generates deployment-ready functions
|
||||
|
||||
### Style Designer
|
||||
- **Functionality**: Visual interface for Material UI theming with support for multiple theme variants (light/dark/custom), custom color management, and AI theme generation from descriptions
|
||||
- **Purpose**: Configure colors, typography, spacing with support for unlimited theme variants and custom color palettes beyond standard specifications, with AI design assistance
|
||||
|
||||
73
src/App.tsx
73
src/App.tsx
@@ -6,11 +6,14 @@ import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||
import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard } from '@phosphor-icons/react'
|
||||
import { ProjectFile, PrismaModel, ComponentNode, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig, NpmSettings } from '@/types/project'
|
||||
import { Code, Database, Tree, PaintBrush, Download, Sparkle, Flask, BookOpen, Play, Wrench, Gear, Cube, FileText, ChartBar, Keyboard, FlowArrow } from '@phosphor-icons/react'
|
||||
import { ProjectFile, PrismaModel, ComponentNode, ComponentTree, ThemeConfig, PlaywrightTest, StorybookStory, UnitTest, FlaskConfig, NextJsConfig, NpmSettings, Workflow, Lambda } from '@/types/project'
|
||||
import { CodeEditor } from '@/components/CodeEditor'
|
||||
import { ModelDesigner } from '@/components/ModelDesigner'
|
||||
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
|
||||
import { ComponentTreeManager } from '@/components/ComponentTreeManager'
|
||||
import { WorkflowDesigner } from '@/components/WorkflowDesigner'
|
||||
import { LambdaDesigner } from '@/components/LambdaDesigner'
|
||||
import { StyleDesigner } from '@/components/StyleDesigner'
|
||||
import { FileExplorer } from '@/components/FileExplorer'
|
||||
import { PlaywrightDesigner } from '@/components/PlaywrightDesigner'
|
||||
@@ -140,6 +143,18 @@ function App() {
|
||||
const [files, setFiles] = useKV<ProjectFile[]>('project-files', DEFAULT_FILES)
|
||||
const [models, setModels] = useKV<PrismaModel[]>('project-models', [])
|
||||
const [components, setComponents] = useKV<ComponentNode[]>('project-components', [])
|
||||
const [componentTrees, setComponentTrees] = useKV<ComponentTree[]>('project-component-trees', [
|
||||
{
|
||||
id: 'default-tree',
|
||||
name: 'Main App',
|
||||
description: 'Default component tree',
|
||||
rootNodes: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
])
|
||||
const [workflows, setWorkflows] = useKV<Workflow[]>('project-workflows', [])
|
||||
const [lambdas, setLambdas] = useKV<Lambda[]>('project-lambdas', [])
|
||||
const [theme, setTheme] = useKV<ThemeConfig>('project-theme', DEFAULT_THEME)
|
||||
const [playwrightTests, setPlaywrightTests] = useKV<PlaywrightTest[]>('project-playwright-tests', [])
|
||||
const [storybookStories, setStorybookStories] = useKV<StorybookStory[]>('project-storybook-stories', [])
|
||||
@@ -156,6 +171,9 @@ function App() {
|
||||
const safeFiles = files || []
|
||||
const safeModels = models || []
|
||||
const safeComponents = components || []
|
||||
const safeComponentTrees = componentTrees || []
|
||||
const safeWorkflows = workflows || []
|
||||
const safeLambdas = lambdas || []
|
||||
const safeTheme = (theme && theme.variants && theme.variants.length > 0) ? theme : DEFAULT_THEME
|
||||
const safePlaywrightTests = playwrightTests || []
|
||||
const safeStorybookStories = storybookStories || []
|
||||
@@ -200,6 +218,24 @@ function App() {
|
||||
{
|
||||
key: '5',
|
||||
ctrl: true,
|
||||
description: 'Go to Component Trees',
|
||||
action: () => setActiveTab('component-trees'),
|
||||
},
|
||||
{
|
||||
key: '6',
|
||||
ctrl: true,
|
||||
description: 'Go to Workflows',
|
||||
action: () => setActiveTab('workflows'),
|
||||
},
|
||||
{
|
||||
key: '7',
|
||||
ctrl: true,
|
||||
description: 'Go to Lambdas',
|
||||
action: () => setActiveTab('lambdas'),
|
||||
},
|
||||
{
|
||||
key: '8',
|
||||
ctrl: true,
|
||||
description: 'Go to Styling',
|
||||
action: () => setActiveTab('styling'),
|
||||
},
|
||||
@@ -457,6 +493,18 @@ Navigate to the backend directory and follow the setup instructions.
|
||||
<Tree size={18} />
|
||||
Components
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="component-trees" className="gap-2">
|
||||
<Tree size={18} />
|
||||
Component Trees
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="workflows" className="gap-2">
|
||||
<FlowArrow size={18} />
|
||||
Workflows
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="lambdas" className="gap-2">
|
||||
<Code size={18} />
|
||||
Lambdas
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="styling" className="gap-2">
|
||||
<PaintBrush size={18} />
|
||||
Styling
|
||||
@@ -549,6 +597,27 @@ Navigate to the backend directory and follow the setup instructions.
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="component-trees" className="h-full m-0">
|
||||
<ComponentTreeManager
|
||||
trees={safeComponentTrees}
|
||||
onTreesChange={setComponentTrees}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="workflows" className="h-full m-0">
|
||||
<WorkflowDesigner
|
||||
workflows={safeWorkflows}
|
||||
onWorkflowsChange={setWorkflows}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="lambdas" className="h-full m-0">
|
||||
<LambdaDesigner
|
||||
lambdas={safeLambdas}
|
||||
onLambdasChange={setLambdas}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="styling" className="h-full m-0">
|
||||
<StyleDesigner theme={safeTheme} onThemeChange={setTheme} />
|
||||
</TabsContent>
|
||||
|
||||
302
src/components/ComponentTreeManager.tsx
Normal file
302
src/components/ComponentTreeManager.tsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useState } from 'react'
|
||||
import { ComponentTree, ComponentNode } from '@/types/project'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Plus, Tree, Trash, Pencil, Copy, FolderOpen } from '@phosphor-icons/react'
|
||||
import { ComponentTreeBuilder } from '@/components/ComponentTreeBuilder'
|
||||
import { toast } from 'sonner'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
|
||||
interface ComponentTreeManagerProps {
|
||||
trees: ComponentTree[]
|
||||
onTreesChange: (updater: (trees: ComponentTree[]) => ComponentTree[]) => void
|
||||
}
|
||||
|
||||
export function ComponentTreeManager({ trees, onTreesChange }: ComponentTreeManagerProps) {
|
||||
const [selectedTreeId, setSelectedTreeId] = useState<string | null>(trees[0]?.id || null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [newTreeName, setNewTreeName] = useState('')
|
||||
const [newTreeDescription, setNewTreeDescription] = useState('')
|
||||
const [editingTree, setEditingTree] = useState<ComponentTree | null>(null)
|
||||
|
||||
const selectedTree = trees.find(t => t.id === selectedTreeId)
|
||||
|
||||
const handleCreateTree = () => {
|
||||
if (!newTreeName.trim()) {
|
||||
toast.error('Please enter a tree name')
|
||||
return
|
||||
}
|
||||
|
||||
const newTree: ComponentTree = {
|
||||
id: `tree-${Date.now()}`,
|
||||
name: newTreeName,
|
||||
description: newTreeDescription,
|
||||
rootNodes: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onTreesChange((current) => [...current, newTree])
|
||||
setSelectedTreeId(newTree.id)
|
||||
setNewTreeName('')
|
||||
setNewTreeDescription('')
|
||||
setCreateDialogOpen(false)
|
||||
toast.success('Component tree created')
|
||||
}
|
||||
|
||||
const handleEditTree = () => {
|
||||
if (!editingTree || !newTreeName.trim()) return
|
||||
|
||||
onTreesChange((current) =>
|
||||
current.map((tree) =>
|
||||
tree.id === editingTree.id
|
||||
? { ...tree, name: newTreeName, description: newTreeDescription, updatedAt: Date.now() }
|
||||
: tree
|
||||
)
|
||||
)
|
||||
setEditDialogOpen(false)
|
||||
setEditingTree(null)
|
||||
setNewTreeName('')
|
||||
setNewTreeDescription('')
|
||||
toast.success('Component tree updated')
|
||||
}
|
||||
|
||||
const handleDeleteTree = (treeId: string) => {
|
||||
if (trees.length === 1) {
|
||||
toast.error('Cannot delete the last component tree')
|
||||
return
|
||||
}
|
||||
|
||||
if (confirm('Are you sure you want to delete this component tree?')) {
|
||||
onTreesChange((current) => current.filter((t) => t.id !== treeId))
|
||||
if (selectedTreeId === treeId) {
|
||||
setSelectedTreeId(trees.find(t => t.id !== treeId)?.id || null)
|
||||
}
|
||||
toast.success('Component tree deleted')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicateTree = (tree: ComponentTree) => {
|
||||
const duplicatedTree: ComponentTree = {
|
||||
...tree,
|
||||
id: `tree-${Date.now()}`,
|
||||
name: `${tree.name} (Copy)`,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onTreesChange((current) => [...current, duplicatedTree])
|
||||
setSelectedTreeId(duplicatedTree.id)
|
||||
toast.success('Component tree duplicated')
|
||||
}
|
||||
|
||||
const handleComponentsChange = (components: ComponentNode[]) => {
|
||||
if (!selectedTreeId) return
|
||||
|
||||
onTreesChange((current) =>
|
||||
current.map((tree) =>
|
||||
tree.id === selectedTreeId
|
||||
? { ...tree, rootNodes: components, updatedAt: Date.now() }
|
||||
: tree
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const openEditDialog = (tree: ComponentTree) => {
|
||||
setEditingTree(tree)
|
||||
setNewTreeName(tree.name)
|
||||
setNewTreeDescription(tree.description)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Tree size={20} weight="duotone" />
|
||||
Component Trees
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2">
|
||||
{trees.map((tree) => (
|
||||
<Card
|
||||
key={tree.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedTreeId === tree.id
|
||||
? 'ring-2 ring-primary bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedTreeId(tree.id)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm truncate">{tree.name}</CardTitle>
|
||||
{tree.description && (
|
||||
<CardDescription className="text-xs mt-1 line-clamp-2">
|
||||
{tree.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{tree.rootNodes.length} components
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(tree)}
|
||||
title="Edit tree"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDuplicateTree(tree)}
|
||||
title="Duplicate tree"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteTree(tree.id)}
|
||||
disabled={trees.length === 1}
|
||||
title="Delete tree"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{trees.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FolderOpen size={48} className="mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No component trees yet</p>
|
||||
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
Create First Tree
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{selectedTree ? (
|
||||
<ComponentTreeBuilder
|
||||
components={selectedTree.rootNodes}
|
||||
onComponentsChange={handleComponentsChange}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Tree size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
|
||||
<p>Select a component tree to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Component Tree</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new component tree to organize your UI components
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="tree-name">Tree Name</Label>
|
||||
<Input
|
||||
id="tree-name"
|
||||
value={newTreeName}
|
||||
onChange={(e) => setNewTreeName(e.target.value)}
|
||||
placeholder="e.g., Main App, Dashboard, Admin Panel"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="tree-description">Description</Label>
|
||||
<Textarea
|
||||
id="tree-description"
|
||||
value={newTreeDescription}
|
||||
onChange={(e) => setNewTreeDescription(e.target.value)}
|
||||
placeholder="Describe the purpose of this component tree"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateTree}>Create Tree</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Component Tree</DialogTitle>
|
||||
<DialogDescription>Update the component tree details</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-tree-name">Tree Name</Label>
|
||||
<Input
|
||||
id="edit-tree-name"
|
||||
value={newTreeName}
|
||||
onChange={(e) => setNewTreeName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-tree-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-tree-description"
|
||||
value={newTreeDescription}
|
||||
onChange={(e) => setNewTreeDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEditTree}>Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
770
src/components/LambdaDesigner.tsx
Normal file
770
src/components/LambdaDesigner.tsx
Normal file
@@ -0,0 +1,770 @@
|
||||
import { useState } from 'react'
|
||||
import { Lambda, LambdaTrigger } from '@/types/project'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
Code,
|
||||
Play,
|
||||
Copy,
|
||||
Pencil,
|
||||
Lightning,
|
||||
Clock,
|
||||
Globe,
|
||||
Queue,
|
||||
} from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import Editor from '@monaco-editor/react'
|
||||
|
||||
interface LambdaDesignerProps {
|
||||
lambdas: Lambda[]
|
||||
onLambdasChange: (updater: (lambdas: Lambda[]) => Lambda[]) => void
|
||||
}
|
||||
|
||||
const DEFAULT_JS_CODE = `// Lambda function
|
||||
export async function handler(event, context) {
|
||||
console.log('Event:', event);
|
||||
|
||||
// Your logic here
|
||||
const result = {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
message: 'Hello from Lambda!',
|
||||
input: event,
|
||||
}),
|
||||
};
|
||||
|
||||
return result;
|
||||
}`
|
||||
|
||||
const DEFAULT_TS_CODE = `// Lambda function
|
||||
interface Event {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Context {
|
||||
requestId: string;
|
||||
functionName: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface Response {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function handler(event: Event, context: Context): Promise<Response> {
|
||||
console.log('Event:', event);
|
||||
|
||||
// Your logic here
|
||||
const result: Response = {
|
||||
statusCode: 200,
|
||||
body: JSON.stringify({
|
||||
message: 'Hello from Lambda!',
|
||||
input: event,
|
||||
}),
|
||||
};
|
||||
|
||||
return result;
|
||||
}`
|
||||
|
||||
const DEFAULT_PYTHON_CODE = `# Lambda function
|
||||
def handler(event, context):
|
||||
print('Event:', event)
|
||||
|
||||
# Your logic here
|
||||
result = {
|
||||
'statusCode': 200,
|
||||
'body': json.dumps({
|
||||
'message': 'Hello from Lambda!',
|
||||
'input': event,
|
||||
})
|
||||
}
|
||||
|
||||
return result`
|
||||
|
||||
const TRIGGER_TYPES = [
|
||||
{ value: 'http', label: 'HTTP API', icon: Globe },
|
||||
{ value: 'schedule', label: 'Schedule', icon: Clock },
|
||||
{ value: 'event', label: 'Event', icon: Lightning },
|
||||
{ value: 'queue', label: 'Queue', icon: Queue },
|
||||
]
|
||||
|
||||
export function LambdaDesigner({ lambdas, onLambdasChange }: LambdaDesignerProps) {
|
||||
const [selectedLambdaId, setSelectedLambdaId] = useState<string | null>(lambdas[0]?.id || null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false)
|
||||
const [triggerDialogOpen, setTriggerDialogOpen] = useState(false)
|
||||
const [newLambdaName, setNewLambdaName] = useState('')
|
||||
const [newLambdaDescription, setNewLambdaDescription] = useState('')
|
||||
const [newLambdaLanguage, setNewLambdaLanguage] = useState<Lambda['language']>('typescript')
|
||||
const [editingLambda, setEditingLambda] = useState<Lambda | null>(null)
|
||||
const [newTriggerType, setNewTriggerType] = useState<LambdaTrigger['type']>('http')
|
||||
|
||||
const selectedLambda = lambdas.find((l) => l.id === selectedLambdaId)
|
||||
|
||||
const getDefaultCode = (language: Lambda['language']) => {
|
||||
switch (language) {
|
||||
case 'javascript':
|
||||
return DEFAULT_JS_CODE
|
||||
case 'typescript':
|
||||
return DEFAULT_TS_CODE
|
||||
case 'python':
|
||||
return DEFAULT_PYTHON_CODE
|
||||
default:
|
||||
return DEFAULT_JS_CODE
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateLambda = () => {
|
||||
if (!newLambdaName.trim()) {
|
||||
toast.error('Please enter a lambda name')
|
||||
return
|
||||
}
|
||||
|
||||
const newLambda: Lambda = {
|
||||
id: `lambda-${Date.now()}`,
|
||||
name: newLambdaName,
|
||||
description: newLambdaDescription,
|
||||
code: getDefaultCode(newLambdaLanguage),
|
||||
language: newLambdaLanguage,
|
||||
runtime: newLambdaLanguage === 'python' ? 'python3.11' : 'nodejs20.x',
|
||||
handler: 'index.handler',
|
||||
timeout: 30,
|
||||
memory: 256,
|
||||
environment: {},
|
||||
triggers: [],
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onLambdasChange((current) => [...current, newLambda])
|
||||
setSelectedLambdaId(newLambda.id)
|
||||
setNewLambdaName('')
|
||||
setNewLambdaDescription('')
|
||||
setNewLambdaLanguage('typescript')
|
||||
setCreateDialogOpen(false)
|
||||
toast.success('Lambda created')
|
||||
}
|
||||
|
||||
const handleEditLambda = () => {
|
||||
if (!editingLambda || !newLambdaName.trim()) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === editingLambda.id
|
||||
? {
|
||||
...lambda,
|
||||
name: newLambdaName,
|
||||
description: newLambdaDescription,
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
setEditDialogOpen(false)
|
||||
setEditingLambda(null)
|
||||
setNewLambdaName('')
|
||||
setNewLambdaDescription('')
|
||||
toast.success('Lambda updated')
|
||||
}
|
||||
|
||||
const handleDeleteLambda = (lambdaId: string) => {
|
||||
if (confirm('Are you sure you want to delete this lambda?')) {
|
||||
onLambdasChange((current) => current.filter((l) => l.id !== lambdaId))
|
||||
if (selectedLambdaId === lambdaId) {
|
||||
setSelectedLambdaId(lambdas.find((l) => l.id !== lambdaId)?.id || null)
|
||||
}
|
||||
toast.success('Lambda deleted')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicateLambda = (lambda: Lambda) => {
|
||||
const duplicatedLambda: Lambda = {
|
||||
...lambda,
|
||||
id: `lambda-${Date.now()}`,
|
||||
name: `${lambda.name} (Copy)`,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onLambdasChange((current) => [...current, duplicatedLambda])
|
||||
setSelectedLambdaId(duplicatedLambda.id)
|
||||
toast.success('Lambda duplicated')
|
||||
}
|
||||
|
||||
const handleUpdateCode = (code: string) => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === selectedLambdaId
|
||||
? { ...lambda, code, updatedAt: Date.now() }
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleUpdateConfig = (field: keyof Lambda, value: any) => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === selectedLambdaId
|
||||
? { ...lambda, [field]: value, updatedAt: Date.now() }
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const handleAddTrigger = () => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
const newTrigger: LambdaTrigger = {
|
||||
id: `trigger-${Date.now()}`,
|
||||
type: newTriggerType,
|
||||
config: {},
|
||||
}
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === selectedLambdaId
|
||||
? {
|
||||
...lambda,
|
||||
triggers: [...lambda.triggers, newTrigger],
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
|
||||
setTriggerDialogOpen(false)
|
||||
toast.success('Trigger added')
|
||||
}
|
||||
|
||||
const handleDeleteTrigger = (triggerId: string) => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === selectedLambdaId
|
||||
? {
|
||||
...lambda,
|
||||
triggers: lambda.triggers.filter((t) => t.id !== triggerId),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
|
||||
toast.success('Trigger deleted')
|
||||
}
|
||||
|
||||
const handleAddEnvironmentVariable = () => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
const key = prompt('Enter environment variable name:')
|
||||
if (!key) return
|
||||
|
||||
const value = prompt('Enter environment variable value:')
|
||||
if (value === null) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) =>
|
||||
lambda.id === selectedLambdaId
|
||||
? {
|
||||
...lambda,
|
||||
environment: { ...lambda.environment, [key]: value },
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: lambda
|
||||
)
|
||||
)
|
||||
|
||||
toast.success('Environment variable added')
|
||||
}
|
||||
|
||||
const handleDeleteEnvironmentVariable = (key: string) => {
|
||||
if (!selectedLambdaId) return
|
||||
|
||||
onLambdasChange((current) =>
|
||||
current.map((lambda) => {
|
||||
if (lambda.id !== selectedLambdaId) return lambda
|
||||
const { [key]: _, ...rest } = lambda.environment
|
||||
return { ...lambda, environment: rest, updatedAt: Date.now() }
|
||||
})
|
||||
)
|
||||
|
||||
toast.success('Environment variable deleted')
|
||||
}
|
||||
|
||||
const openEditDialog = (lambda: Lambda) => {
|
||||
setEditingLambda(lambda)
|
||||
setNewLambdaName(lambda.name)
|
||||
setNewLambdaDescription(lambda.description)
|
||||
setEditDialogOpen(true)
|
||||
}
|
||||
|
||||
const getEditorLanguage = (language: Lambda['language']) => {
|
||||
return language === 'typescript' || language === 'javascript' ? 'typescript' : 'python'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<Code size={20} weight="duotone" />
|
||||
Lambda Functions
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2">
|
||||
{lambdas.map((lambda) => (
|
||||
<Card
|
||||
key={lambda.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedLambdaId === lambda.id
|
||||
? 'ring-2 ring-primary bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedLambdaId(lambda.id)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-sm truncate">{lambda.name}</CardTitle>
|
||||
{lambda.description && (
|
||||
<CardDescription className="text-xs mt-1 line-clamp-2">
|
||||
{lambda.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2 flex-wrap">
|
||||
<Badge variant="outline" className="text-xs capitalize">
|
||||
{lambda.language}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{lambda.triggers.length} triggers
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => openEditDialog(lambda)}
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDuplicateLambda(lambda)}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteLambda(lambda.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{lambdas.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<Code size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
|
||||
<p className="text-sm">No lambdas yet</p>
|
||||
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
Create First Lambda
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedLambda ? (
|
||||
<>
|
||||
<div className="border-b border-border bg-card p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-semibold">{selectedLambda.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedLambda.description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled>
|
||||
<Play size={16} className="mr-2" />
|
||||
Test
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Badge variant="outline" className="capitalize">{selectedLambda.language}</Badge>
|
||||
<Badge variant="outline">{selectedLambda.runtime}</Badge>
|
||||
<Badge variant="outline">{selectedLambda.timeout}s timeout</Badge>
|
||||
<Badge variant="outline">{selectedLambda.memory}MB memory</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="code" className="flex-1 flex flex-col">
|
||||
<div className="border-b border-border bg-card px-4">
|
||||
<TabsList className="bg-transparent">
|
||||
<TabsTrigger value="code">Code</TabsTrigger>
|
||||
<TabsTrigger value="config">Configuration</TabsTrigger>
|
||||
<TabsTrigger value="triggers">Triggers</TabsTrigger>
|
||||
<TabsTrigger value="environment">Environment</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="code" className="flex-1 m-0">
|
||||
<Editor
|
||||
height="100%"
|
||||
language={getEditorLanguage(selectedLambda.language)}
|
||||
value={selectedLambda.code}
|
||||
onChange={(value) => handleUpdateCode(value || '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
}}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="m-0 p-4">
|
||||
<ScrollArea className="h-full">
|
||||
<div className="space-y-4 max-w-2xl">
|
||||
<div>
|
||||
<Label htmlFor="runtime">Runtime</Label>
|
||||
<Select
|
||||
value={selectedLambda.runtime}
|
||||
onValueChange={(value) => handleUpdateConfig('runtime', value)}
|
||||
>
|
||||
<SelectTrigger id="runtime">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="nodejs20.x">Node.js 20.x</SelectItem>
|
||||
<SelectItem value="nodejs18.x">Node.js 18.x</SelectItem>
|
||||
<SelectItem value="python3.11">Python 3.11</SelectItem>
|
||||
<SelectItem value="python3.10">Python 3.10</SelectItem>
|
||||
<SelectItem value="python3.9">Python 3.9</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="handler">Handler</Label>
|
||||
<Input
|
||||
id="handler"
|
||||
value={selectedLambda.handler}
|
||||
onChange={(e) => handleUpdateConfig('handler', e.target.value)}
|
||||
placeholder="index.handler"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="timeout">Timeout (seconds)</Label>
|
||||
<Input
|
||||
id="timeout"
|
||||
type="number"
|
||||
value={selectedLambda.timeout}
|
||||
onChange={(e) => handleUpdateConfig('timeout', parseInt(e.target.value))}
|
||||
min="1"
|
||||
max="900"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="memory">Memory (MB)</Label>
|
||||
<Select
|
||||
value={selectedLambda.memory.toString()}
|
||||
onValueChange={(value) => handleUpdateConfig('memory', parseInt(value))}
|
||||
>
|
||||
<SelectTrigger id="memory">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="128">128 MB</SelectItem>
|
||||
<SelectItem value="256">256 MB</SelectItem>
|
||||
<SelectItem value="512">512 MB</SelectItem>
|
||||
<SelectItem value="1024">1024 MB</SelectItem>
|
||||
<SelectItem value="2048">2048 MB</SelectItem>
|
||||
<SelectItem value="4096">4096 MB</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="triggers" className="m-0 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold">Lambda Triggers</h4>
|
||||
<Button size="sm" onClick={() => setTriggerDialogOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Trigger
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-3rem)]">
|
||||
<div className="space-y-2">
|
||||
{selectedLambda.triggers.map((trigger) => {
|
||||
const triggerType = TRIGGER_TYPES.find((t) => t.value === trigger.type)
|
||||
const Icon = triggerType?.icon || Lightning
|
||||
|
||||
return (
|
||||
<Card key={trigger.id}>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={20} weight="duotone" />
|
||||
<CardTitle className="text-sm capitalize">
|
||||
{trigger.type}
|
||||
</CardTitle>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteTrigger(trigger.id)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{selectedLambda.triggers.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Lightning size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
|
||||
<p className="text-sm">No triggers configured</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="environment" className="m-0 p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold">Environment Variables</h4>
|
||||
<Button size="sm" onClick={handleAddEnvironmentVariable}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="h-[calc(100%-3rem)]">
|
||||
<div className="space-y-2">
|
||||
{Object.entries(selectedLambda.environment).map(([key, value]) => (
|
||||
<Card key={key}>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<code className="text-sm font-semibold">{key}</code>
|
||||
<p className="text-xs text-muted-foreground truncate mt-1">
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteEnvironmentVariable(key)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{Object.keys(selectedLambda.environment).length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Code size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
|
||||
<p className="text-sm">No environment variables</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Code size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
|
||||
<p>Select a lambda to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Lambda Function</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new serverless lambda function
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="lambda-name">Function Name</Label>
|
||||
<Input
|
||||
id="lambda-name"
|
||||
value={newLambdaName}
|
||||
onChange={(e) => setNewLambdaName(e.target.value)}
|
||||
placeholder="e.g., processUserData"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lambda-description">Description</Label>
|
||||
<Textarea
|
||||
id="lambda-description"
|
||||
value={newLambdaDescription}
|
||||
onChange={(e) => setNewLambdaDescription(e.target.value)}
|
||||
placeholder="Describe what this lambda does"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lambda-language">Language</Label>
|
||||
<Select
|
||||
value={newLambdaLanguage}
|
||||
onValueChange={(value: Lambda['language']) => setNewLambdaLanguage(value)}
|
||||
>
|
||||
<SelectTrigger id="lambda-language">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="typescript">TypeScript</SelectItem>
|
||||
<SelectItem value="javascript">JavaScript</SelectItem>
|
||||
<SelectItem value="python">Python</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateLambda}>Create Lambda</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Lambda Function</DialogTitle>
|
||||
<DialogDescription>Update the lambda function details</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="edit-lambda-name">Function Name</Label>
|
||||
<Input
|
||||
id="edit-lambda-name"
|
||||
value={newLambdaName}
|
||||
onChange={(e) => setNewLambdaName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="edit-lambda-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-lambda-description"
|
||||
value={newLambdaDescription}
|
||||
onChange={(e) => setNewLambdaDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleEditLambda}>Save Changes</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={triggerDialogOpen} onOpenChange={setTriggerDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Trigger</DialogTitle>
|
||||
<DialogDescription>Configure a trigger for this lambda function</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="trigger-type">Trigger Type</Label>
|
||||
<Select
|
||||
value={newTriggerType}
|
||||
onValueChange={(value: LambdaTrigger['type']) => setNewTriggerType(value)}
|
||||
>
|
||||
<SelectTrigger id="trigger-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRIGGER_TYPES.map((type) => {
|
||||
const Icon = type.icon
|
||||
return (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} />
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setTriggerDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddTrigger}>Add Trigger</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
866
src/components/WorkflowDesigner.tsx
Normal file
866
src/components/WorkflowDesigner.tsx
Normal file
@@ -0,0 +1,866 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { Workflow, WorkflowNode, WorkflowConnection } from '@/types/project'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Plus,
|
||||
Trash,
|
||||
FlowArrow,
|
||||
Play,
|
||||
Pause,
|
||||
Sparkle,
|
||||
Code,
|
||||
Database,
|
||||
Plugs,
|
||||
GitBranch,
|
||||
Lightning,
|
||||
Copy,
|
||||
Pencil,
|
||||
Link,
|
||||
} from '@phosphor-icons/react'
|
||||
import { toast } from 'sonner'
|
||||
import Editor from '@monaco-editor/react'
|
||||
|
||||
interface WorkflowDesignerProps {
|
||||
workflows: Workflow[]
|
||||
onWorkflowsChange: (updater: (workflows: Workflow[]) => Workflow[]) => void
|
||||
}
|
||||
|
||||
const NODE_TYPES = [
|
||||
{ value: 'trigger', label: 'Trigger', icon: Lightning, color: 'text-yellow-500' },
|
||||
{ value: 'action', label: 'Action', icon: Play, color: 'text-blue-500' },
|
||||
{ value: 'condition', label: 'Condition', icon: GitBranch, color: 'text-purple-500' },
|
||||
{ value: 'transform', label: 'Transform', icon: Code, color: 'text-green-500' },
|
||||
{ value: 'lambda', label: 'Lambda', icon: Code, color: 'text-orange-500' },
|
||||
{ value: 'api', label: 'API', icon: Plugs, color: 'text-cyan-500' },
|
||||
{ value: 'database', label: 'Database', icon: Database, color: 'text-pink-500' },
|
||||
]
|
||||
|
||||
export function WorkflowDesigner({ workflows, onWorkflowsChange }: WorkflowDesignerProps) {
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(
|
||||
workflows[0]?.id || null
|
||||
)
|
||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false)
|
||||
const [createNodeDialogOpen, setCreateNodeDialogOpen] = useState(false)
|
||||
const [newWorkflowName, setNewWorkflowName] = useState('')
|
||||
const [newWorkflowDescription, setNewWorkflowDescription] = useState('')
|
||||
const [newNodeType, setNewNodeType] = useState<WorkflowNode['type']>('action')
|
||||
const [newNodeName, setNewNodeName] = useState('')
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 })
|
||||
const [connectingFrom, setConnectingFrom] = useState<string | null>(null)
|
||||
const canvasRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const selectedWorkflow = workflows.find((w) => w.id === selectedWorkflowId)
|
||||
const selectedNode = selectedWorkflow?.nodes.find((n) => n.id === selectedNodeId)
|
||||
|
||||
const handleCreateWorkflow = () => {
|
||||
if (!newWorkflowName.trim()) {
|
||||
toast.error('Please enter a workflow name')
|
||||
return
|
||||
}
|
||||
|
||||
const newWorkflow: Workflow = {
|
||||
id: `workflow-${Date.now()}`,
|
||||
name: newWorkflowName,
|
||||
description: newWorkflowDescription,
|
||||
nodes: [],
|
||||
connections: [],
|
||||
isActive: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onWorkflowsChange((current) => [...current, newWorkflow])
|
||||
setSelectedWorkflowId(newWorkflow.id)
|
||||
setNewWorkflowName('')
|
||||
setNewWorkflowDescription('')
|
||||
setCreateDialogOpen(false)
|
||||
toast.success('Workflow created')
|
||||
}
|
||||
|
||||
const handleDeleteWorkflow = (workflowId: string) => {
|
||||
if (confirm('Are you sure you want to delete this workflow?')) {
|
||||
onWorkflowsChange((current) => current.filter((w) => w.id !== workflowId))
|
||||
if (selectedWorkflowId === workflowId) {
|
||||
setSelectedWorkflowId(workflows.find((w) => w.id !== workflowId)?.id || null)
|
||||
}
|
||||
toast.success('Workflow deleted')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDuplicateWorkflow = (workflow: Workflow) => {
|
||||
const duplicatedWorkflow: Workflow = {
|
||||
...workflow,
|
||||
id: `workflow-${Date.now()}`,
|
||||
name: `${workflow.name} (Copy)`,
|
||||
nodes: workflow.nodes.map((node) => ({ ...node, id: `node-${Date.now()}-${node.id}` })),
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
onWorkflowsChange((current) => [...current, duplicatedWorkflow])
|
||||
setSelectedWorkflowId(duplicatedWorkflow.id)
|
||||
toast.success('Workflow duplicated')
|
||||
}
|
||||
|
||||
const handleToggleActive = (workflowId: string) => {
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === workflowId ? { ...w, isActive: !w.isActive, updatedAt: Date.now() } : w
|
||||
)
|
||||
)
|
||||
toast.success(selectedWorkflow?.isActive ? 'Workflow deactivated' : 'Workflow activated')
|
||||
}
|
||||
|
||||
const handleAddNode = () => {
|
||||
if (!selectedWorkflowId || !newNodeName.trim()) {
|
||||
toast.error('Please enter a node name')
|
||||
return
|
||||
}
|
||||
|
||||
const newNode: WorkflowNode = {
|
||||
id: `node-${Date.now()}`,
|
||||
type: newNodeType,
|
||||
name: newNodeName,
|
||||
position: { x: 100, y: 100 },
|
||||
data: {},
|
||||
config: {},
|
||||
}
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? { ...w, nodes: [...w.nodes, newNode], updatedAt: Date.now() }
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
setNewNodeName('')
|
||||
setCreateNodeDialogOpen(false)
|
||||
toast.success('Node added')
|
||||
}
|
||||
|
||||
const handleDeleteNode = (nodeId: string) => {
|
||||
if (!selectedWorkflowId) return
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? {
|
||||
...w,
|
||||
nodes: w.nodes.filter((n) => n.id !== nodeId),
|
||||
connections: w.connections.filter(
|
||||
(c) => c.source !== nodeId && c.target !== nodeId
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
if (selectedNodeId === nodeId) {
|
||||
setSelectedNodeId(null)
|
||||
}
|
||||
toast.success('Node deleted')
|
||||
}
|
||||
|
||||
const handleNodeMouseDown = (nodeId: string, e: React.MouseEvent) => {
|
||||
if (!selectedWorkflow) return
|
||||
const node = selectedWorkflow.nodes.find((n) => n.id === nodeId)
|
||||
if (!node) return
|
||||
|
||||
setSelectedNodeId(nodeId)
|
||||
setIsDragging(true)
|
||||
setDragOffset({
|
||||
x: e.clientX - node.position.x,
|
||||
y: e.clientY - node.position.y,
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (!isDragging || !selectedNodeId || !selectedWorkflowId) return
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? {
|
||||
...w,
|
||||
nodes: w.nodes.map((n) =>
|
||||
n.id === selectedNodeId
|
||||
? {
|
||||
...n,
|
||||
position: {
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y,
|
||||
},
|
||||
}
|
||||
: n
|
||||
),
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
},
|
||||
[isDragging, selectedNodeId, selectedWorkflowId, dragOffset, onWorkflowsChange]
|
||||
)
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
setIsDragging(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
window.addEventListener('mousemove', handleMouseMove)
|
||||
window.addEventListener('mouseup', handleMouseUp)
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', handleMouseMove)
|
||||
window.removeEventListener('mouseup', handleMouseUp)
|
||||
}
|
||||
}
|
||||
}, [isDragging, handleMouseMove, handleMouseUp])
|
||||
|
||||
const handleStartConnection = (nodeId: string) => {
|
||||
setConnectingFrom(nodeId)
|
||||
}
|
||||
|
||||
const handleEndConnection = (targetNodeId: string) => {
|
||||
if (!connectingFrom || !selectedWorkflowId || connectingFrom === targetNodeId) {
|
||||
setConnectingFrom(null)
|
||||
return
|
||||
}
|
||||
|
||||
const newConnection: WorkflowConnection = {
|
||||
id: `conn-${Date.now()}`,
|
||||
source: connectingFrom,
|
||||
target: targetNodeId,
|
||||
}
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? { ...w, connections: [...w.connections, newConnection], updatedAt: Date.now() }
|
||||
: w
|
||||
)
|
||||
)
|
||||
|
||||
setConnectingFrom(null)
|
||||
toast.success('Nodes connected')
|
||||
}
|
||||
|
||||
const handleDeleteConnection = (connectionId: string) => {
|
||||
if (!selectedWorkflowId) return
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? {
|
||||
...w,
|
||||
connections: w.connections.filter((c) => c.id !== connectionId),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
toast.success('Connection deleted')
|
||||
}
|
||||
|
||||
const handleUpdateNodeConfig = (field: string, value: any) => {
|
||||
if (!selectedNodeId || !selectedWorkflowId) return
|
||||
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? {
|
||||
...w,
|
||||
nodes: w.nodes.map((n) =>
|
||||
n.id === selectedNodeId
|
||||
? { ...n, config: { ...n.config, [field]: value } }
|
||||
: n
|
||||
),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const getNodeIcon = (type: WorkflowNode['type']) => {
|
||||
const nodeType = NODE_TYPES.find((t) => t.value === type)
|
||||
return nodeType?.icon || Code
|
||||
}
|
||||
|
||||
const getNodeColor = (type: WorkflowNode['type']) => {
|
||||
const nodeType = NODE_TYPES.find((t) => t.value === type)
|
||||
return nodeType?.color || 'text-gray-500'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex">
|
||||
<div className="w-80 border-r border-border bg-card p-4 flex flex-col gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
||||
<FlowArrow size={20} weight="duotone" />
|
||||
Workflows
|
||||
</h2>
|
||||
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="space-y-2">
|
||||
{workflows.map((workflow) => (
|
||||
<Card
|
||||
key={workflow.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
selectedWorkflowId === workflow.id
|
||||
? 'ring-2 ring-primary bg-accent'
|
||||
: 'hover:bg-accent/50'
|
||||
}`}
|
||||
onClick={() => setSelectedWorkflowId(workflow.id)}
|
||||
>
|
||||
<CardHeader className="p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<CardTitle className="text-sm truncate">{workflow.name}</CardTitle>
|
||||
<Badge
|
||||
variant={workflow.isActive ? 'default' : 'outline'}
|
||||
className="text-xs"
|
||||
>
|
||||
{workflow.isActive ? 'Active' : 'Inactive'}
|
||||
</Badge>
|
||||
</div>
|
||||
{workflow.description && (
|
||||
<CardDescription className="text-xs mt-1 line-clamp-2">
|
||||
{workflow.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
<div className="flex gap-2 mt-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{workflow.nodes.length} nodes
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{workflow.connections.length} connections
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-2" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleToggleActive(workflow.id)}
|
||||
title={workflow.isActive ? 'Deactivate' : 'Activate'}
|
||||
>
|
||||
{workflow.isActive ? <Pause size={14} /> : <Play size={14} />}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDuplicateWorkflow(workflow)}
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => handleDeleteWorkflow(workflow.id)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{workflows.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FlowArrow size={48} className="mx-auto mb-2 opacity-50" weight="duotone" />
|
||||
<p className="text-sm">No workflows yet</p>
|
||||
<Button size="sm" className="mt-2" onClick={() => setCreateDialogOpen(true)}>
|
||||
Create First Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex">
|
||||
<div className="flex-1 flex flex-col">
|
||||
{selectedWorkflow ? (
|
||||
<>
|
||||
<div className="border-b border-border bg-card p-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="font-semibold">{selectedWorkflow.name}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{selectedWorkflow.description || 'No description'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => setCreateNodeDialogOpen(true)}>
|
||||
<Plus size={16} className="mr-2" />
|
||||
Add Node
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={selectedWorkflow.isActive ? 'default' : 'outline'}
|
||||
onClick={() => handleToggleActive(selectedWorkflow.id)}
|
||||
>
|
||||
{selectedWorkflow.isActive ? (
|
||||
<>
|
||||
<Pause size={16} className="mr-2" />
|
||||
Deactivate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play size={16} className="mr-2" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="flex-1 bg-muted/20 relative overflow-auto"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(circle, var(--color-border) 1px, transparent 1px)`,
|
||||
backgroundSize: '20px 20px',
|
||||
}}
|
||||
>
|
||||
<svg className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
|
||||
{selectedWorkflow.connections.map((conn) => {
|
||||
const sourceNode = selectedWorkflow.nodes.find((n) => n.id === conn.source)
|
||||
const targetNode = selectedWorkflow.nodes.find((n) => n.id === conn.target)
|
||||
if (!sourceNode || !targetNode) return null
|
||||
|
||||
const x1 = sourceNode.position.x + 120
|
||||
const y1 = sourceNode.position.y + 40
|
||||
const x2 = targetNode.position.x
|
||||
const y2 = targetNode.position.y + 40
|
||||
|
||||
return (
|
||||
<g key={conn.id}>
|
||||
<path
|
||||
d={`M ${x1} ${y1} C ${x1 + 50} ${y1}, ${x2 - 50} ${y2}, ${x2} ${y2}`}
|
||||
stroke="hsl(var(--primary))"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
/>
|
||||
<circle
|
||||
cx={(x1 + x2) / 2}
|
||||
cy={(y1 + y2) / 2}
|
||||
r="8"
|
||||
fill="hsl(var(--destructive))"
|
||||
className="cursor-pointer pointer-events-auto"
|
||||
onClick={() => handleDeleteConnection(conn.id)}
|
||||
>
|
||||
<title>Click to delete connection</title>
|
||||
</circle>
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="10"
|
||||
refX="9"
|
||||
refY="3"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3, 0 6" fill="hsl(var(--primary))" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{selectedWorkflow.nodes.map((node) => {
|
||||
const Icon = getNodeIcon(node.type)
|
||||
const color = getNodeColor(node.type)
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={node.id}
|
||||
className={`absolute w-60 cursor-move ${
|
||||
selectedNodeId === node.id ? 'ring-2 ring-primary' : ''
|
||||
}`}
|
||||
style={{
|
||||
left: node.position.x,
|
||||
top: node.position.y,
|
||||
zIndex: 10,
|
||||
}}
|
||||
onMouseDown={(e) => handleNodeMouseDown(node.id, e)}
|
||||
>
|
||||
<CardHeader className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={20} className={color} weight="duotone" />
|
||||
<CardTitle className="text-sm truncate flex-1">{node.name}</CardTitle>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteNode(node.id)
|
||||
}}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
<CardDescription className="text-xs capitalize">
|
||||
{node.type}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="p-3 pt-0 flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleStartConnection(node.id)
|
||||
}}
|
||||
className="flex-1"
|
||||
disabled={connectingFrom !== null}
|
||||
>
|
||||
<Link size={14} className="mr-1" />
|
||||
Connect
|
||||
</Button>
|
||||
{connectingFrom && connectingFrom !== node.id && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleEndConnection(node.id)
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Link size={14} className="mr-1" />
|
||||
Link Here
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
|
||||
{selectedWorkflow.nodes.length === 0 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FlowArrow size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
|
||||
<p>No nodes yet</p>
|
||||
<Button
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setCreateNodeDialogOpen(true)}
|
||||
>
|
||||
Add First Node
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<FlowArrow size={64} className="mx-auto mb-4 opacity-50" weight="duotone" />
|
||||
<p>Select a workflow to edit</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedNode && selectedWorkflow && (
|
||||
<div className="w-96 border-l border-border bg-card p-4 overflow-auto">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Node Configuration</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{selectedNode.name}</p>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="general">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="general">General</TabsTrigger>
|
||||
<TabsTrigger value="config">Config</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="general" className="space-y-4">
|
||||
<div>
|
||||
<Label>Node Type</Label>
|
||||
<Input value={selectedNode.type} disabled className="capitalize" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Node Name</Label>
|
||||
<Input
|
||||
value={selectedNode.name}
|
||||
onChange={(e) => {
|
||||
onWorkflowsChange((current) =>
|
||||
current.map((w) =>
|
||||
w.id === selectedWorkflowId
|
||||
? {
|
||||
...w,
|
||||
nodes: w.nodes.map((n) =>
|
||||
n.id === selectedNode.id ? { ...n, name: e.target.value } : n
|
||||
),
|
||||
}
|
||||
: w
|
||||
)
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="config" className="space-y-4">
|
||||
{selectedNode.type === 'lambda' && (
|
||||
<div>
|
||||
<Label>Lambda Code</Label>
|
||||
<div className="border border-border rounded-md overflow-hidden mt-2">
|
||||
<Editor
|
||||
height="300px"
|
||||
defaultLanguage="javascript"
|
||||
value={selectedNode.config?.lambdaCode || '// Write your lambda code here\n'}
|
||||
onChange={(value) => handleUpdateNodeConfig('lambdaCode', value || '')}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'api' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>HTTP Method</Label>
|
||||
<Select
|
||||
value={selectedNode.config?.httpMethod || 'GET'}
|
||||
onValueChange={(value) => handleUpdateNodeConfig('httpMethod', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">GET</SelectItem>
|
||||
<SelectItem value="POST">POST</SelectItem>
|
||||
<SelectItem value="PUT">PUT</SelectItem>
|
||||
<SelectItem value="DELETE">DELETE</SelectItem>
|
||||
<SelectItem value="PATCH">PATCH</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>API Endpoint</Label>
|
||||
<Input
|
||||
value={selectedNode.config?.apiEndpoint || ''}
|
||||
onChange={(e) => handleUpdateNodeConfig('apiEndpoint', e.target.value)}
|
||||
placeholder="https://api.example.com/endpoint"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'condition' && (
|
||||
<div>
|
||||
<Label>Condition Expression</Label>
|
||||
<Textarea
|
||||
value={selectedNode.config?.condition || ''}
|
||||
onChange={(e) => handleUpdateNodeConfig('condition', e.target.value)}
|
||||
placeholder="e.g., data.status === 'active'"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'transform' && (
|
||||
<div>
|
||||
<Label>Transform Script</Label>
|
||||
<div className="border border-border rounded-md overflow-hidden mt-2">
|
||||
<Editor
|
||||
height="300px"
|
||||
defaultLanguage="javascript"
|
||||
value={
|
||||
selectedNode.config?.transformScript ||
|
||||
'// Transform the data\nreturn data;'
|
||||
}
|
||||
onChange={(value) =>
|
||||
handleUpdateNodeConfig('transformScript', value || '')
|
||||
}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
minimap: { enabled: false },
|
||||
fontSize: 12,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'database' && (
|
||||
<div>
|
||||
<Label>Database Query</Label>
|
||||
<Textarea
|
||||
value={selectedNode.config?.databaseQuery || ''}
|
||||
onChange={(e) => handleUpdateNodeConfig('databaseQuery', e.target.value)}
|
||||
placeholder="SELECT * FROM users WHERE id = $1"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedNode.type === 'trigger' && (
|
||||
<>
|
||||
<div>
|
||||
<Label>Trigger Type</Label>
|
||||
<Select
|
||||
value={selectedNode.config?.triggerType || 'manual'}
|
||||
onValueChange={(value) => handleUpdateNodeConfig('triggerType', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="manual">Manual</SelectItem>
|
||||
<SelectItem value="schedule">Schedule</SelectItem>
|
||||
<SelectItem value="webhook">Webhook</SelectItem>
|
||||
<SelectItem value="event">Event</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{selectedNode.config?.triggerType === 'schedule' && (
|
||||
<div>
|
||||
<Label>Schedule Expression (Cron)</Label>
|
||||
<Input
|
||||
value={selectedNode.config?.scheduleExpression || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateNodeConfig('scheduleExpression', e.target.value)
|
||||
}
|
||||
placeholder="0 0 * * *"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Workflow</DialogTitle>
|
||||
<DialogDescription>Create a new workflow to automate your processes</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="workflow-name">Workflow Name</Label>
|
||||
<Input
|
||||
id="workflow-name"
|
||||
value={newWorkflowName}
|
||||
onChange={(e) => setNewWorkflowName(e.target.value)}
|
||||
placeholder="e.g., User Registration Flow"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="workflow-description">Description</Label>
|
||||
<Textarea
|
||||
id="workflow-description"
|
||||
value={newWorkflowDescription}
|
||||
onChange={(e) => setNewWorkflowDescription(e.target.value)}
|
||||
placeholder="Describe what this workflow does"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreateWorkflow}>Create Workflow</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={createNodeDialogOpen} onOpenChange={setCreateNodeDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Node</DialogTitle>
|
||||
<DialogDescription>Add a new node to your workflow</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="node-type">Node Type</Label>
|
||||
<Select
|
||||
value={newNodeType}
|
||||
onValueChange={(value: WorkflowNode['type']) => setNewNodeType(value)}
|
||||
>
|
||||
<SelectTrigger id="node-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NODE_TYPES.map((type) => {
|
||||
const Icon = type.icon
|
||||
return (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon size={16} className={type.color} />
|
||||
{type.label}
|
||||
</div>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="node-name">Node Name</Label>
|
||||
<Input
|
||||
id="node-name"
|
||||
value={newNodeName}
|
||||
onChange={(e) => setNewNodeName(e.target.value)}
|
||||
placeholder="e.g., Send Welcome Email"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateNodeDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleAddNode}>Add Node</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -186,11 +186,87 @@ export interface NpmSettings {
|
||||
packageManager: 'npm' | 'yarn' | 'pnpm'
|
||||
}
|
||||
|
||||
export interface ComponentTree {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
rootNodes: ComponentNode[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface WorkflowNode {
|
||||
id: string
|
||||
type: 'trigger' | 'action' | 'condition' | 'transform' | 'lambda' | 'api' | 'database'
|
||||
name: string
|
||||
position: { x: number; y: number }
|
||||
data: Record<string, any>
|
||||
config?: WorkflowNodeConfig
|
||||
}
|
||||
|
||||
export interface WorkflowNodeConfig {
|
||||
lambdaCode?: string
|
||||
apiEndpoint?: string
|
||||
httpMethod?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
headers?: Record<string, string>
|
||||
queryParams?: Record<string, string>
|
||||
condition?: string
|
||||
transformScript?: string
|
||||
databaseQuery?: string
|
||||
triggerType?: 'manual' | 'schedule' | 'webhook' | 'event'
|
||||
scheduleExpression?: string
|
||||
}
|
||||
|
||||
export interface WorkflowConnection {
|
||||
id: string
|
||||
source: string
|
||||
target: string
|
||||
sourceHandle?: string
|
||||
targetHandle?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface Workflow {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
nodes: WorkflowNode[]
|
||||
connections: WorkflowConnection[]
|
||||
isActive: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface Lambda {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
code: string
|
||||
language: 'javascript' | 'typescript' | 'python'
|
||||
runtime: string
|
||||
handler: string
|
||||
timeout: number
|
||||
memory: number
|
||||
environment: Record<string, string>
|
||||
triggers: LambdaTrigger[]
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface LambdaTrigger {
|
||||
id: string
|
||||
type: 'http' | 'schedule' | 'event' | 'queue'
|
||||
config: Record<string, any>
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
name: string
|
||||
files: ProjectFile[]
|
||||
models: PrismaModel[]
|
||||
components: ComponentNode[]
|
||||
componentTrees: ComponentTree[]
|
||||
workflows: Workflow[]
|
||||
lambdas: Lambda[]
|
||||
theme: ThemeConfig
|
||||
playwrightTests?: PlaywrightTest[]
|
||||
storybookStories?: StorybookStory[]
|
||||
|
||||
Reference in New Issue
Block a user