Skip to content

Commit 7839ca5

Browse files
committed
feat(agentflow): update flow date change & save handlings and enhance examples
- Introduced FlowStatePanel to display live flow data and saved snapshots in a resizable side panel. - Updated BasicExample and CustomUIExample to integrate FlowStatePanel for improved user experience. - Enhanced keyboard shortcuts for saving flow data with Cmd+S and Ctrl+S functionality. - Updated coverage thresholds in jest.config.js to include new FlowStatePanel hooks. - Revised TESTS.md to reflect changes in testing status for useFlowHandlers and related components.
1 parent 5e5fa60 commit 7839ca5

12 files changed

Lines changed: 726 additions & 83 deletions

File tree

packages/agentflow/TESTS.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ These modules carry the highest risk. Test in the same PR when modifying.
3636
| `src/infrastructure/api/nodes.ts` | `getAllNodes`, `getNodeByName`, `getNodeIconUrl` | ✅ Done |
3737
| `src/infrastructure/store/AgentflowContext.tsx` | `agentflowReducer` (all actions), `normalizeNodes`. Remaining: `deleteNode()`, `duplicateNode()`, `updateNodeData()`, `getFlowData()` | 🟡 Partial |
3838
| `src/useAgentflow.ts` | `getFlow()`, `toJSON()`, `validate()`, `addNode()`, `clear()` | ⬜ Not yet — thin wrapper |
39-
| `src/features/canvas/hooks/useFlowHandlers.ts` | `onConnect`, `onNodesChange`, `onEdgesChange`, `onAddNode` | ⬜ Not yet — coupled to ReactFlow |
39+
| `src/features/canvas/hooks/useFlowHandlers.ts` | `handleConnect`, `handleNodesChange`, `handleEdgesChange`, `handleAddNode` — synchronous `onFlowChange` callbacks, dirty tracking, viewport resolution, change filtering | ✅ Done |
4040

4141
### Tier 2 — Feature Hooks & Dialogs
4242

@@ -70,7 +70,7 @@ Mostly JSX with minimal logic. Only add tests if business logic is introduced.
7070
| `src/atoms/NodeInputHandler.tsx` | If input rendering or position calculation logic changes | ⬜ Not yet |
7171
| `src/features/canvas/components/ConnectionLine.tsx` | If edge label determination logic becomes more complex | ⬜ Not yet |
7272
| `src/features/canvas/components/NodeStatusIndicator.tsx` | If status-to-color/icon mapping expands | ⬜ Not yet |
73-
| `src/Agentflow.tsx` | Integration test — dark mode, ThemeProvider, CSS variables, header rendering, generate flow dialog, imperative ref | ✅ Done |
73+
| `src/Agentflow.tsx` | Integration test — dark mode, ThemeProvider, CSS variables, header rendering, keyboard shortcuts (Cmd+S / Ctrl+S save), generate flow dialog, imperative ref | ✅ Done |
7474

7575
Files that are pure styling or data constants (`styled.ts`, `nodeIcons.ts`, `MainCard.tsx`, `Input.tsx`, etc.) do not need dedicated tests.
7676

@@ -127,6 +127,7 @@ Key features:
127127
- **Coverage thresholds**: uniform 80% floor (`branches`, `functions`, `lines`, `statements`) enforced per-path:
128128
- `./src/Agentflow.tsx`
129129
- `./src/core/`
130+
- `./src/features/canvas/hooks/useFlowHandlers.ts`
130131
- `./src/features/node-palette/search.ts`
131132
- `./src/infrastructure/api/`
132133
- **Coverage exclusions**:

