test: add tests for some components in base > prompt-editor (#32472)

Co-authored-by: sahil-infocusp <73810410+sahil-infocusp@users.noreply.github.com>
This commit is contained in:
Saumya Talwani
2026-02-25 13:37:14 +05:30
committed by GitHub
parent d773096146
commit 34b6fc92d7
13 changed files with 2298 additions and 15 deletions

View File

@ -0,0 +1,19 @@
import type { Klass, LexicalEditor, LexicalNode } from 'lexical'
import { createEditor } from 'lexical'
export function createTestEditor(nodes: Array<Klass<LexicalNode>> = []) {
const editor = createEditor({
nodes,
onError: (error) => { throw error },
})
const root = document.createElement('div')
editor.setRootElement(root)
return editor
}
export function withEditorUpdate(
editor: LexicalEditor,
fn: () => void,
) {
editor.update(fn, { discrete: true })
}

View File

@ -0,0 +1,398 @@
import { act, render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { UPDATE_DATASETS_EVENT_EMITTER } from '../../constants'
import ContextBlockComponent from './component'
// Mock the hooks used by ContextBlockComponent
const mockUseSelectOrDelete = vi.fn()
const mockUseTrigger = vi.fn()
vi.mock('../../hooks', () => ({
useSelectOrDelete: (...args: unknown[]) => mockUseSelectOrDelete(...args),
useTrigger: (...args: unknown[]) => mockUseTrigger(...args),
}))
// Mock event emitter context
const mockUseSubscription = vi.fn()
vi.mock('@/context/event-emitter', () => ({
useEventEmitterContextContext: () => ({
eventEmitter: {
useSubscription: mockUseSubscription,
},
}),
}))
// Helpers
const defaultSetup = (overrides?: { isSelected?: boolean, open?: boolean }) => {
const triggerSetOpen = vi.fn()
mockUseSelectOrDelete.mockReturnValue([{ current: null }, overrides?.isSelected ?? false])
mockUseTrigger.mockReturnValue([{ current: null }, overrides?.open ?? false, triggerSetOpen])
return { triggerSetOpen }
}
const mockDatasets = [
{ id: '1', name: 'Dataset A', type: 'text' },
{ id: '2', name: 'Dataset B', type: 'text' },
]
describe('ContextBlockComponent', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should render without crashing', () => {
defaultSetup()
const { container } = render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(container.firstChild).toBeInTheDocument()
})
it('should display the context title', () => {
defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(screen.getByText('common.promptEditor.context.item.title')).toBeInTheDocument()
})
it('should display the dataset count', () => {
defaultSetup()
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
expect(screen.getByText('2')).toBeInTheDocument()
})
it('should display zero count when no datasets provided', () => {
defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should render the file icon', () => {
defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
// File05 icon renders as an SVG
const fileIcon = screen.getByTestId('file-icon')
expect(fileIcon).toBeInTheDocument()
})
})
describe('Props', () => {
it('should apply selected border class when isSelected is true', () => {
defaultSetup({ isSelected: true })
const { container } = render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(container.firstChild).toHaveClass('!border-[#9B8AFB]')
})
it('should not apply selected border class when isSelected is false', () => {
defaultSetup({ isSelected: false })
const { container } = render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(container.firstChild).not.toHaveClass('!border-[#9B8AFB]')
})
it('should apply open background class when dropdown is open', () => {
defaultSetup({ open: true })
const { container } = render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(container.firstChild).toHaveClass('bg-[#EBE9FE]')
})
it('should apply default background class when dropdown is closed', () => {
defaultSetup({ open: false })
const { container } = render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(container.firstChild).toHaveClass('bg-[#F4F3FF]')
})
it('should hide the portal trigger when canNotAddContext is true', () => {
defaultSetup()
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
canNotAddContext
/>,
)
// The dataset count badge should not be rendered
expect(screen.queryByText('2')).not.toBeInTheDocument()
})
})
describe('Dropdown Content', () => {
it('should show dataset list when dropdown is open', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
expect(screen.getByText('Dataset A')).toBeInTheDocument()
expect(screen.getByText('Dataset B')).toBeInTheDocument()
})
it('should show modal title with dataset count when open', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
expect(
screen.getByText(/common\.promptEditor\.context\.modal\.title/),
).toBeInTheDocument()
})
it('should show the add context button when open', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
expect(
screen.getByText('common.promptEditor.context.modal.add'),
).toBeInTheDocument()
})
it('should show the footer text when open', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
expect(
screen.getByText('common.promptEditor.context.modal.footer'),
).toBeInTheDocument()
})
it('should render folder icon for each dataset', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
const folders = screen.getAllByTestId('folder-icon')
expect(folders.length).toBeGreaterThanOrEqual(2)
})
it('should not render dropdown content when canNotAddContext is true', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
canNotAddContext
/>,
)
// Modal content should not be present
expect(screen.queryByText('Dataset A')).not.toBeInTheDocument()
expect(
screen.queryByText('common.promptEditor.context.modal.add'),
).not.toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onAddContext when add button is clicked', async () => {
defaultSetup({ open: true })
const handleAddContext = vi.fn()
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={handleAddContext}
/>,
)
const addButton = screen.getByTestId('add-button')
await userEvent.click(addButton)
expect(handleAddContext).toHaveBeenCalledTimes(1)
})
it('should render the count badge with open styles when dropdown is open', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
const countBadge = screen.getByText('2')
expect(countBadge).toHaveClass('bg-[#6938EF]')
expect(countBadge).toHaveClass('text-white')
})
it('should render the count badge with closed styles when dropdown is closed', () => {
defaultSetup({ open: false })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
const countBadge = screen.getByText('2')
expect(countBadge).toHaveClass('bg-white/50')
})
})
describe('Event Emitter Subscription', () => {
it('should subscribe to event emitter on mount', () => {
defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(mockUseSubscription).toHaveBeenCalled()
})
it('should update local datasets when UPDATE_DATASETS_EVENT_EMITTER event fires', () => {
defaultSetup({ open: true })
// Capture the subscription callback
let subscriptionCallback: (v: Record<string, unknown>) => void = () => { }
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown>) => void) => {
subscriptionCallback = cb
})
const { rerender } = render(
<ContextBlockComponent
nodeKey="test-key"
datasets={[]}
onAddContext={vi.fn()}
/>,
)
// Initially no datasets
expect(screen.getByText('0')).toBeInTheDocument()
// Simulate event with new datasets
act(() => {
subscriptionCallback({
type: UPDATE_DATASETS_EVENT_EMITTER,
payload: [
{ id: '3', name: 'New Dataset', type: 'text' },
],
})
})
// Re-render to see state updates
rerender(
<ContextBlockComponent
nodeKey="test-key"
datasets={[]}
onAddContext={vi.fn()}
/>,
)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('New Dataset')).toBeInTheDocument()
})
it('should not update datasets when event type does not match', () => {
defaultSetup({ open: true })
let subscriptionCallback: (v: Record<string, unknown>) => void = () => { }
mockUseSubscription.mockImplementation((cb: (v: Record<string, unknown>) => void) => {
subscriptionCallback = cb
})
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={mockDatasets}
onAddContext={vi.fn()}
/>,
)
// Fire a different event
act(() => {
subscriptionCallback({
type: 'some-other-event',
payload: [{ id: '3', name: 'Should Not Appear', type: 'text' }],
})
})
expect(screen.queryByText('Should Not Appear')).not.toBeInTheDocument()
// Original datasets still there
expect(screen.getByText('Dataset A')).toBeInTheDocument()
})
})
describe('Edge Cases', () => {
it('should handle empty datasets array', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={[]}
onAddContext={vi.fn()}
/>,
)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should default datasets to empty array when undefined', () => {
defaultSetup()
render(
<ContextBlockComponent nodeKey="test-key" onAddContext={vi.fn()} />,
)
expect(screen.getByText('0')).toBeInTheDocument()
})
it('should handle single dataset', () => {
defaultSetup({ open: true })
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={[{ id: '1', name: 'Single', type: 'text' }]}
onAddContext={vi.fn()}
/>,
)
expect(screen.getByText('1')).toBeInTheDocument()
expect(screen.getByText('Single')).toBeInTheDocument()
})
it('should handle dataset with long name by truncating', () => {
defaultSetup({ open: true })
const longName = 'A'.repeat(200)
render(
<ContextBlockComponent
nodeKey="test-key"
datasets={[{ id: '1', name: longName, type: 'text' }]}
onAddContext={vi.fn()}
/>,
)
const nameElement = screen.getByText(longName)
expect(nameElement).toHaveClass('truncate')
})
})
})

