mirror of
https://github.com/langgenius/dify.git
synced 2026-05-04 01:18:05 +08:00
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:
@ -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 })
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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>
|
||||
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
Reference in New Issue
Block a user