mirror of
https://github.com/langgenius/dify.git
synced 2026-04-23 20:36:14 +08:00
feat: support click and show cmd
This commit is contained in:
@ -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<typeof import('lexical')>()
|
||||
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(<SandboxPlaceholder isSupportSandbox={false} />)
|
||||
@ -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(
|
||||
<SandboxPlaceholder
|
||||
disableToolBlocks
|
||||
isSupportSandbox
|
||||
/>,
|
||||
)
|
||||
|
||||
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(<SandboxPlaceholder isSupportSandbox />)
|
||||
it('should render insert and tools actions when tool blocks are enabled', () => {
|
||||
render(<SandboxPlaceholder isSupportSandbox />)
|
||||
|
||||
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(<SandboxPlaceholder isSupportSandbox />)
|
||||
|
||||
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(<SandboxPlaceholder isSupportSandbox />)
|
||||
|
||||
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(<SandboxPlaceholder isSupportSandbox editable={false} />)
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -373,6 +373,7 @@ const PromptEditorContent: FC<PromptEditorContentProps> = ({
|
||||
<Placeholder
|
||||
value={placeholder || (
|
||||
<SandboxPlaceholder
|
||||
editable={editable}
|
||||
disableToolBlocks={disableToolBlocks}
|
||||
isSupportSandbox={isSupportSandbox}
|
||||
/>
|
||||
|
||||
@ -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<SandboxPlaceholderTokenProps> = ({
|
||||
actionLabel,
|
||||
onClick,
|
||||
shortcut,
|
||||
}) => {
|
||||
const handleMouseDown = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
onMouseDown={handleMouseDown}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex cursor-pointer items-center gap-1 text-text-tertiary hover:text-components-button-secondary-accent-text',
|
||||
'pointer-events-auto inline-flex appearance-none items-center gap-1 bg-transparent p-0 text-text-tertiary',
|
||||
'cursor-pointer hover:text-components-button-secondary-accent-text',
|
||||
'disabled:cursor-default disabled:hover:text-text-tertiary',
|
||||
'group/placeholder-token',
|
||||
)}
|
||||
disabled={!onClick}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
@ -28,26 +46,39 @@ const SandboxPlaceholderToken: FC<SandboxPlaceholderTokenProps> = ({
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'pointer-events-auto border-b border-dotted border-current px-0.5 transition-colors',
|
||||
'border-b border-dotted border-current px-0.5 transition-colors',
|
||||
)}
|
||||
>
|
||||
{actionLabel}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type SandboxPlaceholderProps = {
|
||||
editable?: boolean
|
||||
disableToolBlocks?: boolean
|
||||
isSupportSandbox?: boolean
|
||||
}
|
||||
|
||||
const SandboxPlaceholder: FC<SandboxPlaceholderProps> = ({
|
||||
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<SandboxPlaceholderProps> = ({
|
||||
{t('promptEditor.placeholderSandboxPrefix', { ns: 'common' })}
|
||||
<SandboxPlaceholderToken
|
||||
shortcut="/"
|
||||
onClick={editable ? () => handleQuickInsert('/') : undefined}
|
||||
actionLabel={t('promptEditor.placeholderSandboxInsert', { ns: 'common' })}
|
||||
/>
|
||||
{!disableToolBlocks && (
|
||||
@ -63,6 +95,7 @@ const SandboxPlaceholder: FC<SandboxPlaceholderProps> = ({
|
||||
{t('promptEditor.placeholderSandboxSeparator', { ns: 'common' })}
|
||||
<SandboxPlaceholderToken
|
||||
shortcut="@"
|
||||
onClick={editable ? () => handleQuickInsert('@') : undefined}
|
||||
actionLabel={t('promptEditor.placeholderSandboxTools', { ns: 'common' })}
|
||||
/>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user