View File

@ -1,11 +1,8 @@
import type { FC } from 'react'
import type { Dataset } from './index'
import {
RiAddLine,
} from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { File05, Folder } from '@/app/components/base/icons/src/vender/solid/files'
import {
PortalToFollowElem,
PortalToFollowElemContent,
@ -44,12 +41,12 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
<div
className={`
group inline-flex h-6 items-center rounded-[5px] border border-transparent bg-[#F4F3FF] pl-1 pr-0.5 text-[#6938EF] hover:bg-[#EBE9FE]
${open ? 'bg-[#EBE9FE]' : 'bg-[#F4F3FF]'}
${open ? 'bg-[#EBE9FE]' : ''}
${isSelected && '!border-[#9B8AFB]'}
`}
ref={ref}
>
<File05 className="mr-1 h-[14px] w-[14px]" />
<span className="i-custom-vender-solid-files-file-05 mr-1 h-[14px] w-[14px]" data-testid="file-icon" />
<div className="mr-1 text-xs font-medium">{t('promptEditor.context.item.title', { ns: 'common' })}</div>
{!canNotAddContext && (
<PortalToFollowElem
@ -80,7 +77,7 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
localDatasets.map(dataset => (
<div key={dataset.id} className="flex h-8 items-center">
<div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5] bg-[#F5F8FF]">
<Folder className="h-4 w-4 text-[#444CE7]" />
<span className="i-custom-vender-solid-files-folder h-4 w-4 text-[#444CE7]" data-testid="folder-icon" />
</div>
<div className="truncate text-sm text-gray-800" title="">{dataset.name}</div>
</div>
@ -88,8 +85,8 @@ const ContextBlockComponent: FC<ContextBlockComponentProps> = ({
}
</div>
<div className="flex h-8 cursor-pointer items-center text-[#155EEF]" onClick={onAddContext}>
<div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-gray-100">
<RiAddLine className="h-[14px] w-[14px]" />
<div className="mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-gray-100" data-testid="add-button">
<span className="i-ri-add-line h-[14px] w-[14px]" />
</div>
<div className="text-[13px] font-medium" title="">{t('promptEditor.context.modal.add', { ns: 'common' })}</div>
</div>

View File

@ -0,0 +1,296 @@
import type { LexicalEditor } from 'lexical'
import type { ReactNode } from 'react'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render } from '@testing-library/react'
import { $createParagraphNode, $getRoot, $nodesOfType } from 'lexical'
import * as React from 'react'
import { ContextBlockNode } from '../context-block/node'
import { $createCustomTextNode, CustomTextNode } from '../custom-text/node'
import ContextBlockReplacementBlock from './context-block-replacement-block'
// Mock the component rendered by ContextBlockNode.decorate()
vi.mock('./component', () => ({
default: () => null,
}))
function createEditorConfig() {
return {
namespace: 'test',
nodes: [CustomTextNode, ContextBlockNode],
onError: (error: Error) => { throw error },
}
}
function TestWrapper({ children }: { children: ReactNode }) {
return (
<LexicalComposer initialConfig={createEditorConfig()}>
{children}
</LexicalComposer>
)
}
function renderWithEditor(ui: ReactNode) {
return render(ui, { wrapper: TestWrapper })
}
// Captures the editor instance so we can do updates after the initial render
let capturedEditor: LexicalEditor | null = null
const defaultOnCapture = (editor: LexicalEditor) => {
capturedEditor = editor
}
function EditorCapture({ onCapture = defaultOnCapture }: { onCapture?: (e: LexicalEditor) => void }) {
const [editor] = useLexicalComposerContext()
React.useEffect(() => {
onCapture(editor)
}, [editor, onCapture])
return null
}
type ReadResult = {
count: number
datasets: Array<{ id: string, name: string, type: string }>
canNotAddContext: boolean
}
function insertTextAndRead(text: string): ReadResult {
if (!capturedEditor)
throw new Error('Editor not captured')
// Insert CustomTextNode with the given text
capturedEditor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
const textNode = $createCustomTextNode(text)
paragraph.append(textNode)
root.append(paragraph)
}, { discrete: true })
// Read the resulting state — extract all properties inside .read()
const result: ReadResult = { count: 0, datasets: [], canNotAddContext: false }
capturedEditor.getEditorState().read(() => {
const nodes = $nodesOfType(ContextBlockNode)
result.count = nodes.length
if (nodes.length > 0) {
result.datasets = nodes[0].getDatasets()
result.canNotAddContext = nodes[0].getCanNotAddContext()
}
})
return result
}
describe('ContextBlockReplacementBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedEditor = null
})
describe('Rendering', () => {
it('should render without crashing', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
expect(capturedEditor).not.toBeNull()
})
it('should return null (no visible output from the plugin itself)', () => {
const { container } = renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
expect(container.querySelector('[data-testid]')).toBeNull()
})
})
describe('Editor Node Registration Check', () => {
it('should not throw when ContextBlockNode is registered', () => {
expect(() => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
}).not.toThrow()
})
it('should throw when ContextBlockNode is not registered', () => {
const configWithoutNode = {
namespace: 'test',
nodes: [CustomTextNode],
onError: (error: Error) => { throw error },
}
expect(() => {
render(
<LexicalComposer initialConfig={configWithoutNode}>
<ContextBlockReplacementBlock />
</LexicalComposer>,
)
}).toThrow('ContextBlockNodePlugin: ContextBlockNode not registered on editor')
})
})
describe('Text Replacement Transform', () => {
it('should replace context placeholder text with a ContextBlockNode', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#context#}}')
expect(result.count).toBe(1)
})
it('should not replace text that is not the placeholder', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('just some normal text')
expect(result.count).toBe(0)
})
it('should not replace partial placeholder text', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#contex')
expect(result.count).toBe(0)
})
it('should pass datasets to the created ContextBlockNode', () => {
const datasets = [{ id: '1', name: 'Test', type: 'text' }]
renderWithEditor(
<>
<ContextBlockReplacementBlock datasets={datasets} onAddContext={vi.fn()} />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#context#}}')
expect(result.count).toBe(1)
expect(result.datasets).toEqual(datasets)
})
it('should pass canNotAddContext to the created ContextBlockNode', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock canNotAddContext={true} />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#context#}}')
expect(result.count).toBe(1)
expect(result.canNotAddContext).toBe(true)
})
})
describe('onInsert callback', () => {
it('should call onInsert when a placeholder is replaced', () => {
const onInsert = vi.fn()
renderWithEditor(
<>
<ContextBlockReplacementBlock onInsert={onInsert} />
<EditorCapture />
</>,
)
insertTextAndRead('{{#context#}}')
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should not call onInsert when no placeholder is found', () => {
const onInsert = vi.fn()
renderWithEditor(
<>
<ContextBlockReplacementBlock onInsert={onInsert} />
<EditorCapture />
</>,
)
insertTextAndRead('no placeholder here')
expect(onInsert).not.toHaveBeenCalled()
})
})
describe('Props Defaults', () => {
it('should default datasets to empty array', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#context#}}')
expect(result.datasets).toEqual([])
})
it('should default canNotAddContext to false', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('{{#context#}}')
expect(result.canNotAddContext).toBe(false)
})
})
describe('Edge Cases', () => {
it('should handle undefined datasets prop', () => {
expect(() => {
renderWithEditor(
<>
<ContextBlockReplacementBlock datasets={undefined} />
<EditorCapture />
</>,
)
}).not.toThrow()
})
it('should handle empty datasets array', () => {
expect(() => {
renderWithEditor(
<>
<ContextBlockReplacementBlock datasets={[]} />
<EditorCapture />
</>,
)
}).not.toThrow()
})
it('should handle empty string text', () => {
renderWithEditor(
<>
<ContextBlockReplacementBlock />
<EditorCapture />
</>,
)
const result = insertTextAndRead('')
expect(result.count).toBe(0)
})
})
})

