From cce50371132694aaeb0831cea54463ceb02a92e2 Mon Sep 17 00:00:00 2001 From: JackUait Date: Tue, 11 Nov 2025 07:18:33 +0300 Subject: [PATCH] fix: add tests and fix lint issues in toolbox.ts --- src/components/ui/toolbox.ts | 21 +- test/cypress/tests/api/toolbar.cy.ts | 87 -- test/cypress/tests/api/tools.cy.ts | 190 +---- test/cypress/tests/ui/toolbox.cy.ts | 223 ----- test/playwright/tests/api/toolbar.spec.ts | 155 ++++ .../tests/api/toolbox-entries.spec.ts | 448 ++++++++++ test/playwright/tests/ui/toolbox.spec.ts | 615 ++++++++++++++ test/unit/ui/toolbox.test.ts | 763 ++++++++++++++++++ 8 files changed, 1990 insertions(+), 512 deletions(-) delete mode 100644 test/cypress/tests/api/toolbar.cy.ts delete mode 100644 test/cypress/tests/ui/toolbox.cy.ts create mode 100644 test/playwright/tests/api/toolbar.spec.ts create mode 100644 test/playwright/tests/api/toolbox-entries.spec.ts create mode 100644 test/playwright/tests/ui/toolbox.spec.ts create mode 100644 test/unit/ui/toolbox.test.ts diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 91e66358..312e9c9b 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -193,8 +193,8 @@ export default class Toolbox extends EventsDispatcher { * @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 { + await this.insertNewBlock(toolName, blockDataOverrides); } /** @@ -314,7 +314,7 @@ export default class Toolbox extends EventsDispatcher { 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 { } catch (error) {} } - this.insertNewBlock(toolName); + await this.insertNewBlock(toolName); }, }); } @@ -417,16 +417,11 @@ export default class Toolbox extends EventsDispatcher { */ const index = currentBlock.isEmpty ? currentBlockIndex : currentBlockIndex + 1; - let blockData; + const hasBlockDataOverrides = blockDataOverrides !== undefined && Object.keys(blockDataOverrides as Record).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, diff --git a/test/cypress/tests/api/toolbar.cy.ts b/test/cypress/tests/api/toolbar.cy.ts deleted file mode 100644 index 9fcda420..00000000 --- a/test/cypress/tests/api/toolbar.cy.ts +++ /dev/null @@ -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('@editorInstance').then(async (editor) => { - editor.toolbar.toggleToolbox(true); - isToolboxVisible(); - }); - }); - - it('should close the toolbox', () => { - cy.get('@editorInstance').then(async (editor) => { - editor.toolbar.toggleToolbox(true); - - isToolboxVisible(); - - editor.toolbar.toggleToolbox(false); - isToolboxNotVisible(); - }); - }); - it('should toggle the toolbox', () => { - cy.get('@editorInstance').then(async (editor) => { - editor.toolbar.toggleToolbox(); - isToolboxVisible(); - - editor.toolbar.toggleToolbox(); - isToolboxNotVisible(); - }); - }); - }); -}); diff --git a/test/cypress/tests/api/tools.cy.ts b/test/cypress/tests/api/tools.cy.ts index 9a3a6a86..59517287 100644 --- a/test/cypress/tests/api/tools.cy.ts +++ b/test/cypress/tests/api/tools.cy.ts @@ -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 = ''; 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 */ diff --git a/test/cypress/tests/ui/toolbox.cy.ts b/test/cypress/tests/ui/toolbox.cy.ts deleted file mode 100644 index 360d5a97..00000000 --- a/test/cypress/tests/ui/toolbox.cy.ts +++ /dev/null @@ -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('@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('@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'); - }); - }); -}); diff --git a/test/playwright/tests/api/toolbar.spec.ts b/test/playwright/tests/api/toolbar.spec.ts new file mode 100644 index 00000000..ca2bff9e --- /dev/null +++ b/test/playwright/tests/api/toolbar.spec.ts @@ -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 => { + 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 => { + 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(); + }); + }); +}); + diff --git a/test/playwright/tests/api/toolbox-entries.spec.ts b/test/playwright/tests/api/toolbox-entries.spec.ts new file mode 100644 index 00000000..2b9fca34 --- /dev/null +++ b/test/playwright/tests/api/toolbox-entries.spec.ts @@ -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 = ''; + +/** + * Reset the editor holder and destroy any existing instance + * + * @param page - The Playwright page object + */ +const resetEditor = async (page: Page): Promise => { + 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; +}; + +type SerializedToolConfig = { + name: string; + classSource: string; + config?: Record; +}; + +const serializeTools = ( + tools: Record +): 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, + options: ToolRegistrationOptions = {} +): Promise => { + await resetEditor(page); + const serializedTools = serializeTools(tools); + + const registeredToolNames = await page.evaluate( + async ({ holderId, editorTools, globals }) => { + const reviveToolClass = (classSource: string, classGlobals: Record): 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>( + (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 => { + 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, + }); + }); + }); +}); + diff --git a/test/playwright/tests/ui/toolbox.spec.ts b/test/playwright/tests/ui/toolbox.spec.ts new file mode 100644 index 00000000..3d45c711 --- /dev/null +++ b/test/playwright/tests/ui/toolbox.spec.ts @@ -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 => { + 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; +}; + +const registerToolClasses = async (page: Page, tools: SerializedToolConfig[]): Promise => { + 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 +): 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, + data?: OutputData +): Promise => { + 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> + >((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 => { + 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 => { + 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 => { + 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; + editorInstance?: EditorJS; + EditorJS: new (...args: unknown[]) => EditorJS; + } +} + diff --git a/test/unit/ui/toolbox.test.ts b/test/unit/ui/toolbox.test.ts new file mode 100644 index 00000000..dc7419ca --- /dev/null +++ b/test/unit/ui/toolbox.test.ts @@ -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: 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: 'test', + }, + shortcut: 'CMD+T', + } as unknown as BlockToolAdapter; + + // Mock ToolsCollection + const tools = new Map() as unknown as ToolsCollection; + + 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; + + 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; + + 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; + + 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).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: '1', + }, + { + title: 'Entry 2', + icon: '2', + }, + ] as ToolboxConfigEntry[], + } as unknown as BlockToolAdapter; + + const tools = new Map() as unknown as ToolsCollection; + + 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: '', + }, + shortcut: undefined, + } as unknown as BlockToolAdapter; + + const tools = new Map() as unknown as ToolsCollection; + + 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(); + } + }); + }); +}); +