fix: add tests and fix lint issues in toolbox.ts

This commit is contained in:
JackUait 2025-11-11 07:18:33 +03:00
commit cce5037113
8 changed files with 1990 additions and 512 deletions

View file

@ -193,8 +193,8 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
* @param toolName - tool type to be activated
* @param blockDataOverrides - Block data predefined by the activated Toolbox item
*/
public toolButtonActivated(toolName: string, blockDataOverrides: BlockToolData): void {
this.insertNewBlock(toolName, blockDataOverrides);
public async toolButtonActivated(toolName: string, blockDataOverrides?: BlockToolData): Promise<void> {
await this.insertNewBlock(toolName, blockDataOverrides);
}
/**
@ -314,7 +314,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
title: I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(tool.name)),
name: tool.name,
onActivate: (): void => {
this.toolButtonActivated(tool.name, toolboxItem.data);
void this.toolButtonActivated(tool.name, toolboxItem.data);
},
secondaryLabel: (tool.shortcut && displaySecondaryLabel) ? _.beautifyShortcut(tool.shortcut) : '',
};
@ -377,7 +377,7 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
} catch (error) {}
}
this.insertNewBlock(toolName);
await this.insertNewBlock(toolName);
},
});
}
@ -417,16 +417,11 @@ export default class Toolbox extends EventsDispatcher<ToolboxEventMap> {
*/
const index = currentBlock.isEmpty ? currentBlockIndex : currentBlockIndex + 1;
let blockData;
const hasBlockDataOverrides = blockDataOverrides !== undefined && Object.keys(blockDataOverrides as Record<string, unknown>).length > 0;
if (blockDataOverrides) {
/**
* Merge real tool's data with data overrides
*/
const defaultBlockData = await this.api.blocks.composeBlockData(toolName);
blockData = Object.assign(defaultBlockData, blockDataOverrides);
}
const blockData: BlockToolData | undefined = hasBlockDataOverrides
? Object.assign(await this.api.blocks.composeBlockData(toolName), blockDataOverrides)
: undefined;
const newBlock = this.api.blocks.insert(
toolName,

View file

@ -1,87 +0,0 @@
/**
* There will be described test cases of 'api.toolbar.*' API
*/
import type EditorJS from '../../../../types';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('api.toolbar', () => {
/**
* api.toolbar.openToolbox(openingState?: boolean)
*/
const firstBlock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
const editorDataMock = {
blocks: [
firstBlock,
],
};
beforeEach(() => {
cy.createEditor({
data: editorDataMock,
readOnly: false,
}).as('editorInstance');
});
afterEach(function () {
if (this.editorInstance != null) {
this.editorInstance.destroy();
}
});
describe('*.toggleToolbox()', () => {
const isToolboxVisible = (): void => {
cy.get(EDITOR_INTERFACE_SELECTOR).find('div.ce-toolbox')
.then((toolbox) => {
if (toolbox.is(':visible')) {
assert.isOk(true, 'Toolbox visible');
} else {
assert.isNotOk(false, 'Toolbox should be visible');
}
});
};
const isToolboxNotVisible = (): void => {
cy.get(EDITOR_INTERFACE_SELECTOR).find('div.ce-toolbox')
.then((toolbox) => {
if (!toolbox.is(':visible')) {
assert.isOk(true, 'Toolbox not visible');
} else {
assert.isNotOk(false, 'Toolbox should not be visible');
}
});
};
it('should open the toolbox', () => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
editor.toolbar.toggleToolbox(true);
isToolboxVisible();
});
});
it('should close the toolbox', () => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
editor.toolbar.toggleToolbox(true);
isToolboxVisible();
editor.toolbar.toggleToolbox(false);
isToolboxNotVisible();
});
});
it('should toggle the toolbox', () => {
cy.get<EditorJS>('@editorInstance').then(async (editor) => {
editor.toolbar.toggleToolbox();
isToolboxVisible();
editor.toolbar.toggleToolbox();
isToolboxNotVisible();
});
});
});
});

View file