packages/agentflow/examples/src/App.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,7 @@ function LoadingFallback() {
100100

101101
export default function App() {
102102
const [selectedExample, setSelectedExample] = useState<ExampleId>('basic')
103-
const [showProps, setShowProps] = useState(true)
103+
const [showProps, setShowProps] = useState(false)
104104
// Config loaded from environment variables
105105

106106
const currentExample = examples.find((e) => e.id === selectedExample)
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* FlowStatePanel Component
3+
*
4+
* Displays live onFlowChange data and saved flow snapshots
5+
* in a dark-themed, resizable side panel with copy support.
6+
*/
7+
8+
import { useCallback, useRef, useState } from 'react'
9+
10+
import type { FlowData } from '@flowiseai/agentflow'
11+
12+
interface FlowStatePanelProps {
13+
currentFlow: FlowData | null
14+
savedFlow: FlowData | null
15+
changeCount: number
16+
}
17+
18+
export function FlowStatePanel({ currentFlow, savedFlow, changeCount }: FlowStatePanelProps) {
19+
const [tab, setTab] = useState<'live' | 'saved'>('live')
20+
const [copied, setCopied] = useState(false)
21+
const [width, setWidth] = useState(300)
22+
const dragging = useRef(false)
23+
const flow = tab === 'live' ? currentFlow : savedFlow
24+
25+
const resizeBy = useCallback((delta: number) => {
26+
setWidth((w) => Math.max(200, Math.min(800, w + delta)))
27+
}, [])
28+
29+
const handleMouseDown = useCallback(
30+
(e: React.MouseEvent) => {
31+
e.preventDefault()
32+
dragging.current = true
33+
const startX = e.clientX
34+
const startWidth = width
35+
36+
const onMouseMove = (moveEvent: MouseEvent) => {
37+
if (!dragging.current) return
38+
const newWidth = Math.max(200, Math.min(800, startWidth + (startX - moveEvent.clientX)))
39+
setWidth(newWidth)
40+
}
41+
42+
const onMouseUp = () => {
43+
dragging.current = false
44+
document.removeEventListener('mousemove', onMouseMove)
45+
document.removeEventListener('mouseup', onMouseUp)
46+
document.body.style.cursor = ''
47+
document.body.style.userSelect = ''
48+
}
49+
50+
document.addEventListener('mousemove', onMouseMove)
51+
document.addEventListener('mouseup', onMouseUp)
52+
document.body.style.cursor = 'col-resize'
53+
document.body.style.userSelect = 'none'
54+
},
55+
[width]
56+
)
57+
58+
const handleKeyDown = useCallback(
59+
(e: React.KeyboardEvent) => {
60+
if (e.key === 'ArrowLeft') {
61+
e.preventDefault()
62+
resizeBy(-20)
63+
} else if (e.key === 'ArrowRight') {
64+
e.preventDefault()
65+
resizeBy(20)
66+
}
67+
},
68+
[resizeBy]
69+
)
70+
71+
return (
72+
<div
73+
style={{
74+
width: `${width}px`,
75+
minHeight: 0,
76+
background: '#1e1e2e',
77+
color: '#cdd6f4',
78+
display: 'flex',
79+
flexDirection: 'column',
80+
fontSize: '13px',
81+
fontFamily: 'monospace',
82+
borderLeft: '1px solid #313244',
83+
overflow: 'hidden',
84+
position: 'relative'
85+
}}
86+
>
87+
{/* Drag handle */}
88+
<button
89+
aria-label='Resize panel'
90+
onMouseDown={handleMouseDown}
91+
onKeyDown={handleKeyDown}
92+
style={{
93+
position: 'absolute',
94+
left: 0,
95+
top: 0,
96+
bottom: 0,
97+
width: '4px',
98+
cursor: 'col-resize',
99+
zIndex: 10,
100+
padding: 0,
101+
border: 'none',
102+
background: 'transparent'
103+
}}
104+
/>
105+
{/* Tabs */}
106+
<div style={{ display: 'flex', borderBottom: '1px solid #313244' }}>
107+
<button
108+
onClick={() => setTab('live')}
109+
style={{
110+
flex: 1,
111+
padding: '10px',
112+
background: tab === 'live' ? '#313244' : 'transparent',
113+
color: tab === 'live' ? '#cba6f7' : '#6c7086',
114+
border: 'none',
115+
cursor: 'pointer',
116+
fontFamily: 'monospace',
117+
fontSize: '12px',
118+
fontWeight: 600
119+
}}
120+
>
121+
onFlowChange ({changeCount})
122+
</button>
123+
<button
124+
onClick={() => setTab('saved')}
125+
style={{
126+
flex: 1,
127+
padding: '10px',
128+
background: tab === 'saved' ? '#313244' : 'transparent',
129+
color: tab === 'saved' ? '#a6e3a1' : '#6c7086',
130+
border: 'none',
131+
cursor: 'pointer',
132+
fontFamily: 'monospace',
133+
fontSize: '12px',
134+
fontWeight: 600
135+
}}
136+
>
137+
onSave {savedFlow ? '(1)' : '(0)'}
138+
</button>
139+
</div>
140+
141+
{/* Summary stats + copy button */}
142+
{flow && (
143+
<div
144+
style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '10px 14px', borderBottom: '1px solid #313244' }}
145+
>
146+
<span>
147+
<span style={{ color: '#89b4fa' }}>nodes:</span> {flow.nodes.length}
148+
</span>
149+
<span>
150+
<span style={{ color: '#f9e2af' }}>edges:</span> {flow.edges.length}
151+
</span>
152+
<button
153+
onClick={() => {
154+
navigator.clipboard.writeText(JSON.stringify(flow, null, 2))
155+
setCopied(true)
156+
setTimeout(() => setCopied(false), 1500)
157+
}}
158+
style={{
159+
marginLeft: 'auto',
160+
padding: '3px 10px',
161+
background: copied ? '#a6e3a1' : '#45475a',
162+
color: copied ? '#1e1e2e' : '#cdd6f4',
163+
border: 'none',
164+
borderRadius: '4px',
165+
cursor: 'pointer',
166+
fontFamily: 'monospace',
167+
fontSize: '11px',
168+
transition: 'all 0.15s'
169+
}}
170+
>
171+
{copied ? 'Copied!' : 'Copy'}
172+
</button>
173+
</div>
174+
)}
175+
176+
{/* JSON payload */}
177+
<div style={{ flex: 1, overflow: 'auto', padding: '10px 14px' }}>
178+
{flow ? (
179+
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', wordBreak: 'break-word', lineHeight: 1.5 }}>
180+
{JSON.stringify(flow, null, 2)}
181+
</pre>
182+
) : (
183+
<div style={{ color: '#6c7086', padding: '20px 0', textAlign: 'center' }}>
184+
{tab === 'live'
185+
? 'Interact with the canvas to see live flow data.'
186+
: 'Click Save or press Cmd+S to capture a snapshot.'}
187+
</div>
188+
)}
189+
</div>
190+
</div>
191+
)
192+
}

