mirror of
https://github.com/codex-team/editor.js
synced 2026-03-18 00:19:53 +01:00
fix: add tests and fix lint issues in toolbox.ts
This commit is contained in:
parent
1bd18fe258
commit
cce5037113
8 changed files with 1990 additions and 512 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
155
test/playwright/tests/api/toolbar.spec.ts
Normal file
155
test/playwright/tests/api/toolbar.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
448
test/playwright/tests/api/toolbox-entries.spec.ts
Normal file
448
test/playwright/tests/api/toolbox-entries.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
615
test/playwright/tests/ui/toolbox.spec.ts
Normal file
615
test/playwright/tests/ui/toolbox.spec.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
763
test/unit/ui/toolbox.test.ts
Normal file
763
test/unit/ui/toolbox.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
Add table
Add a link
Reference in a new issue