@dilipod/ui 0.4.7 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/activity-timeline.d.ts +44 -0
- package/dist/components/activity-timeline.d.ts.map +1 -0
- package/dist/components/workflow-flow.d.ts +27 -0
- package/dist/components/workflow-flow.d.ts.map +1 -0
- package/dist/components/workflow-viewer.d.ts +190 -0
- package/dist/components/workflow-viewer.d.ts.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1265 -7
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1265 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/activity-timeline.tsx +208 -0
- package/src/components/workflow-flow.tsx +277 -0
- package/src/components/workflow-viewer.tsx +1348 -0
- package/src/index.ts +16 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dilipod/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
4
4
|
"description": "Dilipod Design System - Shared UI components and styles",
|
|
5
5
|
"author": "Dilipod <hello@dilipod.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -77,6 +77,7 @@
|
|
|
77
77
|
"@radix-ui/react-tabs": "^1.1.13",
|
|
78
78
|
"@radix-ui/react-toast": "^1.2.15",
|
|
79
79
|
"@radix-ui/react-tooltip": "^1.2.8",
|
|
80
|
+
"@xyflow/react": "^12.10.0",
|
|
80
81
|
"class-variance-authority": "^0.7.1",
|
|
81
82
|
"clsx": "^2.1.1",
|
|
82
83
|
"tailwind-merge": "^3.3.0",
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, ReactNode } from 'react'
|
|
4
|
+
import { Button } from './button'
|
|
5
|
+
import { Textarea } from './textarea'
|
|
6
|
+
import {
|
|
7
|
+
CircleNotch,
|
|
8
|
+
PaperPlaneTilt,
|
|
9
|
+
CaretDown,
|
|
10
|
+
CaretUp
|
|
11
|
+
} from '@phosphor-icons/react'
|
|
12
|
+
|
|
13
|
+
// ============================================
|
|
14
|
+
// Types
|
|
15
|
+
// ============================================
|
|
16
|
+
|
|
17
|
+
export interface Activity {
|
|
18
|
+
/** Unique identifier for the activity */
|
|
19
|
+
id: string
|
|
20
|
+
/** Type of activity (e.g., 'note', 'status_change', 'workflow_update', 'assignment') */
|
|
21
|
+
activity_type: string
|
|
22
|
+
/** Main content of the activity */
|
|
23
|
+
content: string
|
|
24
|
+
/** User who created the activity (display name or email) */
|
|
25
|
+
user: string
|
|
26
|
+
/** When the activity was created */
|
|
27
|
+
created_at: string | Date
|
|
28
|
+
/** Additional metadata */
|
|
29
|
+
metadata?: Record<string, unknown>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ActivityTimelineProps {
|
|
33
|
+
/** List of activities to display */
|
|
34
|
+
activities: Activity[]
|
|
35
|
+
/** Whether the activities are loading */
|
|
36
|
+
loading?: boolean
|
|
37
|
+
/** Labels for different activity types (type -> label) */
|
|
38
|
+
activityLabels?: Record<string, string>
|
|
39
|
+
/** Number of activities to show before collapsing (default: 3) */
|
|
40
|
+
collapsedCount?: number
|
|
41
|
+
/** Whether to show the note input form */
|
|
42
|
+
showNoteInput?: boolean
|
|
43
|
+
/** Placeholder for the note input */
|
|
44
|
+
notePlaceholder?: string
|
|
45
|
+
/** Callback when a new note is submitted */
|
|
46
|
+
onAddNote?: (note: string) => Promise<void>
|
|
47
|
+
/** Whether the component is currently submitting a note */
|
|
48
|
+
submitting?: boolean
|
|
49
|
+
/** Custom date formatter function */
|
|
50
|
+
formatDate?: (date: Date | string) => string
|
|
51
|
+
/** Custom loading component */
|
|
52
|
+
loadingComponent?: ReactNode
|
|
53
|
+
/** Custom empty state message */
|
|
54
|
+
emptyMessage?: string
|
|
55
|
+
/** Additional CSS class */
|
|
56
|
+
className?: string
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ============================================
|
|
60
|
+
// Default Date Formatter
|
|
61
|
+
// ============================================
|
|
62
|
+
|
|
63
|
+
function defaultFormatDate(date: Date | string): string {
|
|
64
|
+
const d = typeof date === 'string' ? new Date(date) : date
|
|
65
|
+
const now = new Date()
|
|
66
|
+
const diffMs = now.getTime() - d.getTime()
|
|
67
|
+
const diffMins = Math.floor(diffMs / 60000)
|
|
68
|
+
const diffHours = Math.floor(diffMs / 3600000)
|
|
69
|
+
const diffDays = Math.floor(diffMs / 86400000)
|
|
70
|
+
|
|
71
|
+
if (diffMins < 1) return 'just now'
|
|
72
|
+
if (diffMins < 60) return `${diffMins}m ago`
|
|
73
|
+
if (diffHours < 24) return `${diffHours}h ago`
|
|
74
|
+
if (diffDays < 7) return `${diffDays}d ago`
|
|
75
|
+
|
|
76
|
+
return d.toLocaleDateString()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ============================================
|
|
80
|
+
// Main Component
|
|
81
|
+
// ============================================
|
|
82
|
+
|
|
83
|
+
export function ActivityTimeline({
|
|
84
|
+
activities,
|
|
85
|
+
loading = false,
|
|
86
|
+
activityLabels = {},
|
|
87
|
+
collapsedCount = 3,
|
|
88
|
+
showNoteInput = true,
|
|
89
|
+
notePlaceholder = 'Add a note...',
|
|
90
|
+
onAddNote,
|
|
91
|
+
submitting = false,
|
|
92
|
+
formatDate = defaultFormatDate,
|
|
93
|
+
loadingComponent,
|
|
94
|
+
emptyMessage = 'No activity yet',
|
|
95
|
+
className = '',
|
|
96
|
+
}: ActivityTimelineProps) {
|
|
97
|
+
const [newNote, setNewNote] = useState('')
|
|
98
|
+
const [expanded, setExpanded] = useState(false)
|
|
99
|
+
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
100
|
+
|
|
101
|
+
const handleAddNote = async () => {
|
|
102
|
+
if (!newNote.trim() || !onAddNote) return
|
|
103
|
+
|
|
104
|
+
setIsSubmitting(true)
|
|
105
|
+
try {
|
|
106
|
+
await onAddNote(newNote.trim())
|
|
107
|
+
setNewNote('')
|
|
108
|
+
} finally {
|
|
109
|
+
setIsSubmitting(false)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
114
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
115
|
+
handleAddNote()
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Show only first N activities unless expanded
|
|
120
|
+
const visibleActivities = expanded ? activities : activities.slice(0, collapsedCount)
|
|
121
|
+
const hasMore = activities.length > collapsedCount
|
|
122
|
+
|
|
123
|
+
const isCurrentlySubmitting = submitting || isSubmitting
|
|
124
|
+
|
|
125
|
+
const DefaultLoading = (
|
|
126
|
+
<div className="flex items-center justify-center py-4">
|
|
127
|
+
<CircleNotch className="w-5 h-5 animate-spin text-muted-foreground" />
|
|
128
|
+
</div>
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className={`space-y-3 ${className}`}>
|
|
133
|
+
{/* Add Note Form - Compact inline */}
|
|
134
|
+
{showNoteInput && onAddNote && (
|
|
135
|
+
<div className="flex gap-2">
|
|
136
|
+
<Textarea
|
|
137
|
+
value={newNote}
|
|
138
|
+
onChange={(e) => setNewNote(e.target.value)}
|
|
139
|
+
onKeyDown={handleKeyDown}
|
|
140
|
+
placeholder={notePlaceholder}
|
|
141
|
+
rows={1}
|
|
142
|
+
className="resize-none min-h-[36px] py-2"
|
|
143
|
+
/>
|
|
144
|
+
<Button
|
|
145
|
+
onClick={handleAddNote}
|
|
146
|
+
disabled={isCurrentlySubmitting || !newNote.trim()}
|
|
147
|
+
size="sm"
|
|
148
|
+
className="flex-shrink-0 h-9"
|
|
149
|
+
>
|
|
150
|
+
{isCurrentlySubmitting ? (
|
|
151
|
+
<CircleNotch className="w-4 h-4 animate-spin" />
|
|
152
|
+
) : (
|
|
153
|
+
<PaperPlaneTilt className="w-4 h-4" weight="bold" />
|
|
154
|
+
)}
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
)}
|
|
158
|
+
|
|
159
|
+
{/* Activity List - Minimal */}
|
|
160
|
+
{loading ? (
|
|
161
|
+
loadingComponent || DefaultLoading
|
|
162
|
+
) : activities.length === 0 ? (
|
|
163
|
+
<p className="text-xs text-muted-foreground text-center py-2">
|
|
164
|
+
{emptyMessage}
|
|
165
|
+
</p>
|
|
166
|
+
) : (
|
|
167
|
+
<div className="space-y-2">
|
|
168
|
+
{visibleActivities.map((activity) => {
|
|
169
|
+
const label = activityLabels[activity.activity_type] || ''
|
|
170
|
+
return (
|
|
171
|
+
<div key={activity.id} className="text-sm border-l-2 border-gray-200 pl-3 py-1">
|
|
172
|
+
<p className="text-gray-700">
|
|
173
|
+
{label && <span className="text-muted-foreground">{label} </span>}
|
|
174
|
+
{activity.content}
|
|
175
|
+
</p>
|
|
176
|
+
<p className="text-xs text-muted-foreground mt-0.5">
|
|
177
|
+
{activity.user} · {formatDate(activity.created_at)}
|
|
178
|
+
</p>
|
|
179
|
+
</div>
|
|
180
|
+
)
|
|
181
|
+
})}
|
|
182
|
+
|
|
183
|
+
{/* Show more/less toggle */}
|
|
184
|
+
{hasMore && (
|
|
185
|
+
<button
|
|
186
|
+
onClick={() => setExpanded(!expanded)}
|
|
187
|
+
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
|
188
|
+
>
|
|
189
|
+
{expanded ? (
|
|
190
|
+
<>
|
|
191
|
+
<CaretUp className="w-3 h-3" />
|
|
192
|
+
Show less
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<>
|
|
196
|
+
<CaretDown className="w-3 h-3" />
|
|
197
|
+
Show {activities.length - collapsedCount} more
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</button>
|
|
201
|
+
)}
|
|
202
|
+
</div>
|
|
203
|
+
)}
|
|
204
|
+
</div>
|
|
205
|
+
)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export default ActivityTimeline
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react'
|
|
4
|
+
import {
|
|
5
|
+
ReactFlow,
|
|
6
|
+
Node,
|
|
7
|
+
Edge,
|
|
8
|
+
Background,
|
|
9
|
+
useNodesState,
|
|
10
|
+
useEdgesState,
|
|
11
|
+
Position,
|
|
12
|
+
MarkerType,
|
|
13
|
+
Handle,
|
|
14
|
+
} from '@xyflow/react'
|
|
15
|
+
import '@xyflow/react/dist/style.css'
|
|
16
|
+
|
|
17
|
+
// ============================================
|
|
18
|
+
// Types
|
|
19
|
+
// ============================================
|
|
20
|
+
|
|
21
|
+
export interface N8nNode {
|
|
22
|
+
id: string
|
|
23
|
+
name: string
|
|
24
|
+
type: string
|
|
25
|
+
parameters?: Record<string, unknown>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface N8nWorkflow {
|
|
29
|
+
name: string
|
|
30
|
+
nodes: N8nNode[]
|
|
31
|
+
connections: Record<string, Record<string, Array<Array<{ node: string; type: string; index: number }>>>>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface WorkflowFlowProps {
|
|
35
|
+
/** The n8n workflow to visualize */
|
|
36
|
+
workflow: N8nWorkflow
|
|
37
|
+
/** Height of the flow diagram container */
|
|
38
|
+
height?: number
|
|
39
|
+
/** Additional CSS class name */
|
|
40
|
+
className?: string
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ============================================
|
|
44
|
+
// Helper Functions
|
|
45
|
+
// ============================================
|
|
46
|
+
|
|
47
|
+
function getNodeTypeLabel(type: string): string {
|
|
48
|
+
const labels: Record<string, string> = {
|
|
49
|
+
'n8n-nodes-base.webhook': 'Webhook',
|
|
50
|
+
'n8n-nodes-base.scheduleTrigger': 'Schedule',
|
|
51
|
+
'n8n-nodes-base.if': 'Condition',
|
|
52
|
+
'n8n-nodes-base.httpRequest': 'HTTP Request',
|
|
53
|
+
'n8n-nodes-base.set': 'Set Data',
|
|
54
|
+
'n8n-nodes-base.code': 'Code',
|
|
55
|
+
'n8n-nodes-base.respondToWebhook': 'Response',
|
|
56
|
+
'@n8n/n8n-nodes-langchain.agent': 'AI Agent',
|
|
57
|
+
'@n8n/n8n-nodes-langchain.lmChatOpenAi': 'OpenAI',
|
|
58
|
+
'@n8n/n8n-nodes-langchain.lmChatAnthropic': 'Anthropic',
|
|
59
|
+
}
|
|
60
|
+
return labels[type] || type.split('.').pop()?.replace(/([A-Z])/g, ' $1').trim() || type
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================
|
|
64
|
+
// Custom Node Component
|
|
65
|
+
// ============================================
|
|
66
|
+
|
|
67
|
+
function CustomNode({ data }: { data: { label: string; type: string } }) {
|
|
68
|
+
return (
|
|
69
|
+
<div className="px-3 py-2 rounded bg-white border border-slate-200 shadow-sm min-w-[110px] text-center">
|
|
70
|
+
<Handle type="target" position={Position.Left} className="!bg-slate-300 !w-1.5 !h-1.5 !border-0" />
|
|
71
|
+
<div className="text-xs font-medium text-slate-700 truncate max-w-[130px]">
|
|
72
|
+
{data.label}
|
|
73
|
+
</div>
|
|
74
|
+
<div className="text-[10px] text-slate-400 truncate max-w-[130px]">
|
|
75
|
+
{getNodeTypeLabel(data.type)}
|
|
76
|
+
</div>
|
|
77
|
+
<Handle type="source" position={Position.Right} className="!bg-slate-300 !w-1.5 !h-1.5 !border-0" />
|
|
78
|
+
</div>
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const nodeTypes = { custom: CustomNode }
|
|
83
|
+
|
|
84
|
+
// ============================================
|
|
85
|
+
// Main Component
|
|
86
|
+
// ============================================
|
|
87
|
+
|
|
88
|
+
export function WorkflowFlow({ workflow, height = 350, className = '' }: WorkflowFlowProps) {
|
|
89
|
+
const { initialNodes, initialEdges } = useMemo(() => {
|
|
90
|
+
const n8nNodes = workflow.nodes || []
|
|
91
|
+
const connections = workflow.connections || {}
|
|
92
|
+
const nodeIdMap = new Map(n8nNodes.map(n => [n.name, n.id || n.name]))
|
|
93
|
+
|
|
94
|
+
// Build adjacency lists (forward and backward)
|
|
95
|
+
const forwardEdges = new Map<string, string[]>() // from -> [to]
|
|
96
|
+
const backwardEdges = new Map<string, string[]>() // to -> [from]
|
|
97
|
+
const allEdgeData: Array<{ from: string; to: string; outputType: string; oi: number; ci: number }> = []
|
|
98
|
+
|
|
99
|
+
Object.entries(connections).forEach(([fromNodeName, outputTypes]) => {
|
|
100
|
+
Object.entries(outputTypes).forEach(([outputType, outputs]) => {
|
|
101
|
+
if (!Array.isArray(outputs)) return
|
|
102
|
+
outputs.forEach((outputArray, oi) => {
|
|
103
|
+
if (!Array.isArray(outputArray)) return
|
|
104
|
+
outputArray.forEach((conn, ci) => {
|
|
105
|
+
if (!forwardEdges.has(fromNodeName)) forwardEdges.set(fromNodeName, [])
|
|
106
|
+
forwardEdges.get(fromNodeName)!.push(conn.node)
|
|
107
|
+
|
|
108
|
+
if (!backwardEdges.has(conn.node)) backwardEdges.set(conn.node, [])
|
|
109
|
+
backwardEdges.get(conn.node)!.push(fromNodeName)
|
|
110
|
+
|
|
111
|
+
allEdgeData.push({ from: fromNodeName, to: conn.node, outputType, oi, ci })
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
// Find trigger nodes (webhooks, schedule triggers) - these are the true starting points
|
|
118
|
+
const triggerNodes = n8nNodes.filter(n =>
|
|
119
|
+
n.type.includes('webhook') || n.type.includes('Trigger') || n.type.includes('schedule')
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// If no triggers found, find nodes with no incoming edges as fallback
|
|
123
|
+
const roots = triggerNodes.length > 0
|
|
124
|
+
? triggerNodes
|
|
125
|
+
: n8nNodes.filter(n => !backwardEdges.has(n.name) || backwardEdges.get(n.name)!.length === 0)
|
|
126
|
+
|
|
127
|
+
// Assign levels using BFS from trigger/root nodes only
|
|
128
|
+
const levels = new Map<string, number>()
|
|
129
|
+
const queue: string[] = []
|
|
130
|
+
|
|
131
|
+
roots.forEach(r => {
|
|
132
|
+
levels.set(r.name, 0)
|
|
133
|
+
queue.push(r.name)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
const visited = new Set<string>()
|
|
137
|
+
while (queue.length > 0) {
|
|
138
|
+
const name = queue.shift()!
|
|
139
|
+
if (visited.has(name)) continue
|
|
140
|
+
visited.add(name)
|
|
141
|
+
|
|
142
|
+
const children = forwardEdges.get(name) || []
|
|
143
|
+
const myLevel = levels.get(name) || 0
|
|
144
|
+
|
|
145
|
+
children.forEach(child => {
|
|
146
|
+
const childLevel = levels.get(child)
|
|
147
|
+
if (childLevel === undefined || myLevel + 1 > childLevel) {
|
|
148
|
+
levels.set(child, myLevel + 1)
|
|
149
|
+
}
|
|
150
|
+
if (!visited.has(child)) {
|
|
151
|
+
queue.push(child)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Handle nodes that weren't reached (like AI models that feed into other nodes)
|
|
157
|
+
// Place them one level before the node they connect to
|
|
158
|
+
n8nNodes.forEach(node => {
|
|
159
|
+
if (!levels.has(node.name)) {
|
|
160
|
+
const targets = forwardEdges.get(node.name) || []
|
|
161
|
+
if (targets.length > 0) {
|
|
162
|
+
// Find the level of the target and place this node one level before
|
|
163
|
+
const targetLevels = targets.map(t => levels.get(t) ?? 0)
|
|
164
|
+
const targetLevel = Math.min(...targetLevels)
|
|
165
|
+
levels.set(node.name, Math.max(0, targetLevel - 1))
|
|
166
|
+
} else {
|
|
167
|
+
// No connections at all, place at the end
|
|
168
|
+
const maxLevel = Math.max(0, ...Array.from(levels.values()))
|
|
169
|
+
levels.set(node.name, maxLevel)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// Group nodes by level
|
|
175
|
+
const nodesByLevel = new Map<number, string[]>()
|
|
176
|
+
levels.forEach((level, name) => {
|
|
177
|
+
if (!nodesByLevel.has(level)) nodesByLevel.set(level, [])
|
|
178
|
+
nodesByLevel.get(level)!.push(name)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// Sort nodes within each level to minimize crossings
|
|
182
|
+
// Simple heuristic: sort by average position of connected nodes in previous level
|
|
183
|
+
const sortedLevels = Array.from(nodesByLevel.keys()).sort((a, b) => a - b)
|
|
184
|
+
|
|
185
|
+
sortedLevels.forEach((level, levelIdx) => {
|
|
186
|
+
if (levelIdx === 0) return // Skip first level
|
|
187
|
+
|
|
188
|
+
const nodesInLevel = nodesByLevel.get(level)!
|
|
189
|
+
const prevLevel = sortedLevels[levelIdx - 1]
|
|
190
|
+
const prevNodes = nodesByLevel.get(prevLevel) || []
|
|
191
|
+
const prevPositions = new Map(prevNodes.map((n, i) => [n, i]))
|
|
192
|
+
|
|
193
|
+
// Calculate average position of parents for each node
|
|
194
|
+
nodesInLevel.sort((a, b) => {
|
|
195
|
+
const parentsA = backwardEdges.get(a) || []
|
|
196
|
+
const parentsB = backwardEdges.get(b) || []
|
|
197
|
+
|
|
198
|
+
const avgA = parentsA.length > 0
|
|
199
|
+
? parentsA.reduce((sum, p) => sum + (prevPositions.get(p) ?? 0), 0) / parentsA.length
|
|
200
|
+
: 0
|
|
201
|
+
const avgB = parentsB.length > 0
|
|
202
|
+
? parentsB.reduce((sum, p) => sum + (prevPositions.get(p) ?? 0), 0) / parentsB.length
|
|
203
|
+
: 0
|
|
204
|
+
|
|
205
|
+
return avgA - avgB
|
|
206
|
+
})
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
// Assign positions
|
|
210
|
+
const positions = new Map<string, { x: number; y: number }>()
|
|
211
|
+
const xGap = 170
|
|
212
|
+
const yGap = 70
|
|
213
|
+
|
|
214
|
+
sortedLevels.forEach(level => {
|
|
215
|
+
const nodesInLevel = nodesByLevel.get(level)!
|
|
216
|
+
const totalHeight = (nodesInLevel.length - 1) * yGap
|
|
217
|
+
const startY = -totalHeight / 2
|
|
218
|
+
|
|
219
|
+
nodesInLevel.forEach((name, i) => {
|
|
220
|
+
positions.set(name, { x: level * xGap, y: startY + i * yGap })
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
// Create ReactFlow nodes
|
|
225
|
+
const nodes: Node[] = n8nNodes.map(node => ({
|
|
226
|
+
id: node.id || node.name,
|
|
227
|
+
type: 'custom',
|
|
228
|
+
position: positions.get(node.name) || { x: 0, y: 0 },
|
|
229
|
+
data: { label: node.name, type: node.type },
|
|
230
|
+
}))
|
|
231
|
+
|
|
232
|
+
// Create edges with better styling
|
|
233
|
+
const edges: Edge[] = allEdgeData.map(({ from, to, oi, ci }) => {
|
|
234
|
+
const fromId = nodeIdMap.get(from)!
|
|
235
|
+
const toId = nodeIdMap.get(to)!
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
id: `${fromId}-${toId}-${oi}-${ci}`,
|
|
239
|
+
source: fromId,
|
|
240
|
+
target: toId,
|
|
241
|
+
type: 'smoothstep',
|
|
242
|
+
pathOptions: { borderRadius: 20 },
|
|
243
|
+
style: { stroke: '#94a3b8', strokeWidth: 1.5 },
|
|
244
|
+
markerEnd: { type: MarkerType.ArrowClosed, color: '#94a3b8', width: 14, height: 14 },
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
return { initialNodes: nodes, initialEdges: edges }
|
|
249
|
+
}, [workflow])
|
|
250
|
+
|
|
251
|
+
const [nodes, , onNodesChange] = useNodesState(initialNodes)
|
|
252
|
+
const [edges, , onEdgesChange] = useEdgesState(initialEdges)
|
|
253
|
+
|
|
254
|
+
return (
|
|
255
|
+
<div style={{ height }} className={`bg-slate-50 rounded-lg border border-slate-200 overflow-hidden ${className}`}>
|
|
256
|
+
<ReactFlow
|
|
257
|
+
nodes={nodes}
|
|
258
|
+
edges={edges}
|
|
259
|
+
onNodesChange={onNodesChange}
|
|
260
|
+
onEdgesChange={onEdgesChange}
|
|
261
|
+
nodeTypes={nodeTypes}
|
|
262
|
+
fitView
|
|
263
|
+
fitViewOptions={{ padding: 0.1, minZoom: 0.8 }}
|
|
264
|
+
minZoom={0.5}
|
|
265
|
+
maxZoom={2}
|
|
266
|
+
proOptions={{ hideAttribution: true }}
|
|
267
|
+
defaultEdgeOptions={{
|
|
268
|
+
type: 'smoothstep',
|
|
269
|
+
}}
|
|
270
|
+
>
|
|
271
|
+
<Background color="#e2e8f0" gap={24} size={1} />
|
|
272
|
+
</ReactFlow>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export default WorkflowFlow
|