mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
Generated
+300
-257
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user