Skip to content

Commit 21e6106

Browse files
FLOWISE-262 Add ConditionAgent ScenariosInput with dynamic output ports (#6018)
* feat(agentflow): add ConditionAgent ScenariosInput with dynamic output ports Implement FLOWISE-262: port ConditionAgent scenarios editing from agentflow v2 to the SDK package. - Add ScenariosInput atom for editing scenario strings with add/delete - Integrate into EditNodeDialog with dynamic output anchor generation - Wire up edge label computation for conditionAgentAgentflow in useFlowHandlers - Fix cleanupOrphanedEdges to always filter by anchor count (no stale prevCount) - Make updateNodeData accept optional edges for atomic node+edge updates - Add coverage threshold for ScenariosInput.tsx in jest.config.js Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(agentflow): address PR review comment and fix CI build error Clarify ScenariosInput docstring to note Else is a visual indicator, not a dynamic output anchor. Fix nodeFactory test outputs to use `type` instead of non-existent `description` property on NodeOutput. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat(agentflow): enhance ExpandTextDialog to handle value updates correctly - Added tests to ensure the dialog displays the current value when opened after changes while closed. - Implemented logic to reset the dialog's value to the original when reopened after cancellation. - Updated state management in ExpandTextDialog to synchronize local value immediately upon opening. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 172523f commit 21e6106

20 files changed

+922
-157
lines changed

packages/agentflow/jest.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ module.exports = {
4747
'./src/atoms/ArrayInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
4848
'./src/atoms/ExpandTextDialog.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
4949
'./src/atoms/MessagesInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
50+
'./src/atoms/ScenariosInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
5051
// Tier 3 UI atom — only the onChange/disabled/sync logic is tested, not styled internals
5152
'./src/atoms/RichTextEditor.tsx': { branches: 30, functions: 50, lines: 50, statements: 50 },
5253
'./src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 },

packages/agentflow/src/atoms/ExpandTextDialog.test.tsx

Lines changed: 75 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -13,32 +13,32 @@ beforeEach(() => {
1313

1414
describe('ExpandTextDialog', () => {
1515
it('should not render content when closed', () => {
16-
render(<ExpandTextDialog open={false} value='' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
16+
render(<ExpandTextDialog open={false} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
1717

1818
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
1919
})
2020

2121
it('should render with the provided value when open', () => {
22-
render(<ExpandTextDialog open={true} value='Hello world' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
22+
render(<ExpandTextDialog open={true} value='Hello world' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
2323

2424
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
2525
expect(textarea).toHaveValue('Hello world')
2626
})
2727

2828
it('should render title when provided', () => {
29-
render(<ExpandTextDialog open={true} value='' title='Content' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
29+
render(<ExpandTextDialog open={true} value='' title='Content' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
3030

3131
expect(screen.getByText('Content')).toBeInTheDocument()
3232
})
3333

3434
it('should not render title when not provided', () => {
35-
render(<ExpandTextDialog open={true} value='' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
35+
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
3636

3737
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
3838
})
3939

4040
it('should call onConfirm with edited value when Save is clicked', () => {
41-
render(<ExpandTextDialog open={true} value='Original' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
41+
render(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
4242

4343
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
4444
fireEvent.change(textarea, { target: { value: 'Updated' } })
@@ -48,7 +48,7 @@ describe('ExpandTextDialog', () => {
4848
})
4949

5050
it('should call onCancel when Cancel is clicked', () => {
51-
render(<ExpandTextDialog open={true} value='Original' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
51+
render(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
5252

5353
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
5454

@@ -57,25 +57,73 @@ describe('ExpandTextDialog', () => {
5757
})
5858

5959
it('should disable textarea and Save button when disabled', () => {
60-
render(<ExpandTextDialog open={true} value='test' disabled={true} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
60+
render(
61+
<ExpandTextDialog open={true} value='test' inputType='code' disabled={true} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
62+
)
6163

6264
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
6365
expect(textarea).toBeDisabled()
6466
expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled()
6567
})
6668

6769
it('should render placeholder when provided', () => {
68-
render(<ExpandTextDialog open={true} value='' placeholder='Type here...' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
70+
render(
71+
<ExpandTextDialog
72+
open={true}
73+
value=''
74+
inputType='code'
75+
placeholder='Type here...'
76+
onConfirm={mockOnConfirm}
77+
onCancel={mockOnCancel}
78+
/>
79+
)
6980

7081
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
7182
expect(textarea).toHaveAttribute('placeholder', 'Type here...')
7283
})
7384

85+
it('should show current value when opened after value changed while closed', () => {
86+
const { rerender } = render(
87+
<ExpandTextDialog open={false} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
88+
)
89+
90+
// Simulate value changing while dialog is closed (user typing in inline editor)
91+
rerender(<ExpandTextDialog open={false} value='Updated text' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
92+
93+
// Open the dialog — it should show the updated value, not the initial empty value
94+
rerender(<ExpandTextDialog open={true} value='Updated text' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
95+
96+
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
97+
expect(textarea).toHaveValue('Updated text')
98+
})
99+
100+
it('should reset to current value when re-opened after cancel', () => {
101+
const { rerender } = render(
102+
<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
103+
)
104+
105+
// User types in the dialog then cancels
106+
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
107+
fireEvent.change(textarea, { target: { value: 'Unsaved edits' } })
108+
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
109+
110+
// Close the dialog
111+
rerender(<ExpandTextDialog open={false} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
112+
113+
// Re-open — should show the original value, not the unsaved edits
114+
rerender(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
115+
116+
const textarea2 = screen.getByTestId('expand-content-input').querySelector('textarea')!
117+
expect(textarea2).toHaveValue('Original')
118+
})
119+
74120
// --- Rich text mode ---
75121

76-
describe('mode="richtext"', () => {
122+
describe('inputType="string" (richtext)', () => {
77123
it('should render the TipTap editor instead of a TextField', async () => {
78-
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
124+
render(
125+
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
126+
)
79127

80128
// RichTextEditor renders data-testid='rich-text-editor' which wraps tiptap
81129
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
@@ -85,15 +133,17 @@ describe('ExpandTextDialog', () => {
85133
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
86134
})
87135

88-
it('should render plain TextField in default text mode', () => {
89-
render(<ExpandTextDialog open={true} value='Hello' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
136+
it('should render plain TextField for non-string input types', () => {
137+
render(<ExpandTextDialog open={true} value='Hello' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
90138

91139
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
92140
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
93141
})
94142

95143
it('should still show Save and Cancel buttons in richtext mode', () => {
96-
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
144+
render(
145+
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
146+
)
97147

98148
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
99149
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
@@ -104,7 +154,7 @@ describe('ExpandTextDialog', () => {
104154
<ExpandTextDialog
105155
open={true}
106156
value='<p>Hello</p>'
107-
mode='richtext'
157+
inputType='string'
108158
disabled={true}
109159
onConfirm={mockOnConfirm}
110160
onCancel={mockOnCancel}
@@ -116,14 +166,23 @@ describe('ExpandTextDialog', () => {
116166

117167
it('should render title in richtext mode', () => {
118168
render(
119-
<ExpandTextDialog open={true} value='' title='Content' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
169+
<ExpandTextDialog
170+
open={true}
171+
value=''
172+
title='Content'
173+
inputType='string'
174+
onConfirm={mockOnConfirm}
175+
onCancel={mockOnCancel}
176+
/>
120177
)
121178

122179
expect(screen.getByText('Content')).toBeInTheDocument()
123180
})
124181

125182
it('should call onCancel when Cancel is clicked in richtext mode', () => {
126-
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
183+
render(
184+
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
185+
)
127186

128187
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
129188

packages/agentflow/src/atoms/ExpandTextDialog.tsx

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useState } from 'react'
1+
import { useCallback, useState } from 'react'
22

33
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, Typography } from '@mui/material'
44

@@ -10,8 +10,9 @@ export interface ExpandTextDialogProps {
1010
title?: string
1111
placeholder?: string
1212
disabled?: boolean
13-
/** Editor mode — 'text' renders a plain TextField, 'richtext' renders the TipTap RichTextEditor. */
14-
mode?: 'text' | 'richtext'
13+
/** The input param type — determines which editor to render. 'string' uses the TipTap RichTextEditor; others fall back to a plain TextField. */
14+
// TODO: handle 'code' type separately with a dedicated CodeMirror editor
15+
inputType?: string
1516
onConfirm: (value: string) => void
1617
onCancel: () => void
1718
}
@@ -26,18 +27,22 @@ export function ExpandTextDialog({
2627
title,
2728
placeholder,
2829
disabled = false,
29-
mode = 'text',
30+
inputType = 'string',
3031
onConfirm,
3132
onCancel
3233
}: ExpandTextDialogProps) {
3334
const [localValue, setLocalValue] = useState(value)
35+
const [prevOpen, setPrevOpen] = useState(open)
3436

35-
// Sync local state when the value prop changes while dialog is open
36-
useEffect(() => {
37-
if (open) {
38-
setLocalValue(value)
39-
}
40-
}, [open, value])
37+
// Sync localValue synchronously when the dialog opens so the TipTap editor
38+
// initialises with the correct content (useEffect would leave a one-render
39+
// gap where localValue is stale, causing the editor to show empty/old text).
40+
if (open && !prevOpen) {
41+
setLocalValue(value)
42+
setPrevOpen(true)
43+
} else if (!open && prevOpen) {
44+
setPrevOpen(false)
45+
}
4146

4247
const handleConfirm = useCallback(() => {
4348
onConfirm(localValue)
@@ -51,7 +56,7 @@ export function ExpandTextDialog({
5156
{title}
5257
</Typography>
5358
)}
54-
{mode === 'richtext' ? (
59+
{inputType === 'string' ? (
5560
<Box
5661
sx={{
5762
borderRadius: '12px',

packages/agentflow/src/atoms/MessagesInput.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ export function MessagesInput({ inputParam, data, disabled = false, onDataChange
232232
title='Content'
233233
placeholder='Message content (supports {{ variable }} syntax)'
234234
disabled={disabled}
235-
mode='richtext'
235+
inputType='string'
236236
onConfirm={handleExpandConfirm}
237237
onCancel={handleExpandCancel}
238238
/>

packages/agentflow/src/atoms/NodeInputHandler.test.tsx

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,31 @@ jest.mock('reactflow', () => ({
1414
useUpdateNodeInternals: () => jest.fn()
1515
}))
1616

17+
jest.mock('./RichTextEditor.lazy', () => ({
18+
RichTextEditor: ({
19+
value,
20+
onChange,
21+
placeholder,
22+
disabled
23+
}: {
24+
value: string
25+
onChange: (v: string) => void
26+
placeholder?: string
27+
disabled?: boolean
28+
}) => (
29+
<textarea
30+
data-testid='rich-text-editor'
31+
value={value}
32+
onChange={(e) => onChange(e.target.value)}
33+
placeholder={placeholder || ''}
34+
disabled={disabled}
35+
/>
36+
)
37+
}))
38+
1739
jest.mock('@tabler/icons-react', () => ({
1840
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
41+
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
1942
IconVariable: () => <span data-testid='icon-variable' />,
2043
IconRefresh: () => <span data-testid='icon-refresh' />
2144
}))
@@ -77,7 +100,7 @@ describe('NodeInputHandler – static types', () => {
77100
})
78101

79102
describe('NodeInputHandler – expand dialog', () => {
80-
it('should open expand dialog when expand icon is clicked on multiline string field', () => {
103+
it('should render richtext inline and in expand dialog for multiline string field', () => {
81104
render(
82105
<NodeInputHandler
83106
inputParam={makeParam({ type: 'string', rows: 4 })}
@@ -87,13 +110,19 @@ describe('NodeInputHandler – expand dialog', () => {
87110
/>
88111
)
89112

90-
fireEvent.click(screen.getByTitle('Expand'))
113+
// Inline editor is a RichTextEditor
114+
const editors = screen.getAllByTestId('rich-text-editor')
115+
expect(editors[0]).toHaveValue('Some long text')
91116

92-
const expandInput = screen.getByTestId('expand-content-input').querySelector('textarea')!
93-
expect(expandInput).toHaveValue('Some long text')
117+
// Expand opens a second RichTextEditor (not a plain textarea)
118+
fireEvent.click(screen.getByTitle('Expand'))
119+
const expandedEditors = screen.getAllByTestId('rich-text-editor')
120+
expect(expandedEditors).toHaveLength(2)
121+
expect(expandedEditors[1]).toHaveValue('Some long text')
122+
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
94123
})
95124

96-
it('should save expanded content via onDataChange on confirm', () => {
125+
it('should save expanded richtext content via onDataChange on confirm', () => {
97126
render(
98127
<NodeInputHandler
99128
inputParam={makeParam({ type: 'string', rows: 4 })}
@@ -105,8 +134,9 @@ describe('NodeInputHandler – expand dialog', () => {
105134

106135
fireEvent.click(screen.getByTitle('Expand'))
107136

108-
const expandTextarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
109-
fireEvent.change(expandTextarea, { target: { value: 'Expanded text' } })
137+
// Target the expand dialog's editor (second instance)
138+
const editors = screen.getAllByTestId('rich-text-editor')
139+
fireEvent.change(editors[1], { target: { value: 'Expanded text' } })
110140
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
111141

112142
expect(mockOnDataChange).toHaveBeenCalledWith({
@@ -115,6 +145,24 @@ describe('NodeInputHandler – expand dialog', () => {
115145
})
116146
})
117147

148+
it('should reflect updated data prop in expand dialog after rerender', () => {
149+
const param = makeParam({ type: 'string', rows: 4 })
150+
const initialData = { ...baseNodeData, inputValues: { myField: '' } }
151+
152+
const { rerender } = render(
153+
<NodeInputHandler inputParam={param} data={initialData} isAdditionalParams onDataChange={mockOnDataChange} />
154+
)
155+
156+
// Simulate parent updating data after user types in inline editor
157+
const updatedData = { ...baseNodeData, inputValues: { myField: '<p>Updated instructions</p>' } }
158+
rerender(<NodeInputHandler inputParam={param} data={updatedData} isAdditionalParams onDataChange={mockOnDataChange} />)
159+
160+
// Open expand dialog — it should show the updated value, not the initial empty value
161+
fireEvent.click(screen.getByTitle('Expand'))
162+
const editors = screen.getAllByTestId('rich-text-editor')
163+
expect(editors[1]).toHaveValue('<p>Updated instructions</p>')
164+
})
165+
118166
it('should not show expand icon for non-multiline string fields', () => {
119167
render(
120168
<NodeInputHandler

0 commit comments

Comments
 (0)