View File

@ -0,0 +1,236 @@
import type { LexicalEditor } from 'lexical'
import type { ReactNode } from 'react'
import type { Dataset } from './index'
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { render } from '@testing-library/react'
import { $createParagraphNode, $getRoot } from 'lexical'
import * as React from 'react'
import { ContextBlock, DELETE_CONTEXT_BLOCK_COMMAND, INSERT_CONTEXT_BLOCK_COMMAND } from './index'
import { ContextBlockNode } from './node'
const mockCreateContextBlockNode = vi.fn()
vi.mock('./node', async () => {
const actual = await vi.importActual<typeof import('./node')>('./node')
return {
...actual,
$createContextBlockNode: (datasets: Dataset[], onAddContext: () => void, canNotAddContext?: boolean) => {
mockCreateContextBlockNode(datasets, onAddContext, canNotAddContext)
return actual.$createContextBlockNode(datasets, onAddContext, canNotAddContext)
},
}
})
vi.mock('./component', () => ({
default: () => null,
}))
type EditorConfig = {
namespace: string
nodes: [typeof ContextBlockNode] | []
onError: (error: Error) => void
}
function createEditorConfig(includeContextBlockNode = true): EditorConfig {
return {
namespace: 'test',
nodes: includeContextBlockNode ? [ContextBlockNode] : [],
onError: (error: Error) => { throw error },
}
}
let capturedEditor: LexicalEditor | null = null
function EditorCapture() {
const [editor] = useLexicalComposerContext()
React.useEffect(() => {
capturedEditor = editor
}, [editor])
return null
}
function renderWithEditor(ui: ReactNode, includeContextBlockNode = true) {
return render(
<LexicalComposer initialConfig={createEditorConfig(includeContextBlockNode)}>
{ui}
<EditorCapture />
</LexicalComposer>,
)
}
function setupParagraphSelection() {
if (!capturedEditor)
throw new Error('Editor not captured')
capturedEditor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
root.append(paragraph)
paragraph.select()
}, { discrete: true })
}
function dispatchInsert() {
if (!capturedEditor)
throw new Error('Editor not captured')
setupParagraphSelection()
return capturedEditor.dispatchCommand(INSERT_CONTEXT_BLOCK_COMMAND, undefined)
}
function dispatchDelete() {
if (!capturedEditor)
throw new Error('Editor not captured')
return capturedEditor.dispatchCommand(DELETE_CONTEXT_BLOCK_COMMAND, undefined)
}
describe('ContextBlock', () => {
beforeEach(() => {
vi.clearAllMocks()
capturedEditor = null
})
describe('Rendering', () => {
it('should render (no visible output)', () => {
const { container } = renderWithEditor(<ContextBlock />)
expect(container.childElementCount).toBe(0)
})
})
describe('Editor Node Registration Check', () => {
it('should not throw when ContextBlockNode is registered', () => {
expect(() => {
renderWithEditor(<ContextBlock />)
}).not.toThrow()
})
it('should throw when ContextBlockNode is not registered', () => {
expect(() => {
renderWithEditor(<ContextBlock />, false)
}).toThrow('ContextBlockPlugin: ContextBlock not registered on editor')
})
})
describe('INSERT_CONTEXT_BLOCK_COMMAND handler', () => {
it('should insert a context block node with default props', () => {
renderWithEditor(<ContextBlock />)
const handled = dispatchInsert()
expect(handled).toBe(true)
expect(mockCreateContextBlockNode).toHaveBeenCalledWith([], expect.any(Function), undefined)
})
it('should call onInsert when provided', () => {
const onInsert = vi.fn()
renderWithEditor(<ContextBlock onInsert={onInsert} />)
dispatchInsert()
expect(onInsert).toHaveBeenCalledTimes(1)
})
it('should pass datasets to the created node', () => {
const datasets: Dataset[] = [{ id: '1', name: 'Test', type: 'text' }]
renderWithEditor(<ContextBlock datasets={datasets} />)
dispatchInsert()
expect(mockCreateContextBlockNode).toHaveBeenCalledWith(datasets, expect.any(Function), undefined)
})
it('should pass canNotAddContext to the created node', () => {
renderWithEditor(<ContextBlock canNotAddContext={true} />)
dispatchInsert()
expect(mockCreateContextBlockNode).toHaveBeenCalledWith(
expect.anything(),
expect.any(Function),
true,
)
})
})
describe('DELETE_CONTEXT_BLOCK_COMMAND handler', () => {
it('should return true when dispatched', () => {
renderWithEditor(<ContextBlock />)
const handled = dispatchDelete()
expect(handled).toBe(true)
})
it('should call onDelete when provided', () => {
const onDelete = vi.fn()
renderWithEditor(<ContextBlock onDelete={onDelete} />)
dispatchDelete()
expect(onDelete).toHaveBeenCalledTimes(1)
})
it('should not throw when onDelete is not provided', () => {
renderWithEditor(<ContextBlock />)
expect(() => dispatchDelete()).not.toThrow()
})
})
describe('Props Defaults', () => {
it('should default onAddContext to a noop function', () => {
renderWithEditor(<ContextBlock />)
dispatchInsert()
const onAddContextArg = mockCreateContextBlockNode.mock.calls[0][1] as () => void
expect(typeof onAddContextArg).toBe('function')
expect(() => onAddContextArg()).not.toThrow()
})
})
describe('Lifecycle', () => {
it('should unregister commands on unmount', () => {
const onDelete = vi.fn()
const { unmount } = renderWithEditor(<ContextBlock onDelete={onDelete} />)
unmount()
const handledAfterUnmount = dispatchDelete()
expect(handledAfterUnmount).toBe(false)
expect(onDelete).not.toHaveBeenCalled()
})
})
describe('Exports', () => {
it('should export INSERT_CONTEXT_BLOCK_COMMAND', () => {
expect(INSERT_CONTEXT_BLOCK_COMMAND).toBeDefined()
})
it('should export DELETE_CONTEXT_BLOCK_COMMAND', () => {
expect(DELETE_CONTEXT_BLOCK_COMMAND).toBeDefined()
})
it('should export ContextBlock component', () => {
expect(ContextBlock).toBeDefined()
})
})
describe('Edge Cases', () => {
it('should handle undefined datasets prop', () => {
renderWithEditor(<ContextBlock datasets={undefined} />)
dispatchInsert()
expect(mockCreateContextBlockNode).toHaveBeenCalledWith([], expect.any(Function), undefined)
})
it('should handle empty datasets array', () => {
renderWithEditor(<ContextBlock datasets={[]} />)
dispatchInsert()
expect(mockCreateContextBlockNode).toHaveBeenCalledWith([], expect.any(Function), undefined)
})
})
})

