mirror of
https://github.com/FlowiseAI/Flowise.git
synced 2026-10-12 02:02:17 +00:00
Feat/agentflow Port Markdown Editor & Edit/Source Toggle from legacy UI to agentflow (#6125)
* Initial flow * Fix markdown import shape * Add support to check for HTML or markdown * Fix values not being persisted * Make implementation similar to v2 * Add vairable syntax support to expandDialog * Add variable syntax support to messageInput * Test cases added * Remove eslint-disable * Add getHtml as a fallback * Fixed gemini comments * Fix test cases and lint warnings * Fixed editorUtils.test to not have nested structure * Fix review comments
This commit is contained in:
@@ -73,6 +73,7 @@
|
||||
"@codemirror/lang-python": "^6.1.0",
|
||||
"@tabler/icons-react": "^3.7.0",
|
||||
"@tiptap/core": "^3.20.4",
|
||||
"@tiptap/markdown": "^3.20.4",
|
||||
"@tiptap/extension-code-block-lowlight": "^3.20.4",
|
||||
"@tiptap/extension-mention": "^3.20.4",
|
||||
"@tiptap/extension-placeholder": "^3.20.4",
|
||||
|
||||
@@ -1,12 +1,25 @@
|
||||
import { createElement, forwardRef } from 'react'
|
||||
|
||||
export const useEditor = (config?: Record<string, unknown>) => ({
|
||||
getHTML: () => (config?.content as string) ?? '<p></p>',
|
||||
getMarkdown: () => (config?.content as string) ?? '',
|
||||
setEditable: jest.fn(),
|
||||
commands: { focus: jest.fn(), setContent: jest.fn() },
|
||||
_onUpdate: config?.onUpdate
|
||||
})
|
||||
export const useEditor = (config?: Record<string, unknown>) => {
|
||||
// Track the current content so getMarkdown/getHTML reflect setContent() calls,
|
||||
// mirroring real TipTap behaviour where setContent updates the editor state.
|
||||
let currentContent: string = (config?.content as string) ?? ''
|
||||
return {
|
||||
getHTML: () => currentContent || '<p></p>',
|
||||
getMarkdown: () => currentContent,
|
||||
isEmpty: !currentContent,
|
||||
setEditable: jest.fn(),
|
||||
commands: {
|
||||
focus: jest.fn(),
|
||||
// Capture the first argument (the content string) so getMarkdown/getHTML
|
||||
// return the value that was last loaded into the editor.
|
||||
setContent: jest.fn((content: string) => {
|
||||
currentContent = content
|
||||
})
|
||||
},
|
||||
_onUpdate: config?.onUpdate
|
||||
}
|
||||
}
|
||||
|
||||
export const EditorContent = forwardRef<HTMLDivElement, { editor?: unknown; [k: string]: unknown }>(({ editor, ...rest }, ref) =>
|
||||
createElement('div', { ref, 'data-testid': 'tiptap-editor-content', 'data-has-editor': !!editor, ...rest })
|
||||
|
||||
@@ -175,15 +175,20 @@ describe('ExpandTextDialog', () => {
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show Edit/Source toggle in code mode', () => {
|
||||
render(<ExpandTextDialog open={true} value='' inputType='code' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --- Rich text mode ---
|
||||
|
||||
describe('inputType="string" (richtext)', () => {
|
||||
it('should render the TipTap editor instead of a TextField', async () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
it('should render the TipTap editor instead of a TextField in Edit mode', async () => {
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
// RichTextEditor renders data-testid='rich-text-editor' which wraps tiptap
|
||||
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
@@ -191,10 +196,11 @@ describe('ExpandTextDialog', () => {
|
||||
|
||||
// Plain TextField should NOT be present
|
||||
expect(screen.queryByTestId('expand-content-input')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render plain TextField for non-string, non-code input types', () => {
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByTestId('expand-content-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
@@ -202,9 +208,7 @@ describe('ExpandTextDialog', () => {
|
||||
})
|
||||
|
||||
it('should still show Save and Cancel buttons in richtext mode', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
@@ -214,7 +218,7 @@ describe('ExpandTextDialog', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value='<p>Hello</p>'
|
||||
value='Hello'
|
||||
inputType='string'
|
||||
disabled={true}
|
||||
onConfirm={mockOnConfirm}
|
||||
@@ -241,14 +245,204 @@ describe('ExpandTextDialog', () => {
|
||||
})
|
||||
|
||||
it('should call onCancel when Cancel is clicked in richtext mode', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='<p>Hello</p>' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
expect(mockOnCancel).toHaveBeenCalled()
|
||||
expect(mockOnConfirm).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// --- Edit/Source toggle ---
|
||||
|
||||
it('should show Edit and Source toggle buttons in richtext mode', () => {
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not show Edit/Source toggle for non-string input types', () => {
|
||||
render(<ExpandTextDialog open={true} value='42' inputType='number' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Edit' })).not.toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: 'Source' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not change mode when the active toggle button is clicked again', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Already in Edit mode — clicking Edit again should keep the rich-text editor visible
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to Source mode showing a raw text field when Source is clicked', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
|
||||
// Source mode renders a monospace TextField with data-testid='source-input'
|
||||
expect(screen.getByTestId('source-input')).toBeInTheDocument()
|
||||
|
||||
// TipTap editor should be unmounted
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show the current markdown value in Source mode', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
|
||||
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
|
||||
expect(sourceTextarea).toHaveValue('## My heading')
|
||||
})
|
||||
|
||||
it('should switch back to Edit mode when Edit is clicked from Source', () => {
|
||||
render(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
expect(screen.getByTestId('source-input')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save edits made in Source mode when Save is clicked', () => {
|
||||
render(
|
||||
<ExpandTextDialog open={true} value='## My heading' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
|
||||
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
|
||||
fireEvent.change(sourceTextarea, { target: { value: '## Updated heading' } })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('## Updated heading')
|
||||
})
|
||||
|
||||
it('should disable the toggle buttons when disabled', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value='Hello'
|
||||
inputType='string'
|
||||
disabled={true}
|
||||
onConfirm={mockOnConfirm}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: 'Source' })).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should reset to Edit mode when the dialog is reopened', () => {
|
||||
const { rerender } = render(
|
||||
<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />
|
||||
)
|
||||
|
||||
// Switch to Source mode
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
expect(screen.getByTestId('source-input')).toBeInTheDocument()
|
||||
|
||||
// Close dialog
|
||||
rerender(<ExpandTextDialog open={false} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
// Reopen — should be back in Edit mode
|
||||
rerender(<ExpandTextDialog open={true} value='Hello' inputType='string' onConfirm={mockOnConfirm} onCancel={mockOnCancel} />)
|
||||
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// --- With suggestionItems (VariableInput mode) ---
|
||||
|
||||
describe('inputType="string" with suggestionItems', () => {
|
||||
const mockSuggestionItems = [{ id: 'question', label: 'question', description: "User's question", category: 'Chat Context' }]
|
||||
|
||||
it('should render VariableInput instead of RichTextEditor in Edit mode when suggestionItems provided', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value='Hello'
|
||||
inputType='string'
|
||||
suggestionItems={mockSuggestionItems}
|
||||
onConfirm={mockOnConfirm}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should switch to Source mode and back to VariableInput when suggestionItems provided', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value='Hello {{question}}'
|
||||
inputType='string'
|
||||
suggestionItems={mockSuggestionItems}
|
||||
onConfirm={mockOnConfirm}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
expect(screen.getByTestId('source-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('variable-input')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('source-input')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should still show Edit/Source toggle when suggestionItems provided', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value=''
|
||||
inputType='string'
|
||||
suggestionItems={mockSuggestionItems}
|
||||
onConfirm={mockOnConfirm}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Edit' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Source' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should save edits made in Source mode when suggestionItems provided', () => {
|
||||
render(
|
||||
<ExpandTextDialog
|
||||
open={true}
|
||||
value='Hello {{question}}'
|
||||
inputType='string'
|
||||
suggestionItems={mockSuggestionItems}
|
||||
onConfirm={mockOnConfirm}
|
||||
onCancel={mockOnCancel}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
|
||||
const sourceTextarea = screen.getByTestId('source-input').querySelector('textarea')!
|
||||
fireEvent.change(sourceTextarea, { target: { value: 'Updated {{question}}' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||
|
||||
expect(mockOnConfirm).toHaveBeenCalledWith('Updated {{question}}')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, Typography } from '@mui/material'
|
||||
import { Box, Button, Dialog, DialogActions, DialogContent, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
|
||||
import { IconCode, IconPencil } from '@tabler/icons-react'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
|
||||
import { getEditorMarkdown } from '@/atoms/utils/'
|
||||
|
||||
import { CodeInput } from './CodeInput'
|
||||
import { RichTextEditor } from './RichTextEditor.lazy'
|
||||
import type { SuggestionItem } from './SuggestionDropdown'
|
||||
import type { SuggestionItem } from './VariableInput'
|
||||
import { VariableInput } from './VariableInput'
|
||||
|
||||
export interface ExpandTextDialogProps {
|
||||
@@ -23,9 +27,14 @@ export interface ExpandTextDialogProps {
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
type EditorMode = 'edit' | 'source'
|
||||
|
||||
/**
|
||||
* A reusable expand dialog for editing long text content in a larger viewport.
|
||||
* Used by NodeInputHandler (multiline string fields) and MessagesInput (message content).
|
||||
*
|
||||
* For `inputType='string'`, an Edit/Source toggle lets users switch between the
|
||||
* WYSIWYG TipTap editor and a raw markdown text view.
|
||||
*/
|
||||
export function ExpandTextDialog({
|
||||
open,
|
||||
@@ -41,12 +50,15 @@ export function ExpandTextDialog({
|
||||
}: ExpandTextDialogProps) {
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const [prevOpen, setPrevOpen] = useState(open)
|
||||
const [mode, setMode] = useState<EditorMode>('edit')
|
||||
const editorRef = useRef<Editor | null>(null)
|
||||
|
||||
// Sync localValue synchronously when the dialog opens so the TipTap editor
|
||||
// initialises with the correct content (useEffect would leave a one-render
|
||||
// Sync localValue and reset mode synchronously when the dialog opens so the TipTap
|
||||
// editor initialises with the correct content (useEffect would leave a one-render
|
||||
// gap where localValue is stale, causing the editor to show empty/old text).
|
||||
if (open && !prevOpen) {
|
||||
setLocalValue(value)
|
||||
setMode('edit')
|
||||
setPrevOpen(true)
|
||||
} else if (!open && prevOpen) {
|
||||
setPrevOpen(false)
|
||||
@@ -56,13 +68,49 @@ export function ExpandTextDialog({
|
||||
onConfirm(localValue)
|
||||
}, [localValue, onConfirm])
|
||||
|
||||
const handleModeChange = (_: React.MouseEvent<HTMLElement>, newMode: EditorMode | null) => {
|
||||
if (!newMode || newMode === mode) return
|
||||
// When switching to Source, flush the editor's current state to markdown so the
|
||||
// textarea shows markdown rather than a raw HTML string (Gap 3 fix — mirrors PR #6021).
|
||||
if (newMode === 'source' && editorRef.current) {
|
||||
setLocalValue(getEditorMarkdown(editorRef.current))
|
||||
}
|
||||
setMode(newMode)
|
||||
}
|
||||
|
||||
const isRichText = inputType === 'string'
|
||||
|
||||
return (
|
||||
<Dialog open={open} fullWidth maxWidth='md'>
|
||||
<DialogContent>
|
||||
{title && (
|
||||
<Typography variant='h4' sx={{ mb: '10px' }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{(title || isRichText) && (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', mb: '10px' }}>
|
||||
{title && (
|
||||
<Typography variant='h4' sx={{ flex: 1 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
)}
|
||||
{!title && <Box sx={{ flex: 1 }} />}
|
||||
{isRichText && (
|
||||
<ToggleButtonGroup
|
||||
value={mode}
|
||||
exclusive
|
||||
onChange={handleModeChange}
|
||||
size='small'
|
||||
disabled={disabled}
|
||||
aria-label='editor mode'
|
||||
>
|
||||
<ToggleButton value='edit' aria-label='Edit'>
|
||||
<IconPencil size='1rem' stroke={1.5} style={{ marginRight: 4 }} />
|
||||
Edit
|
||||
</ToggleButton>
|
||||
<ToggleButton value='source' aria-label='Source'>
|
||||
<IconCode size='1rem' stroke={1.5} style={{ marginRight: 4 }} />
|
||||
Source
|
||||
</ToggleButton>
|
||||
</ToggleButtonGroup>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{inputType === 'code' ? (
|
||||
<CodeInput
|
||||
@@ -72,26 +120,41 @@ export function ExpandTextDialog({
|
||||
disabled={disabled}
|
||||
height='calc(100vh - 220px)'
|
||||
/>
|
||||
) : inputType === 'string' ? (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
{suggestionItems && suggestionItems.length > 0 ? (
|
||||
<VariableInput
|
||||
value={localValue}
|
||||
onChange={setLocalValue}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={15}
|
||||
suggestionItems={suggestionItems}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
) : isRichText ? (
|
||||
mode === 'source' ? (
|
||||
<TextField
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={15}
|
||||
value={localValue}
|
||||
disabled={disabled}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
data-testid='source-input'
|
||||
inputProps={{ style: { fontFamily: 'monospace', fontSize: '0.85em' } }}
|
||||
/>
|
||||
) : suggestionItems?.length ? (
|
||||
<VariableInput
|
||||
value={localValue}
|
||||
onChange={setLocalValue}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
rows={15}
|
||||
autoFocus
|
||||
suggestionItems={suggestionItems}
|
||||
onEditorReady={(editor) => {
|
||||
editorRef.current = editor
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
maxHeight: 'calc(100vh - 220px)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden'
|
||||
}}
|
||||
>
|
||||
<RichTextEditor
|
||||
value={localValue}
|
||||
onChange={setLocalValue}
|
||||
@@ -99,9 +162,12 @@ export function ExpandTextDialog({
|
||||
disabled={disabled}
|
||||
rows={15}
|
||||
autoFocus
|
||||
onEditorReady={(editor) => {
|
||||
editorRef.current = editor
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
) : (
|
||||
<TextField
|
||||
fullWidth
|
||||
|
||||
@@ -9,13 +9,15 @@ const mockOnDataChange = jest.fn()
|
||||
|
||||
jest.mock('@tabler/icons-react', () => ({
|
||||
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
|
||||
IconCode: () => <span />,
|
||||
IconPencil: () => <span />,
|
||||
IconPlus: () => <span data-testid='icon-plus' />,
|
||||
IconTrash: () => <span data-testid='icon-trash' />,
|
||||
IconVariable: () => <span data-testid='icon-variable' />
|
||||
}))
|
||||
|
||||
// Replace the TipTap-based RichTextEditor with a plain textarea so tests can
|
||||
// simulate content changes via fireEvent.change without TipTap internals.
|
||||
// Replace the TipTap-based RichTextEditor with a plain textarea so tests that open the
|
||||
// expand dialog (without variableItems) can verify dialog content via fireEvent.change.
|
||||
jest.mock('./RichTextEditor', () => ({
|
||||
RichTextEditor: ({
|
||||
value,
|
||||
@@ -40,6 +42,8 @@ jest.mock('./RichTextEditor', () => ({
|
||||
)
|
||||
}))
|
||||
|
||||
// Replace the TipTap-based VariableInput with a plain textarea so tests can
|
||||
// simulate content changes via fireEvent.change without TipTap internals.
|
||||
jest.mock('./VariableInput', () => ({
|
||||
VariableInput: ({
|
||||
value,
|
||||
@@ -48,9 +52,10 @@ jest.mock('./VariableInput', () => ({
|
||||
placeholder
|
||||
}: {
|
||||
value: string
|
||||
onChange: (html: string) => void
|
||||
onChange: (v: string) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
[k: string]: unknown
|
||||
}) => (
|
||||
<div data-testid='variable-input'>
|
||||
<textarea
|
||||
@@ -123,9 +128,9 @@ describe('MessagesInput', () => {
|
||||
const roleSelects = screen.getAllByRole('combobox')
|
||||
expect(roleSelects).toHaveLength(2)
|
||||
|
||||
// Content fields rendered as rich text editors (TipTap) — lazy-loaded
|
||||
// Content fields rendered as VariableInput (TipTap-based)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('rich-text-editor')).toHaveLength(2)
|
||||
expect(screen.getAllByTestId('variable-input')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,7 +219,7 @@ describe('MessagesInput', () => {
|
||||
|
||||
// --- Content field ---
|
||||
|
||||
it('should render rich text editor for content field', async () => {
|
||||
it('should render VariableInput for content field', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
@@ -224,13 +229,13 @@ describe('MessagesInput', () => {
|
||||
|
||||
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
|
||||
|
||||
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('variable-input-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --- Content change ---
|
||||
|
||||
it('should update content when RichTextEditor fires onChange', async () => {
|
||||
it('should update content when VariableInput fires onChange', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
@@ -240,7 +245,7 @@ describe('MessagesInput', () => {
|
||||
|
||||
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
|
||||
|
||||
const textarea = await screen.findByTestId('tiptap-editor-content')
|
||||
const textarea = await screen.findByTestId('variable-input-content')
|
||||
fireEvent.change(textarea, { target: { value: 'Updated content' } })
|
||||
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith({
|
||||
@@ -249,7 +254,7 @@ describe('MessagesInput', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should support variable syntax in content via RichTextEditor', async () => {
|
||||
it('should support variable syntax in content via VariableInput', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
@@ -259,7 +264,7 @@ describe('MessagesInput', () => {
|
||||
|
||||
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
|
||||
|
||||
const textarea = await screen.findByTestId('tiptap-editor-content')
|
||||
const textarea = await screen.findByTestId('variable-input-content')
|
||||
fireEvent.change(textarea, { target: { value: '{{ question }}' } })
|
||||
|
||||
expect(mockOnDataChange).toHaveBeenCalledWith({
|
||||
@@ -281,21 +286,20 @@ describe('MessagesInput', () => {
|
||||
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
|
||||
|
||||
// Edit inline content (parent has NOT re-rendered with new data yet)
|
||||
const textarea = await screen.findByTestId('tiptap-editor-content')
|
||||
const textarea = await screen.findByTestId('variable-input-content')
|
||||
fireEvent.change(textarea, { target: { value: 'Edited inline' } })
|
||||
|
||||
// Open expand dialog
|
||||
fireEvent.click(screen.getByTitle('Expand'))
|
||||
|
||||
// Wait for the lazy-loaded RichTextEditor inside the expand dialog to mount
|
||||
// Wait for the RichTextEditor inside the expand dialog to mount (no suggestionItems)
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('tiptap-editor-content')).toHaveLength(2)
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// The expand dialog should show the edited value from latestContentRef,
|
||||
// not the stale 'Original' from messages prop
|
||||
const editors = screen.getAllByTestId('tiptap-editor-content')
|
||||
const expandTextarea = editors[editors.length - 1] as HTMLTextAreaElement
|
||||
const expandTextarea = screen.getByTestId('tiptap-editor-content') as HTMLTextAreaElement
|
||||
expect(expandTextarea.value).toBe('Edited inline')
|
||||
})
|
||||
|
||||
@@ -314,7 +318,7 @@ describe('MessagesInput', () => {
|
||||
const { rerender } = render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} />)
|
||||
|
||||
// Edit the third message (index 2) inline
|
||||
const textareas = await screen.findAllByTestId('tiptap-editor-content')
|
||||
const textareas = await screen.findAllByTestId('variable-input-content')
|
||||
fireEvent.change(textareas[2], { target: { value: 'Edited assistant reply' } })
|
||||
|
||||
// Delete the first message (index 0) — this should shift index 2 → 1 in latestContentRef
|
||||
@@ -338,8 +342,7 @@ describe('MessagesInput', () => {
|
||||
fireEvent.click(expandButtons[1])
|
||||
|
||||
// The expand dialog should show the edited content from the shifted ref
|
||||
const editors = screen.getAllByTestId('tiptap-editor-content')
|
||||
const expandTextarea = editors[editors.length - 1] as HTMLTextAreaElement
|
||||
const expandTextarea = screen.getByTestId('tiptap-editor-content') as HTMLTextAreaElement
|
||||
expect(expandTextarea.value).toBe('Edited assistant reply')
|
||||
})
|
||||
|
||||
@@ -361,8 +364,8 @@ describe('MessagesInput', () => {
|
||||
// Delete button disabled
|
||||
expect(screen.getByTitle('Delete')).toBeDisabled()
|
||||
|
||||
// Rich text editor is rendered (disabled state is handled by TipTap internally)
|
||||
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
// VariableInput is rendered (disabled state is handled by TipTap internally)
|
||||
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// --- minItems constraint ---
|
||||
@@ -451,7 +454,7 @@ describe('MessagesInput', () => {
|
||||
|
||||
// --- Expand dialog (now uses rich text mode with TipTap) ---
|
||||
|
||||
it('should open expand dialog with rich text editor when expand icon is clicked', async () => {
|
||||
it('should open expand dialog with rich text editor when expand icon is clicked (no variableItems)', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
@@ -463,14 +466,42 @@ describe('MessagesInput', () => {
|
||||
|
||||
fireEvent.click(screen.getByTitle('Expand'))
|
||||
|
||||
// Inline editor + expand dialog editor = 2 rich text editors
|
||||
// Inline uses VariableInput; dialog (no suggestionItems) falls back to RichTextEditor
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('rich-text-editor')).toHaveLength(2)
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should open expand dialog with VariableInput when variableItems are provided', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
agentMessages: [{ role: 'user', content: 'Hello {{question}}' }]
|
||||
}
|
||||
} as NodeData
|
||||
const variableItems = [{ value: '{{question}}', label: 'question', description: "User's question", category: 'Chat Context' }]
|
||||
|
||||
render(
|
||||
<MessagesInput
|
||||
inputParam={mockInputParam}
|
||||
data={dataWithMessages}
|
||||
variableItems={variableItems}
|
||||
onDataChange={mockOnDataChange}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTitle('Expand'))
|
||||
|
||||
// Both inline and expand dialog use VariableInput when suggestionItems are available
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('variable-input')).toHaveLength(2)
|
||||
})
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onConfirm with current value when Save is clicked in expand dialog', () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
@@ -556,7 +587,7 @@ describe('MessagesInput', () => {
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should fall back to RichTextEditor when variableItems is empty', async () => {
|
||||
it('should render VariableInput even when variableItems is empty', async () => {
|
||||
const dataWithMessages: NodeData = {
|
||||
...mockNodeData,
|
||||
inputs: {
|
||||
@@ -566,8 +597,8 @@ describe('MessagesInput', () => {
|
||||
|
||||
render(<MessagesInput inputParam={mockInputParam} data={dataWithMessages} onDataChange={mockOnDataChange} variableItems={[]} />)
|
||||
|
||||
expect(await screen.findByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('variable-input')).not.toBeInTheDocument()
|
||||
expect(await screen.findByTestId('variable-input')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('rich-text-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should update content via VariableInput when variableItems are provided', async () => {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { IconArrowsMaximize, IconPlus, IconTrash, IconVariable } from '@tabler/i
|
||||
import type { InputParam, NodeData } from '@/core/types'
|
||||
|
||||
import { ExpandTextDialog } from './ExpandTextDialog'
|
||||
import { RichTextEditor } from './RichTextEditor.lazy'
|
||||
import { toSuggestionItems } from './toSuggestionItems'
|
||||
import { useStableKeys } from './useStableKeys'
|
||||
import { VariableInput } from './VariableInput'
|
||||
@@ -31,9 +30,9 @@ export interface MessagesInputProps {
|
||||
inputParam: InputParam
|
||||
data: NodeData
|
||||
disabled?: boolean
|
||||
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
|
||||
/** Variable items for {{ autocomplete in message content fields. */
|
||||
/** Variable items for `{{ }}` autocomplete in message content fields. */
|
||||
variableItems?: VariableItem[]
|
||||
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +40,7 @@ export interface MessagesInputProps {
|
||||
* Each entry has a role dropdown (system/assistant/developer/user)
|
||||
* and a multiline content textarea with variable support ({{ variable }} syntax).
|
||||
*/
|
||||
export function MessagesInput({ inputParam, data, disabled = false, onDataChange, variableItems }: MessagesInputProps) {
|
||||
export function MessagesInput({ inputParam, data, disabled = false, variableItems, onDataChange }: MessagesInputProps) {
|
||||
const theme = useTheme()
|
||||
|
||||
const messages = useMemo(
|
||||
@@ -207,24 +206,14 @@ export function MessagesInput({ inputParam, data, disabled = false, onDataChange
|
||||
<IconArrowsMaximize />
|
||||
</IconButton>
|
||||
</div>
|
||||
{suggestionItems && suggestionItems.length > 0 ? (
|
||||
<VariableInput
|
||||
value={message.content}
|
||||
onChange={(html) => handleContentChange(index, html)}
|
||||
placeholder='Message content (supports {{ variable }} syntax)'
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
suggestionItems={suggestionItems}
|
||||
/>
|
||||
) : (
|
||||
<RichTextEditor
|
||||
value={message.content}
|
||||
onChange={(html) => handleContentChange(index, html)}
|
||||
placeholder='Message content (supports {{ variable }} syntax)'
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
<VariableInput
|
||||
value={message.content}
|
||||
onChange={(v) => handleContentChange(index, v)}
|
||||
placeholder='Message content (supports {{ variable }} syntax)'
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
suggestionItems={suggestionItems}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -44,7 +44,9 @@ jest.mock('./VariableInput', () => ({
|
||||
|
||||
jest.mock('@tabler/icons-react', () => ({
|
||||
IconArrowsMaximize: () => <span data-testid='icon-expand' />,
|
||||
IconCode: () => <span />,
|
||||
IconInfoCircle: () => <span data-testid='icon-info-circle' />,
|
||||
IconPencil: () => <span />,
|
||||
IconVariable: () => <span data-testid='icon-variable' />,
|
||||
IconRefresh: () => <span data-testid='icon-refresh' />
|
||||
}))
|
||||
|
||||
@@ -3,7 +3,9 @@ import { render, screen } from '@testing-library/react'
|
||||
import { RichTextEditor } from './RichTextEditor'
|
||||
|
||||
// --- Mock TipTap ---
|
||||
let capturedOnUpdate: ((args: { editor: { getHTML: () => string } }) => void) | undefined
|
||||
// editor.getMarkdown() is added directly on the Editor interface by @tiptap/markdown
|
||||
// via module augmentation — it is NOT nested under storage.markdown.
|
||||
let capturedOnUpdate: ((args: { editor: { getMarkdown: () => string } }) => void) | undefined
|
||||
|
||||
jest.mock('@tiptap/react', () => ({
|
||||
useEditor: (config: Record<string, unknown>) => {
|
||||
@@ -12,7 +14,7 @@ jest.mock('@tiptap/react', () => ({
|
||||
return {
|
||||
setEditable: jest.fn(),
|
||||
commands: { focus: jest.fn(), setContent: jest.fn() },
|
||||
getHTML: () => '<p>mock</p>'
|
||||
getMarkdown: () => 'mock markdown'
|
||||
}
|
||||
},
|
||||
EditorContent: ({ editor, ...rest }: { editor: unknown; [key: string]: unknown }) => (
|
||||
@@ -38,6 +40,10 @@ jest.mock('@tiptap/extension-placeholder', () => ({
|
||||
default: { configure: jest.fn(() => 'Placeholder') }
|
||||
}))
|
||||
|
||||
jest.mock('@tiptap/markdown', () => ({
|
||||
Markdown: { configure: jest.fn(() => 'Markdown') }
|
||||
}))
|
||||
|
||||
jest.mock('lowlight', () => ({
|
||||
common: {},
|
||||
createLowlight: jest.fn(() => ({ register: jest.fn() }))
|
||||
@@ -52,26 +58,35 @@ beforeEach(() => {
|
||||
|
||||
describe('RichTextEditor', () => {
|
||||
it('should render the editor container', () => {
|
||||
render(<RichTextEditor value='<p>Hello</p>' onChange={mockOnChange} />)
|
||||
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByTestId('rich-text-editor')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('tiptap-editor-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass the editor instance to EditorContent', () => {
|
||||
render(<RichTextEditor value='<p>Hello</p>' onChange={mockOnChange} />)
|
||||
render(<RichTextEditor value='Hello' onChange={mockOnChange} />)
|
||||
|
||||
expect(screen.getByTestId('tiptap-editor-content')).toHaveAttribute('data-has-editor', 'true')
|
||||
})
|
||||
|
||||
it('should call onChange when editor content updates', () => {
|
||||
it('should call onChange with markdown when editor content updates', () => {
|
||||
render(<RichTextEditor value='' onChange={mockOnChange} />)
|
||||
|
||||
// Simulate TipTap onUpdate callback
|
||||
expect(capturedOnUpdate).toBeDefined()
|
||||
capturedOnUpdate!({ editor: { getHTML: () => '<p>Updated</p>' } })
|
||||
capturedOnUpdate!({ editor: { getMarkdown: () => '**Updated**' } })
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith('<p>Updated</p>')
|
||||
expect(mockOnChange).toHaveBeenCalledWith('**Updated**')
|
||||
})
|
||||
|
||||
it('should call onChange with Markdown', () => {
|
||||
render(<RichTextEditor value='' onChange={mockOnChange} />)
|
||||
|
||||
capturedOnUpdate!({ editor: { getMarkdown: () => '## Heading' } })
|
||||
|
||||
// Must be the markdown string, not wrapped in HTML tags
|
||||
expect(mockOnChange).toHaveBeenCalledWith('## Heading')
|
||||
expect(mockOnChange).not.toHaveBeenCalledWith(expect.stringContaining('<h2>'))
|
||||
})
|
||||
|
||||
it('should render with rows prop', () => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Box } from '@mui/material'
|
||||
import { styled } from '@mui/material/styles'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
// Individual language imports instead of lowlight/common to tree-shake highlight.js
|
||||
@@ -14,6 +16,7 @@ import python from 'highlight.js/lib/languages/python'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import { createLowlight } from 'lowlight'
|
||||
|
||||
import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
|
||||
const lowlight = createLowlight()
|
||||
@@ -23,21 +26,26 @@ lowlight.register('python', python)
|
||||
lowlight.register('typescript', typescript)
|
||||
|
||||
export interface RichTextEditorProps {
|
||||
/** HTML content */
|
||||
/** Markdown string, or legacy HTML string for backward compatibility */
|
||||
value: string
|
||||
/** Called with the updated HTML string on every edit */
|
||||
onChange: (html: string) => void
|
||||
/** Called with the updated markdown string on every edit */
|
||||
onChange: (markdown: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Number of visible text rows (controls editor height) */
|
||||
rows?: number
|
||||
/** Auto-focus when the editor mounts */
|
||||
autoFocus?: boolean
|
||||
/** Called with the live editor instance once it is ready (and null on unmount). Used by ExpandTextDialog to call getMarkdown() on mode switch. */
|
||||
onEditorReady?: (editor: Editor | null) => void
|
||||
/** When true, emits markdown; when false, emits HTML. Defaults to true. */
|
||||
useMarkdown?: boolean
|
||||
}
|
||||
|
||||
/* ── TipTap extensions (no mention/variable support — that belongs in features/) ── */
|
||||
|
||||
const buildExtensions = (placeholder?: string) => [
|
||||
Markdown,
|
||||
StarterKit.configure({ codeBlock: false }),
|
||||
CodeBlockLowlight.configure({ lowlight, enableTabIndentation: true, tabSize: 2 }),
|
||||
...(placeholder ? [Placeholder.configure({ placeholder })] : [])
|
||||
@@ -134,34 +142,76 @@ const StyledEditorContent = styled(EditorContent, {
|
||||
/**
|
||||
* A TipTap-based rich text editor atom with code block syntax highlighting.
|
||||
*
|
||||
* Stores and emits content as markdown. Legacy HTML values (content starting with `<`)
|
||||
* are accepted for backward compatibility — TipTap renders them correctly, and
|
||||
* subsequent edits will emit markdown.
|
||||
*
|
||||
* This is a "dumb" UI primitive — it receives all data via props and owns no
|
||||
* business logic. Variable/mention support lives in the features layer.
|
||||
*/
|
||||
export function RichTextEditor({ value, onChange, placeholder, disabled = false, rows, autoFocus = false }: RichTextEditorProps) {
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
rows,
|
||||
autoFocus = false,
|
||||
onEditorReady,
|
||||
useMarkdown = true
|
||||
}: RichTextEditorProps) {
|
||||
// Keep a ref to the latest onChange so the TipTap onUpdate callback never goes stale
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Track the last value we emitted (or loaded) to avoid re-setting content
|
||||
// when the parent echoes our own onChange value back as the new prop.
|
||||
const lastEmittedRef = useRef<string>(value || '')
|
||||
|
||||
// Capture initial value and useMarkdown for the mount effect below so we can
|
||||
// depend only on `editor` without suppressing the exhaustive-deps rule.
|
||||
const initialValueRef = useRef(value)
|
||||
const useMarkdownRef = useRef(useMarkdown)
|
||||
|
||||
const extensions = useMemo(() => buildExtensions(placeholder), [placeholder])
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: value,
|
||||
content: '',
|
||||
editable: !disabled,
|
||||
autofocus: autoFocus ? 'end' : false,
|
||||
onUpdate: ({ editor: ed }: { editor: { getHTML: () => string } }) => {
|
||||
onChangeRef.current(ed.getHTML())
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
const value = useMarkdown ? getEditorMarkdown(ed) : ed.getHTML()
|
||||
lastEmittedRef.current = value
|
||||
onChangeRef.current(value)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync external value changes into the editor (e.g. when parent state updates)
|
||||
// Notify parent when the editor instance is ready (used by ExpandTextDialog to flush
|
||||
// the current editor state to markdown when switching to Source mode).
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value, { emitUpdate: false })
|
||||
onEditorReady?.(editor)
|
||||
return () => onEditorReady?.(null)
|
||||
}, [editor, onEditorReady])
|
||||
|
||||
// Load initial content once the editor is ready, detecting legacy HTML vs markdown.
|
||||
// Reads from refs so only `editor` needs to be in the dep array.
|
||||
useEffect(() => {
|
||||
if (!editor || !initialValueRef.current) return
|
||||
const contentType = !useMarkdownRef.current || isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
|
||||
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
|
||||
lastEmittedRef.current = initialValueRef.current
|
||||
}, [editor])
|
||||
|
||||
// Sync genuine external value changes (e.g. parent resets the field programmatically).
|
||||
useEffect(() => {
|
||||
if (editor && value !== lastEmittedRef.current) {
|
||||
const contentType = !useMarkdown || isHtmlContent(value) ? 'html' : 'markdown'
|
||||
editor.commands.setContent(value, { emitUpdate: false, contentType })
|
||||
lastEmittedRef.current = value
|
||||
}
|
||||
}, [editor, value])
|
||||
}, [editor, value, useMarkdown])
|
||||
|
||||
// Sync editable state when disabled prop changes
|
||||
useEffect(() => {
|
||||
|
||||
@@ -69,19 +69,33 @@ describe('VariableInput', () => {
|
||||
expect(screen.getByTestId('variable-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('initialises the editor with the provided value', () => {
|
||||
// Verify the editor is created with content matching the value prop, so
|
||||
// the editor is not empty on the first render before the sync useEffect fires.
|
||||
it('always initialises the editor with empty content (value is loaded via setContent effect)', () => {
|
||||
// The editor always starts empty; value is loaded once via editor.commands.setContent
|
||||
// after the editor instance is ready — this avoids content flicker on re-renders.
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ value: 'Hello world' })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: 'Hello world' }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('initialises the editor with empty string when value is empty', () => {
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ value: '' })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ content: '' }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes autofocus:"end" to useEditor when autoFocus is true', () => {
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ autoFocus: true })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ autofocus: 'end' }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes autofocus:false to useEditor when autoFocus is false (default)', () => {
|
||||
const useEditorSpy = jest.spyOn(TiptapReact, 'useEditor')
|
||||
renderVariableInput({ autoFocus: false })
|
||||
expect(useEditorSpy).toHaveBeenCalledWith(expect.objectContaining({ autofocus: false }))
|
||||
useEditorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('calls onEditorReady with the editor instance after mount', () => {
|
||||
const onEditorReady = jest.fn()
|
||||
renderVariableInput({ onEditorReady })
|
||||
// The mock editor has getHTML/getMarkdown/commands etc.
|
||||
expect(onEditorReady).toHaveBeenCalledWith(expect.objectContaining({ getHTML: expect.any(Function) }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,6 +5,8 @@ import { styled } from '@mui/material/styles'
|
||||
import { mergeAttributes } from '@tiptap/core'
|
||||
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
|
||||
import Placeholder from '@tiptap/extension-placeholder'
|
||||
import { Markdown } from '@tiptap/markdown'
|
||||
import type { Editor } from '@tiptap/react'
|
||||
import { EditorContent, useEditor } from '@tiptap/react'
|
||||
import StarterKit from '@tiptap/starter-kit'
|
||||
import javascript from 'highlight.js/lib/languages/javascript'
|
||||
@@ -13,6 +15,7 @@ import python from 'highlight.js/lib/languages/python'
|
||||
import typescript from 'highlight.js/lib/languages/typescript'
|
||||
import { createLowlight } from 'lowlight'
|
||||
|
||||
import { getEditorMarkdown, isHtmlContent } from '@/atoms/utils/'
|
||||
import { CustomMention } from '@/core/primitives/customMention'
|
||||
import { tokens } from '@/core/theme/tokens'
|
||||
|
||||
@@ -32,12 +35,14 @@ export interface VariableInputProps {
|
||||
onChange: (value: string) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
/** Number of visible text rows. When set, renders as a multiline editor and uses markdown serialization. */
|
||||
/** Number of visible text rows. When set, renders as a multiline editor. */
|
||||
rows?: number
|
||||
/** Available variables for autocomplete when typing `{{` */
|
||||
suggestionItems?: SuggestionItem[]
|
||||
/** Auto-focus the editor on mount */
|
||||
autoFocus?: boolean
|
||||
/** Called with the live editor instance once it is ready (and null on unmount). Used by ExpandTextDialog to call getMarkdown() on mode switch. */
|
||||
onEditorReady?: (editor: Editor | null) => void
|
||||
}
|
||||
|
||||
/* ── Styled wrapper matching RichTextEditor styling ── */
|
||||
@@ -133,17 +138,38 @@ const StyledEditorContent = styled(EditorContent, {
|
||||
*
|
||||
* When the user types `{{`, a suggestion dropdown appears anchored to the cursor
|
||||
* with available variables. Selecting a variable inserts it as a styled mention chip
|
||||
* that renders as `{{variableName}}` in the output text.
|
||||
* that renders as `{{variableName}}` in the markdown output.
|
||||
*
|
||||
* Content is stored and emitted as markdown. The CustomMention extension's
|
||||
* markdownTokenizer/parseMarkdown/renderMarkdown hooks ensure `{{variable}}` syntax
|
||||
* survives markdown round-trips intact. Legacy HTML values are accepted for backward
|
||||
* compatibility — TipTap renders them correctly and subsequent edits emit markdown.
|
||||
*
|
||||
* Serialization matches the UI's RichInput behavior:
|
||||
* This is the agentflow equivalent of the UI package's RichInput component.
|
||||
*/
|
||||
export function VariableInput({ value, onChange, placeholder, disabled = false, rows, suggestionItems, autoFocus }: VariableInputProps) {
|
||||
export function VariableInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
disabled = false,
|
||||
rows,
|
||||
suggestionItems,
|
||||
autoFocus = false,
|
||||
onEditorReady
|
||||
}: VariableInputProps) {
|
||||
const onChangeRef = useRef(onChange)
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange
|
||||
}, [onChange])
|
||||
|
||||
// Track the last value we emitted (or loaded) to avoid re-setting content
|
||||
// when the parent echoes our own onChange value back as the new prop.
|
||||
const lastEmittedRef = useRef<string>(value || '')
|
||||
|
||||
// Capture initial value for the mount effect below so we can depend only on
|
||||
// `editor` without suppressing the exhaustive-deps rule.
|
||||
const initialValueRef = useRef(value)
|
||||
|
||||
const suggestionConfig = useMemo(
|
||||
() => (suggestionItems?.length ? createSuggestionConfig(suggestionItems) : undefined),
|
||||
[suggestionItems]
|
||||
@@ -151,6 +177,7 @@ export function VariableInput({ value, onChange, placeholder, disabled = false,
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
Markdown,
|
||||
StarterKit.configure({
|
||||
codeBlock: false
|
||||
}),
|
||||
@@ -178,25 +205,41 @@ export function VariableInput({ value, onChange, placeholder, disabled = false,
|
||||
|
||||
const editor = useEditor({
|
||||
extensions,
|
||||
content: value || '',
|
||||
content: '',
|
||||
editable: !disabled,
|
||||
autofocus: autoFocus ?? false,
|
||||
autofocus: autoFocus ? 'end' : false,
|
||||
onUpdate: ({ editor: ed }) => {
|
||||
// Always use HTML serialization. The @tiptap/markdown v3 getMarkdown()
|
||||
// returns empty strings (known issue with the MarkdownManager in v3.20.4).
|
||||
// HTML round-trips correctly because the Mention extension's parseHTML
|
||||
// matches on span[data-type="mention"] with data-id/data-label attributes.
|
||||
onChangeRef.current(ed.getHTML())
|
||||
const value = getEditorMarkdown(ed)
|
||||
lastEmittedRef.current = value
|
||||
onChangeRef.current(value)
|
||||
}
|
||||
})
|
||||
|
||||
// Sync external value changes into the editor (e.g. when parent state updates)
|
||||
// Load initial content once the editor is ready, detecting legacy HTML vs markdown.
|
||||
// Reads from a ref so only `editor` needs to be in the dep array.
|
||||
useEffect(() => {
|
||||
if (editor && value !== editor.getHTML()) {
|
||||
editor.commands.setContent(value, { emitUpdate: false })
|
||||
if (!editor || !initialValueRef.current) return
|
||||
const contentType = isHtmlContent(initialValueRef.current) ? 'html' : 'markdown'
|
||||
editor.commands.setContent(initialValueRef.current, { emitUpdate: false, contentType })
|
||||
lastEmittedRef.current = initialValueRef.current
|
||||
}, [editor])
|
||||
|
||||
// Sync genuine external value changes (e.g. parent resets the field programmatically).
|
||||
useEffect(() => {
|
||||
if (editor && value !== lastEmittedRef.current) {
|
||||
const contentType = isHtmlContent(value) ? 'html' : 'markdown'
|
||||
editor.commands.setContent(value, { emitUpdate: false, contentType })
|
||||
lastEmittedRef.current = value
|
||||
}
|
||||
}, [editor, value])
|
||||
|
||||
// Notify parent when the editor instance is ready (used by ExpandTextDialog to flush
|
||||
// the current editor state to markdown when switching to Source mode).
|
||||
useEffect(() => {
|
||||
onEditorReady?.(editor)
|
||||
return () => onEditorReady?.(null)
|
||||
}, [editor, onEditorReady])
|
||||
|
||||
// Sync editable state
|
||||
useEffect(() => {
|
||||
if (editor) editor.setEditable(!disabled)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { getEditorMarkdown, isHtmlContent } from '../../atoms/utils/editorUtils'
|
||||
|
||||
describe('editorUtils', () => {
|
||||
describe('isHtmlContent', () => {
|
||||
// Falsy / non-string inputs
|
||||
it('returns false for empty string', () => {
|
||||
expect(isHtmlContent('')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for null', () => {
|
||||
expect(isHtmlContent(null)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isHtmlContent(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for a number', () => {
|
||||
expect(isHtmlContent(42)).toBe(false)
|
||||
})
|
||||
|
||||
// Markdown content (must NOT be detected as HTML)
|
||||
it('returns false for plain text', () => {
|
||||
expect(isHtmlContent('You are a helpful assistant.')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for markdown heading', () => {
|
||||
expect(isHtmlContent('## My heading')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for markdown bullet list', () => {
|
||||
expect(isHtmlContent('- item 1\n- item 2')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for markdown with variable syntax', () => {
|
||||
expect(isHtmlContent('Hello {{question}}, today is {{current_date_time}}')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for markdown code fence', () => {
|
||||
expect(isHtmlContent('```js\nconsole.log("hello")\n```')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns false for markdown bold/italic', () => {
|
||||
expect(isHtmlContent('**bold** and *italic* text')).toBe(false)
|
||||
})
|
||||
|
||||
// Legacy HTML content (MUST be detected)
|
||||
it('returns true for <p> tag', () => {
|
||||
expect(isHtmlContent('<p>Hello world</p>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <p> with variable mention span', () => {
|
||||
expect(isHtmlContent('<p>Hello <span data-type="mention" data-id="question">{{question}}</span></p>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <h1> heading tag', () => {
|
||||
expect(isHtmlContent('<h1>Title</h1>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <h2> heading tag', () => {
|
||||
expect(isHtmlContent('<h2>Section</h2>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <ul> list tag', () => {
|
||||
expect(isHtmlContent('<ul><li>item</li></ul>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <pre><code> block', () => {
|
||||
expect(isHtmlContent('<pre><code>const x = 1</code></pre>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <strong> tag', () => {
|
||||
expect(isHtmlContent('<strong>bold</strong>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <em> tag', () => {
|
||||
expect(isHtmlContent('<em>italic</em>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for <blockquote> tag', () => {
|
||||
expect(isHtmlContent('<blockquote>quote</blockquote>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for multiline HTML with mixed content', () => {
|
||||
expect(isHtmlContent('<p>First paragraph</p>\n<p>Second paragraph</p>')).toBe(true)
|
||||
})
|
||||
|
||||
it('returns true for uppercase tag like <P>', () => {
|
||||
expect(isHtmlContent('<P>uppercase</P>')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getEditorMarkdown', () => {
|
||||
it('returns markdown when getMarkdown() returns a non-empty string', () => {
|
||||
const editor = { getMarkdown: () => '## heading', getHTML: () => '<h2>heading</h2>', isEmpty: false }
|
||||
expect(getEditorMarkdown(editor)).toBe('## heading')
|
||||
})
|
||||
|
||||
it('returns empty string when getMarkdown() returns "" and editor is empty', () => {
|
||||
const editor = { getMarkdown: () => '', getHTML: () => '', isEmpty: true }
|
||||
expect(getEditorMarkdown(editor)).toBe('')
|
||||
})
|
||||
|
||||
it('falls back to HTML when getMarkdown() returns "" but editor is not empty', () => {
|
||||
const editor = { getMarkdown: () => '', getHTML: () => '<p>hello</p>', isEmpty: false }
|
||||
expect(getEditorMarkdown(editor)).toBe('<p>hello</p>')
|
||||
})
|
||||
|
||||
it('falls back to HTML when getMarkdown() throws', () => {
|
||||
const editor = {
|
||||
getMarkdown: () => {
|
||||
throw new Error('serialization failed')
|
||||
},
|
||||
getHTML: () => '<p>fallback</p>',
|
||||
isEmpty: false
|
||||
}
|
||||
expect(getEditorMarkdown(editor)).toBe('<p>fallback</p>')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Returns true when `content` looks like legacy HTML (saved by the old getHTML()
|
||||
* serializer), so callers can choose the right TipTap `setContent` contentType.
|
||||
*
|
||||
* Ported from packages/ui/src/ui-component/input/RichInput.jsx (PR #6021).
|
||||
* A simple tag-presence check is intentionally used here — it is fast, has no
|
||||
* dependencies, and the false-positive/negative risk for agentflow node inputs
|
||||
* is negligible (users don't normally write raw HTML into prompt fields).
|
||||
*/
|
||||
export function isHtmlContent(content: unknown): boolean {
|
||||
if (!content || typeof content !== 'string') return false
|
||||
return /<(?:p|div|span|h[1-6]|ul|ol|li|br|code|pre|blockquote|table|strong|em)\b/i.test(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely serialise a TipTap editor to markdown, falling back to HTML when
|
||||
* `getMarkdown()` returns an empty string for a non-empty document — a known
|
||||
* issue in `@tiptap/markdown` v3 where the MarkdownManager may silently fail
|
||||
* to serialise certain node types.
|
||||
*/
|
||||
export function getEditorMarkdown(editor: { getMarkdown(): string; getHTML(): string; isEmpty: boolean }): string {
|
||||
try {
|
||||
const markdown = editor.getMarkdown()
|
||||
if (markdown) return markdown
|
||||
if (editor.isEmpty) return ''
|
||||
return editor.getHTML()
|
||||
} catch {
|
||||
return editor.getHTML()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { getEditorMarkdown, isHtmlContent } from './editorUtils'
|
||||
@@ -367,8 +367,8 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
|
||||
inputParam={inputParam}
|
||||
data={data}
|
||||
disabled={dialogProps.disabled}
|
||||
onDataChange={onCustomDataChange}
|
||||
variableItems={variableItems}
|
||||
onDataChange={onCustomDataChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Generated
+3
@@ -144,6 +144,9 @@ importers:
|
||||
'@tiptap/extension-placeholder':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected]))
|
||||
'@tiptap/markdown':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4(@tiptap/[email protected](@tiptap/[email protected]))(@tiptap/[email protected])
|
||||
'@tiptap/pm':
|
||||
specifier: ^3.20.4
|
||||
version: 3.20.4
|
||||
|
||||
Reference in New Issue
Block a user