Feat/agentflow Add JSON, code, and SelectVariable input types to NodeInputHandler (#6016)

* Initial flow

* enhance JSON input handling with dialog support and improve variable selection UI

* Fix syntax highlighting bug

* Added test cases

* Fix gemini comments

* lock file fix

* Build error

* Test fix

* Remove 'inputs' folder under atoms and move all components directly under atoms/
This commit is contained in:
j-sanaa
2026-03-19 14:23:12 -07:00
committed by GitHub
parent fff4b80c43
commit 03a0cf6b3f
20 changed files with 1490 additions and 293 deletions
+7
View File
@@ -68,12 +68,19 @@
"reactflow": "^11.5.0"
},
"dependencies": {
"@codemirror/lang-javascript": "^6.2.0",
"@codemirror/lang-json": "^6.0.0",
"@codemirror/lang-python": "^6.1.0",
"@tabler/icons-react": "^3.7.0",
"@tiptap/extension-code-block-lowlight": "^3.4.3",
"@tiptap/extension-placeholder": "^2.11.5",
"@tiptap/react": "^2.11.5",
"@tiptap/starter-kit": "^2.11.5",
"@uiw/codemirror-theme-sublime": "^4.21.0",
"@uiw/codemirror-theme-vscode": "^4.21.0",
"@uiw/react-codemirror": "^4.21.0",
"axios": "^1.7.2",
"flowise-react-json-view": "^1.21.7",
"lodash": "^4.17.21",
"lowlight": "^3.3.0",
"uuid": "^10.0.0"
@@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import { CodeInput } from './CodeInput'
// Mock CodeMirror — jsdom doesn't support it
jest.mock('@uiw/react-codemirror', () => {
const MockCodeMirror = ({ value, readOnly, height }: { value: string; readOnly?: boolean; height?: string }) => (
<textarea data-testid='codemirror' value={value} readOnly={readOnly} data-height={height} onChange={() => {}} />
)
MockCodeMirror.displayName = 'MockCodeMirror'
return { __esModule: true, default: MockCodeMirror }
})
jest.mock('@uiw/codemirror-theme-vscode', () => ({ vscodeDark: 'vscodeDark' }))
jest.mock('@uiw/codemirror-theme-sublime', () => ({ sublime: 'sublime' }))
jest.mock('@codemirror/lang-javascript', () => ({ javascript: () => [] }))
jest.mock('@codemirror/lang-json', () => ({ json: () => [] }))
jest.mock('@codemirror/lang-python', () => ({ python: () => [] }))
describe('CodeInput', () => {
it('renders CodeMirror with the provided value', () => {
render(<CodeInput value='const x = 1' onChange={jest.fn()} />)
const editor = screen.getByTestId('codemirror')
expect(editor).toHaveValue('const x = 1')
})
it('renders with default height of 200px', () => {
render(<CodeInput value='' onChange={jest.fn()} />)
expect(screen.getByTestId('codemirror')).toHaveAttribute('data-height', '200px')
})
it('renders with custom height', () => {
render(<CodeInput value='' onChange={jest.fn()} height='400px' />)
expect(screen.getByTestId('codemirror')).toHaveAttribute('data-height', '400px')
})
it('sets readOnly when disabled', () => {
render(<CodeInput value='code' onChange={jest.fn()} disabled />)
expect(screen.getByTestId('codemirror')).toHaveAttribute('readonly')
})
it('is not readOnly when enabled', () => {
render(<CodeInput value='code' onChange={jest.fn()} />)
expect(screen.getByTestId('codemirror')).not.toHaveAttribute('readonly')
})
it('renders empty string when value is empty', () => {
render(<CodeInput value='' onChange={jest.fn()} />)
expect(screen.getByTestId('codemirror')).toHaveValue('')
})
it('renders inside a bordered container', () => {
const { container } = render(<CodeInput value='' onChange={jest.fn()} />)
const box = container.firstChild as HTMLElement
expect(box).toBeTruthy()
})
})
@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { python } from '@codemirror/lang-python'
import { Box } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { sublime } from '@uiw/codemirror-theme-sublime'
import { vscodeDark } from '@uiw/codemirror-theme-vscode'
import CodeMirror from '@uiw/react-codemirror'
export interface CodeInputProps {
value: string
onChange: (code: string) => void
language?: string
disabled?: boolean
height?: string
}
/**
* CodeMirror-based code editor atom.
*
* Supports javascript (default), python, and json syntax highlighting.
* Theme switches automatically based on dark mode.
*/
export function CodeInput({ value, onChange, language = 'javascript', disabled = false, height = '200px' }: CodeInputProps) {
const theme = useTheme()
const isDarkMode = theme.palette.mode === 'dark'
const extensions = useMemo(() => {
switch (language) {
case 'python':
return [python()]
case 'json':
return [json()]
case 'typescript':
return [javascript({ typescript: true })]
default:
return [javascript()]
}
}, [language])
return (
<Box
sx={{
mt: 1,
border: '1px solid',
borderColor: 'grey.400',
borderRadius: '6px',
overflow: 'hidden'
}}
>
<CodeMirror
value={value || ''}
height={height}
theme={isDarkMode ? (language === 'json' ? sublime : vscodeDark) : 'light'}
extensions={extensions}
onChange={onChange}
readOnly={disabled}
basicSetup={{ lineNumbers: true, foldGutter: true }}
/>
</Box>
)
}
@@ -4,6 +4,12 @@ import { ExpandTextDialog } from './ExpandTextDialog'
// TipTap modules are auto-mocked via moduleNameMapper in jest.config.js
jest.mock('./CodeInput', () => ({
CodeInput: ({ value, language }: { value: string; language?: string }) => (
<textarea data-testid='code-input' data-language={language} value={value} onChange={() => {}} />
)
}))
const mockOnConfirm = jest.fn()
const mockOnCancel = jest.fn()
@@ -13,32 +19,34 @@ beforeEach(() => {
describe('ExpandTextDialog', () => {
it('should not render content when closed', () => {
render(<ExpandTextDialog open={false} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={false} value='' inputType='number' 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' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Hello world' inputType='number' 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' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(
<ExpandTextDialog open={true} value='' title='Content' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
expect(screen.getByText('Content')).toBeInTheDocument()
})
it('should not render title when not provided', () => {
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='' inputType='number' 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' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Original' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
fireEvent.change(textarea, { target: { value: 'Updated' } })
@@ -48,7 +56,7 @@ describe('ExpandTextDialog', () => {
})
it('should call onCancel when Cancel is clicked', () => {
render(<ExpandTextDialog open={true} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
render(<ExpandTextDialog open={true} value='Original' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
@@ -58,7 +66,14 @@ describe('ExpandTextDialog', () => {
it('should disable textarea and Save button when disabled', () => {
render(
<ExpandTextDialog open={true} value='test' inputType='code' disabled={true} onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
<ExpandTextDialog
open={true}
value='test'
inputType='number'
disabled={true}
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
@@ -71,7 +86,7 @@ describe('ExpandTextDialog', () => {
<ExpandTextDialog
open={true}
value=''
inputType='code'
inputType='number'
placeholder='Type here...'
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
@@ -84,14 +99,16 @@ describe('ExpandTextDialog', () => {
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} />
<ExpandTextDialog open={false} value='' inputType='number' 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} />)
rerender(
<ExpandTextDialog open={false} value='Updated text' inputType='number' 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} />)
rerender(<ExpandTextDialog open={true} value='Updated text' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea).toHaveValue('Updated text')
@@ -99,7 +116,7 @@ describe('ExpandTextDialog', () => {
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} />
<ExpandTextDialog open={true} value='Original' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
)
// User types in the dialog then cancels
@@ -108,15 +125,58 @@ describe('ExpandTextDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
// Close the dialog
rerender(<ExpandTextDialog open={false} value='Original' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
rerender(<ExpandTextDialog open={false} value='Original' inputType='number' 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} />)
rerender(<ExpandTextDialog open={true} value='Original' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
const textarea2 = screen.getByTestId('expand-content-input').querySelector('textarea')!
expect(textarea2).toHaveValue('Original')
})
// --- Code mode ---
describe('inputType="code"', () => {
it('should render CodeInput instead of TextField', () => {
render(
<ExpandTextDialog
open={true}
value='const x = 1'
inputType='code'
language='javascript'
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByTestId('code-input')).toBeInTheDocument()
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
})
it('should pass language prop to CodeInput', () => {
render(
<ExpandTextDialog
open={true}
value='{}'
inputType='code'
language='json'
onConfirm={mockOnConfirm}
onCancel={mockOnCancel}
/>
)
expect(screen.getByTestId('code-input')).toHaveAttribute('data-language', 'json')
})
it('should show Save and Cancel buttons in code mode', () => {
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
})
})
// --- Rich text mode ---
describe('inputType="string" (richtext)', () => {
@@ -133,11 +193,12 @@ describe('ExpandTextDialog', () => {
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
})
it('should render plain TextField for non-string input types', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
it('should render plain TextField for non-string, non-code input types', () => {
render(<ExpandTextDialog open={true} value='Hello' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
expect(screen.queryByTestId('code-input')).not.toBeInTheDocument()
})
it('should still show Save and Cancel buttons in richtext mode', () => {
@@ -2,6 +2,7 @@ import { useCallback, useState } from 'react'
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, Typography } from '@mui/material'
import { CodeInput } from './CodeInput'
import { RichTextEditor } from './RichTextEditor.lazy'
export interface ExpandTextDialogProps {
@@ -10,9 +11,10 @@ export interface ExpandTextDialogProps {
title?: string
placeholder?: string
disabled?: boolean
/** 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
/** The input param type — determines which editor to render. 'string' uses the TipTap RichTextEditor, 'code' renders CodeInput; others fall back to a plain TextField. */
inputType?: string
/** Language hint for 'code' mode (e.g. 'javascript', 'python', 'json'). */
language?: string
onConfirm: (value: string) => void
onCancel: () => void
}
@@ -28,6 +30,7 @@ export function ExpandTextDialog({
placeholder,
disabled = false,
inputType = 'string',
language,
onConfirm,
onCancel
}: ExpandTextDialogProps) {
@@ -56,7 +59,15 @@ export function ExpandTextDialog({
{title}
</Typography>
)}
{inputType === 'string' ? (
{inputType === 'code' ? (
<CodeInput
value={localValue}
onChange={setLocalValue}
language={language}
disabled={disabled}
height='calc(100vh - 220px)'
/>
) : inputType === 'string' ? (
<Box
sx={{
borderRadius: '12px',
@@ -0,0 +1,74 @@
import { render, screen } from '@testing-library/react'
import { JsonInput } from './JsonInput'
// Mock ReactJson
jest.mock('flowise-react-json-view', () => {
const MockReactJson = (props: {
src: object
theme?: string
name?: false | null
enableClipboard?: unknown
onEdit?: unknown
onAdd?: unknown
onDelete?: unknown
}) => <div data-testid='react-json' data-src={JSON.stringify(props.src)} data-theme={props.theme} data-editable={!!props.onEdit} />
MockReactJson.displayName = 'MockReactJson'
return { __esModule: true, default: MockReactJson }
})
describe('JsonInput', () => {
it('renders ReactJson with parsed value', () => {
render(<JsonInput value='{"name":"test"}' onChange={jest.fn()} />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-src', '{"name":"test"}')
})
it('renders {} for empty value', () => {
render(<JsonInput value='' onChange={jest.fn()} />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-src', '{}')
})
it('renders {} for invalid JSON', () => {
render(<JsonInput value='not json' onChange={jest.fn()} />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-src', '{}')
})
it('is editable when not disabled', () => {
render(<JsonInput value='{}' onChange={jest.fn()} />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-editable', 'true')
})
it('is read-only when disabled', () => {
render(<JsonInput value='{}' onChange={jest.fn()} disabled />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-editable', 'false')
})
it('uses rjv-default theme in light mode', () => {
render(<JsonInput value='{}' onChange={jest.fn()} />)
const jsonView = screen.getByTestId('react-json')
expect(jsonView).toHaveAttribute('data-theme', 'rjv-default')
})
it('renders stopPropagation wrapper when not disabled', () => {
render(<JsonInput value='{}' onChange={jest.fn()} />)
expect(screen.getByRole('button', { name: 'JSON Editor' })).toBeInTheDocument()
})
it('does not render wrapper div when disabled', () => {
render(<JsonInput value='{}' onChange={jest.fn()} disabled />)
expect(screen.queryByRole('button', { name: 'JSON Editor' })).not.toBeInTheDocument()
})
})
+167
View File
@@ -0,0 +1,167 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { FormControl, Popover } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import ReactJson from 'flowise-react-json-view'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
export interface JsonInputProps {
value: string
onChange: (json: string) => void
disabled?: boolean
/** Variable items for per-key injection. When provided, clicking a JSON key opens the variable picker. */
variableItems?: VariableItem[]
}
const JSON_STYLE = { padding: 10, borderRadius: 10, marginTop: 8 }
function safeParse(str: string): object {
try {
return str ? JSON.parse(str) : {}
} catch {
return {}
}
}
/**
* Interactive JSON tree editor atom.
*
* Stores data as a JSON **string** — parses on mount, stringifies on every edit.
* Falls back to `{}` for empty or invalid input.
*
* When `variableItems` is provided, clicking a JSON key opens a popover to
* inject a variable into that specific key (matching the original Flowise behaviour).
*/
export function JsonInput({ value, onChange, disabled = false, variableItems }: JsonInputProps) {
const theme = useTheme()
const isDarkMode = theme.palette.mode === 'dark'
const [myValue, setMyValue] = useState<object>(() => safeParse(value))
const myValueRef = useRef(myValue)
myValueRef.current = myValue
// Sync internal state when the value prop changes externally
useEffect(() => {
const parsed = safeParse(value)
if (JSON.stringify(parsed) !== JSON.stringify(myValueRef.current)) {
setMyValue(parsed)
}
}, [value])
// ── Per-key variable popover state ───────────────────────────────────────
const mouseUpKeyRef = useRef('')
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
// Counter that increments on variable injection to force ReactJson remount.
// ReactJson doesn't re-render when `src` changes via setState — it manages
// its own internal tree. Bumping the key forces a fresh mount with the new data.
const [remountKey, setRemountKey] = useState(0)
const openPopOver = Boolean(anchorEl)
const handleClosePopOver = useCallback(() => {
setAnchorEl(null)
}, [])
const setNewVal = useCallback(
(val: string) => {
setMyValue((prev) => {
const updated = { ...prev, [mouseUpKeyRef.current]: val }
onChange(JSON.stringify(updated))
return updated
})
setRemountKey((k) => k + 1)
},
[onChange]
)
// ── Clipboard ────────────────────────────────────────────────────────────
const onClipboardCopy = useCallback((e: { src: unknown }) => {
const src = e.src
if (Array.isArray(src) || typeof src === 'object') {
navigator.clipboard.writeText(JSON.stringify(src, null, ' '))
} else {
navigator.clipboard.writeText(String(src))
}
}, [])
const hasVariables = variableItems && variableItems.length > 0
const jsonTheme = isDarkMode ? 'ocean' : 'rjv-default'
return (
<>
<FormControl sx={{ mt: 1, width: '100%' }} size='small'>
{disabled && (
<ReactJson
theme={jsonTheme}
style={JSON_STYLE}
src={myValue}
name={false}
quotesOnKeys={false}
displayDataTypes={false}
enableClipboard={onClipboardCopy}
/>
)}
{!disabled && (
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation()
}
}}
role='button'
aria-label='JSON Editor'
tabIndex={0}
>
<ReactJson
key={remountKey}
theme={jsonTheme}
style={JSON_STYLE}
src={myValue}
name={false}
quotesOnKeys={false}
displayDataTypes={false}
enableClipboard={onClipboardCopy}
onMouseUp={(event: { name: string; currentTarget: HTMLElement }) => {
if (hasVariables) {
mouseUpKeyRef.current = event.name
setAnchorEl(event.currentTarget)
}
}}
onEdit={(edit: { updated_src: object }) => {
setMyValue(edit.updated_src)
onChange(JSON.stringify(edit.updated_src))
}}
onAdd={() => {
// no-op: new keys are added visually but not persisted until user edits the value
}}
onDelete={(deleteobj: { updated_src: object }) => {
setMyValue(deleteobj.updated_src)
onChange(JSON.stringify(deleteobj.updated_src))
}}
/>
</div>
)}
</FormControl>
{hasVariables && (
<Popover
open={openPopOver}
anchorEl={anchorEl}
onClose={handleClosePopOver}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
>
<SelectVariable
items={variableItems!}
onSelect={(val) => {
setNewVal(val)
handleClosePopOver()
}}
/>
</Popover>
)}
</>
)
}
@@ -315,6 +315,196 @@ describe('NodeInputHandler – loadConfig rendering', () => {
})
})
// Mock CodeInput and JsonInput to avoid pulling in heavy dependencies
jest.mock('./CodeInput', () => ({
CodeInput: ({ value, language, disabled }: { value: string; language?: string; disabled?: boolean }) => (
<textarea data-testid='code-input' data-language={language} value={value} readOnly={disabled} onChange={() => {}} />
)
}))
jest.mock('./JsonInput', () => ({
JsonInput: ({ value, disabled }: { value: string; disabled?: boolean }) => (
<div data-testid='json-input' data-value={value} data-disabled={disabled} />
)
}))
jest.mock('./SelectVariable', () => ({
SelectVariable: ({ items, onSelect }: { items: Array<{ value: string }>; onSelect: (v: string) => void }) => (
<div data-testid='select-variable'>
{items.map((item, i) => (
<button key={i} data-testid={`var-${item.value}`} onClick={() => onSelect(item.value)}>
{item.value}
</button>
))}
</div>
)
}))
describe('NodeInputHandler – json type', () => {
it('renders JsonInput for json type', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'json' })}
data={{ ...baseNodeData, inputValues: { myField: '{"key":"val"}' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.getByTestId('json-input')).toBeTruthy()
expect(screen.getByTestId('json-input')).toHaveAttribute('data-value', '{"key":"val"}')
})
it('renders a button for json with acceptVariable and variableItems', () => {
const variableItems = [{ label: 'question', value: '{{question}}', category: 'Chat Context' }]
render(
<NodeInputHandler
inputParam={makeParam({ type: 'json', acceptVariable: true, label: 'Override Config' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
variableItems={variableItems}
/>
)
expect(screen.getByRole('button', { name: 'Override Config' })).toBeTruthy()
expect(screen.queryByTestId('json-input')).toBeNull()
})
it('renders inline JsonInput for json with acceptVariable but no variableItems', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'json', acceptVariable: true })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.getByTestId('json-input')).toBeTruthy()
})
})
describe('NodeInputHandler – code type', () => {
it('renders CodeInput for code type', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'code', codeLanguage: 'javascript' })}
data={{ ...baseNodeData, inputValues: { myField: 'const x = 1' } }}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
const editor = screen.getByTestId('code-input')
expect(editor).toBeTruthy()
expect(editor).toHaveValue('const x = 1')
expect(editor).toHaveAttribute('data-language', 'javascript')
})
it('shows expand icon for code type', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'code' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.getByTitle('Expand')).toBeTruthy()
})
it('shows See Example button when codeExample is set', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'code', codeExample: 'console.log("hi")' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.getByText('See Example')).toBeTruthy()
})
it('sets value to codeExample when See Example is clicked', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'code', codeExample: 'console.log("hi")' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
fireEvent.click(screen.getByText('See Example'))
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: expect.objectContaining({ type: 'code' }),
newValue: 'console.log("hi")'
})
})
it('does not show See Example when no codeExample', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'code' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.queryByText('See Example')).toBeNull()
})
})
describe('NodeInputHandler – variable popover', () => {
it('shows variable icon when acceptVariable and variableItems are provided for string type', () => {
const variableItems = [{ label: 'question', value: '{{question}}', category: 'Chat Context' }]
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string', acceptVariable: true })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
variableItems={variableItems}
/>
)
// The variable icon is rendered inside a Tooltip > IconButton
expect(screen.getByTestId('icon-variable')).toBeTruthy()
})
it('does not show variable icon without acceptVariable', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.queryByTestId('icon-variable')).toBeNull()
})
it('does not show variable icon with empty variableItems', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'string', acceptVariable: true })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
variableItems={[]}
/>
)
expect(screen.queryByTestId('icon-variable')).toBeNull()
})
})
describe('NodeInputHandler – credential type rendering', () => {
const StubAsyncInput: ComponentType<AsyncInputProps> = ({ onChange }) => (
<button data-testid='credential-select' onClick={() => onChange('cred-id-123')}>
@@ -1,7 +1,23 @@
import { ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { Box, FormControlLabel, IconButton, MenuItem, Select, Switch, TextField, Tooltip, TooltipProps, Typography } from '@mui/material'
import {
Box,
Button,
Dialog,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
MenuItem,
Popover,
Select,
Switch,
TextField,
Tooltip,
TooltipProps,
Typography
} from '@mui/material'
import Autocomplete from '@mui/material/Autocomplete'
import { styled, useTheme } from '@mui/material/styles'
import { tooltipClasses } from '@mui/material/Tooltip'
@@ -10,8 +26,12 @@ import { IconArrowsMaximize, IconInfoCircle, IconVariable } from '@tabler/icons-
import type { InputAnchor, InputParam, NodeData } from '@/core/types'
import ArrayInput from './ArrayInput'
import { CodeInput } from './CodeInput'
import { ExpandTextDialog } from './ExpandTextDialog'
import { JsonInput } from './JsonInput'
import { RichTextEditor } from './RichTextEditor.lazy'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
const CustomWidthTooltip = styled(({ className, ...props }: TooltipProps) => <Tooltip {...props} classes={{ popper: className }} />)({
[`& .${tooltipClasses.tooltip}`]: {
@@ -67,6 +87,8 @@ export interface NodeInputHandlerProps {
arrayIndex?: number | null
/** For array-based configs: the parent array InputParam definition. */
parentArrayParam?: InputParam | null
/** Variable items for the SelectVariable popover (injected from features layer). */
variableItems?: VariableItem[]
}
// ─── Main component ───────────────────────────────────────────────────────────
@@ -88,7 +110,8 @@ export function NodeInputHandler({
ConfigInputComponent,
onConfigChange,
arrayIndex = null,
parentArrayParam = null
parentArrayParam = null,
variableItems
}: NodeInputHandlerProps) {
const theme = useTheme()
const ref = useRef<HTMLDivElement>(null)
@@ -96,6 +119,8 @@ export function NodeInputHandler({
const [position, setPosition] = useState(0)
const [expandOpen, setExpandOpen] = useState(false)
const [variableAnchorEl, setVariableAnchorEl] = useState<HTMLElement | null>(null)
const [jsonDialogOpen, setJsonDialogOpen] = useState(false)
const handleDataChange = useCallback(
(newValue: unknown) => {
@@ -136,6 +161,24 @@ export function NodeInputHandler({
[handleDataChange]
)
const handleVariableSelect = useCallback(
(variableString: string) => {
if (!inputParam) return
const current = data.inputValues?.[inputParam.name] ?? inputParam.default ?? ''
const currentStr = typeof current === 'string' ? current : JSON.stringify(current)
handleDataChange(currentStr + variableString)
setVariableAnchorEl(null)
},
[inputParam, data.inputValues, handleDataChange]
)
const showVariableButton = !!(
inputParam?.acceptVariable &&
variableItems &&
variableItems.length > 0 &&
['string', 'password', 'code'].includes(inputParam?.type ?? '')
)
const renderInput = () => {
if (!inputParam) return null
@@ -251,6 +294,47 @@ export function NodeInputHandler({
)
}
case 'json': {
const jsonStr = typeof value === 'string' ? value : JSON.stringify(value || {})
if (inputParam.acceptVariable && variableItems && variableItems.length > 0) {
// acceptVariable: show a button that opens a dialog with JsonInput + variable support
return (
<Button
sx={{ borderRadius: 25, width: '100%', mb: 0, mt: 2 }}
variant='outlined'
disabled={disabled}
onClick={() => setJsonDialogOpen(true)}
>
{inputParam.label}
</Button>
)
}
// No acceptVariable: render inline JSON tree
return <JsonInput value={jsonStr} onChange={(json) => handleDataChange(json)} disabled={disabled} />
}
case 'code':
return (
<>
<CodeInput
value={typeof value === 'string' ? value : ''}
onChange={(code) => handleDataChange(code)}
language={inputParam.codeLanguage}
disabled={disabled}
/>
{inputParam.codeExample && !disabled && (
<Button
size='small'
variant='text'
sx={{ mt: 0.5, textTransform: 'none' }}
onClick={() => handleDataChange(inputParam.codeExample)}
>
See Example
</Button>
)}
</>
)
case 'array':
return (
<ArrayInput
@@ -380,9 +464,16 @@ export function NodeInputHandler({
)}
</Typography>
<div style={{ flexGrow: 1 }} />
{inputParam?.acceptVariable && inputParam?.type === 'string' && (
<Tooltip title='Type {{ to select variables'>
<IconVariable size={20} style={{ color: 'teal' }} />
{showVariableButton && (
<Tooltip title='Select variable'>
<IconButton
size='small'
sx={{ height: 25, width: 25 }}
disabled={disabled}
onClick={(e) => setVariableAnchorEl(e.currentTarget)}
>
<IconVariable size={20} style={{ color: 'teal' }} />
</IconButton>
</Tooltip>
)}
{isExpandable && (
@@ -415,10 +506,38 @@ export function NodeInputHandler({
placeholder={inputParam?.placeholder}
disabled={disabled}
inputType={inputParam?.type}
language={inputParam?.type === 'code' ? inputParam.codeLanguage : undefined}
onConfirm={handleExpandConfirm}
onCancel={() => setExpandOpen(false)}
/>
)}
{showVariableButton && inputParam?.type !== 'json' && (
<Popover
open={!!variableAnchorEl}
anchorEl={variableAnchorEl}
onClose={() => setVariableAnchorEl(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'right' }}
transformOrigin={{ vertical: 'top', horizontal: 'right' }}
slotProps={{ paper: { sx: { width: 320, maxHeight: 400 } } }}
>
<SelectVariable items={variableItems!} onSelect={handleVariableSelect} />
</Popover>
)}
{inputParam?.type === 'json' && inputParam.acceptVariable && variableItems && variableItems.length > 0 && (
<Dialog open={jsonDialogOpen} onClose={() => setJsonDialogOpen(false)} fullWidth maxWidth='sm'>
<DialogTitle>{inputParam.label}</DialogTitle>
<DialogContent>
<JsonInput
value={expandValue}
onChange={(json) => handleDataChange(json)}
disabled={disabled}
variableItems={variableItems}
/>
</DialogContent>
</Dialog>
)}
</div>
)
}
@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react'
import type { VariableItem } from './SelectVariable'
import { SelectVariable } from './SelectVariable'
jest.mock('@tabler/icons-react', () => ({
IconBinaryTree: () => <span data-testid='icon-tree' />,
IconHistory: () => <span data-testid='icon-history' />,
IconMessageChatbot: () => <span data-testid='icon-message' />,
IconPaperclip: () => <span data-testid='icon-paperclip' />
}))
const mockItems: VariableItem[] = [
{ label: 'question', description: "User's question", category: 'Chat Context', value: '{{question}}' },
{ label: 'chat_history', description: 'Past history', category: 'Chat Context', value: '{{chat_history}}' },
{ label: 'Start', description: 'Output from Start', category: 'Node Outputs', value: '{{start_0.data.instance}}' }
]
describe('SelectVariable', () => {
it('renders the title', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText('Select Variable')).toBeInTheDocument()
})
it('renders all items', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText('question')).toBeInTheDocument()
expect(screen.getByText('chat_history')).toBeInTheDocument()
expect(screen.getByText('Start')).toBeInTheDocument()
})
it('renders descriptions', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
expect(screen.getByText("User's question")).toBeInTheDocument()
expect(screen.getByText('Output from Start')).toBeInTheDocument()
})
it('calls onSelect with the item value when clicked', () => {
const onSelect = jest.fn()
render(<SelectVariable items={mockItems} onSelect={onSelect} />)
fireEvent.click(screen.getByText('question'))
expect(onSelect).toHaveBeenCalledWith('{{question}}')
})
it('returns null when disabled', () => {
const { container } = render(<SelectVariable items={mockItems} onSelect={jest.fn()} disabled />)
expect(container.firstChild).toBeNull()
})
it('returns null when items is empty', () => {
const { container } = render(<SelectVariable items={[]} onSelect={jest.fn()} />)
expect(container.firstChild).toBeNull()
})
it('renders items as a flat list without category headers', () => {
render(<SelectVariable items={mockItems} onSelect={jest.fn()} />)
// Should NOT have category headers like "CHAT CONTEXT" or "NODE OUTPUTS"
expect(screen.queryByText('Chat Context')).not.toBeInTheDocument()
expect(screen.queryByText('Node Outputs')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,83 @@
import { Box, List, ListItem, ListItemAvatar, ListItemButton, ListItemText, Stack, Typography } from '@mui/material'
import { IconBinaryTree, IconHistory, IconMessageChatbot, IconPaperclip } from '@tabler/icons-react'
export interface VariableItem {
label: string
description?: string
category?: string
value: string
}
export interface SelectVariableProps {
items: VariableItem[]
disabled?: boolean
onSelect: (variableString: string) => void
}
/** Maps each category to an icon and color matching the original Flowise SelectVariable. */
const CATEGORY_STYLE: Record<string, { icon: React.ElementType; color: string }> = {
'Chat Context': { icon: IconMessageChatbot, color: '#6EC6E6' },
'Node Outputs': { icon: IconHistory, color: '#64B5F6' },
'Flow State': { icon: IconBinaryTree, color: '#FFA07A' }
}
const DEFAULT_STYLE = { icon: IconPaperclip, color: '#90A4AE' }
/**
* Presentational variable picker atom.
*
* Renders a flat, scrollable list of variables matching the original Flowise
* SelectVariable styling — 50x50 white circle avatars with colored icons,
* no category headers, consistent card-like item spacing.
*/
export function SelectVariable({ items, disabled = false, onSelect }: SelectVariableProps) {
if (disabled || items.length === 0) return null
return (
<div style={{ flex: 30 }}>
<Stack flexDirection='row' sx={{ mb: 1, ml: 2, mt: 2 }}>
<Typography variant='h5'>Select Variable</Typography>
</Stack>
<Box sx={{ maxHeight: 'calc(100vh - 220px)', overflowY: 'auto', overflowX: 'hidden', px: 2 }}>
<List>
{items.map((item, idx) => {
const style = CATEGORY_STYLE[item.category ?? ''] || DEFAULT_STYLE
const Icon = style.icon
return (
<ListItemButton
key={`${item.value}-${idx}`}
sx={{
p: 0,
borderRadius: '8px',
boxShadow: '0 2px 14px 0 rgb(32 40 45 / 8%)',
mb: 1
}}
onClick={() => onSelect(item.value)}
>
<ListItem alignItems='center'>
<ListItemAvatar>
<Box
sx={{
width: 50,
height: 50,
borderRadius: '50%',
backgroundColor: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<Icon size={30} stroke={1.5} color={style.color} />
</Box>
</ListItemAvatar>
<ListItemText sx={{ ml: 1 }} primary={item.label} secondary={item.description} />
</ListItem>
</ListItemButton>
)
})}
</List>
</Box>
</div>
)
}
@@ -14,6 +14,12 @@ jest.mock('@tabler/icons-react', () => ({
IconTrash: () => <span data-testid='icon-trash' />
}))
jest.mock('./CodeInput', () => ({
CodeInput: ({ value, onChange, language }: { value: string; onChange: (v: string) => void; language?: string }) => (
<textarea data-testid='code-input' data-language={language} value={value} onChange={(e) => onChange(e.target.value)} />
)
}))
jest.mock('@/atoms/ExpandTextDialog', () => ({
ExpandTextDialog: ({
open,
@@ -219,7 +225,7 @@ describe('StructuredOutputBuilder', () => {
expect(screen.getByText('Enum Values')).toBeInTheDocument()
expect(screen.getByTestId('enum-values-0')).toBeInTheDocument()
expect(screen.queryByTestId('json-schema-0')).not.toBeInTheDocument()
expect(screen.queryByTestId('code-input')).not.toBeInTheDocument()
})
it('should show JSON Schema field when type is "jsonArray"', () => {
@@ -233,7 +239,7 @@ describe('StructuredOutputBuilder', () => {
render(<StructuredOutputBuilder inputParam={mockInputParam} data={dataWithEntries} onDataChange={mockOnDataChange} />)
expect(screen.getByText('JSON Schema')).toBeInTheDocument()
expect(screen.getByTestId('json-schema-0')).toBeInTheDocument()
expect(screen.getByTestId('code-input')).toBeInTheDocument()
expect(screen.queryByTestId('enum-values-0')).not.toBeInTheDocument()
})
@@ -7,6 +7,7 @@ import { IconArrowsMaximize, IconInfoCircle, IconPlus, IconTrash } from '@tabler
import { ExpandTextDialog } from '@/atoms'
import type { InputParam, NodeData } from '@/core/types'
import { CodeInput } from './CodeInput'
import { useStableKeys } from './useStableKeys'
const OUTPUT_TYPES = [
@@ -218,17 +219,12 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
<IconArrowsMaximize />
</IconButton>
</div>
<TextField
fullWidth
multiline
minRows={4}
size='small'
<CodeInput
value={entry.jsonSchema ?? ''}
onChange={(val) => handleFieldChange(index, 'jsonSchema', val)}
language='json'
disabled={disabled}
onChange={(e) => handleFieldChange(index, 'jsonSchema', e.target.value)}
placeholder='{ "key": { "type": "string", "description": "..." } }'
sx={{ mt: 1 }}
data-testid={`json-schema-${index}`}
height='200px'
/>
</Box>
)}
@@ -276,6 +272,8 @@ export function StructuredOutputBuilder({ inputParam, data, disabled = false, on
title='JSON Schema'
placeholder='{ "key": { "type": "string", "description": "..." } }'
disabled={disabled}
inputType='code'
language='json'
onConfirm={(val) => {
handleFieldChange(expandOpen.index, 'jsonSchema', val)
setExpandOpen(null)
+3
View File
@@ -1,10 +1,13 @@
// UI Components - Internal design system
export { ArrayInput, type ArrayInputProps } from './ArrayInput'
export { CodeInput, type CodeInputProps } from './CodeInput'
export { ConditionBuilder, type ConditionBuilderProps } from './ConditionBuilder'
export { ExpandTextDialog, type ExpandTextDialogProps } from './ExpandTextDialog'
export { JsonInput, type JsonInputProps } from './JsonInput'
export { MainCard, type MainCardProps } from './MainCard'
export { type MessageEntry, MessagesInput, type MessagesInputProps } from './MessagesInput'
export { type AsyncInputProps, type ConfigInputComponentProps, NodeInputHandler } from './NodeInputHandler'
export { SelectVariable, type SelectVariableProps, type VariableItem } from './SelectVariable'
// RichTextEditor is exported from the .lazy wrapper (not the real module) to avoid
// eagerly pulling TipTap + highlight.js into the main bundle. Importing directly
// from ./RichTextEditor would defeat code-splitting since barrel imports are resolved eagerly.
@@ -82,6 +82,8 @@ export interface InputParam {
loadMethod?: string // Registry key for async option loading (asyncOptions / asyncMultiOptions)
loadConfig?: boolean // When true, renders a config accordion beneath the async dropdown for the selected component
credentialNames?: string[] // If set, bypasses loadMethod and fetches matching credentials
codeLanguage?: string // Language hint for code editor (e.g. 'javascript', 'python', 'json')
codeExample?: string // Example code snippet shown via an "Example" button
}
export interface EdgeData {
@@ -17,7 +17,7 @@ const mockCleanupOrphanedEdges = jest.fn()
jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
state: {},
state: { nodes: [], edges: [] },
updateNodeData: mockUpdateNodeData
}),
useConfigContext: () => ({
@@ -12,6 +12,7 @@ import { useAgentflowContext, useConfigContext } from '@/infrastructure/store'
import { AsyncInput } from './AsyncInput'
import { ConfigInput } from './ConfigInput'
import { useAvailableVariables } from './useAvailableVariables'
import { useDynamicOutputPorts } from './useDynamicOutputPorts'
/** Array param names that should render as MessagesInput instead of generic ArrayInput. */
@@ -59,6 +60,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
// conditionAgentflow has an Else port; conditionAgentAgentflow does not
const includeElse = !isConditionAgentNode
const { cleanupOrphanedEdges } = useDynamicOutputPorts(data?.id ?? '', hasDynamicPorts, includeElse)
const variableItems = useAvailableVariables(data?.id ?? '')
// Ref to read current data
const dataRef = useRef(data)
@@ -378,6 +380,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
AsyncInputComponent={AsyncInput}
ConfigInputComponent={ConfigInput}
onConfigChange={onConfigChange}
variableItems={inputParam.acceptVariable ? variableItems : undefined}
/>
)
})}
@@ -0,0 +1,135 @@
import { renderHook } from '@testing-library/react'
import { useAvailableVariables } from './useAvailableVariables'
// --- Mocks ---
const mockState = {
nodes: [] as Array<{ id: string; data: Record<string, unknown> }>,
edges: [] as Array<{ source: string; target: string }>
}
jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
state: mockState
})
}))
// --- Helpers ---
function makeNode(id: string, name: string, overrides: Record<string, unknown> = {}) {
return {
id,
data: {
id,
name,
label: name,
inputValues: {},
...overrides
}
}
}
// --- Tests ---
describe('useAvailableVariables', () => {
beforeEach(() => {
mockState.nodes = []
mockState.edges = []
})
it('always returns global variables (question, chat_history, file_attachment)', () => {
const { result } = renderHook(() => useAvailableVariables('node_0'))
const labels = result.current.map((i) => i.label)
expect(labels).toContain('question')
expect(labels).toContain('chat_history')
expect(labels).toContain('file_attachment')
expect(result.current).toHaveLength(3)
})
it('returns upstream node outputs based on edges', () => {
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
const { result } = renderHook(() => useAvailableVariables('agent_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(1)
expect(nodeOutputs[0].value).toBe('{{start_0.data.instance}}')
expect(nodeOutputs[0].label).toBe('startAgentflow')
})
it('does not return downstream node outputs', () => {
mockState.nodes = [makeNode('start_0', 'startAgentflow'), makeNode('agent_0', 'agentAgentflow')]
mockState.edges = [{ source: 'start_0', target: 'agent_0' }]
const { result } = renderHook(() => useAvailableVariables('start_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(0)
})
it('uses chainName/functionName/variableName for display label when available', () => {
mockState.nodes = [makeNode('func_0', 'customFunction', { inputValues: { functionName: 'myFunc' }, label: 'Custom Function' })]
mockState.edges = [{ source: 'func_0', target: 'target_0' }]
const { result } = renderHook(() => useAvailableVariables('target_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs[0].label).toBe('myFunc')
})
it('returns flow state variables from startAgentflow node', () => {
mockState.nodes = [
makeNode('start_0', 'startAgentflow', {
inputValues: {
startState: [{ key: 'count' }, { key: 'userName' }]
}
})
]
const { result } = renderHook(() => useAvailableVariables('node_0'))
const stateItems = result.current.filter((i) => i.category === 'Flow State')
expect(stateItems).toHaveLength(2)
expect(stateItems[0].label).toBe('$flow.state.count')
expect(stateItems[0].value).toBe('$flow.state.count')
expect(stateItems[1].label).toBe('$flow.state.userName')
})
it('returns empty state when no startAgentflow node exists', () => {
mockState.nodes = [makeNode('agent_0', 'agentAgentflow')]
const { result } = renderHook(() => useAvailableVariables('agent_0'))
const stateItems = result.current.filter((i) => i.category === 'Flow State')
expect(stateItems).toHaveLength(0)
})
it('handles malformed startState entries gracefully', () => {
mockState.nodes = [
makeNode('start_0', 'startAgentflow', {
inputValues: {
startState: [{ key: 'valid' }, null, { noKey: true }, 42]
}
})
]
const { result } = renderHook(() => useAvailableVariables('node_0'))
const stateItems = result.current.filter((i) => i.category === 'Flow State')
expect(stateItems).toHaveLength(1)
expect(stateItems[0].label).toBe('$flow.state.valid')
})
it('returns empty node outputs when node has no upstream connections', () => {
mockState.nodes = [makeNode('lonely_0', 'agentAgentflow')]
mockState.edges = []
const { result } = renderHook(() => useAvailableVariables('lonely_0'))
const nodeOutputs = result.current.filter((i) => i.category === 'Node Outputs')
expect(nodeOutputs).toHaveLength(0)
})
})
@@ -0,0 +1,98 @@
import { useMemo } from 'react'
import type { VariableItem } from '@/atoms/SelectVariable'
import type { FlowEdge, FlowNode } from '@/core/types'
import { useAgentflowContext } from '@/infrastructure/store'
// ── Static global variables (matches original SelectVariable.jsx) ───────────
const GLOBAL_VARIABLES: VariableItem[] = [
{ label: 'question', description: "User's question from chatbox", category: 'Chat Context', value: '{{question}}' },
{
label: 'chat_history',
description: 'Past conversation history between user and AI',
category: 'Chat Context',
value: '{{chat_history}}'
},
{
label: 'file_attachment',
description: 'Files uploaded from the chat when Full File Upload is enabled on the Configuration',
category: 'Chat Context',
value: '{{file_attachment}}'
}
]
// ── Helpers ──────────────────────────────────────────────────────────────────
/**
* Walk edges backward from `nodeId` to collect all direct upstream source nodes.
*/
function getUpstreamNodes(nodeId: string, nodes: FlowNode[], edges: FlowEdge[]): FlowNode[] {
const sourceIds = new Set<string>()
for (const edge of edges) {
if (edge.target === nodeId) {
sourceIds.add(edge.source)
}
}
return nodes.filter((n) => sourceIds.has(n.id))
}
// ── Hook ─────────────────────────────────────────────────────────────────────
/**
* Returns the list of variable items available for a given node.
*
* Matches the original SelectVariable.jsx behaviour:
* - Global variables: question, chat_history, file_attachment
* - Upstream node outputs (from edges)
* - Flow state variables (from startAgentflow node's startState)
*
* Lives in the features layer so it can read from AgentflowContext.
* The returned items are passed to the SelectVariable atom via props.
*/
export function useAvailableVariables(nodeId: string): VariableItem[] {
const { state } = useAgentflowContext()
const { nodes, edges } = state
return useMemo(() => {
const items: VariableItem[] = [...GLOBAL_VARIABLES]
// ── Upstream node outputs ────────────────────────────────────────
const upstreamNodes = getUpstreamNodes(nodeId, nodes, edges)
for (const node of upstreamNodes) {
const displayName =
(node.data.inputValues?.chainName as string) ??
(node.data.inputValues?.functionName as string) ??
(node.data.inputValues?.variableName as string) ??
node.data.label ??
node.data.id
items.push({
label: displayName,
description: `Output from ${node.data.label ?? node.data.name}`,
category: 'Node Outputs',
value: `{{${node.id}.data.instance}}`
})
}
// ── Flow state variables from startAgentflow node ────────────────
const startNode = nodes.find((n) => n.data.name === 'startAgentflow')
if (startNode) {
const startState = startNode.data.inputValues?.startState
if (Array.isArray(startState)) {
for (const entry of startState) {
if (entry && typeof entry === 'object' && 'key' in entry && typeof entry.key === 'string') {
items.push({
label: `$flow.state.${entry.key}`,
description: `Current value of the state variable with specified key`,
category: 'Flow State',
value: `$flow.state.${entry.key}`
})
}
}
}
}
return items
}, [nodeId, nodes, edges])
}
+300 -257
View File
File diff suppressed because it is too large Load Diff