View File

@ -0,0 +1,244 @@
import { $getRoot } from 'lexical'
import { createTestEditor, withEditorUpdate } from '../__tests__/utils'
import { $createContextBlockNode, $isContextBlockNode, ContextBlockNode } from './node'
const mockDatasets = [
{ id: '1', name: 'Dataset A', type: 'text' },
{ id: '2', name: 'Dataset B', type: 'text' },
]
const mockOnAddContext = vi.fn()
const createContextBlockTestEditor = () => createTestEditor([ContextBlockNode])
describe('ContextBlockNode', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Static Methods', () => {
it('should return correct type', () => {
expect(ContextBlockNode.getType()).toBe('context-block')
})
it('should clone a node', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, true)
$getRoot().append(node)
const cloned = ContextBlockNode.clone(node)
expect(cloned).toBeInstanceOf(ContextBlockNode)
})
})
})
describe('Constructor', () => {
it('should store datasets', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
$getRoot().append(node)
expect(node.getDatasets()).toEqual(mockDatasets)
})
})
it('should store onAddContext callback', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
$getRoot().append(node)
expect(node.getOnAddContext()).toBe(mockOnAddContext)
})
})
it('should store canNotAddContext', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, true)
$getRoot().append(node)
expect(node.getCanNotAddContext()).toBe(true)
})
})
it('should default canNotAddContext to false', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
$getRoot().append(node)
expect(node.getCanNotAddContext()).toBe(false)
})
})
})
describe('isInline', () => {
it('should return true', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
expect(node.isInline()).toBe(true)
})
})
})
describe('createDOM', () => {
it('should create a div element', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
const dom = node.createDOM()
expect(dom.tagName).toBe('DIV')
})
})
it('should add correct CSS classes', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
const dom = node.createDOM()
expect(dom.classList.contains('inline-flex')).toBe(true)
expect(dom.classList.contains('items-center')).toBe(true)
expect(dom.classList.contains('align-middle')).toBe(true)
})
})
})
describe('updateDOM', () => {
it('should return false', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
expect(node.updateDOM()).toBe(false)
})
})
})
describe('decorate', () => {
it('should return a React element', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, true)
$getRoot().append(node)
const result = node.decorate()
expect(result).toBeDefined()
expect(result.props).toEqual(
expect.objectContaining({
datasets: mockDatasets,
onAddContext: mockOnAddContext,
canNotAddContext: true,
}),
)
})
})
it('should pass nodeKey prop', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
$getRoot().append(node)
const result = node.decorate()
expect(result.props.nodeKey).toBe(node.getKey())
})
})
})
describe('getTextContent', () => {
it('should return the context placeholder', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
expect(node.getTextContent()).toBe('{{#context#}}')
})
})
})
describe('exportJSON', () => {
it('should export correct JSON structure', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, true)
$getRoot().append(node)
const json = node.exportJSON()
expect(json.type).toBe('context-block')
expect(json.version).toBe(1)
expect(json.datasets).toEqual(mockDatasets)
expect(json.onAddContext).toBe(mockOnAddContext)
expect(json.canNotAddContext).toBe(true)
})
})
})
describe('importJSON', () => {
it('should create a node from serialized data', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const serialized = {
type: 'context-block' as const,
version: 1,
datasets: mockDatasets,
onAddContext: mockOnAddContext,
canNotAddContext: false,
}
const node = ContextBlockNode.importJSON(serialized)
$getRoot().append(node)
expect(node).toBeInstanceOf(ContextBlockNode)
expect(node.getDatasets()).toEqual(mockDatasets)
expect(node.getOnAddContext()).toBe(mockOnAddContext)
expect(node.getCanNotAddContext()).toBe(false)
})
})
})
describe('$createContextBlockNode', () => {
it('should create a ContextBlockNode instance', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
expect(node).toBeInstanceOf(ContextBlockNode)
})
})
it('should pass canNotAddContext when provided', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, true)
$getRoot().append(node)
expect(node.getCanNotAddContext()).toBe(true)
})
})
})
describe('$isContextBlockNode', () => {
it('should return true for ContextBlockNode instances', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext)
expect($isContextBlockNode(node)).toBe(true)
})
})
it('should return false for null', () => {
expect($isContextBlockNode(null)).toBe(false)
})
it('should return false for undefined', () => {
expect($isContextBlockNode(undefined)).toBe(false)
})
})
describe('Edge Cases', () => {
it('should handle empty datasets', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode([], mockOnAddContext)
$getRoot().append(node)
expect(node.getDatasets()).toEqual([])
})
})
it('should handle canNotAddContext as false explicitly', () => {
const editor = createContextBlockTestEditor()
withEditorUpdate(editor, () => {
const node = $createContextBlockNode(mockDatasets, mockOnAddContext, false)
$getRoot().append(node)
expect(node.getCanNotAddContext()).toBe(false)
})
})
})
})

