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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Jocelyn Lin
2026-03-19 09:59:23 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 172523f25c
commit 21e6106abb
20 changed files with 922 additions and 157 deletions
+1
View File
@@ -47,6 +47,7 @@ module.exports = {
'./src/atoms/ArrayInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/atoms/ExpandTextDialog.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/atoms/MessagesInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
'./src/atoms/ScenariosInput.tsx': { branches: 80, functions: 80, lines: 80, statements: 80 },
// Tier 3 UI atom — only the onChange/disabled/sync logic is tested, not styled internals
'./src/atoms/RichTextEditor.tsx': { branches: 30, functions: 50, lines: 50, statements: 50 },
'./src/core/': { branches: 80, functions: 80, lines: 80, statements: 80 },
@@ -13,32 +13,32 @@ beforeEach(() => {
describe('ExpandTextDialog', () => {
it('should not render content when closed', () => {
render(<ExpandTextDialog open={false} value='' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={false} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
})
it('should render with the provided value when open', () => {
render(<ExpandTextDialog open={true} value='Hello world' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Hello world' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea).toHaveValue('Hello world')
})
it('should render title when provided', () => {
render(<ExpandTextDialog open={true} value='' title='Content' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='' title='Content' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should not render title when not provided', () => {
render(<ExpandTextDialog open={true} value='' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.queryByRole('heading')).not.toBeInTheDocument()
})
it('should call onConfirm with edited value when Save is clicked', () => {
render(<ExpandTextDialog open={true} value='Original' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'Updated' } })
@@ -48,7 +48,7 @@ describe('ExpandTextDialog', () => {
})
it('should call onCancel when Cancel is clicked', () => {
render(<ExpandTextDialog open={true} value='Original' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
@@ -57,7 +57,9 @@ describe('ExpandTextDialog', () => {
})
it('should disable textarea and Save button when disabled', () => {
render(<ExpandTextDialog open={true} value='test' disabled={true} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog open={true} value='test' inputType='code' disabled={true} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea).toBeDisabled()
@@ -65,17 +67,63 @@ describe('ExpandTextDialog', () => {
})
it('should render placeholder when provided', () => {
render(<ExpandTextDialog open={true} value='' placeholder='Type here...' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog
open={true}
value=''
inputType='code'
placeholder='Type here...'
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea).toHaveAttribute('placeholder', 'Type here...')
})
it('should show current value when opened after value changed while closed', () => {
const { rerender } = render(
<ExpandTextDialog open={false} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// Simulate value changing while dialog is closed (user typing in inline editor)
rerender(<ExpandTextDialog open={false} value='Updated text' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// Open the dialog — it should show the updated value, not the initial empty value
rerender(<ExpandTextDialog open={true} value='Updated text' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea).toHaveValue('Updated text')
})
it('should reset to current value when re-opened after cancel', () => {
const { rerender } = render(
<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// User types in the dialog then cancels
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'Unsaved edits' } })
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
// Close the dialog
rerender(<ExpandTextDialog open={false} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
// Re-open — should show the original value, not the unsaved edits
rerender(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea2 = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea2).toHaveValue('Original')
})
// --- Rich text mode ---
describe('mode="richtext"', () => {
describe('inputType="string" (richtext)', () => {
it('should render the TipTap editor instead of a TextField', async () => {
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// RichTextEditor renders data-testid='rich-text-editor' which wraps tiptap
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
@@ -85,15 +133,17 @@ describe('ExpandTextDialog', () => {
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
})
it('should render plain TextField in default text mode', () => {
render(<ExpandTextDialog open={true} value='Hello' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
it('should render plain TextField for non-string input types', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should still show Save and Cancel buttons in richtext mode', () => {
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
@@ -104,7 +154,7 @@ describe('ExpandTextDialog', () => {
<ExpandTextDialog
open={true}
value='<p>Hello</p>'
mode='richtext'
inputType='string'
disabled={true}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
@@ -116,14 +166,23 @@ describe('ExpandTextDialog', () => {
it('should render title in richtext mode', () => {
render(
<ExpandTextDialog open={true} value='' title='Content' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
<ExpandTextDialog
open={true}
value=''
title='Content'
inputType='string'
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should call onCancel when Cancel is clicked in richtext mode', () => {
render(<ExpandTextDialog open={true} value='<p>Hello</p>' mode='richtext' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useState } from 'react'
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, Typography } from '@mui/material'
@@ -10,8 +10,9 @@ export interface ExpandTextDialogProps {
title?: string
placeholder?: string
disabled?: boolean
/** Editor mode — 'text' renders a plain TextField, 'richtext' renders the TipTap RichTextEditor. */
mode?: 'text' | 'richtext'
/** The input param type — determines which editor to render. 'string' uses the TipTap RichTextEditor; others fall back to a plain TextField. */
// TODO: handle 'code' type separately with a dedicated CodeMirror editor
inputType?: string
onConfirm: (value: string) => void
onCancel: () => void
}
@@ -26,18 +27,22 @@ export function ExpandTextDialog({
title,
placeholder,
disabled = false,
mode = 'text',
inputType = 'string',
onConfirm,
onCancel
}: ExpandTextDialogProps) {
const [localValue, setLocalValue] = useState(value)
const [prevOpen, setPrevOpen] = useState(open)
// Sync local state when the value prop changes while dialog is open
useEffect(() => {
if (open) {
setLocalValue(value)
}
}, [open, value])
// Sync localValue synchronously when the dialog opens so the TipTap editor
// initialises with the correct content (useEffect would leave a one-render
// gap where localValue is stale, causing the editor to show empty/old text).
if (open && !prevOpen) {
setLocalValue(value)
setPrevOpen(true)
} else if (!open && prevOpen) {
setPrevOpen(false)
}
const handleConfirm = useCallback(() => {
onConfirm(localValue)
@@ -51,7 +56,7 @@ export function ExpandTextDialog({
{title}
</Typography>
)}
{mode === 'richtext' ? (
{inputType === 'string' ? (
<Box
sx={{
borderRadius: '12px',
@@ -232,7 +232,7 @@ export function MessagesInput({ inputParam, data, disabled = false, onDataChange
title='Content'
placeholder='Message content (supports {{ variable }} syntax)'
disabled={disabled}
mode='richtext'
inputType='string'
onConfirm={handleExpandConfirm}
onCancel={handleExpandCancel}
/>
@@ -14,8 +14,31 @@ jest.mock('reactflow', () => ({
useUpdateNodeInternals: () => jest.fn()
}))
jest.mock('./RichTextEditor.lazy', () => ({
RichTextEditor: ({
value,
onChange,
placeholder,
disabled
}: {
value: string
onChange: (v: string) => void
placeholder?: string
disabled?: boolean
}) => (
<textarea
data-testid='rich-text-editor'
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder || ''}
disabled={disabled}
/>
)
}))
jest.mock('@tabler/icons-react', () => ({
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
IconVariable: () => <span data-testid='icon-variable' />,
IconRefresh: () => <span data-testid='icon-refresh' />
}))
@@ -77,7 +100,7 @@ describe('NodeInputHandler – static types', () => {
})
describe('NodeInputHandler – expand dialog', () => {
it('should open expand dialog when expand icon is clicked on multiline string field', () => {
it('should render richtext inline and in expand dialog for multiline string field', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string', rows: 4 })}
@@ -87,13 +110,19 @@ describe('NodeInputHandler – expand dialog', () => {
/>
)
fireEvent.click(screen.getByTitle('Expand'))
// Inline editor is a RichTextEditor
const editors = screen.getAllByTestId('rich-text-editor')
expect(editors[0]).toHaveValue('Some long text')
const expandInput = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(expandInput).toHaveValue('Some long text')
// Expand opens a second RichTextEditor (not a plain textarea)
fireEvent.click(screen.getByTitle('Expand'))
const expandedEditors = screen.getAllByTestId('rich-text-editor')
expect(expandedEditors).toHaveLength(2)
expect(expandedEditors[1]).toHaveValue('Some long text')
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
})
it('should save expanded content via onDataChange on confirm', () => {
it('should save expanded richtext content via onDataChange on confirm', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string', rows: 4 })}
@@ -105,8 +134,9 @@ describe('NodeInputHandler – expand dialog', () => {
fireEvent.click(screen.getByTitle('Expand'))
const expandTextarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
fireEvent.change(expandTextarea, { target: { value: 'Expanded text' } })
// Target the expand dialog's editor (second instance)
const editors = screen.getAllByTestId('rich-text-editor')
fireEvent.change(editors[1], { target: { value: 'Expanded text' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(mockOnDataChange).toHaveBeenCalledWith({
@@ -115,6 +145,24 @@ describe('NodeInputHandler – expand dialog', () => {
})
})
it('should reflect updated data prop in expand dialog after rerender', () => {
const param = makeParam({ type: 'string', rows: 4 })
const initialData = { ...baseNodeData, inputValues: { myField: '' } }
const { rerender } = render(
<NodeInputHandler inputParam={param} data={initialData} isAdditionalParams onDataChange={mockOnDataChange} />
)
// Simulate parent updating data after user types in inline editor
const updatedData = { ...baseNodeData, inputValues: { myField: '<p>Updated instructions</p>' } }
rerender(<NodeInputHandler inputParam={param} data={updatedData} isAdditionalParams onDataChange={mockOnDataChange} />)
// Open expand dialog — it should show the updated value, not the initial empty value
fireEvent.click(screen.getByTitle('Expand'))
const editors = screen.getAllByTestId('rich-text-editor')
expect(editors[1]).toHaveValue('<p>Updated instructions</p>')
})
it('should not show expand icon for non-multiline string fields', () => {
render(
<NodeInputHandler
@@ -5,12 +5,13 @@ import { Box, FormControlLabel, IconButton, MenuItem, Select, Switch, TextField,
import Autocomplete from '@mui/material/Autocomplete'
import { styled, useTheme } from '@mui/material/styles'
import { tooltipClasses } from '@mui/material/Tooltip'
import { IconArrowsMaximize, IconVariable } from '@tabler/icons-react'
import { IconArrowsMaximize, IconInfoCircle, IconVariable } from '@tabler/icons-react'
import type { InputAnchor, InputParam, NodeData } from '@/core/types'
import ArrayInput from './ArrayInput'
import { ExpandTextDialog } from './ExpandTextDialog'
import { RichTextEditor } from './RichTextEditor.lazy'
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
[`& .${tooltipClasses.tooltip}`]: {
@@ -142,6 +143,28 @@ export function NodeInputHandler({
switch (inputParam.type) {
case 'string':
if (isExpandable) {
return (
<RichTextEditor
value={typeof value === 'string' ? value : ''}
onChange={(html) => handleDataChange(html)}
placeholder={inputParam.placeholder}
disabled={disabled}
rows={inputParam.rows || 4}
/>
)
}
return (
<TextField
fullWidth
size='small'
disabled={disabled}
placeholder={inputParam.placeholder}
value={value}
onChange={(e) => handleDataChange(e.target.value)}
sx={{ mt: 1 }}
/>
)
case 'password':
case 'number':
return (
@@ -149,9 +172,7 @@ export function NodeInputHandler({
fullWidth
size='small'
disabled={disabled}
type={inputParam.type === 'password' ? 'password' : inputParam.type === 'number' ? 'number' : 'text'}
multiline={!!inputParam.rows && inputParam.rows > 1}
rows={inputParam.rows || undefined}
type={inputParam.type === 'password' ? 'password' : 'number'}
placeholder={inputParam.placeholder}
value={value}
onChange={(e) => handleDataChange(e.target.value)}
@@ -350,6 +371,13 @@ export function NodeInputHandler({
<Typography>
{inputParam?.label}
{!inputParam?.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
{inputParam?.description && (
<Tooltip title={inputParam.description} placement='top'>
<span style={{ display: 'inline-flex', verticalAlign: 'middle', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
)}
</Typography>
<div style={{ flexGrow: 1 }} />
{inputParam?.acceptVariable && inputParam?.type === 'string' && (
@@ -386,6 +414,7 @@ export function NodeInputHandler({
title={inputParam?.label}
placeholder={inputParam?.placeholder}
disabled={disabled}
inputType={inputParam?.type}
onConfirm={handleExpandConfirm}
onCancel={() => setExpandOpen(false)}
/>
@@ -0,0 +1,201 @@
import { makeNodeData } from '@test-utils/factories'
import { fireEvent, render, screen } from '@testing-library/react'
import type { InputParam, NodeData } from '@/core/types'
import { ScenariosInput } from './ScenariosInput'
// --- Mocks ---
const mockOnDataChange = jest.fn()
jest.mock('./NodeInputHandler', () => ({
NodeInputHandler: ({
inputParam,
onDataChange
}: {
inputParam: InputParam
data: NodeData
onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
}) => (
<div data-testid={`input-handler-${inputParam.name}`}>
<label>{inputParam.label}</label>
<input data-testid={`input-${inputParam.name}`} onChange={(e) => onDataChange({ inputParam, newValue: e.target.value })} />
</div>
)
}))
jest.mock('@tabler/icons-react', () => ({
IconInfoCircle: () => <span data-testid='icon-info' />,
IconPlus: () => <span data-testid='icon-plus' />,
IconTrash: () => <span data-testid='icon-trash' />
}))
const scenarioInputParam: InputParam = {
id: 'conditionAgentScenarios',
name: 'conditionAgentScenarios',
label: 'Scenarios',
type: 'array',
array: [{ id: 'scenario', name: 'scenario', label: 'Scenario', type: 'string', default: '' } as InputParam]
}
const mockNodeData = makeNodeData({
id: 'conditionAgentAgentflow_0',
name: 'conditionAgentAgentflow',
label: 'Condition Agent',
inputValues: {}
})
describe('ScenariosInput', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should render section header with label and required indicator', () => {
render(<ScenariosInput inputParam={scenarioInputParam} data={mockNodeData} onDataChange={mockOnDataChange} />)
expect(screen.getByText('Scenarios')).toBeInTheDocument()
expect(screen.getByText('*')).toBeInTheDocument()
})
it('should render description tooltip when inputParam has description', () => {
const paramWithDesc: InputParam = { ...scenarioInputParam, description: 'Define scenarios for splitting' }
render(<ScenariosInput inputParam={paramWithDesc} data={mockNodeData} onDataChange={mockOnDataChange} />)
expect(screen.getByTestId('icon-info')).toBeInTheDocument()
})
it('should render scenario items with "Scenario N" labels', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }, { scenario: 'User is angry' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} onDataChange={mockOnDataChange} />)
expect(screen.getByText('Scenario 0')).toBeInTheDocument()
expect(screen.getByText('Scenario 1')).toBeInTheDocument()
})
it('should always render Else indicator', () => {
render(<ScenariosInput inputParam={scenarioInputParam} data={mockNodeData} onDataChange={mockOnDataChange} />)
expect(screen.getByText('Else')).toBeInTheDocument()
expect(screen.getByText('Executes when no scenarios match')).toBeInTheDocument()
})
it('should render Add Scenario button', () => {
render(<ScenariosInput inputParam={scenarioInputParam} data={mockNodeData} onDataChange={mockOnDataChange} />)
expect(screen.getByRole('button', { name: /Add Scenario/i })).toBeInTheDocument()
})
it('should add a new scenario with default values', () => {
render(<ScenariosInput inputParam={scenarioInputParam} data={mockNodeData} onDataChange={mockOnDataChange} />)
fireEvent.click(screen.getByRole('button', { name: /Add Scenario/i }))
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: scenarioInputParam,
newValue: [{ scenario: '' }]
})
})
it('should delete a scenario item', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }, { scenario: 'User is angry' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} onDataChange={mockOnDataChange} />)
const deleteButtons = screen.getAllByTitle('Delete')
fireEvent.click(deleteButtons[0])
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: scenarioInputParam,
newValue: [{ scenario: 'User is angry' }]
})
})
it('should handle nested field changes within a scenario', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} onDataChange={mockOnDataChange} />)
const scenarioInput = screen.getByTestId('input-scenario')
fireEvent.change(scenarioInput, { target: { value: 'User is neutral' } })
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: scenarioInputParam,
newValue: [{ scenario: 'User is neutral' }]
})
})
it('should disable buttons when disabled prop is true', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} disabled={true} onDataChange={mockOnDataChange} />)
expect(screen.getByRole('button', { name: /Add Scenario/i })).toBeDisabled()
expect(screen.getByTitle('Delete')).toBeDisabled()
})
it('should respect minItems constraint', () => {
const inputParamWithMin: InputParam = { ...scenarioInputParam, minItems: 1 }
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }]
}
})
render(<ScenariosInput inputParam={inputParamWithMin} data={data} onDataChange={mockOnDataChange} />)
expect(screen.getByTitle('Delete')).toBeDisabled()
})
it('should render fields for each scenario item', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'User is happy' }, { scenario: 'User is angry' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} onDataChange={mockOnDataChange} />)
expect(screen.getAllByTestId('input-handler-scenario')).toHaveLength(2)
})
it('should append to existing scenarios when adding', () => {
const data = makeNodeData({
...mockNodeData,
inputValues: {
conditionAgentScenarios: [{ scenario: 'Existing scenario' }]
}
})
render(<ScenariosInput inputParam={scenarioInputParam} data={data} onDataChange={mockOnDataChange} />)
fireEvent.click(screen.getByRole('button', { name: /Add Scenario/i }))
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: scenarioInputParam,
newValue: [{ scenario: 'Existing scenario' }, { scenario: '' }]
})
})
})
@@ -0,0 +1,185 @@
import { useCallback, useMemo } from 'react'
import { Box, Button, Chip, IconButton, Tooltip, Typography } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconInfoCircle, IconPlus, IconTrash } from '@tabler/icons-react'
import type { InputParam, NodeData } from '@/core/types'
import { NodeInputHandler } from './NodeInputHandler'
import { useStableKeys } from './useStableKeys'
export interface ScenariosInputProps {
inputParam: InputParam
data: NodeData
disabled?: boolean
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
}
/**
* Array input for ConditionAgent scenario strings.
* Each entry creates a dynamic output anchor (e.g., Scenario 0, Scenario 1).
* The UI also includes a visual indicator for the implicit "Else" case, which executes when no scenarios match.
* Simpler than ConditionBuilder — each item has a single string field.
*/
export function ScenariosInput({ inputParam, data, disabled = false, onDataChange }: ScenariosInputProps) {
const theme = useTheme()
const arrayItems = useMemo(
() => (Array.isArray(data.inputValues?.[inputParam.name]) ? (data.inputValues[inputParam.name] as Record<string, unknown>[]) : []),
[data.inputValues, inputParam.name]
)
const { keys: effectiveKeys, removeKey } = useStableKeys(arrayItems.length, 'scenario')
const handleItemInputChange = useCallback(
(itemIndex: number, changedParam: InputParam, newValue: unknown) => {
const updatedArrayItems = [...arrayItems]
const updatedItem = { ...updatedArrayItems[itemIndex] }
updatedItem[changedParam.name] = newValue
updatedArrayItems[itemIndex] = updatedItem
onDataChange?.({ inputParam, newValue: updatedArrayItems })
},
[arrayItems, inputParam, onDataChange]
)
const handleAddItem = useCallback(() => {
const newItem: Record<string, unknown> = {}
if (inputParam.array) {
for (const field of inputParam.array) {
if (field.default != null) {
newItem[field.name] = field.default
} else {
newItem[field.name] = ''
}
}
}
onDataChange?.({ inputParam, newValue: [...arrayItems, newItem] })
}, [arrayItems, inputParam, onDataChange])
const handleDeleteItem = useCallback(
(indexToDelete: number) => {
removeKey(indexToDelete)
onDataChange?.({ inputParam, newValue: arrayItems.filter((_, i) => i !== indexToDelete) })
},
[arrayItems, inputParam, onDataChange, removeKey]
)
const itemHandlers = useMemo(
() =>
arrayItems.map((_, index) => ({ inputParam: changedParam, newValue }: { inputParam: InputParam; newValue: unknown }) => {
handleItemInputChange(index, changedParam, newValue)
}),
[arrayItems, handleItemInputChange]
)
const canDeleteItem = !inputParam.minItems || arrayItems.length > inputParam.minItems
return (
<Box sx={{ p: 2 }}>
<Typography>
{inputParam.label}
{!inputParam.optional && <span style={{ color: 'red' }}>&nbsp;*</span>}
{inputParam.description && (
<Tooltip title={inputParam.description} placement='top'>
<span style={{ display: 'inline-flex', verticalAlign: 'middle', marginLeft: 6, cursor: 'pointer' }}>
<IconInfoCircle size={16} style={{ opacity: 0.6 }} />
</span>
</Tooltip>
)}
</Typography>
{arrayItems.map((itemValues, index) => {
const itemData: NodeData = {
...data,
inputValues: itemValues
}
return (
<Box
key={effectiveKeys[index]}
sx={{
p: 2,
mt: 2,
mb: 1,
border: 1,
borderColor: theme.palette.grey[300],
borderRadius: 2,
position: 'relative'
}}
>
<IconButton
title='Delete'
onClick={() => handleDeleteItem(index)}
disabled={disabled || !canDeleteItem}
sx={{
position: 'absolute',
height: 35,
width: 35,
right: 10,
top: 10,
'&:hover': { color: theme.palette.error.main },
...(!canDeleteItem && {
opacity: 0.3,
cursor: 'not-allowed'
})
}}
>
<IconTrash />
</IconButton>
<Chip label={`Scenario ${index}`} size='small' sx={{ position: 'absolute', right: 55, top: 16 }} />
{(inputParam.array || [])
.filter((param) => param.display !== false)
.map((param) => (
<NodeInputHandler
key={param.name}
inputParam={param}
data={itemData}
disabled={disabled}
isAdditionalParams={true}
disablePadding={false}
onDataChange={itemHandlers[index]}
/>
))}
</Box>
)
})}
{/* Else indicator */}
<Box
sx={{
p: 2,
mt: 2,
mb: 1,
border: 1,
borderColor: theme.palette.grey[300],
borderRadius: 2,
backgroundColor: theme.palette.action.hover
}}
>
<Typography variant='body2' color='text.secondary' fontWeight={500}>
Else
</Typography>
<Typography variant='caption' color='text.secondary'>
Executes when no scenarios match
</Typography>
</Box>
<Button
fullWidth
size='small'
variant='outlined'
disabled={disabled}
sx={{ borderRadius: '16px', mt: 2 }}
startIcon={<IconPlus />}
onClick={handleAddItem}
>
Add Scenario
</Button>
</Box>
)
}
export default ScenariosInput
+1
View File
@@ -10,4 +10,5 @@ export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler
// from ./RichTextEditor would defeat code-splitting since barrel imports are resolved eagerly.
export type { RichTextEditorProps } from './RichTextEditor'
export { RichTextEditor } from './RichTextEditor.lazy'
export { ScenariosInput, type ScenariosInputProps } from './ScenariosInput'
export { StructuredOutputBuilder, type StructuredOutputBuilderProps, type StructuredOutputEntry } from './StructuredOutputBuilder'
@@ -41,7 +41,7 @@ export interface AgentflowProps {
/** Initial flow data to render */
initialFlow?: FlowData
/** Flow ID for loading existing flow */
/** Flow ID (placeholder — not yet implemented; reserved for future use such as executing or prediction API) */
flowId?: string
/** Array of allowed node component names to show in palette */
@@ -282,4 +282,36 @@ describe('initNode', () => {
expect.objectContaining({ id: 'conditionAgentflow_0-output-1', name: '1', label: '1', description: 'Else' })
)
})
it('should generate dynamic outputAnchors for conditionAgentAgentflow nodes', () => {
const conditionAgentNodeData = makeNodeData({
name: 'conditionAgentAgentflow',
label: 'Condition Agent',
inputs: [
{
id: 'conditionAgentScenarios',
name: 'conditionAgentScenarios',
label: 'Scenarios',
type: 'array',
default: [{ scenario: '' }, { scenario: '' }],
array: [{ id: 'scenario', name: 'scenario', label: 'Scenario', type: 'string' }]
}
],
outputs: [
{ label: '0', name: '0', type: 'output' },
{ label: '1', name: '1', type: 'output' }
]
} as Partial<NodeData>)
const result = initNode(conditionAgentNodeData, 'conditionAgentAgentflow_0')
// 2 default scenarios → 2 anchors (Scenario 0, Scenario 1) — no Else port
expect(result.outputAnchors).toHaveLength(2)
expect(result.outputAnchors![0]).toEqual(
expect.objectContaining({ id: 'conditionAgentAgentflow_0-output-0', name: '0', label: '0', description: 'Scenario 0' })
)
expect(result.outputAnchors![1]).toEqual(
expect.objectContaining({ id: 'conditionAgentAgentflow_0-output-1', name: '1', label: '1', description: 'Scenario 1' })
)
})
})
@@ -188,6 +188,11 @@ export function initNode(nodeData: NodeData, newNodeId: string, isAgentflow = tr
const conditions = initialInputValues.conditions
const conditionCount = Array.isArray(conditions) ? conditions.length : 0
outputAnchors = buildDynamicOutputAnchors(newNodeId, conditionCount, 'Condition', true)
} else if (nodeData.name === 'conditionAgentAgentflow') {
// ConditionAgent outputs match scenario count exactly (no separate Else port)
const scenarios = initialInputValues.conditionAgentScenarios
const scenarioCount = Array.isArray(scenarios) ? scenarios.length : 0
outputAnchors = buildDynamicOutputAnchors(newNodeId, scenarioCount, 'Scenario', false)
} else {
outputAnchors = createAgentFlowOutputs(nodeData, newNodeId)
}
@@ -49,17 +49,19 @@ function NodeToolbarActionsComponent({ nodeId, nodeName, isVisible, onInfoClick
<IconCopy size={20} />
</IconButton>
)}
<IconButton
size='small'
title='Edit'
onClick={handleEditClick}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.primary.main }
}}
>
<IconEdit size={20} />
</IconButton>
{nodeName !== 'stickyNoteAgentflow' && (
<IconButton
size='small'
title='Edit'
onClick={handleEditClick}
sx={{
color: isDarkMode ? 'white' : theme.palette.grey[600],
'&:hover': { color: theme.palette.primary.main }
}}
>
<IconEdit size={20} />
</IconButton>
)}
<IconButton
size='small'
title='Delete'
@@ -120,6 +120,56 @@ describe('useFlowHandlers', () => {
expect(onFlowChange).not.toHaveBeenCalled()
})
it('should compute numeric edgeLabel for conditionAgentflow source', () => {
nodes = [makeFlowNode('c', { data: { id: 'c', name: 'conditionAgentflow', label: 'Condition' } }), makeFlowNode('b')]
const { result } = renderUseFlowHandlers()
act(() => {
result.current.handleConnect({ source: 'c', target: 'b', sourceHandle: 'c-output-2', targetHandle: null })
})
expect(onFlowChange).toHaveBeenCalledWith(
expect.objectContaining({
edges: expect.arrayContaining([expect.objectContaining({ data: expect.objectContaining({ edgeLabel: '2' }) })])
})
)
})
it('should compute numeric edgeLabel for conditionAgentAgentflow source', () => {
nodes = [
makeFlowNode('ca', { data: { id: 'ca', name: 'conditionAgentAgentflow', label: 'Condition Agent' } }),
makeFlowNode('b')
]
const { result } = renderUseFlowHandlers()
act(() => {
result.current.handleConnect({ source: 'ca', target: 'b', sourceHandle: 'ca-output-1', targetHandle: null })
})
expect(onFlowChange).toHaveBeenCalledWith(
expect.objectContaining({
edges: expect.arrayContaining([expect.objectContaining({ data: expect.objectContaining({ edgeLabel: '1' }) })])
})
)
})
it('should compute proceed/reject edgeLabel for humanInputAgentflow source', () => {
nodes = [makeFlowNode('h', { data: { id: 'h', name: 'humanInputAgentflow', label: 'Human Input' } }), makeFlowNode('b')]
const { result } = renderUseFlowHandlers()
act(() => {
result.current.handleConnect({ source: 'h', target: 'b', sourceHandle: 'h-output-0', targetHandle: null })
})
expect(onFlowChange).toHaveBeenCalledWith(
expect.objectContaining({
edges: expect.arrayContaining([
expect.objectContaining({ data: expect.objectContaining({ edgeLabel: 'proceed', isHumanInput: true }) })
])
})
)
})
it('should not call onFlowChange when source or target is missing', () => {
const { result } = renderUseFlowHandlers()
@@ -56,15 +56,29 @@ export function useFlowHandlers({
const sourceNode = nodes.find((n) => n.id === params.source)
const targetNode = nodes.find((n) => n.id === params.target)
const sourceColor = getNodeColor(sourceNode?.data?.name || '')
const sourceName = sourceNode?.data?.name || ''
const sourceColor = getNodeColor(sourceName)
const targetColor = getNodeColor(targetNode?.data?.name || '')
// Compute edge label for nodes with dynamic output ports
let edgeLabel: string | undefined
if (sourceName === 'conditionAgentflow' || sourceName === 'conditionAgentAgentflow') {
const raw = params.sourceHandle?.split('-').pop() || '0'
edgeLabel = (isNaN(Number(raw)) ? 0 : raw).toString()
}
if (sourceName === 'humanInputAgentflow') {
const raw = params.sourceHandle?.split('-').pop() || '0'
edgeLabel = raw === '0' ? 'proceed' : 'reject'
}
const newEdge = {
...params,
type: 'agentflowEdge',
data: {
sourceColor,
targetColor
targetColor,
edgeLabel,
isHumanInput: sourceName === 'humanInputAgentflow'
}
}
// Use functional updater to avoid stale edge state from rapid sequential connections
@@ -33,7 +33,7 @@ jest.mock('./useDynamicOutputPorts', () => ({
jest.mock('@/core/utils', () => ({
...jest.requireActual('@/core/utils'),
buildDynamicOutputAnchors: (nodeId: string, count: number, labelPrefix: string) => {
buildDynamicOutputAnchors: (nodeId: string, count: number, labelPrefix: string, includeElse: boolean = true) => {
const anchors = []
for (let i = 0; i < count; i++) {
anchors.push({
@@ -44,7 +44,9 @@ jest.mock('@/core/utils', () => ({
description: `${labelPrefix} ${i}`
})
}
anchors.push({ id: `${nodeId}-output-${count}`, name: `${count}`, label: `${count}`, type: labelPrefix, description: 'Else' })
if (includeElse) {
anchors.push({ id: `${nodeId}-output-${count}`, name: `${count}`, label: `${count}`, type: labelPrefix, description: 'Else' })
}
return anchors
}
}))
@@ -190,6 +192,32 @@ jest.mock('@/atoms', () => ({
</button>
</div>
)
},
ScenariosInput: ({
inputParam,
onDataChange,
data
}: {
inputParam: InputParam
data: NodeData
onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
}) => {
const currentArray = (data.inputValues?.[inputParam.name] as Record<string, unknown>[]) || []
return (
<div data-testid='scenarios-input'>
<button
data-testid='add-scenario'
onClick={() => {
onDataChange({
inputParam,
newValue: [...currentArray, { scenario: '' }]
})
}}
>
Add Scenario
</button>
</div>
)
}
}))
@@ -666,17 +694,95 @@ describe('EditNodeDialog', () => {
fireEvent.click(screen.getByTestId('add-condition'))
// Should merge inputValues and outputAnchors into a single updateNodeData call
expect(mockUpdateNodeData).toHaveBeenCalledWith('conditionAgentflow_0', {
inputValues: expect.objectContaining({ conditions: expect.any(Array) }),
outputAnchors: expect.arrayContaining([
expect.objectContaining({ description: 'Condition 0' }),
expect.objectContaining({ description: 'Condition 1' }),
expect.objectContaining({ description: 'Else' })
])
})
// Should call cleanupOrphanedEdges with the new count
// Should merge inputValues, outputAnchors, and cleaned edges into a single updateNodeData call
expect(mockCleanupOrphanedEdges).toHaveBeenCalledWith(2)
expect(mockUpdateNodeData).toHaveBeenCalledWith(
'conditionAgentflow_0',
{
inputValues: expect.objectContaining({ conditions: expect.any(Array) }),
outputAnchors: expect.arrayContaining([
expect.objectContaining({ description: 'Condition 0' }),
expect.objectContaining({ description: 'Condition 1' }),
expect.objectContaining({ description: 'Else' })
])
},
undefined // cleanupOrphanedEdges returns undefined when no edges removed
)
})
it('should render ScenariosInput for conditionAgentAgentflow node', () => {
const scenarioParams: InputParam[] = [
{
name: 'conditionAgentScenarios',
label: 'Scenarios',
type: 'array',
array: [{ name: 'scenario', label: 'Scenario', type: 'string' } as InputParam]
} as InputParam
]
const scenarioData: NodeData = {
id: 'conditionAgentAgentflow_0',
name: 'conditionAgentAgentflow',
label: 'Condition Agent',
inputValues: { conditionAgentScenarios: [{ scenario: 'User is happy' }] }
} as NodeData
render(
<EditNodeDialog
show={true}
dialogProps={{ inputParams: scenarioParams, data: scenarioData, disabled: false }}
onCancel={jest.fn()}
/>
)
expect(screen.getByTestId('scenarios-input')).toBeInTheDocument()
// Should NOT render generic NodeInputHandler for the scenarios param
expect(screen.queryByTestId('input-handler-conditionAgentScenarios')).not.toBeInTheDocument()
})
it('should merge outputAnchors into a single updateNodeData call when scenarios change', () => {
const scenarioParams: InputParam[] = [
{
name: 'conditionAgentScenarios',
label: 'Scenarios',
type: 'array',
array: [{ name: 'scenario', label: 'Scenario', type: 'string' } as InputParam]
} as InputParam
]
const scenarioData: NodeData = {
id: 'conditionAgentAgentflow_0',
name: 'conditionAgentAgentflow',
label: 'Condition Agent',
inputValues: { conditionAgentScenarios: [{ scenario: 'User is happy' }] }
} as NodeData
render(
<EditNodeDialog
show={true}
dialogProps={{ inputParams: scenarioParams, data: scenarioData, disabled: false }}
onCancel={jest.fn()}
/>
)
fireEvent.click(screen.getByTestId('add-scenario'))
// Adding to 1 item → 2 items → 2 anchors (Scenario 0, Scenario 1) — no Else port
expect(mockCleanupOrphanedEdges).toHaveBeenCalledWith(2)
expect(mockUpdateNodeData).toHaveBeenCalledWith(
'conditionAgentAgentflow_0',
{
inputValues: expect.objectContaining({ conditionAgentScenarios: expect.any(Array) }),
outputAnchors: expect.arrayContaining([
expect.objectContaining({ description: 'Scenario 0' }),
expect.objectContaining({ description: 'Scenario 1' })
])
},
undefined // cleanupOrphanedEdges returns undefined when no edges removed
)
// Verify no Else anchor
const call = mockUpdateNodeData.mock.calls[0]
expect(call[1].outputAnchors).toHaveLength(2)
})
it('should render MessagesInput for agentMessages param on Agent node', () => {
@@ -5,7 +5,7 @@ import { Avatar, Box, ButtonBase, Dialog, DialogContent, Stack, TextField, Typog
import { useTheme } from '@mui/material/styles'
import { IconCheck, IconInfoCircle, IconPencil, IconX } from '@tabler/icons-react'
import { ConditionBuilder, MessagesInput, NodeInputHandler, StructuredOutputBuilder } from '@/atoms'
import { ConditionBuilder, MessagesInput, NodeInputHandler, ScenariosInput, StructuredOutputBuilder } from '@/atoms'
import type { EditDialogProps, InputParam, NodeData } from '@/core/types'
import { buildDynamicOutputAnchors, evaluateFieldVisibility } from '@/core/utils'
import { useAgentflowContext, useConfigContext } from '@/infrastructure/store'
@@ -54,7 +54,11 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
const [arrayItemParameters, setArrayItemParameters] = useState<Record<string, InputParam[][]>>({})
const isConditionNode = data?.name === 'conditionAgentflow'
const { cleanupOrphanedEdges } = useDynamicOutputPorts(data?.id ?? '', isConditionNode)
const isConditionAgentNode = data?.name === 'conditionAgentAgentflow'
const hasDynamicPorts = isConditionNode || isConditionAgentNode
// conditionAgentflow has an Else port; conditionAgentAgentflow does not
const includeElse = !isConditionAgentNode
const { cleanupOrphanedEdges } = useDynamicOutputPorts(data?.id ?? '', hasDynamicPorts, includeElse)
// Ref to read current data
const dataRef = useRef(data)
@@ -105,13 +109,23 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
setInputParams(updatedParams)
setArrayItemParameters(computeArrayItemParameters(inputParams, updatedInputValues))
// When conditions array changes, merge inputValues and outputAnchors
// into a single updateNodeData call to avoid stale-closure overwrites.
// When conditions/scenarios array changes, merge inputValues, outputAnchors,
// and cleaned edges into a single updateNodeData call so that onFlowChange
// fires once with the complete updated state.
if (isConditionNode && inputParam.name === 'conditions' && Array.isArray(newValue)) {
const outputAnchors = buildDynamicOutputAnchors(data.id, newValue.length, 'Condition', true)
updateNodeData(data.id, { inputValues: updatedInputValues, outputAnchors })
const cleanedEdges = cleanupOrphanedEdges(newValue.length)
updateNodeData(data.id, { inputValues: updatedInputValues, outputAnchors }, cleanedEdges)
setData({ ...data, inputValues: updatedInputValues, outputAnchors })
return
}
if (isConditionAgentNode && inputParam.name === 'conditionAgentScenarios' && Array.isArray(newValue)) {
// ConditionAgent outputs match scenario count exactly (no separate Else port)
const outputAnchors = buildDynamicOutputAnchors(data.id, newValue.length, 'Scenario', false)
const cleanedEdges = cleanupOrphanedEdges(newValue.length)
updateNodeData(data.id, { inputValues: updatedInputValues, outputAnchors }, cleanedEdges)
setData({ ...data, inputValues: updatedInputValues, outputAnchors })
cleanupOrphanedEdges(newValue.length)
return
}
@@ -132,6 +146,17 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
}
}, [dialogProps])
// Reset state when dialog closes so the next node opens with clean state
useEffect(() => {
if (!show) {
setData(null)
setInputParams([])
setArrayItemParameters({})
setNodeName('')
setEditingNodeName(false)
}
}, [show])
if (!show) return null
return (
@@ -302,6 +327,19 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
)
}
// Render ScenariosInput for condition agent's scenarios array
if (isConditionAgentNode && inputParam.type === 'array' && inputParam.name === 'conditionAgentScenarios') {
return (
<ScenariosInput
key={index}
inputParam={inputParam}
data={data}
disabled={dialogProps.disabled}
onDataChange={onCustomDataChange}
/>
)
}
// Render MessagesInput for Agent/LLM message arrays
if (inputParam.type === 'array' && MESSAGE_PARAM_NAMES.has(inputParam.name)) {
return (
@@ -4,7 +4,6 @@ import { useDynamicOutputPorts } from './useDynamicOutputPorts'
// --- Mocks ---
const mockUpdateNodeInternals = jest.fn()
const mockSetEdges = jest.fn()
let mockEdges: Array<{ id: string; source: string; target: string; sourceHandle?: string }> = []
jest.mock('reactflow', () => ({
@@ -13,8 +12,7 @@ jest.mock('reactflow', () => ({
jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
state: { edges: mockEdges },
setEdges: mockSetEdges
state: { edges: mockEdges }
})
}))
@@ -24,7 +22,7 @@ describe('useDynamicOutputPorts', () => {
mockEdges = []
})
it('should remove orphaned edges when count decreases', () => {
it('should return cleaned edges removing orphaned handles', () => {
mockEdges = [
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-1', source: 'node-1', target: 'node-3', sourceHandle: 'node-1-output-1' },
@@ -35,26 +33,20 @@ describe('useDynamicOutputPorts', () => {
const { result } = renderHook(() => useDynamicOutputPorts('node-1'))
// Set initial count to 3 (outputs: 0, 1, 2, 3=Else)
// count=1, includeElse=true → totalNewAnchors=2 → keep indices 0,1 only
let cleanedEdges: ReturnType<typeof result.current.cleanupOrphanedEdges>
act(() => {
result.current.cleanupOrphanedEdges(3)
cleanedEdges = result.current.cleanupOrphanedEdges(1)
})
mockSetEdges.mockClear()
// Reduce to 1 (outputs: 0, 1=Else) — edges at index 2 and 3 should be removed
act(() => {
result.current.cleanupOrphanedEdges(1)
})
expect(mockSetEdges).toHaveBeenCalledWith([
expect(cleanedEdges!).toEqual([
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-1', source: 'node-1', target: 'node-3', sourceHandle: 'node-1-output-1' },
{ id: 'edge-other', source: 'node-9', target: 'node-10', sourceHandle: 'node-9-output-0' }
])
})
it('should call updateNodeInternals after cleanup', () => {
it('should call updateNodeInternals', () => {
const { result } = renderHook(() => useDynamicOutputPorts('node-1'))
act(() => {
@@ -64,50 +56,38 @@ describe('useDynamicOutputPorts', () => {
expect(mockUpdateNodeInternals).toHaveBeenCalledWith('node-1')
})
it('should not call setEdges when count increases', () => {
it('should return undefined when no edges are removed', () => {
mockEdges = [{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' }]
const { result } = renderHook(() => useDynamicOutputPorts('node-1'))
// Set initial count
// count=3, includeElse=true → totalNewAnchors=4, edge at index 0 is fine
let cleanedEdges: ReturnType<typeof result.current.cleanupOrphanedEdges>
act(() => {
result.current.cleanupOrphanedEdges(1)
cleanedEdges = result.current.cleanupOrphanedEdges(3)
})
mockSetEdges.mockClear()
// Increase count — no edges should be removed
act(() => {
result.current.cleanupOrphanedEdges(3)
})
expect(mockSetEdges).not.toHaveBeenCalled()
expect(cleanedEdges!).toBeUndefined()
})
it('should not call setEdges on first call (no previous count)', () => {
mockEdges = [{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' }]
it('should return undefined when enabled is false', () => {
mockEdges = [
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-1', source: 'node-1', target: 'node-3', sourceHandle: 'node-1-output-1' }
]
const { result } = renderHook(() => useDynamicOutputPorts('node-1'))
act(() => {
result.current.cleanupOrphanedEdges(2)
})
expect(mockSetEdges).not.toHaveBeenCalled()
})
it('should be inert when enabled is false', () => {
const { result } = renderHook(() => useDynamicOutputPorts('node-1', false))
let cleanedEdges: ReturnType<typeof result.current.cleanupOrphanedEdges>
act(() => {
result.current.cleanupOrphanedEdges(2)
cleanedEdges = result.current.cleanupOrphanedEdges(0)
})
expect(mockSetEdges).not.toHaveBeenCalled()
expect(cleanedEdges!).toBeUndefined()
expect(mockUpdateNodeInternals).not.toHaveBeenCalled()
})
it('should preserve edges from other nodes when cleaning up', () => {
it('should preserve edges from other nodes and edges without sourceHandle', () => {
mockEdges = [
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-1', source: 'node-1', target: 'node-3', sourceHandle: 'node-1-output-1' },
@@ -117,21 +97,33 @@ describe('useDynamicOutputPorts', () => {
const { result } = renderHook(() => useDynamicOutputPorts('node-1'))
// Set initial count to 2
// count=0, includeElse=true → totalNewAnchors=1 → keep only index 0
let cleanedEdges: ReturnType<typeof result.current.cleanupOrphanedEdges>
act(() => {
result.current.cleanupOrphanedEdges(2)
})
mockSetEdges.mockClear()
// Reduce to 0 (only Else anchor at index 0)
act(() => {
result.current.cleanupOrphanedEdges(0)
cleanedEdges = result.current.cleanupOrphanedEdges(0)
})
expect(mockSetEdges).toHaveBeenCalledWith([
expect(cleanedEdges!).toEqual([
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-other', source: 'node-9', target: 'node-10', sourceHandle: 'node-9-output-0' },
{ id: 'edge-no-handle', source: 'node-1', target: 'node-5' }
])
})
it('should respect includeElse=false for conditionAgent nodes', () => {
mockEdges = [
{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' },
{ id: 'edge-1', source: 'node-1', target: 'node-3', sourceHandle: 'node-1-output-1' }
]
const { result } = renderHook(() => useDynamicOutputPorts('node-1', true, false))
// count=1, includeElse=false → totalNewAnchors=1 → keep only index 0
let cleanedEdges: ReturnType<typeof result.current.cleanupOrphanedEdges>
act(() => {
cleanedEdges = result.current.cleanupOrphanedEdges(1)
})
expect(cleanedEdges!).toEqual([{ id: 'edge-0', source: 'node-1', target: 'node-2', sourceHandle: 'node-1-output-0' }])
})
})
@@ -1,6 +1,7 @@
import { useCallback, useRef } from 'react'
import { useCallback } from 'react'
import { useUpdateNodeInternals } from 'reactflow'
import type { FlowEdge } from '@/core/types'
import { parseOutputHandleIndex } from '@/core/utils'
import { useAgentflowContext } from '@/infrastructure/store'
@@ -8,50 +9,45 @@ import { useAgentflowContext } from '@/infrastructure/store'
* Hook for managing dynamic output ports on nodes whose anchor count
* depends on runtime data (e.g. condition nodes).
*
* Provides `cleanupOrphanedEdges` to remove edges that point to output
* handles that no longer exist after the count decreases.
*
* The caller is responsible for computing outputAnchors (via
* `buildDynamicOutputAnchors`) and merging them into the same
* `updateNodeData` call that updates inputValues — this avoids the
* stale-closure problem of two sequential store updates in one tick.
* Provides `cleanupOrphanedEdges` which filters out edges pointing to
* output handles that no longer exist and returns the cleaned array.
* The caller should pass the returned edges to `updateNodeData` so that
* nodes and edges are updated atomically in a single `onFlowChange` call.
*
* Pass `enabled: false` to make the hook inert for non-applicable nodes.
*/
export function useDynamicOutputPorts(nodeId: string, enabled: boolean = true, includeElse: boolean = true) {
const { state, setEdges } = useAgentflowContext()
const { state } = useAgentflowContext()
const updateNodeInternals = useUpdateNodeInternals()
const prevCountRef = useRef<number | null>(null)
/**
* Removes edges connected to output handles that no longer exist
* after the item count changes, and notifies ReactFlow to re-measure
* the node's handles.
* Filters out edges connected to output handles whose index is ≥ the
* new anchor count, notifies ReactFlow to re-measure the node's
* handles, and returns the cleaned edges array.
*
* Returns `undefined` when no edges were removed (caller can skip the
* edges update in that case).
*/
const cleanupOrphanedEdges = useCallback(
(count: number) => {
if (!enabled) return
(count: number): FlowEdge[] | undefined => {
if (!enabled) return undefined
const prevCount = prevCountRef.current
prevCountRef.current = count
// Remove edges connected to output handles that no longer exist
if (prevCount !== null && count < prevCount) {
const totalNewAnchors = includeElse ? count + 1 : count
const updatedEdges = state.edges.filter((edge) => {
if (edge.source !== nodeId || !edge.sourceHandle) return true
const handleIndex = parseOutputHandleIndex(nodeId, edge.sourceHandle)
if (isNaN(handleIndex)) return true
return handleIndex < totalNewAnchors
})
if (updatedEdges.length !== state.edges.length) {
setEdges(updatedEdges)
}
}
const totalNewAnchors = includeElse ? count + 1 : count
const updatedEdges = state.edges.filter((edge) => {
if (edge.source !== nodeId || !edge.sourceHandle) return true
const handleIndex = parseOutputHandleIndex(nodeId, edge.sourceHandle)
if (isNaN(handleIndex)) return true
return handleIndex < totalNewAnchors
})
updateNodeInternals(nodeId)
if (updatedEdges.length !== state.edges.length) {
return updatedEdges
}
return undefined
},
[nodeId, enabled, includeElse, state.edges, setEdges, updateNodeInternals]
[nodeId, enabled, includeElse, state.edges, updateNodeInternals]
)
return { cleanupOrphanedEdges }
@@ -66,7 +66,7 @@ export interface AgentflowContextValue {
// Node operations
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string, distance?: number) => void
updateNodeData: (nodeId: string, data: Partial<FlowNode['data']>) => void
updateNodeData: (nodeId: string, data: Partial<FlowNode['data']>, edges?: FlowEdge[]) => void
// Edge operations
deleteEdge: (edgeId: string) => void
@@ -230,7 +230,7 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState
)
const updateNodeData = useCallback(
(nodeId: string, data: Partial<FlowNode['data']>) => {
(nodeId: string, data: Partial<FlowNode['data']>, edges?: FlowEdge[]) => {
const newNodes = state.nodes.map((node) => {
if (node.id === nodeId) {
return {
@@ -241,12 +241,13 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState
return node
})
syncStateUpdate({ nodes: newNodes })
const effectiveEdges = edges ?? state.edges
syncStateUpdate({ nodes: newNodes, ...(edges !== undefined && { edges }) })
// Notify parent of flow change (e.g. node data edits from EditNodeDialog)
if (onFlowChangeRef.current) {
const viewport = state.reactFlowInstance?.getViewport() || { x: 0, y: 0, zoom: 1 }
onFlowChangeRef.current({ nodes: newNodes, edges: state.edges, viewport })
onFlowChangeRef.current({ nodes: newNodes, edges: effectiveEdges, viewport })
}
},
[state.nodes, state.edges, state.reactFlowInstance, syncStateUpdate]