diff --git a/web/app/components/base/prompt-editor/__tests__/sandbox-placeholder.spec.tsx b/web/app/components/base/prompt-editor/__tests__/sandbox-placeholder.spec.tsx index 6d391e10a6..2000838120 100644 --- a/web/app/components/base/prompt-editor/__tests__/sandbox-placeholder.spec.tsx +++ b/web/app/components/base/prompt-editor/__tests__/sandbox-placeholder.spec.tsx @@ -1,6 +1,44 @@ -import { render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' +import { CLEAR_HIDE_MENU_TIMEOUT } from '../plugins/workflow-variable-block' import SandboxPlaceholder from '../sandbox-placeholder' +const mocks = vi.hoisted(() => { + const selectEnd = vi.fn() + const insertNodes = vi.fn() + const createTextNode = vi.fn((text: string) => ({ text })) + const editor = { + focus: vi.fn((callback?: () => void) => callback?.()), + update: vi.fn((callback: () => void) => callback()), + dispatchCommand: vi.fn(), + } + + return { + createTextNode, + editor, + insertNodes, + selectEnd, + } +}) + +vi.mock('@lexical/react/LexicalComposerContext', () => ({ + useLexicalComposerContext: () => [mocks.editor], +})) + +vi.mock('lexical', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + $getRoot: () => ({ + selectEnd: mocks.selectEnd, + }), + $insertNodes: mocks.insertNodes, + } +}) + +vi.mock('../plugins/custom-text/node', () => ({ + $createCustomTextNode: (text: string) => mocks.createTextNode(text), +})) + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => { @@ -20,7 +58,7 @@ describe('SandboxPlaceholder', () => { vi.clearAllMocks() }) - // Rendering branches for sandbox availability and tool-block support. + // Rendering states for sandbox support and tool visibility. describe('Rendering', () => { it('should render nothing when sandbox is not supported', () => { const { container } = render() @@ -28,63 +66,65 @@ describe('SandboxPlaceholder', () => { expect(container).toBeEmptyDOMElement() }) - it('should render only the insert pair when tool blocks are disabled', () => { - const { container } = render( + it('should render only the insert action when tool blocks are disabled', () => { + render( , ) - expect(container).toHaveTextContent('Write instructions here, /insert') - const tokens = container.querySelectorAll('.group\\/placeholder-token') - const kbdTokens = container.querySelectorAll('.system-kbd') - const actionTokens = container.querySelectorAll('.border-dotted') - - expect(tokens).toHaveLength(1) - expect(kbdTokens).toHaveLength(1) - expect(actionTokens).toHaveLength(1) - expect(tokens[0]).toHaveClass( - 'inline-flex', - 'cursor-pointer', - 'items-center', - 'gap-1', - 'text-text-tertiary', - 'hover:text-components-button-secondary-accent-text', - ) - expect(kbdTokens[0]).toHaveClass( - 'bg-components-kbd-bg-gray', - 'group-hover/placeholder-token:bg-components-button-secondary-accent-text-disabled', - ) - expect(kbdTokens[0]).toHaveTextContent('/') - expect(actionTokens[0]).toHaveClass( - 'pointer-events-auto', - 'border-b', - 'border-dotted', - 'border-current', - ) - expect(actionTokens[0]).toHaveTextContent('insert') + expect(screen.getByText('Write instructions here,')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /insert/i })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: /tools/i })).not.toBeInTheDocument() }) - it('should render both insert and tools pairs when tool blocks are enabled', () => { - const { container } = render() + it('should render insert and tools actions when tool blocks are enabled', () => { + render() - expect(container).toHaveTextContent('Write instructions here, /insert, @tools') - const tokens = container.querySelectorAll('.group\\/placeholder-token') - const kbdTokens = container.querySelectorAll('.system-kbd') - const actionTokens = container.querySelectorAll('.border-dotted') + expect(screen.getByRole('button', { name: /insert/i })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /tools/i })).toBeInTheDocument() + expect(screen.getAllByRole('button')).toHaveLength(2) + }) + }) - expect(tokens).toHaveLength(2) - expect(kbdTokens).toHaveLength(2) - expect(actionTokens).toHaveLength(2) - expect(kbdTokens[0]).toHaveTextContent('/') - expect(kbdTokens[1]).toHaveTextContent('@') - expect(actionTokens[0]).toHaveTextContent('insert') - expect(actionTokens[1]).toHaveTextContent('tools') - expect(tokens[1]).toHaveClass( - 'group/placeholder-token', - 'hover:text-components-button-secondary-accent-text', - ) + // Click interactions should reuse the editor trigger workflow. + describe('Interactions', () => { + it('should insert slash and clear the hide timeout when clicking insert', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /insert/i })) + + expect(mocks.editor.focus).toHaveBeenCalledTimes(1) + expect(mocks.editor.update).toHaveBeenCalledTimes(1) + expect(mocks.selectEnd).toHaveBeenCalledTimes(1) + expect(mocks.createTextNode).toHaveBeenCalledWith('/') + expect(mocks.insertNodes).toHaveBeenCalledWith([{ text: '/' }]) + expect(mocks.editor.dispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined) + }) + + it('should insert at-sign and clear the hide timeout when clicking tools', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /tools/i })) + + expect(mocks.editor.focus).toHaveBeenCalledTimes(1) + expect(mocks.editor.update).toHaveBeenCalledTimes(1) + expect(mocks.selectEnd).toHaveBeenCalledTimes(1) + expect(mocks.createTextNode).toHaveBeenCalledWith('@') + expect(mocks.insertNodes).toHaveBeenCalledWith([{ text: '@' }]) + expect(mocks.editor.dispatchCommand).toHaveBeenCalledWith(CLEAR_HIDE_MENU_TIMEOUT, undefined) + }) + + it('should not trigger editor insertion when placeholder is not editable', () => { + render() + + fireEvent.click(screen.getByRole('button', { name: /insert/i })) + + expect(mocks.editor.focus).not.toHaveBeenCalled() + expect(mocks.editor.update).not.toHaveBeenCalled() + expect(mocks.insertNodes).not.toHaveBeenCalled() + expect(mocks.editor.dispatchCommand).not.toHaveBeenCalled() }) }) }) diff --git a/web/app/components/base/prompt-editor/index.tsx b/web/app/components/base/prompt-editor/index.tsx index 141b960249..a1490e77cd 100644 --- a/web/app/components/base/prompt-editor/index.tsx +++ b/web/app/components/base/prompt-editor/index.tsx @@ -373,6 +373,7 @@ const PromptEditorContent: FC = ({ diff --git a/web/app/components/base/prompt-editor/sandbox-placeholder.tsx b/web/app/components/base/prompt-editor/sandbox-placeholder.tsx index 4beab897af..21d1ff9374 100644 --- a/web/app/components/base/prompt-editor/sandbox-placeholder.tsx +++ b/web/app/components/base/prompt-editor/sandbox-placeholder.tsx @@ -1,22 +1,40 @@ -import type { FC } from 'react' +import type { FC, MouseEvent } from 'react' +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext' +import { $getRoot, $insertNodes } from 'lexical' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { cn } from '@/utils/classnames' +import { $createCustomTextNode } from './plugins/custom-text/node' +import { CLEAR_HIDE_MENU_TIMEOUT } from './plugins/workflow-variable-block' type SandboxPlaceholderTokenProps = { actionLabel?: string + onClick?: () => void shortcut: '/' | '@' } const SandboxPlaceholderToken: FC = ({ actionLabel, + onClick, shortcut, }) => { + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault() + } + return ( - = ({ {actionLabel} - + ) } type SandboxPlaceholderProps = { + editable?: boolean disableToolBlocks?: boolean isSupportSandbox?: boolean } const SandboxPlaceholder: FC = ({ + editable = true, disableToolBlocks, isSupportSandbox, }) => { + const [editor] = useLexicalComposerContext() const { t } = useTranslation() + const handleQuickInsert = useCallback((trigger: '/' | '@') => { + editor.focus(() => { + editor.update(() => { + $getRoot().selectEnd() + $insertNodes([$createCustomTextNode(trigger)]) + editor.dispatchCommand(CLEAR_HIDE_MENU_TIMEOUT, undefined) + }) + }) + }, [editor]) + if (!isSupportSandbox) return null @@ -56,6 +87,7 @@ const SandboxPlaceholder: FC = ({ {t('promptEditor.placeholderSandboxPrefix', { ns: 'common' })} handleQuickInsert('/') : undefined} actionLabel={t('promptEditor.placeholderSandboxInsert', { ns: 'common' })} /> {!disableToolBlocks && ( @@ -63,6 +95,7 @@ const SandboxPlaceholder: FC = ({ {t('promptEditor.placeholderSandboxSeparator', { ns: 'common' })} handleQuickInsert('@') : undefined} actionLabel={t('promptEditor.placeholderSandboxTools', { ns: 'common' })} />