View File

@ -0,0 +1,141 @@
import type { EditorConfig, LexicalEditor } from 'lexical'
import { $createParagraphNode, $getRoot } from 'lexical'
import { createTestEditor, withEditorUpdate } from '../__tests__/utils'
import { $createCustomTextNode, CustomTextNode } from './node'
const createCustomTextTestEditor = () => createTestEditor([CustomTextNode])
describe('CustomTextNode', () => {
let editor: LexicalEditor
beforeEach(() => {
editor = createCustomTextTestEditor()
})
afterEach(() => {
editor.setRootElement(null)
})
describe('Static Methods', () => {
it('should return correct type', () => {
expect(CustomTextNode.getType()).toBe('custom-text')
})
it('should clone a node', () => {
withEditorUpdate(editor, () => {
const paragraph = $createParagraphNode()
$getRoot().append(paragraph)
const node = $createCustomTextNode('hello')
paragraph.append(node)
const cloned = CustomTextNode.clone(node)
expect(cloned).toBeInstanceOf(CustomTextNode)
})
})
})
describe('createDOM', () => {
it('should create a DOM element', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('test')
const config: EditorConfig = { namespace: 'test', theme: {} }
const dom = node.createDOM(config)
expect(dom).toBeDefined()
})
})
})
describe('exportJSON', () => {
it('should export correct JSON structure', () => {
withEditorUpdate(editor, () => {
const paragraph = $createParagraphNode()
$getRoot().append(paragraph)
const node = $createCustomTextNode('hello world')
paragraph.append(node)
const json = node.exportJSON()
expect(json.type).toBe('custom-text')
expect(json.version).toBe(1)
expect(json.text).toBe('hello world')
expect(json.format).toBeDefined()
expect(json.detail).toBeDefined()
expect(json.style).toBeDefined()
})
})
})
describe('importJSON', () => {
it('should create a text node from serialized data', () => {
withEditorUpdate(editor, () => {
const serialized = {
type: 'custom-text' as const,
version: 1,
text: 'imported text',
format: 0,
detail: 0,
mode: 'normal' as const,
style: '',
}
const node = CustomTextNode.importJSON(serialized)
expect(node).toBeDefined()
expect(node.getTextContent()).toBe('imported text')
})
})
})
describe('isSimpleText', () => {
it('should return true for custom-text type with mode 0', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('simple')
expect(node.isSimpleText()).toBe(true)
})
})
})
describe('getTextContent', () => {
it('should return the text content', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('my content')
expect(node.getTextContent()).toBe('my content')
})
})
})
describe('$createCustomTextNode', () => {
it('should create a CustomTextNode instance', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('test')
expect(node).toBeInstanceOf(CustomTextNode)
})
})
it('should set the text content', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('hello')
expect(node.getTextContent()).toBe('hello')
})
})
})
describe('Edge Cases', () => {
it('should handle empty string', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('')
expect(node.getTextContent()).toBe('')
})
})
it('should handle special characters', () => {
withEditorUpdate(editor, () => {
const node = $createCustomTextNode('{{#context#}}')
expect(node.getTextContent()).toBe('{{#context#}}')
})
})
it('should handle very long text', () => {
withEditorUpdate(editor, () => {
const longText = 'A'.repeat(10000)
const node = $createCustomTextNode(longText)
expect(node.getTextContent()).toBe(longText)
})
})
})
})