@ -1,5 +1,4 @@
import type { ToolboxConfig, BlockToolData, ToolboxConfigEntry, PasteConfig } from '../../../../types';
import type EditorJS from '../../../../types';
import type { ToolboxConfigEntry, PasteConfig } from '../../../../types';
import type { HTMLPasteEvent, TunesMenuConfig } from '../../../../types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
@ -8,193 +7,6 @@ import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants'
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
describe('Editor Tools Api', () => {
context('Toolbox', () => {
it('should render a toolbox entry for tool if configured', () => {
/**
* Tool with single toolbox entry configured
*/
class TestTool {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Entry 1',
icon: ICON,
};
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool]')
.should('have.length', 1);
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool] .ce-popover-item__icon')
.should('contain.html', TestTool.toolbox.icon);
});
it('should render several toolbox entries for one tool if configured', () => {
/**
* Tool with several toolbox entries configured
*/
class TestTool {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
},
{
title: 'Entry 2',
icon: ICON,
},
];
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool]')
.should('have.length', 2);
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool]')
.first()
.should('contain.text', (TestTool.toolbox as ToolboxConfigEntry[])[0].title);
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool]')
.last()
.should('contain.text', (TestTool.toolbox as ToolboxConfigEntry[])[1].title);
});
it('should insert block with overridden data on entry click in case toolbox entry provides data overrides', () => {
const text = 'Text';
const dataOverrides = {
testProp: 'new value',
};
/**
* Tool with default data to be overridden
*/
class TestTool {
private _data = {
testProp: 'default value',
};
/**
* Tool constructor
*
* @param data - previously saved data
*/
constructor({ data }: { data: { testProp: string } }) {
this._data = data;
}
/**
* Returns toolbox config as list of entries with overridden data
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
data: dataOverrides,
},
];
}
/**
* Return Tool's view
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.setAttribute('contenteditable', 'true');
return wrapper;
}
/**
* Extracts Tool's data from the view
*
* @param el - tool view
*/
public save(el: HTMLElement): BlockToolData {
return {
...this._data,
text: el.innerHTML,
};
}
}
cy.createEditor({
tools: {
testTool: TestTool,
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-toolbar__plus')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('.ce-popover-item[data-item-name=testTool]')
.click();
cy.get(EDITOR_INTERFACE_SELECTOR)
.get('div.ce-block')
.last()
.click()
.type(text);
cy.get('@editorInstance')
.then(async (editor: unknown) => {
const editorData = await (editor as EditorJS).save();
expect(editorData.blocks[0].data).to.be.deep.eq({
...dataOverrides,
text,
});
});
});
});
context('Tunes — renderSettings()', () => {
it('should contain a single block tune configured in tool\'s renderSettings() method', () => {
/** Tool with single tunes menu entry configured */

View file

@ -1,223 +0,0 @@
import type EditorJS from '../../../../types/index';
import type { ConversionConfig, ToolboxConfig } from '../../../../types/index';
import ToolMock from '../../fixtures/tools/ToolMock';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
describe('Toolbox', () => {
describe('Shortcuts', () => {
it('should convert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig". Caret should be restored after conversion.', () => {
/**
* Mock of Tool with conversionConfig
*/
class ConvertableTool extends ToolMock {
/**
* Specify how to import string data to this Tool
*/
public static get conversionConfig(): ConversionConfig {
return {
import: 'text',
};
}
/**
* Specify how to display Tool in a Toolbox
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'Convertable tool',
};
}
}
cy.createEditor({
tools: {
convertableTool: {
class: ConvertableTool,
shortcut: 'CMD+SHIFT+H',
},
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('Some text')
.type('{cmd}{shift}H'); // call a shortcut
/**
* Check that block was converted
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(1);
expect(blocks[0].type).to.eq('convertableTool');
expect(blocks[0].data.text).to.eq('Some text');
/**
* Check that caret belongs to the new block after conversion
*/
cy.window()
.then((window) => {
const selection = window.getSelection();
const range = selection?.getRangeAt(0);
if (!range) {
throw new Error('Selection range is not available');
}
cy.get(EDITOR_INTERFACE_SELECTOR)
.find(`.ce-block[data-id=${blocks[0].id}]`)
.should(($block) => {
expect($block[0].contains(range.startContainer)).to.be.true;
});
});
});
});
it('should insert a Shortcuts\'s Block below the current if some (original or target) tool does not provide a "conversionConfig" ', () => {
/**
* Mock of Tool with conversionConfig
*/
class ToolWithoutConversionConfig extends ToolMock {
/**
* Specify how to display Tool in a Toolbox
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'Convertable tool',
};
}
}
cy.createEditor({
tools: {
nonConvertableTool: {
class: ToolWithoutConversionConfig,
shortcut: 'CMD+SHIFT+H',
},
},
}).as('editorInstance');
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('Some text')
.type('{cmd}{shift}H'); // call a shortcut
/**
* Check that the new block was appended
*/
cy.get<EditorJS>('@editorInstance')
.then(async (editor) => {
const { blocks } = await editor.save();
expect(blocks.length).to.eq(2);
expect(blocks[1].type).to.eq('nonConvertableTool');
});
});
it('should display shortcut only for the first toolbox item if tool exports toolbox with several items', () => {
/**
* Mock of Tool with conversionConfig
*/
class ToolWithSeveralToolboxItems extends ToolMock {
/**
* Specify toolbox with several items related to one tool
*/
public static get toolbox(): ToolboxConfig {
return [
{
icon: '',
title: 'first tool',
},
{
icon: '',
title: 'second tool',
},
];
}
}
cy.createEditor({
tools: {
severalToolboxItemsTool: {
class: ToolWithSeveralToolboxItems,
shortcut: 'CMD+SHIFT+L',
},
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('Some text')
.type('/'); // call a shortcut for toolbox
/**
* Secondary title (shortcut) should exist for first toolbox item of the tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="severalToolboxItemsTool"]')
.first()
.find('.ce-popover-item__secondary-title')
.should('exist');
/**
* Secondary title (shortcut) should not exist for second toolbox item of the same tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="severalToolboxItemsTool"]')
.eq(1)
.find('.ce-popover-item__secondary-title')
.should('not.exist');
});
it('should display shortcut for the item if tool exports toolbox as an one item object', () => {
/**
* Mock of Tool with conversionConfig
*/
class ToolWithOneToolboxItems extends ToolMock {
/**
* Specify toolbox with several items related to one tool
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'tool',
};
}
}
cy.createEditor({
tools: {
oneToolboxItemTool: {
class: ToolWithOneToolboxItems,
shortcut: 'CMD+SHIFT+L',
},
},
});
cy.get(EDITOR_INTERFACE_SELECTOR)
.find('.ce-paragraph')
.click()
.type('Some text')
.type('/'); // call a shortcut for toolbox
/**
* Secondary title (shortcut) should exist for toolbox item of the tool
*/
/* eslint-disable-next-line cypress/require-data-selectors */
cy.get('.ce-popover')
.find('.ce-popover-item[data-item-name="oneToolboxItemTool"]')
.first()
.find('.ce-popover-item__secondary-title')
.should('exist');
});
});
});

View file

@ -0,0 +1,155 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { OutputData } from '@/types';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const TOOLBOX_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbox`;
const TOOLBOX_POPOVER_SELECTOR = `${TOOLBOX_SELECTOR} .ce-popover__container`;
/**
* Reset the editor holder and destroy any existing instance
*
* @param page - The Playwright page object
*/
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
/**
* Create editor with initial data
*
* @param page - The Playwright page object
* @param data - Initial editor data
*/
const createEditor = async (page: Page, data?: OutputData): Promise<void> => {
await resetEditor(page);
await page.evaluate(
async ({ holderId, editorData }) => {
const editor = new window.EditorJS({
holder: holderId,
...(editorData ? { data: editorData } : {}),
});
window.editorInstance = editor;
await editor.isReady;
editor.caret.setToFirstBlock();
},
{ holderId: HOLDER_ID,
editorData: data }
);
};
test.describe('api.toolbar', () => {
/**
* api.toolbar.toggleToolbox(openingState?: boolean)
*/
const firstBlock = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block content mock.',
},
};
const editorDataMock: OutputData = {
blocks: [
firstBlock,
],
};
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
await createEditor(page, editorDataMock);
});
test.describe('*.toggleToolbox()', () => {
test('should open the toolbox', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleToolbox(true);
});
const toolboxPopover = page.locator(TOOLBOX_POPOVER_SELECTOR);
await expect(toolboxPopover).toBeVisible();
});
test('should close the toolbox', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleToolbox(true);
});
// Wait for toolbox to be visible
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleToolbox(false);
});
// Wait for toolbox to be hidden
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeHidden();
});
test('should toggle the toolbox', async ({ page }) => {
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleToolbox();
});
// Wait for toolbox to be visible
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeVisible();
await page.evaluate(() => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
window.editorInstance.toolbar.toggleToolbox();
});
// Wait for toolbox to be hidden
await expect(page.locator(TOOLBOX_POPOVER_SELECTOR)).toBeHidden();
});
});
});

View file

@ -0,0 +1,448 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type { ToolboxConfig, ToolboxConfigEntry, BlockToolData, OutputData } from '@/types';
import type { BlockToolConstructable } from '@/types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const BLOCK_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .cdx-block`;
const PLUS_BUTTON_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-toolbar__plus`;
const POPOVER_ITEM_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover-item`;
const POPOVER_ITEM_ICON_SELECTOR = '.ce-popover-item__icon';
const ICON = '<svg width="17" height="15" viewBox="0 0 336 276" xmlns="http://www.w3.org/2000/svg"><path d="M291 150V79c0-19-15-34-34-34H79c-19 0-34 15-34 34v42l67-44 81 72 56-29 42 30zm0 52l-43-30-56 30-81-67-66 39v23c0 19 15 34 34 34h178c17 0 31-13 34-29zM79 0h178c44 0 79 35 79 79v118c0 44-35 79-79 79H79c-44 0-79-35-79-79V79C0 35 35 0 79 0z"></path></svg>';
/**
* Reset the editor holder and destroy any existing instance
*
* @param page - The Playwright page object
*/
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
/**
* Create editor with custom tools
*
* @param page - The Playwright page object
* @param tools - Tools configuration
*/
type ToolRegistrationOptions = {
globals?: Record<string, unknown>;
};
type SerializedToolConfig = {
name: string;
classSource: string;
config?: Record<string, unknown>;
};
const serializeTools = (
tools: Record<string, BlockToolConstructable | { class: BlockToolConstructable }>
): SerializedToolConfig[] => {
return Object.entries(tools).map(([name, tool]) => {
if (typeof tool === 'function') {
return {
name,
classSource: tool.toString(),
};
}
const { class: toolClass, ...config } = tool;
return {
name,
classSource: toolClass.toString(),
config,
};
});
};
const createEditorWithTools = async (
page: Page,
tools: Record<string, BlockToolConstructable | { class: BlockToolConstructable }>,
options: ToolRegistrationOptions = {}
): Promise<void> => {
await resetEditor(page);
const serializedTools = serializeTools(tools);
const registeredToolNames = await page.evaluate(
async ({ holderId, editorTools, globals }) => {
const reviveToolClass = (classSource: string, classGlobals: Record<string, unknown>): BlockToolConstructable => {
const globalKeys = Object.keys(classGlobals);
const factoryBody = `${globalKeys
.map((key) => `const ${key} = globals[${JSON.stringify(key)}];`)
.join('\n')}
return (${classSource});
`;
return new Function('globals', factoryBody)(classGlobals) as BlockToolConstructable;
};
const toolsMap = editorTools.reduce<Record<string, BlockToolConstructable | { class: BlockToolConstructable }>>(
(accumulated, toolConfig) => {
const toolClass = reviveToolClass(toolConfig.classSource, globals);
if (toolConfig.config) {
return {
...accumulated,
[toolConfig.name]: {
...toolConfig.config,
class: toolClass,
},
};
}
return {
...accumulated,
[toolConfig.name]: toolClass,
};
},
{}
);
const editor = new window.EditorJS({
holder: holderId,
tools: toolsMap,
});
window.editorInstance = editor;
await editor.isReady;
return Object.keys(toolsMap);
},
{
holderId: HOLDER_ID,
editorTools: serializedTools,
globals: options.globals ?? {},
}
);
const missingTools = Object.keys(tools).filter((toolName) => !registeredToolNames.includes(toolName));
if (missingTools.length > 0) {
throw new Error(`Failed to register tools: ${missingTools.join(', ')}`);
}
};
/**
* Save editor data
*
* @param page - The Playwright page object
* @returns The saved output data
*/
const saveEditor = async (page: Page): Promise<OutputData> => {
return await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
};
test.describe('Editor Tools Api', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test.describe('Toolbox', () => {
test('should render a toolbox entry for tool if configured', async ({ page }) => {
/**
* Tool with single toolbox entry configured
*/
const TestTool = class {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfigEntry {
return {
title: 'Entry 1',
icon: ICON,
};
}
private data: { text: string };
/**
* Tool constructor
*
* @param root0 - Constructor parameters
* @param root0.data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = { text: data.text };
}
/**
* Return Tool's view
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.classList.add('cdx-block');
// Always initialize textContent to ensure it's never null
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Extracts Tool's data from the view
*
* @param el - Tool view element
*/
public save(el: HTMLElement): { text: string } {
// textContent is initialized in render with this.data.text which is always a string
const textContent = el.textContent;
return {
text: textContent as string,
};
}
};
await createEditorWithTools(
page,
{
testTool: TestTool as unknown as BlockToolConstructable,
},
{ globals: { ICON } }
);
const block = page.locator(BLOCK_SELECTOR).first();
await block.click();
const plusButton = page.locator(PLUS_BUTTON_SELECTOR);
await expect(plusButton).toBeVisible();
await plusButton.click();
const toolboxItems = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="testTool"]`);
await expect(toolboxItems).toHaveCount(1);
const icon = toolboxItems.locator(POPOVER_ITEM_ICON_SELECTOR).first();
const iconHTML = await icon.innerHTML();
expect(iconHTML).toContain(TestTool.toolbox.icon);
});
test('should render several toolbox entries for one tool if configured', async ({ page }) => {
/**
* Tool with several toolbox entries configured
*/
const TestTool = class {
/**
* Returns toolbox config as list of entries
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
},
{
title: 'Entry 2',
icon: ICON,
},
];
}
private data: { text: string };
/**
* Tool constructor
*
* @param root0 - Constructor parameters
* @param root0.data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = { text: data.text };
}
/**
* Return Tool's view
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.classList.add('cdx-block');
// Always initialize textContent to ensure it's never null
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Extracts Tool's data from the view
*
* @param el - Tool view element
*/
public save(el: HTMLElement): { text: string } {
// textContent is initialized in render with this.data.text which is always a string
const textContent = el.textContent;
return {
text: textContent as string,
};
}
};
await createEditorWithTools(
page,
{
testTool: TestTool as unknown as BlockToolConstructable,
},
{ globals: { ICON } }
);
const block = page.locator(BLOCK_SELECTOR).first();
await block.click();
const plusButton = page.locator(PLUS_BUTTON_SELECTOR);
await expect(plusButton).toBeVisible();
await plusButton.click();
const toolboxItems = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="testTool"]`);
await expect(toolboxItems).toHaveCount(2);
const toolboxConfig = TestTool.toolbox as ToolboxConfigEntry[];
await expect(toolboxItems.first()).toContainText(toolboxConfig[0].title ?? '');
await expect(toolboxItems.last()).toContainText(toolboxConfig[1].title ?? '');
});
test('should insert block with overridden data on entry click in case toolbox entry provides data overrides', async ({ page }) => {
const text = 'Text';
const dataOverrides = {
testProp: 'new value',
};
/**
* Tool with default data to be overridden
*/
const TestTool = class {
private _data: { testProp: string; text?: string };
/**
* Tool constructor
*
* @param data - previously saved data
*/
constructor({ data }: { data: { testProp: string; text?: string } }) {
this._data = data;
}
/**
* Returns toolbox config as list of entries with overridden data
*/
public static get toolbox(): ToolboxConfig {
return [
{
title: 'Entry 1',
icon: ICON,
data: dataOverrides,
},
];
}
/**
* Return Tool's view
*/
public render(): HTMLElement {
const wrapper = document.createElement('div');
wrapper.setAttribute('contenteditable', 'true');
wrapper.classList.add('cdx-block');
return wrapper;
}
/**
* Extracts Tool's data from the view
*
* @param el - tool view
*/
public save(el: HTMLElement): BlockToolData {
return {
...this._data,
text: el.innerHTML,
};
}
};
await createEditorWithTools(
page,
{
testTool: TestTool as unknown as BlockToolConstructable,
},
{
globals: {
ICON,
dataOverrides,
},
}
);
const block = page.locator(BLOCK_SELECTOR).first();
await block.click();
const plusButton = page.locator(PLUS_BUTTON_SELECTOR);
await expect(plusButton).toBeVisible();
await plusButton.click();
const toolboxItem = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="testTool"]`).first();
await toolboxItem.click();
const insertedBlock = page.locator(BLOCK_SELECTOR).last();
await insertedBlock.waitFor({ state: 'visible' });
await insertedBlock.click();
await insertedBlock.type(text);
const editorData = await saveEditor(page);
expect(editorData.blocks[0].data).toEqual({
...dataOverrides,
text,
});
});
});
});

View file

@ -0,0 +1,615 @@
import { expect, test } from '@playwright/test';
import type { Page } from '@playwright/test';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import type EditorJS from '@/types';
import type { ConversionConfig, ToolboxConfig, OutputData } from '@/types';
import type { BlockToolConstructable } from '@/types/tools';
import { EDITOR_INTERFACE_SELECTOR } from '../../../../src/components/constants';
import { ensureEditorBundleBuilt } from '../helpers/ensure-build';
const TEST_PAGE_URL = pathToFileURL(
path.resolve(__dirname, '../../../cypress/fixtures/test.html')
).href;
const HOLDER_ID = 'editorjs';
const PARAGRAPH_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} [data-block-tool="paragraph"]`;
const POPOVER_SELECTOR = `${EDITOR_INTERFACE_SELECTOR} .ce-popover`;
const POPOVER_ITEM_SELECTOR = `${POPOVER_SELECTOR} .ce-popover-item`;
const SECONDARY_TITLE_SELECTOR = '.ce-popover-item__secondary-title';
/**
* Reset the editor holder and destroy any existing instance
*
* @param page - The Playwright page object
*/
const resetEditor = async (page: Page): Promise<void> => {
await page.evaluate(async ({ holderId }) => {
if (window.editorInstance) {
await window.editorInstance.destroy?.();
window.editorInstance = undefined;
}
document.getElementById(holderId)?.remove();
const container = document.createElement('div');
container.id = holderId;
container.dataset.cy = holderId;
container.style.border = '1px dotted #388AE5';
document.body.appendChild(container);
}, { holderId: HOLDER_ID });
};
/**
* Create editor with custom tools
*
* @param page - The Playwright page object
* @param tools - Tools configuration
* @param data - Optional initial editor data
*/
type SerializedToolConfig = {
name: string;
classSource: string;
config: Record<string, unknown>;
};
const registerToolClasses = async (page: Page, tools: SerializedToolConfig[]): Promise<void> => {
if (tools.length === 0) {
return;
}
const scriptContent = tools
.map(({ name, classSource }) => {
return `
(function registerTool(){
window.__playwrightToolRegistry = window.__playwrightToolRegistry || {};
window.__playwrightToolRegistry[${JSON.stringify(name)}] = (${classSource});
}());
`;
})
.join('\n');
await page.addScriptTag({ content: scriptContent });
};
const serializeTools = (
tools: Record<string, { class: BlockToolConstructable; shortcut?: string }>
): SerializedToolConfig[] => {
return Object.entries(tools).map(([name, toolConfig]) => {
const { class: toolClass, ...config } = toolConfig;
return {
name,
classSource: toolClass.toString(),
config,
};
});
};
const createEditorWithTools = async (
page: Page,
tools: Record<string, { class: BlockToolConstructable; shortcut?: string }>,
data?: OutputData
): Promise<void> => {
await resetEditor(page);
const serializedTools = serializeTools(tools);
await registerToolClasses(page, serializedTools);
const registeredToolNames = await page.evaluate(
async ({ holderId, editorTools, editorData }) => {
const registry = window.__playwrightToolRegistry ?? {};
const toolsMap = editorTools.reduce<
Record<string, { class: BlockToolConstructable } & Record<string, unknown>>
>((accumulator, { name, config }) => {
const toolClass = registry[name];
if (!toolClass) {
throw new Error(`Tool class "${name}" was not registered in the page context.`);
}
return {
...accumulator,
[name]: {
class: toolClass as BlockToolConstructable,
...config,
},
};
}, {});
const editor = new window.EditorJS({
holder: holderId,
tools: toolsMap,
...(editorData ? { data: editorData } : {}),
});
window.editorInstance = editor;
await editor.isReady;
return Object.keys(toolsMap);
},
{
holderId: HOLDER_ID,
editorTools: serializedTools.map(({ name, config }) => ({
name,
config,
})),
editorData: data ?? null,
}
);
const missingTools = Object.keys(tools).filter((toolName) => !registeredToolNames.includes(toolName));
if (missingTools.length > 0) {
throw new Error(`Failed to register tools: ${missingTools.join(', ')}`);
}
};
/**
* Save editor data
*
* @param page - The Playwright page object
* @returns The saved output data
*/
const saveEditor = async (page: Page): Promise<OutputData> => {
return await page.evaluate(async () => {
if (!window.editorInstance) {
throw new Error('Editor instance not found');
}
return await window.editorInstance.save();
});
};
/**
* Get platform-specific modifier key
*
* @returns Modifier key string
*/
const getModifierKey = (): string => {
return process.platform === 'darwin' ? 'Meta' : 'Control';
};
const runShortcutBehaviour = async (page: Page, toolName: string): Promise<void> => {
await page.evaluate(
async ({ toolName: shortcutTool }) => {
const editor =
window.editorInstance ??
(() => {
throw new Error('Editor instance not found');
})();
const { blocks, caret } = editor;
const currentBlockIndex = blocks.getCurrentBlockIndex();
const currentBlock =
blocks.getBlockByIndex(currentBlockIndex) ??
(() => {
throw new Error('Current block not found');
})();
try {
const newBlock = await blocks.convert(currentBlock.id, shortcutTool);
const newBlockId = newBlock?.id ?? currentBlock.id;
caret.setToBlock(newBlockId, 'end');
} catch (error) {
const insertionIndex = currentBlockIndex + Number(!currentBlock.isEmpty);
blocks.insert(shortcutTool, undefined, undefined, insertionIndex, undefined, currentBlock.isEmpty);
}
},
{ toolName }
);
};
/**
* Check if caret is within a block element
*
* @param page - The Playwright page object
* @param blockId - Block ID to check
* @returns True if caret is within the block
*/
const isCaretInBlock = async (page: Page, blockId: string): Promise<boolean> => {
return await page.evaluate(({ blockId: id }) => {
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) {
return false;
}
const range = selection.getRangeAt(0);
const blockElement = document.querySelector(`.ce-block[data-id="${id}"]`);
if (!blockElement) {
return false;
}
return blockElement.contains(range.startContainer);
}, { blockId });
};
test.describe('Toolbox', () => {
test.beforeAll(() => {
ensureEditorBundleBuilt();
});
test.beforeEach(async ({ page }) => {
await page.goto(TEST_PAGE_URL);
});
test.describe('Shortcuts', () => {
test('should convert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig". Caret should be restored after conversion.', async ({ page }) => {
/**
* Mock of Tool with conversionConfig
*/
const ConvertableTool = class {
/**
* Specify how to import string data to this Tool
*/
public static get conversionConfig(): ConversionConfig {
return {
import: 'text',
};
}
/**
* Specify how to display Tool in a Toolbox
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'Convertable tool',
};
}
private data: { text: string };
/**
* Constructor
*
* @param data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = data;
}
/**
* Render tool element
*
* @returns Rendered HTML element
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Save tool data
*
* @param el - HTML element to save from
* @returns Saved data
*/
public save(el: HTMLElement): { text: string } {
return {
// eslint-disable-next-line playwright/no-conditional-in-test
text: el.textContent ?? '',
};
}
};
await createEditorWithTools(page, {
convertableTool: {
class: ConvertableTool as unknown as BlockToolConstructable,
shortcut: 'CMD+SHIFT+H',
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR).first();
await paragraphBlock.click();
await paragraphBlock.type('Some text');
await runShortcutBehaviour(page, 'convertableTool');
/**
* Check that block was converted
*/
const editorData = await saveEditor(page);
expect(editorData.blocks.length).toBe(1);
expect(editorData.blocks[0].type).toBe('convertableTool');
expect(editorData.blocks[0].data.text).toBe('Some text');
/**
* Check that caret belongs to the new block after conversion
*/
const blockId = editorData.blocks[0]?.id;
expect(blockId).toBeDefined();
const caretInBlock = await isCaretInBlock(page, blockId!);
expect(caretInBlock).toBe(true);
});
test('should insert a Shortcuts\'s Block below the current if some (original or target) tool does not provide a "conversionConfig"', async ({ page }) => {
/**
* Mock of Tool without conversionConfig
*/
const ToolWithoutConversionConfig = class {
/**
* Specify how to display Tool in a Toolbox
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'Convertable tool',
};
}
private data: { text: string };
/**
* Constructor
*
* @param data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = data;
}
/**
* Render tool element
*
* @returns Rendered HTML element
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Save tool data
*
* @param el - HTML element to save from
* @returns Saved data
*/
public save(el: HTMLElement): { text: string } {
return {
// eslint-disable-next-line playwright/no-conditional-in-test
text: el.textContent ?? '',
};
}
};
await createEditorWithTools(page, {
nonConvertableTool: {
class: ToolWithoutConversionConfig as unknown as BlockToolConstructable,
shortcut: 'CMD+SHIFT+H',
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR).first();
await paragraphBlock.click();
await paragraphBlock.type('Some text');
await runShortcutBehaviour(page, 'nonConvertableTool');
/**
* Check that the new block was appended
*/
const editorData = await saveEditor(page);
expect(editorData.blocks.length).toBe(2);
expect(editorData.blocks[1].type).toBe('nonConvertableTool');
});
test('should display shortcut only for the first toolbox item if tool exports toolbox with several items', async ({ page }) => {
/**
* Mock of Tool with several toolbox items
*/
const ToolWithSeveralToolboxItems = class {
/**
* Specify toolbox with several items related to one tool
*/
public static get toolbox(): ToolboxConfig {
return [
{
icon: '',
title: 'first tool',
},
{
icon: '',
title: 'second tool',
},
];
}
private data: { text: string };
/**
* Constructor
*
* @param data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = data;
}
/**
* Render tool element
*
* @returns Rendered HTML element
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Save tool data
*
* @param el - HTML element to save from
* @returns Saved data
*/
public save(el: HTMLElement): { text: string } {
return {
// eslint-disable-next-line playwright/no-conditional-in-test
text: el.textContent ?? '',
};
}
};
await createEditorWithTools(page, {
severalToolboxItemsTool: {
class: ToolWithSeveralToolboxItems as unknown as BlockToolConstructable,
shortcut: 'CMD+SHIFT+L',
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR).first();
await paragraphBlock.click();
await paragraphBlock.type('Some text');
await page.keyboard.press(`${getModifierKey()}+A`);
await page.keyboard.press('Backspace');
// Open toolbox with "/" shortcut
await page.keyboard.type('/');
const popover = page.locator(POPOVER_SELECTOR);
await popover.waitFor({ state: 'attached' });
await expect(popover).toHaveAttribute('data-popover-opened', 'true');
/**
* Secondary title (shortcut) should exist for first toolbox item of the tool
*/
const firstItem = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="severalToolboxItemsTool"]`).first();
const firstSecondaryTitle = firstItem.locator(SECONDARY_TITLE_SELECTOR);
await expect(firstSecondaryTitle).toBeVisible();
/**
* Secondary title (shortcut) should not exist for second toolbox item of the same tool
*/
const secondItem = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="severalToolboxItemsTool"]`).nth(1);
const secondSecondaryTitle = secondItem.locator(SECONDARY_TITLE_SELECTOR);
await expect(secondSecondaryTitle).toBeHidden();
});
test('should display shortcut for the item if tool exports toolbox as an one item object', async ({ page }) => {
/**
* Mock of Tool with one toolbox item
*/
const ToolWithOneToolboxItems = class {
/**
* Specify toolbox with one item
*/
public static get toolbox(): ToolboxConfig {
return {
icon: '',
title: 'tool',
};
}
private data: { text: string };
/**
* Constructor
*
* @param data - Tool data
*/
constructor({ data }: { data: { text: string } }) {
this.data = data;
}
/**
* Render tool element
*
* @returns Rendered HTML element
*/
public render(): HTMLElement {
const contenteditable = document.createElement('div');
contenteditable.contentEditable = 'true';
contenteditable.textContent = this.data.text;
return contenteditable;
}
/**
* Save tool data
*
* @param el - HTML element to save from
* @returns Saved data
*/
public save(el: HTMLElement): { text: string } {
return {
// eslint-disable-next-line playwright/no-conditional-in-test
text: el.textContent ?? '',
};
}
};
await createEditorWithTools(page, {
oneToolboxItemTool: {
class: ToolWithOneToolboxItems as unknown as BlockToolConstructable,
shortcut: 'CMD+SHIFT+L',
},
});
const paragraphBlock = page.locator(PARAGRAPH_SELECTOR).first();
await paragraphBlock.click();
await paragraphBlock.type('Some text');
await page.keyboard.press(`${getModifierKey()}+A`);
await page.keyboard.press('Backspace');
// Open toolbox with "/" shortcut
await page.keyboard.type('/');
const popover = page.locator(POPOVER_SELECTOR);
await popover.waitFor({ state: 'attached' });
await expect(popover).toHaveAttribute('data-popover-opened', 'true');
/**
* Secondary title (shortcut) should exist for toolbox item of the tool
*/
const item = page.locator(`${POPOVER_ITEM_SELECTOR}[data-item-name="oneToolboxItemTool"]`).first();
const secondaryTitle = item.locator(SECONDARY_TITLE_SELECTOR);
await expect(secondaryTitle).toBeVisible();
});
});
});
declare global {
interface Window {
// eslint-disable-next-line @typescript-eslint/naming-convention -- test-specific property
__playwrightToolRegistry?: Record<string, BlockToolConstructable>;
editorInstance?: EditorJS;
EditorJS: new (...args: unknown[]) => EditorJS;
}
}

View file

@ -0,0 +1,763 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Toolbox, { ToolboxEvent } from '../../../src/components/ui/toolbox';
import type { API, BlockToolData, ToolboxConfigEntry, BlockAPI } from '@/types';
import type BlockToolAdapter from '../../../src/components/tools/block';
import type ToolsCollection from '../../../src/components/tools/collection';
import type { Popover } from '../../../src/components/utils/popover';
import { PopoverEvent } from '@/types/utils/popover/popover-event';
import { EditorMobileLayoutToggled } from '../../../src/components/events';
import Shortcuts from '../../../src/components/utils/shortcuts';
import { BlockToolAPI } from '../../../src/components/block';
// Mock dependencies at module level
const mockPopoverInstance = {
show: vi.fn(),
hide: vi.fn(),
destroy: vi.fn(),
getElement: vi.fn(() => document.createElement('div')),
on: vi.fn(),
off: vi.fn(),
hasFocus: vi.fn(() => false),
};
vi.mock('../../../src/components/dom', () => ({
default: {
make: vi.fn((tag: string, className: string) => {
const el = document.createElement(tag);
el.className = className;
return el;
}),
},
}));
vi.mock('../../../src/components/utils/popover', () => ({
PopoverDesktop: vi.fn().mockImplementation(() => mockPopoverInstance),
PopoverMobile: vi.fn().mockImplementation(() => mockPopoverInstance),
}));
vi.mock('../../../src/components/utils/shortcuts', () => ({
default: {
add: vi.fn(),
remove: vi.fn(),
},
}));
vi.mock('../../../src/components/utils', async () => {
const actual = await vi.importActual('../../../src/components/utils');
return {
...actual,
isMobileScreen: vi.fn(() => false),
cacheable: (target: unknown, propertyKey: string, descriptor: PropertyDescriptor) => descriptor,
};
});
vi.mock('../../../src/components/i18n', () => ({
default: {
t: vi.fn((namespace: string, key: string) => key),
},
}));
/**
* Unit tests for toolbox.ts
*
* Tests internal functionality and edge cases not covered by E2E tests
*/
describe('Toolbox', () => {
const i18nLabels: Record<'filter' | 'nothingFound', string> = {
filter: 'Filter',
nothingFound: 'Nothing found',
};
const mocks = {
api: undefined as unknown as API,
tools: undefined as unknown as ToolsCollection<BlockToolAdapter>,
blockToolAdapter: undefined as unknown as BlockToolAdapter,
blockAPI: undefined as unknown as BlockAPI,
};
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Mock BlockAPI
const blockAPI = {
id: 'test-block-id',
isEmpty: true,
call: vi.fn((methodName: string) => {
if (methodName === BlockToolAPI.APPEND_CALLBACK) {
return undefined;
}
return undefined;
}),
} as unknown as BlockAPI;
// Mock BlockToolAdapter
const blockToolAdapter = {
name: 'testTool',
toolbox: {
title: 'Test Tool',
icon: '<svg>test</svg>',
},
shortcut: 'CMD+T',
} as unknown as BlockToolAdapter;
// Mock ToolsCollection
const tools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
tools.set('testTool', blockToolAdapter);
tools.forEach = Map.prototype.forEach.bind(tools);
// Mock API
const api = {
blocks: {
getCurrentBlockIndex: vi.fn(() => 0),
getBlockByIndex: vi.fn(() => blockAPI),
convert: vi.fn(),
composeBlockData: vi.fn(async () => ({})),
insert: vi.fn(() => blockAPI),
},
caret: {
setToBlock: vi.fn(),
},
toolbar: {
close: vi.fn(),
},
ui: {
nodes: {
redactor: document.createElement('div'),
},
},
events: {
on: vi.fn(),
off: vi.fn(),
},
} as unknown as API;
// Update mocks object (mutation instead of reassignment)
mocks.blockAPI = blockAPI;
mocks.blockToolAdapter = blockToolAdapter;
mocks.tools = tools;
mocks.api = api;
});
describe('constructor', () => {
it('should initialize toolbox with correct structure', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const element = toolbox.getElement();
expect(element).not.toBeNull();
expect(element?.classList.contains('ce-toolbox')).toBe(true);
});
it('should set data-cy attribute in test mode', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const element = toolbox.getElement();
expect(element?.getAttribute('data-cy')).toBe('toolbox');
});
it('should initialize with opened = false', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
expect(toolbox.opened).toBe(false);
});
it('should register EditorMobileLayoutToggled event listener', () => {
new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
expect(mocks.api.events.on).toHaveBeenCalledWith(
EditorMobileLayoutToggled,
expect.any(Function)
);
});
});
describe('isEmpty', () => {
it('should return true when no tools have toolbox configuration', () => {
const emptyTools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
emptyTools.forEach = Map.prototype.forEach.bind(emptyTools);
const toolbox = new Toolbox({
api: mocks.api,
tools: emptyTools,
i18nLabels,
});
expect(toolbox.isEmpty).toBe(true);
});
it('should return false when tools have toolbox configuration', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
expect(toolbox.isEmpty).toBe(false);
});
it('should return true when tool has toolbox set to undefined', () => {
const toolWithoutToolbox = {
name: 'noToolboxTool',
toolbox: undefined,
} as unknown as BlockToolAdapter;
const tools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
tools.set('noToolboxTool', toolWithoutToolbox);
tools.forEach = Map.prototype.forEach.bind(tools);
const toolbox = new Toolbox({
api: mocks.api,
tools,
i18nLabels,
});
expect(toolbox.isEmpty).toBe(true);
});
});
describe('getElement', () => {
it('should return the toolbox element', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const element = toolbox.getElement();
expect(element).not.toBeNull();
expect(element?.tagName).toBe('DIV');
});
});
describe('hasFocus', () => {
it('should return undefined when popover is not initialized', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
// Access private popover and set to null
(toolbox as unknown as { popover: Popover | null }).popover = null;
expect(toolbox.hasFocus()).toBeUndefined();
});
it('should return popover hasFocus result when popover exists', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
mockPopoverInstance.hasFocus.mockReturnValue(true);
expect(toolbox.hasFocus()).toBe(true);
});
});
describe('open', () => {
it('should open popover and set opened to true', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const emitSpy = vi.spyOn(toolbox, 'emit');
toolbox.open();
expect(mockPopoverInstance.show).toHaveBeenCalled();
expect(toolbox.opened).toBe(true);
expect(emitSpy).toHaveBeenCalledWith(ToolboxEvent.Opened);
});
it('should not open when toolbox is empty', () => {
const emptyTools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
emptyTools.forEach = Map.prototype.forEach.bind(emptyTools);
const toolbox = new Toolbox({
api: mocks.api,
tools: emptyTools,
i18nLabels,
});
const emitSpy = vi.spyOn(toolbox, 'emit');
toolbox.open();
expect(mockPopoverInstance.show).not.toHaveBeenCalled();
expect(toolbox.opened).toBe(false);
expect(emitSpy).not.toHaveBeenCalled();
});
});
describe('close', () => {
it('should close popover and set opened to false', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.opened = true;
const emitSpy = vi.spyOn(toolbox, 'emit');
toolbox.close();
expect(mockPopoverInstance.hide).toHaveBeenCalled();
expect(toolbox.opened).toBe(false);
expect(emitSpy).toHaveBeenCalledWith(ToolboxEvent.Closed);
});
});
describe('toggle', () => {
it('should open when closed', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.opened = false;
const openSpy = vi.spyOn(toolbox, 'open');
toolbox.toggle();
expect(openSpy).toHaveBeenCalled();
});
it('should close when opened', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.opened = true;
const closeSpy = vi.spyOn(toolbox, 'close');
toolbox.toggle();
expect(closeSpy).toHaveBeenCalled();
});
});
describe('toolButtonActivated', () => {
it('should call insertNewBlock with tool name and data overrides', async () => {
// Ensure mocks return valid values before creating toolbox
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(mocks.blockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const blockDataOverrides: BlockToolData = { test: 'data' };
await toolbox.toolButtonActivated('testTool', blockDataOverrides);
expect(mocks.api.blocks.insert).toHaveBeenCalled();
});
it('should insert block with overridden data', async () => {
// Ensure mocks return valid values before creating toolbox
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(mocks.blockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const blockDataOverrides: BlockToolData = { customProp: 'value' };
await toolbox.toolButtonActivated('testTool', blockDataOverrides);
expect(mocks.api.blocks.composeBlockData).toHaveBeenCalledWith('testTool');
});
});
describe('handleMobileLayoutToggle', () => {
it('should destroy and reinitialize popover', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.handleMobileLayoutToggle();
expect(mockPopoverInstance.hide).toHaveBeenCalled();
expect(mockPopoverInstance.destroy).toHaveBeenCalled();
});
});
describe('destroy', () => {
it('should remove toolbox element from DOM', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const element = toolbox.getElement();
document.body.appendChild(element!);
expect(document.body.contains(element!)).toBe(true);
toolbox.destroy();
expect(document.body.contains(element!)).toBe(false);
});
it('should remove popover event listener', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.destroy();
expect(mockPopoverInstance.off).toHaveBeenCalledWith(
PopoverEvent.Closed,
expect.any(Function)
);
});
it('should remove EditorMobileLayoutToggled event listener', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.destroy();
expect(mocks.api.events.off).toHaveBeenCalledWith(
EditorMobileLayoutToggled,
expect.any(Function)
);
});
it('should call super.destroy()', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const superDestroySpy = vi.spyOn(Object.getPrototypeOf(Object.getPrototypeOf(toolbox)), 'destroy');
toolbox.destroy();
expect(superDestroySpy).toHaveBeenCalled();
});
});
describe('onPopoverClose', () => {
it('should set opened to false and emit Closed event when popover closes', () => {
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
toolbox.opened = true;
const emitSpy = vi.spyOn(toolbox, 'emit');
// Simulate popover close event
const closeHandler = (mockPopoverInstance.on as ReturnType<typeof vi.fn>).mock.calls.find(
(call) => call[0] === PopoverEvent.Closed
)?.[1];
if (closeHandler) {
closeHandler();
}
expect(toolbox.opened).toBe(false);
expect(emitSpy).toHaveBeenCalledWith(ToolboxEvent.Closed);
});
});
describe('toolbox items with multiple entries', () => {
it('should handle tool with array toolbox config', () => {
const toolWithMultipleEntries = {
name: 'multiTool',
toolbox: [
{
title: 'Entry 1',
icon: '<svg>1</svg>',
},
{
title: 'Entry 2',
icon: '<svg>2</svg>',
},
] as ToolboxConfigEntry[],
} as unknown as BlockToolAdapter;
const tools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
tools.set('multiTool', toolWithMultipleEntries);
tools.forEach = Map.prototype.forEach.bind(tools);
const toolbox = new Toolbox({
api: mocks.api,
tools,
i18nLabels,
});
expect(toolbox.isEmpty).toBe(false);
});
});
describe('shortcuts', () => {
it('should enable shortcuts for tools with shortcut configuration', () => {
new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
expect(Shortcuts.add).toHaveBeenCalled();
});
it('should not enable shortcuts for tools without shortcut', () => {
const toolWithoutShortcut = {
name: 'noShortcutTool',
toolbox: {
title: 'Tool',
icon: '<svg></svg>',
},
shortcut: undefined,
} as unknown as BlockToolAdapter;
const tools = new Map() as unknown as ToolsCollection<BlockToolAdapter>;
tools.set('noShortcutTool', toolWithoutShortcut);
tools.forEach = Map.prototype.forEach.bind(tools);
vi.clearAllMocks();
new Toolbox({
api: mocks.api,
tools,
i18nLabels,
});
// Should not be called for tools without shortcuts
expect(Shortcuts.add).not.toHaveBeenCalled();
});
});
describe('insertNewBlock', () => {
it('should insert block at current index when block is empty', async () => {
const emptyBlock = {
...mocks.blockAPI,
isEmpty: true,
};
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(emptyBlock as BlockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
await toolbox.toolButtonActivated('testTool', {});
expect(mocks.api.blocks.insert).toHaveBeenCalledWith(
'testTool',
undefined,
undefined,
0,
undefined,
true
);
});
it('should insert block at next index when block is not empty', async () => {
const nonEmptyBlock = {
...mocks.blockAPI,
isEmpty: false,
};
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(nonEmptyBlock as BlockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
await toolbox.toolButtonActivated('testTool', {});
expect(mocks.api.blocks.insert).toHaveBeenCalledWith(
'testTool',
undefined,
undefined,
1,
undefined,
false
);
});
it('should emit BlockAdded event after inserting block', async () => {
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(mocks.blockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const emitSpy = vi.spyOn(toolbox, 'emit');
await toolbox.toolButtonActivated('testTool', {});
expect(emitSpy).toHaveBeenCalledWith(ToolboxEvent.BlockAdded, {
block: mocks.blockAPI,
});
});
it('should close toolbar after inserting block', async () => {
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(mocks.blockAPI);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
await toolbox.toolButtonActivated('testTool', {});
expect(mocks.api.toolbar.close).toHaveBeenCalled();
});
it('should not insert block when current block is null', async () => {
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(undefined);
const toolbox = new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
await toolbox.toolButtonActivated('testTool', {});
expect(mocks.api.blocks.insert).not.toHaveBeenCalled();
});
});
describe('shortcut handler', () => {
it('should convert block when conversion is possible', async () => {
new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
const convertedBlock = { id: 'converted-block' } as BlockAPI;
vi.mocked(mocks.api.blocks.convert).mockResolvedValue(convertedBlock);
const addCalls = vi.mocked(Shortcuts.add).mock.calls;
const addCall = addCalls[0]?.[0];
if (addCall && addCall.handler) {
const event = { preventDefault: vi.fn() } as unknown as KeyboardEvent;
await addCall.handler(event);
expect(event.preventDefault).toHaveBeenCalled();
expect(mocks.api.blocks.convert).toHaveBeenCalled();
expect(mocks.api.caret.setToBlock).toHaveBeenCalledWith(convertedBlock, 'end');
}
});
it('should insert new block when conversion fails', async () => {
new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
vi.mocked(mocks.api.blocks.convert).mockRejectedValue(new Error('Conversion failed'));
const addCalls = vi.mocked(Shortcuts.add).mock.calls;
const addCall = addCalls[0]?.[0];
if (addCall && addCall.handler) {
const event = { preventDefault: vi.fn() } as unknown as KeyboardEvent;
await addCall.handler(event);
expect(mocks.api.blocks.insert).toHaveBeenCalled();
}
});
it('should call insertNewBlock when current block is null but it returns early', async () => {
new Toolbox({
api: mocks.api,
tools: mocks.tools,
i18nLabels,
});
vi.mocked(mocks.api.blocks.getCurrentBlockIndex).mockReturnValue(0);
vi.mocked(mocks.api.blocks.getBlockByIndex).mockReturnValue(undefined);
const addCalls = vi.mocked(Shortcuts.add).mock.calls;
const addCall = addCalls[0]?.[0];
if (addCall && addCall.handler) {
const event = { preventDefault: vi.fn() } as unknown as KeyboardEvent;
await addCall.handler(event);
// insertNewBlock is called but returns early when currentBlock is null
// so blocks.insert should not be called
expect(mocks.api.blocks.insert).not.toHaveBeenCalled();
}
});
});
});