@runtypelabs/react-flow 0.1.0

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.
@@ -0,0 +1,414 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'react'
2
+ import {
3
+ useNodesState,
4
+ useEdgesState,
5
+ addEdge,
6
+ type OnNodesChange,
7
+ type OnEdgesChange,
8
+ type OnConnect,
9
+ type Connection,
10
+ } from '@xyflow/react'
11
+ import type { RuntypeClient } from '@runtypelabs/sdk'
12
+ import type {
13
+ FlowStep,
14
+ RuntypeNode,
15
+ RuntypeEdge,
16
+ SavedFlow,
17
+ UseRuntypeFlowReturn,
18
+ } from '../types'
19
+ import {
20
+ flowStepsToNodes,
21
+ nodesToFlowSteps,
22
+ createEdgesFromNodes,
23
+ createDefaultStep,
24
+ } from '../utils/adapter'
25
+ import { autoLayout } from '../utils/layout'
26
+ import type { FlowStepType } from '@travrse/shared'
27
+
28
+ // ============================================================================
29
+ // Hook Options
30
+ // ============================================================================
31
+
32
+ export interface UseRuntypeFlowOptions {
33
+ /** Runtype API client instance */
34
+ client: RuntypeClient
35
+ /** Initial flow ID to load */
36
+ flowId?: string
37
+ /** Initial flow name */
38
+ initialName?: string
39
+ /** Initial flow description */
40
+ initialDescription?: string
41
+ /** Initial steps */
42
+ initialSteps?: FlowStep[]
43
+ /** Callback when flow changes */
44
+ onChange?: (nodes: RuntypeNode[], edges: RuntypeEdge[]) => void
45
+ /** Auto-layout nodes on load */
46
+ autoLayoutOnLoad?: boolean
47
+ }
48
+
49
+ // ============================================================================
50
+ // Main Hook
51
+ // ============================================================================
52
+
53
+ export function useRuntypeFlow(options: UseRuntypeFlowOptions): UseRuntypeFlowReturn {
54
+ const {
55
+ client,
56
+ flowId: initialFlowId,
57
+ initialName = '',
58
+ initialDescription = '',
59
+ initialSteps = [],
60
+ onChange,
61
+ autoLayoutOnLoad = true,
62
+ } = options
63
+
64
+ // State
65
+ const [flowId, setFlowId] = useState<string | null>(initialFlowId || null)
66
+ const [flowName, setFlowName] = useState(initialName)
67
+ const [flowDescription, setFlowDescription] = useState(initialDescription)
68
+ const [isLoading, setIsLoading] = useState(false)
69
+ const [isSaving, setIsSaving] = useState(false)
70
+ const [error, setError] = useState<Error | null>(null)
71
+ const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
72
+
73
+ // React Flow state
74
+ const [nodes, setNodes, onNodesChange] = useNodesState<RuntypeNode>([])
75
+ const [edges, setEdges, onEdgesChange] = useEdgesState<RuntypeEdge>([])
76
+
77
+ // Track last saved state for change detection
78
+ const lastSavedState = useRef<string>('')
79
+
80
+ // ============================================================================
81
+ // Handlers
82
+ // ============================================================================
83
+
84
+ const handleStepChange = useCallback(
85
+ (stepId: string, updates: Partial<FlowStep>) => {
86
+ setNodes((nds) =>
87
+ nds.map((node) => {
88
+ if (node.id === stepId) {
89
+ return {
90
+ ...node,
91
+ data: {
92
+ ...node.data,
93
+ step: {
94
+ ...node.data.step,
95
+ ...updates,
96
+ config: updates.config
97
+ ? { ...node.data.step.config, ...updates.config }
98
+ : node.data.step.config,
99
+ },
100
+ },
101
+ }
102
+ }
103
+ return node
104
+ })
105
+ )
106
+ setHasUnsavedChanges(true)
107
+ },
108
+ [setNodes]
109
+ )
110
+
111
+ const handleStepDelete = useCallback(
112
+ (stepId: string) => {
113
+ setNodes((nds) => nds.filter((node) => node.id !== stepId))
114
+ setEdges((eds) =>
115
+ eds.filter((edge) => edge.source !== stepId && edge.target !== stepId)
116
+ )
117
+ setHasUnsavedChanges(true)
118
+ },
119
+ [setNodes, setEdges]
120
+ )
121
+
122
+ const handleConnect: OnConnect = useCallback(
123
+ (connection: Connection) => {
124
+ setEdges((eds) => addEdge({ ...connection, type: 'smoothstep' }, eds))
125
+ setHasUnsavedChanges(true)
126
+ },
127
+ [setEdges]
128
+ )
129
+
130
+ // ============================================================================
131
+ // API Operations
132
+ // ============================================================================
133
+
134
+ /**
135
+ * Load a flow by ID
136
+ */
137
+ const loadFlow = useCallback(
138
+ async (id: string) => {
139
+ setIsLoading(true)
140
+ setError(null)
141
+
142
+ try {
143
+ const flow = await client.flows.get(id)
144
+
145
+ // Get flow steps
146
+ const stepsResponse = await client.flowSteps.getByFlow(id)
147
+ const steps: FlowStep[] = Array.isArray(stepsResponse) ? stepsResponse : []
148
+
149
+ // Convert to React Flow nodes
150
+ let newNodes = flowStepsToNodes(steps, {
151
+ onChange: handleStepChange,
152
+ onDelete: handleStepDelete,
153
+ })
154
+
155
+ // Auto-layout if enabled
156
+ if (autoLayoutOnLoad) {
157
+ const newEdges = createEdgesFromNodes(newNodes)
158
+ newNodes = autoLayout(newNodes, newEdges)
159
+ }
160
+
161
+ const newEdges = createEdgesFromNodes(newNodes)
162
+
163
+ setFlowId(id)
164
+ setFlowName(flow.name || '')
165
+ setFlowDescription(flow.description || '')
166
+ setNodes(newNodes)
167
+ setEdges(newEdges)
168
+ setHasUnsavedChanges(false)
169
+
170
+ // Store last saved state
171
+ lastSavedState.current = JSON.stringify(nodesToFlowSteps(newNodes))
172
+ } catch (err) {
173
+ setError(err instanceof Error ? err : new Error('Failed to load flow'))
174
+ throw err
175
+ } finally {
176
+ setIsLoading(false)
177
+ }
178
+ },
179
+ [client, handleStepChange, handleStepDelete, autoLayoutOnLoad, setNodes, setEdges]
180
+ )
181
+
182
+ /**
183
+ * Save the current flow
184
+ */
185
+ const saveFlow = useCallback(async (): Promise<SavedFlow> => {
186
+ if (!flowId) {
187
+ throw new Error('No flow ID. Use createFlow() to create a new flow first.')
188
+ }
189
+
190
+ setIsSaving(true)
191
+ setError(null)
192
+
193
+ try {
194
+ const steps = nodesToFlowSteps(nodes)
195
+
196
+ // Update flow metadata
197
+ await client.flows.update(flowId, {
198
+ name: flowName,
199
+ description: flowDescription,
200
+ })
201
+
202
+ // Update steps - delete existing and create new
203
+ const existingSteps = await client.flowSteps.getByFlow(flowId)
204
+ for (const step of existingSteps) {
205
+ await client.flowSteps.delete(step.id)
206
+ }
207
+
208
+ // Create new steps
209
+ for (const step of steps) {
210
+ await client.flowSteps.create({
211
+ flowId,
212
+ type: step.type,
213
+ name: step.name,
214
+ order: step.order,
215
+ enabled: step.enabled,
216
+ config: step.config,
217
+ })
218
+ }
219
+
220
+ setHasUnsavedChanges(false)
221
+ lastSavedState.current = JSON.stringify(steps)
222
+
223
+ return {
224
+ id: flowId,
225
+ name: flowName,
226
+ description: flowDescription,
227
+ steps,
228
+ }
229
+ } catch (err) {
230
+ setError(err instanceof Error ? err : new Error('Failed to save flow'))
231
+ throw err
232
+ } finally {
233
+ setIsSaving(false)
234
+ }
235
+ }, [flowId, flowName, flowDescription, nodes, client])
236
+
237
+ /**
238
+ * Create a new flow
239
+ */
240
+ const createFlow = useCallback(
241
+ async (name: string, description?: string): Promise<SavedFlow> => {
242
+ setIsSaving(true)
243
+ setError(null)
244
+
245
+ try {
246
+ const flow = await client.flows.create({
247
+ name,
248
+ description,
249
+ prompts: [],
250
+ })
251
+
252
+ setFlowId(flow.id)
253
+ setFlowName(name)
254
+ setFlowDescription(description || '')
255
+ setNodes([])
256
+ setEdges([])
257
+ setHasUnsavedChanges(false)
258
+ lastSavedState.current = '[]'
259
+
260
+ return {
261
+ id: flow.id,
262
+ name,
263
+ description,
264
+ steps: [],
265
+ }
266
+ } catch (err) {
267
+ setError(err instanceof Error ? err : new Error('Failed to create flow'))
268
+ throw err
269
+ } finally {
270
+ setIsSaving(false)
271
+ }
272
+ },
273
+ [client, setNodes, setEdges]
274
+ )
275
+
276
+ /**
277
+ * Delete a step
278
+ */
279
+ const deleteStep = useCallback(
280
+ (stepId: string) => {
281
+ handleStepDelete(stepId)
282
+ },
283
+ [handleStepDelete]
284
+ )
285
+
286
+ /**
287
+ * Update a step's configuration
288
+ */
289
+ const updateStep = useCallback(
290
+ (stepId: string, updates: Partial<FlowStep>) => {
291
+ handleStepChange(stepId, updates)
292
+ },
293
+ [handleStepChange]
294
+ )
295
+
296
+ /**
297
+ * Add a new step
298
+ */
299
+ const addStep = useCallback(
300
+ (type: FlowStepType, position?: { x: number; y: number }) => {
301
+ const newStep = createDefaultStep(type, nodes.length)
302
+
303
+ const newNode: RuntypeNode = {
304
+ id: newStep.id,
305
+ type: newStep.type,
306
+ position: position || { x: 400, y: nodes.length * 230 + 50 },
307
+ data: {
308
+ step: newStep,
309
+ label: newStep.name,
310
+ onChange: handleStepChange,
311
+ onDelete: handleStepDelete,
312
+ },
313
+ draggable: true,
314
+ selectable: true,
315
+ }
316
+
317
+ setNodes((nds) => [...nds, newNode])
318
+
319
+ // Add edge from previous node if exists
320
+ if (nodes.length > 0) {
321
+ const lastNode = nodes[nodes.length - 1]
322
+ setEdges((eds) => [
323
+ ...eds,
324
+ {
325
+ id: `edge-${lastNode.id}-${newNode.id}`,
326
+ source: lastNode.id,
327
+ target: newNode.id,
328
+ type: 'smoothstep',
329
+ },
330
+ ])
331
+ }
332
+
333
+ setHasUnsavedChanges(true)
334
+ },
335
+ [nodes, setNodes, setEdges, handleStepChange, handleStepDelete]
336
+ )
337
+
338
+ // ============================================================================
339
+ // Effects
340
+ // ============================================================================
341
+
342
+ // Load initial flow if flowId is provided
343
+ useEffect(() => {
344
+ if (initialFlowId) {
345
+ loadFlow(initialFlowId)
346
+ } else if (initialSteps.length > 0) {
347
+ // Initialize with provided steps
348
+ let newNodes = flowStepsToNodes(initialSteps, {
349
+ onChange: handleStepChange,
350
+ onDelete: handleStepDelete,
351
+ })
352
+
353
+ if (autoLayoutOnLoad) {
354
+ const newEdges = createEdgesFromNodes(newNodes)
355
+ newNodes = autoLayout(newNodes, newEdges)
356
+ }
357
+
358
+ const newEdges = createEdgesFromNodes(newNodes)
359
+
360
+ setNodes(newNodes)
361
+ setEdges(newEdges)
362
+ }
363
+ }, []) // Only run on mount
364
+
365
+ // Notify onChange when nodes/edges change
366
+ useEffect(() => {
367
+ onChange?.(nodes, edges)
368
+ }, [nodes, edges, onChange])
369
+
370
+ // Detect unsaved changes
371
+ useEffect(() => {
372
+ const currentState = JSON.stringify(nodesToFlowSteps(nodes))
373
+ if (lastSavedState.current && currentState !== lastSavedState.current) {
374
+ setHasUnsavedChanges(true)
375
+ }
376
+ }, [nodes])
377
+
378
+ // ============================================================================
379
+ // Return
380
+ // ============================================================================
381
+
382
+ return {
383
+ // React Flow state
384
+ nodes,
385
+ edges,
386
+ onNodesChange: onNodesChange as unknown as (changes: unknown) => void,
387
+ onEdgesChange: onEdgesChange as unknown as (changes: unknown) => void,
388
+ onConnect: handleConnect as unknown as (connection: unknown) => void,
389
+
390
+ // Flow metadata
391
+ flowName,
392
+ flowDescription,
393
+ flowId,
394
+ setFlowName,
395
+ setFlowDescription,
396
+
397
+ // API operations
398
+ loadFlow,
399
+ saveFlow,
400
+ createFlow,
401
+ deleteStep,
402
+ updateStep,
403
+ addStep,
404
+
405
+ // Status
406
+ isLoading,
407
+ isSaving,
408
+ error,
409
+ hasUnsavedChanges,
410
+ }
411
+ }
412
+
413
+ export default useRuntypeFlow
414
+
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ // Types
2
+ export * from './types'
3
+
4
+ // Components
5
+ export { RuntypeFlowEditor } from './components/RuntypeFlowEditor'
6
+ export { BaseNode } from './components/nodes/BaseNode'
7
+ export { PromptNode } from './components/nodes/PromptNode'
8
+ export { FetchUrlNode } from './components/nodes/FetchUrlNode'
9
+ export { CodeNode } from './components/nodes/CodeNode'
10
+ export { ConditionalNode } from './components/nodes/ConditionalNode'
11
+ export { SendEmailNode } from './components/nodes/SendEmailNode'
12
+
13
+ // Hooks
14
+ export { useRuntypeFlow } from './hooks/useRuntypeFlow'
15
+ export { useFlowValidation } from './hooks/useFlowValidation'
16
+
17
+ // Utilities
18
+ export {
19
+ flowStepsToNodes,
20
+ nodesToFlowSteps,
21
+ createEdgesFromNodes,
22
+ createDefaultStep,
23
+ getDefaultStepName,
24
+ generateStepId,
25
+ cloneStep,
26
+ } from './utils/adapter'
27
+ export { autoLayout, centerNodes, snapToGrid, getNodesBoundingBox } from './utils/layout'
28
+