packages/agentflow/examples/src/demos/BasicExample.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
11
/**
22
* Basic Example
33
*
4-
* Demonstrates basic @flowiseai/agentflow usage with imperative methods.
4+
* Demonstrates basic @flowiseai/agentflow usage with imperative methods,
5+
* onFlowChange tracking, and save flow functionality.
56
*/
67

7-
import { useRef, useState } from 'react'
8+
import { useCallback, useRef, useState } from 'react'
89

910
import type { AgentFlowInstance, FlowData, ValidationResult } from '@flowiseai/agentflow'
1011
import { Agentflow } from '@flowiseai/agentflow'
1112

1213
import { apiBaseUrl, token } from '../config'
14+
import { FlowStatePanel } from '../FlowStatePanel'
1315

1416
// Example flow data
1517
const initialFlow: FlowData = {
@@ -33,18 +35,22 @@ const initialFlow: FlowData = {
3335
}
3436

3537
export function BasicExample() {
36-
// Config loaded from environment variables
3738
const agentflowRef = useRef<AgentFlowInstance>(null)
3839
const [validationResult, setValidationResult] = useState<ValidationResult | null>(null)
40+
const [currentFlow, setCurrentFlow] = useState<FlowData | null>(null)
41+
const [savedFlow, setSavedFlow] = useState<FlowData | null>(null)
42+
const [changeCount, setChangeCount] = useState(0)
3943

40-
const handleFlowChange = (flow: FlowData) => {
41-
console.log('Flow changed:', flow)
42-
}
44+
const handleFlowChange = useCallback((flow: FlowData) => {
45+
setCurrentFlow(flow)
46+
setChangeCount((c) => c + 1)
47+
console.log('onFlowChange:', flow)
48+
}, [])
4349

44-
const handleSave = (flow: FlowData) => {
45-
console.log('Flow saved:', flow)
46-
alert('Flow saved! Check console for data.')
47-
}
50+
const handleSave = useCallback((flow: FlowData) => {
51+
setSavedFlow(flow)
52+
console.log('onSave:', flow)
53+
}, [])
4854

4955
const handleValidate = () => {
5056
if (agentflowRef.current) {
@@ -98,17 +104,20 @@ export function BasicExample() {
98104
)}
99105
</div>
100106

101-
{/* Canvas */}
102-
<div style={{ flex: 1 }}>
103-
<Agentflow
104-
ref={agentflowRef}
105-
apiBaseUrl={apiBaseUrl}
106-
token={token ?? undefined}
107-
initialFlow={initialFlow}
108-
onFlowChange={handleFlowChange}
109-
onSave={handleSave}
110-
showDefaultHeader={true}
111-
/>
107+
{/* Canvas + Flow State Panel */}
108+
<div style={{ flex: 1, display: 'flex', minHeight: 0 }}>
109+
<div style={{ flex: 1 }}>
110+
<Agentflow
111+
ref={agentflowRef}
112+
apiBaseUrl={apiBaseUrl}
113+
token={token ?? undefined}
114+
initialFlow={initialFlow}
115+
onFlowChange={handleFlowChange}
116+
onSave={handleSave}
117+
showDefaultHeader={true}
118+
/>
119+
</div>
120+
<FlowStatePanel currentFlow={currentFlow} savedFlow={savedFlow} changeCount={changeCount} />
112121
</div>
113122
</div>
114123
)

packages/agentflow/examples/src/demos/CustomUIExample.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -260,11 +260,11 @@ export function CustomUIExample() {
260260
apiBaseUrl={apiBaseUrl}
261261
token={token ?? undefined}
262262
initialFlow={initialFlow}
263-
renderHeader={(props) => <CustomHeader {...props} />}
264-
renderNodePalette={(props) => <CustomPalette {...props} />}
263+
renderHeader={(props: HeaderRenderProps) => <CustomHeader {...props} />}
264+
renderNodePalette={(props: PaletteRenderProps) => <CustomPalette {...props} />}
265265
showDefaultHeader={false}
266266
showDefaultPalette={false}
267-
onSave={(flow) => {
267+
onSave={(flow: FlowData) => {
268268
console.log('Saving flow:', flow)
269269
alert('Flow saved! Check console.')
270270
}}

packages/agentflow/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ module.exports = {
3838
coverageThreshold: {
3939
'./src/Agentflow.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
4040
'./src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 },
41+
'./src/features/canvas/hooks/useFlowHandlers.ts': { branches: 80, functions: 80, lines: 80, statements: 80 },
4142
'./src/features/node-palette/search.ts': { branches: 80, functions: 80, lines: 80, statements: 80 },
4243
'./src/infrastructure/api/': { branches: 80, functions: 80, lines: 80, statements: 80 }
4344
},

packages/agentflow/src/Agentflow.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,65 @@ describe('Agentflow Component', () => {
305305
})
306306
})
307307

308+
describe('Keyboard Shortcuts', () => {
309+
it('should trigger save on Cmd+S', async () => {
310+
const onSave = jest.fn()
311+
const { container } = render(<Agentflow {...defaultProps} onSave={onSave} />)
312+
313+
await waitFor(() => {
314+
expect(container.querySelector('.agentflow-container')).toBeInTheDocument()
315+
})
316+
317+
fireEvent.keyDown(document, { key: 's', metaKey: true })
318+
319+
expect(onSave).toHaveBeenCalledTimes(1)
320+
expect(onSave).toHaveBeenCalledWith(
321+
expect.objectContaining({
322+
nodes: expect.any(Array),
323+
edges: expect.any(Array)
324+
})
325+
)
326+
})
327+
328+
it('should trigger save on Ctrl+S', async () => {
329+
const onSave = jest.fn()
330+
const { container } = render(<Agentflow {...defaultProps} onSave={onSave} />)
331+
332+
await waitFor(() => {
333+
expect(container.querySelector('.agentflow-container')).toBeInTheDocument()
334+
})
335+
336+
fireEvent.keyDown(document, { key: 's', ctrlKey: true })
337+
338+
expect(onSave).toHaveBeenCalledTimes(1)
339+
})
340+
341+
it('should not trigger save on plain S key', async () => {
342+
const onSave = jest.fn()
343+
const { container } = render(<Agentflow {...defaultProps} onSave={onSave} />)
344+
345+
await waitFor(() => {
346+
expect(container.querySelector('.agentflow-container')).toBeInTheDocument()
347+
})
348+
349+
fireEvent.keyDown(document, { key: 's' })
350+
351+
expect(onSave).not.toHaveBeenCalled()
352+
})
353+
354+
it('should not error on Cmd+S when no onSave callback is provided', async () => {
355+
const { container } = render(<Agentflow {...defaultProps} />)
356+
357+
await waitFor(() => {
358+
expect(container.querySelector('.agentflow-container')).toBeInTheDocument()
359+
})
360+
361+
expect(() => {
362+
fireEvent.keyDown(document, { key: 's', metaKey: true })
363+
}).not.toThrow()
364+
})
365+
})
366+
308367
describe('Generate Flow', () => {
309368
it('should open generate dialog when button is clicked', async () => {
310369
const { container, getByTestId } = render(<Agentflow {...defaultProps} />)

0 commit comments

Comments
 (0)