View File

@ -0,0 +1,112 @@
import { LexicalComposer } from '@lexical/react/LexicalComposer'
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import DraggableBlockPlugin from '.'
const CONTENT_EDITABLE_TEST_ID = 'draggable-content-editable'
let namespaceCounter = 0
function renderWithEditor(anchorElem?: HTMLElement) {
render(
<LexicalComposer
initialConfig={{
namespace: `draggable-plugin-test-${namespaceCounter++}`,
onError: (error: Error) => { throw error },
}}
>
<RichTextPlugin
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_TEST_ID} />}
placeholder={null}
ErrorBoundary={LexicalErrorBoundary}
/>
<DraggableBlockPlugin anchorElem={anchorElem} />
</LexicalComposer>,
)
return screen.getByTestId(CONTENT_EDITABLE_TEST_ID)
}
function appendChildToRoot(rootElement: HTMLElement, className = '') {
const element = document.createElement('div')
element.className = className
rootElement.appendChild(element)
return element
}
describe('DraggableBlockPlugin', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Rendering', () => {
it('should use body as default anchor and render target line', () => {
renderWithEditor()
const targetLine = screen.getByTestId('draggable-target-line')
expect(targetLine).toBeInTheDocument()
expect(document.body.contains(targetLine)).toBe(true)
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
it('should render inside custom anchor element when provided', () => {
const customAnchor = document.createElement('div')
document.body.appendChild(customAnchor)
renderWithEditor(customAnchor)
const targetLine = screen.getByTestId('draggable-target-line')
expect(customAnchor.contains(targetLine)).toBe(true)
customAnchor.remove()
})
})
describe('Drag Support Detection', () => {
it('should render drag menu when mouse moves over a support-drag element', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
fireEvent.mouseMove(supportDragTarget)
await waitFor(() => {
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
})
it('should hide drag menu when support-drag target is removed and mouse moves again', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
fireEvent.mouseMove(supportDragTarget)
await waitFor(() => {
expect(screen.getByTestId('draggable-menu')).toBeInTheDocument()
})
supportDragTarget.remove()
fireEvent.mouseMove(rootElement)
await waitFor(() => {
expect(screen.queryByTestId('draggable-menu')).not.toBeInTheDocument()
})
})
})
describe('Menu Detection Contract', () => {
it('should render menu with draggable-block-menu class and keep non-menu elements outside it', async () => {
const rootElement = renderWithEditor()
const supportDragTarget = appendChildToRoot(rootElement, 'support-drag')
fireEvent.mouseMove(supportDragTarget)
const menuIcon = await screen.findByTestId('draggable-menu-icon')
expect(menuIcon.closest('.draggable-block-menu')).not.toBeNull()
const normalElement = document.createElement('div')
document.body.appendChild(normalElement)
expect(normalElement.closest('.draggable-block-menu')).toBeNull()
normalElement.remove()
})
})
})

View File

@ -1,7 +1,6 @@
import type { JSX } from 'react'
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
import { RiDraggable } from '@remixicon/react'
import { useEffect, useRef, useState } from 'react'
import { cn } from '@/utils/classnames'
@ -61,8 +60,8 @@ export default function DraggableBlockPlugin({
menuComponent={
isSupportDrag
? (
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
<RiDraggable className="size-3.5 text-text-tertiary" />
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')} data-testid="draggable-menu">
<span className="i-ri-draggable size-3.5 text-text-tertiary" data-testid="draggable-menu-icon" />
</div>
)
: null
@ -71,6 +70,7 @@ export default function DraggableBlockPlugin({
<div
ref={targetLineRef}
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
data-testid="draggable-target-line"
// style={{ width: 500 }} // width not worked here
>
<div