mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
co-authored by
Claude Opus 4.6
parent
172523f25c
commit
21e6106abb
@@ -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' }}> *</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' }}> *</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
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user