From 3ec36a5d7cc7eb34623ff48614ef9c9bf134ec3c Mon Sep 17 00:00:00 2001 From: JackUait Date: Mon, 10 Nov 2025 22:13:35 +0300 Subject: [PATCH] test: add tests for i18n --- .../inline-tools/inline-tool-convert.ts | 14 +- .../components/search-input/search-input.ts | 1 + test/cypress/tests/i18n.cy.ts | 244 --- test/playwright/tests/i18n.spec.ts | 1466 +++++++++++++++++ 4 files changed, 1479 insertions(+), 246 deletions(-) delete mode 100644 test/cypress/tests/i18n.cy.ts create mode 100644 test/playwright/tests/i18n.spec.ts diff --git a/src/components/inline-tools/inline-tool-convert.ts b/src/components/inline-tools/inline-tool-convert.ts index b0347a68..567dfdc6 100644 --- a/src/components/inline-tools/inline-tool-convert.ts +++ b/src/components/inline-tools/inline-tool-convert.ts @@ -7,6 +7,7 @@ import SelectionUtils from '../selection'; import { getConvertibleToolsForBlock } from '../utils/blocks'; import I18nInternal from '../i18n'; import { I18nInternalNS } from '../i18n/namespace-internal'; +import type BlockToolAdapter from '../tools/block'; /** * Inline tools for converting blocks @@ -58,13 +59,18 @@ export default class ConvertInlineTool implements InlineTool { */ public async render(): Promise { const currentSelection = SelectionUtils.get(); + + if (currentSelection === null) { + return []; + } + const currentBlock = this.blocksAPI.getBlockByElement(currentSelection.anchorNode as HTMLElement); if (currentBlock === undefined) { return []; } - const allBlockTools = this.toolsAPI.getBlockTools(); + const allBlockTools = this.toolsAPI.getBlockTools() as BlockToolAdapter[]; const convertibleTools = await getConvertibleToolsForBlock(currentBlock, allBlockTools); if (convertibleTools.length === 0) { @@ -73,6 +79,10 @@ export default class ConvertInlineTool implements InlineTool { const convertToItems = convertibleTools.reduce((result, tool) => { tool.toolbox?.forEach((toolboxItem) => { + if (toolboxItem.title === undefined) { + return; + } + result.push({ icon: toolboxItem.icon, title: I18nInternal.t(I18nInternalNS.toolNames, toolboxItem.title), @@ -97,7 +107,7 @@ export default class ConvertInlineTool implements InlineTool { icon, name: 'convert-to', hint: { - title: this.i18nAPI.t('Convert to'), + title: I18nInternal.ui(I18nInternalNS.ui.inlineToolbar.converter, 'Convert to'), }, children: { searchable: isDesktop, diff --git a/src/components/utils/popover/components/search-input/search-input.ts b/src/components/utils/popover/components/search-input/search-input.ts index 0714ff1c..69f266f5 100644 --- a/src/components/utils/popover/components/search-input/search-input.ts +++ b/src/components/utils/popover/components/search-input/search-input.ts @@ -57,6 +57,7 @@ export class SearchInput extends EventsDispatcher { }); this.input = Dom.make('input', css.input, { + type: 'search', placeholder, /** * Used to prevent focusing on the input by Tab key diff --git a/test/cypress/tests/i18n.cy.ts b/test/cypress/tests/i18n.cy.ts deleted file mode 100644 index 9450fae5..00000000 --- a/test/cypress/tests/i18n.cy.ts +++ /dev/null @@ -1,244 +0,0 @@ -import Header from '@editorjs/header'; -import type { ToolboxConfig } from '../../../types'; -import { EDITOR_SELECTOR } from '../support/constants'; - -describe('Editor i18n', () => { - context('Toolbox', () => { - it('should translate tool title in a toolbox', function () { - if (this != null && this.editorInstance != null) { - this.editorInstance.destroy(); - } - const toolNamesDictionary = { - Heading: 'Заголовок', - }; - - cy.createEditor({ - tools: { - header: Header, - }, - i18n: { - messages: { - toolNames: toolNamesDictionary, - }, - }, - }).as('editorInstance'); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-block') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-toolbar__plus') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-popover-item[data-item-name=header]') - .should('contain.text', toolNamesDictionary.Heading); - }); - - it('should translate titles of toolbox entries', function () { - if (this != null && this.editorInstance != null) { - this.editorInstance.destroy(); - } - const toolNamesDictionary = { - Title1: 'Название 1', - Title2: 'Название 2', - }; - - /** - * Tool with several toolbox entries configured - */ - class TestTool { - /** - * Returns toolbox config as list of entries - */ - public static get toolbox(): ToolboxConfig { - return [ - { - title: 'Title1', - icon: 'Icon 1', - }, - { - title: 'Title2', - icon: 'Icon 2', - }, - ]; - } - } - - cy.createEditor({ - tools: { - testTool: TestTool, - }, - i18n: { - messages: { - toolNames: toolNamesDictionary, - }, - }, - }).as('editorInstance'); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-block') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-toolbar__plus') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-popover-item[data-item-name=testTool]') - .first() - .should('contain.text', toolNamesDictionary.Title1); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-popover-item[data-item-name=testTool]') - .last() - .should('contain.text', toolNamesDictionary.Title2); - }); - - it('should use capitalized tool name as translation key if toolbox title is missing', function () { - if (this != null && this.editorInstance != null) { - this.editorInstance.destroy(); - } - - /** - * Tool class allowing to test case when capitalized tool name is used as translation key if toolbox title is missing - */ - class TestTool { - /** - * Returns toolbox config without title - */ - public static get toolbox(): ToolboxConfig { - return { - title: '', - icon: '', - }; - } - } - const toolNamesDictionary = { - TestTool: 'ТестТул', - }; - - cy.createEditor({ - tools: { - testTool: TestTool, - }, - i18n: { - messages: { - toolNames: toolNamesDictionary, - }, - }, - }); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-block') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-toolbar__plus') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-popover-item[data-item-name=testTool]') - .should('contain.text', toolNamesDictionary.TestTool); - }); - }); - - context('Block Tunes', () => { - it('should translate tool name in Convert To', () => { - const toolNamesDictionary = { - Heading: 'Заголовок', - }; - - cy.createEditor({ - tools: { - header: Header, - }, - i18n: { - messages: { - toolNames: toolNamesDictionary, - }, - }, - data: { - blocks: [ - { - type: 'paragraph', - data: { - text: 'Some text', - level: 1, - }, - }, - ], - }, - }); - - cy.get(EDITOR_SELECTOR) - .get('div.ce-block') - .click(); - - /** Open block tunes menu */ - cy.get(EDITOR_SELECTOR) - .get('.ce-block') - .click(); - - cy.get(EDITOR_SELECTOR) - .get('.ce-toolbar__settings-btn') - .click(); - - /** Open "Convert to" menu */ - cy.get(EDITOR_SELECTOR) - .get('[data-item-name=convert-to]') - .click(); - - /** Check item in convert to menu is internationalized */ - cy.get(EDITOR_SELECTOR) - .get('.ce-popover--nested .ce-popover-item[data-item-name=header]') - .should('contain.text', toolNamesDictionary.Heading); - }); - }); - - context('Inline Toolbar', () => { - it('should translate tool name in Convert To', () => { - const toolNamesDictionary = { - Heading: 'Заголовок', - }; - - cy.createEditor({ - tools: { - header: Header, - }, - i18n: { - messages: { - toolNames: toolNamesDictionary, - }, - }, - data: { - blocks: [ - { - type: 'paragraph', - data: { - text: 'Some text', - level: 1, - }, - }, - ], - }, - }); - - /** Open Inline Toolbar */ - cy.get(EDITOR_SELECTOR) - .find('.ce-paragraph') - .selectText('Some text'); - - /** Open "Convert to" menu */ - cy.get(EDITOR_SELECTOR) - .get('[data-item-name=convert-to]') - .click(); - - /** Check item in convert to menu is internationalized */ - cy.get(EDITOR_SELECTOR) - .get('.ce-popover--nested .ce-popover-item[data-item-name=header]') - .should('contain.text', toolNamesDictionary.Heading); - }); - }); -}); diff --git a/test/playwright/tests/i18n.spec.ts b/test/playwright/tests/i18n.spec.ts new file mode 100644 index 00000000..c3cce189 --- /dev/null +++ b/test/playwright/tests/i18n.spec.ts @@ -0,0 +1,1466 @@ +import { expect, test } from '@playwright/test'; +import type { Locator, Page } from '@playwright/test'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { OutputData } from '@/types'; +import { ensureEditorBundleBuilt } from './helpers/ensure-build'; +import { EDITOR_SELECTOR } from './constants'; + +const TEST_PAGE_URL = pathToFileURL( + path.resolve(__dirname, '../../cypress/fixtures/test.html') +).href; + +const HOLDER_ID = 'editorjs'; +const BLOCK_SELECTOR = `${EDITOR_SELECTOR} div.ce-block`; +const PARAGRAPH_SELECTOR = `${EDITOR_SELECTOR} [data-block-tool="paragraph"]`; +const SETTINGS_BUTTON_SELECTOR = `${EDITOR_SELECTOR} .ce-toolbar__settings-btn`; +const PLUS_BUTTON_SELECTOR = `${EDITOR_SELECTOR} .ce-toolbar__plus`; +const INLINE_TOOLBAR_SELECTOR = `${EDITOR_SELECTOR} [data-interface=inline-toolbar]`; +const POPOVER_SELECTOR = `${EDITOR_SELECTOR} .ce-popover`; +const TOOLTIP_SELECTOR = '.ct'; +const LINK_TOOL_SHORTCUT_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control'; + +/** + * 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 i18n configuration + * + * @param page - The Playwright page object + * @param config - Editor configuration including i18n settings + */ +const createEditorWithI18n = async ( + page: Page, + config: { + tools?: Record; + i18n?: { messages?: Record }; + data?: { blocks?: OutputData['blocks'] }; + } +): Promise => { + await resetEditor(page); + await page.evaluate( + async ({ holderId, editorConfig }) => { + const resolveFromWindow = (value: unknown): unknown => { + if (typeof value === 'string') { + return value.split('.').reduce((acc, key) => { + if (acc && typeof acc === 'object') { + return (acc as Record)[key]; + } + + return undefined; + }, window as unknown); + } + + return value; + }; + + const resolveClassConfig = (classConfig: string): unknown => { + const resolvedClass = resolveFromWindow(classConfig); + + if (resolvedClass === undefined) { + throw new Error(`Unable to resolve tool class "${classConfig}" from window.`); + } + + return resolvedClass; + }; + + const normalizeToolConfig = (toolConfig: unknown): unknown => { + if (toolConfig === undefined || toolConfig === null) { + return toolConfig; + } + + if (typeof toolConfig === 'function') { + return toolConfig; + } + + if (typeof toolConfig === 'string') { + const resolvedTool = resolveFromWindow(toolConfig); + + if (resolvedTool === undefined) { + throw new Error(`Unable to resolve tool "${toolConfig}" from window.`); + } + + return resolvedTool; + } + + if (typeof toolConfig === 'object') { + const normalizedConfig = { ...(toolConfig as Record) }; + + if ('class' in normalizedConfig && typeof normalizedConfig.class === 'string') { + normalizedConfig.class = resolveClassConfig(normalizedConfig.class); + } + + return normalizedConfig; + } + + return toolConfig; + }; + + const normalizeTools = (tools: Record | undefined): Record | undefined => { + if (!tools) { + return tools; + } + + return Object.entries(tools).reduce>((acc, [toolName, toolConfig]) => { + const normalizedConfig = normalizeToolConfig(toolConfig); + + if (normalizedConfig === undefined) { + throw new Error(`Tool "${toolName}" is undefined. Provide a valid constructor or configuration object.`); + } + + return { + ...acc, + [toolName]: normalizedConfig, + }; + }, {}); + }; + + const { tools, ...restConfig } = editorConfig ?? {}; + const normalizedTools = normalizeTools(tools as Record | undefined); + + const editor = new window.EditorJS({ + holder: holderId, + ...restConfig, + ...(normalizedTools ? { tools: normalizedTools } : {}), + }); + + window.editorInstance = editor; + await editor.isReady; + + await new Promise((resolve) => { + if ('requestIdleCallback' in window) { + window.requestIdleCallback(() => resolve(), { timeout: 100 }); + + return; + } + + setTimeout(resolve, 0); + }); + }, + { holderId: HOLDER_ID, + editorConfig: config } + ); +}; + +/** + * Select text content within a locator by string match + * + * @param locator - The Playwright locator for the element containing the text + * @param text - The text string to select within the element + */ +const selectText = async (locator: Locator, text: string): Promise => { + await locator.evaluate((element, targetText) => { + const walker = element.ownerDocument.createTreeWalker(element, NodeFilter.SHOW_TEXT); + let textNode: Node | null = null; + let start = -1; + + while (walker.nextNode()) { + const node = walker.currentNode; + const content = node.textContent ?? ''; + const idx = content.indexOf(targetText); + + if (idx !== -1) { + textNode = node; + start = idx; + break; + } + } + + if (!textNode || start === -1) { + throw new Error(`Text "${targetText}" was not found in element`); + } + + const range = element.ownerDocument.createRange(); + + range.setStart(textNode, start); + range.setEnd(textNode, start + targetText.length); + + const selection = element.ownerDocument.getSelection(); + + selection?.removeAllRanges(); + selection?.addRange(range); + + element.ownerDocument.dispatchEvent(new Event('selectionchange')); + }, text); +}; + +/** + * Wait for tooltip to appear and get its text + * + * @param page - The Playwright page object + * @param triggerElement - Element to hover over to trigger tooltip + * @returns The tooltip text content + */ +const getTooltipText = async (page: Page, triggerElement: Locator): Promise => { + await triggerElement.hover(); + + const tooltip = page.locator(TOOLTIP_SELECTOR); + + await expect(tooltip).toBeVisible(); + + return (await tooltip.textContent()) ?? ''; +}; + +/** + * Opens the inline toolbar popover and waits until it becomes visible. + * + * @param page - The Playwright page object + * @returns Locator for the inline toolbar popover + */ +const openInlineToolbarPopover = async (page: Page): Promise => { + const inlineToolbar = page.locator(INLINE_TOOLBAR_SELECTOR); + + await expect(inlineToolbar).toHaveCount(1); + + await page.evaluate(() => { + window.editorInstance?.inlineToolbar?.open(); + }); + + const inlinePopover = inlineToolbar.locator('.ce-popover').first(); + const inlinePopoverContainer = inlinePopover.locator('.ce-popover__container').first(); + + await expect(inlinePopoverContainer).toBeVisible(); + + return inlinePopover; +}; + +test.describe('Editor i18n', () => { + test.beforeAll(() => { + ensureEditorBundleBuilt(); + }); + + test.beforeEach(async ({ page }) => { + await page.goto(TEST_PAGE_URL); + await page.waitForFunction(() => typeof window.EditorJS === 'function'); + }); + + test.describe('Toolbox', () => { + test('should translate tool title in a toolbox', async ({ page }) => { + const toolNamesDictionary = { + Heading: 'Заголовок', + }; + + // Create a simple header tool in the browser context + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * Creates a `SimpleHeader` instance with initial block data. + * + * @param root0 - Editor.js constructor arguments containing the block data. + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * Persists the heading content to the Editor.js data format. + * + * @param element - Heading element that contains the current block content. + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + }; + }); + + await createEditorWithI18n(page, { + tools: { + header: 'SimpleHeader', + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(PLUS_BUTTON_SELECTOR).click(); + + const headerItem = page.locator(`${POPOVER_SELECTOR} [data-item-name="header"]`); + + await expect(headerItem).toBeVisible(); + await expect(headerItem).toContainText(toolNamesDictionary.Heading); + }); + + test('should translate titles of toolbox entries', async ({ page }) => { + const toolNamesDictionary = { + Title1: 'Название 1', + Title2: 'Название 2', + }; + + // Create a test tool with multiple toolbox entries + await page.evaluate(() => { + // @ts-expect-error - Define TestTool in window for editor creation + window.TestTool = class { + /** + * + */ + public static get toolbox(): Array<{ title: string; icon: string }> { + return [ + { + title: 'Title1', + icon: 'Icon 1', + }, + { + title: 'Title2', + icon: 'Icon 2', + }, + ]; + } + + /** + * + */ + public render(): HTMLDivElement { + const wrapper = document.createElement('div'); + + wrapper.contentEditable = 'true'; + wrapper.innerHTML = 'Test tool content'; + + return wrapper; + } + + /** + * + */ + public save(): Record { + return {}; + } + }; + }); + + await createEditorWithI18n(page, { + tools: { + testTool: 'TestTool', + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(PLUS_BUTTON_SELECTOR).click(); + + const testToolItems = page.locator(`${POPOVER_SELECTOR} [data-item-name="testTool"]`); + + await expect(testToolItems.first()).toBeVisible(); + await expect(testToolItems.first()).toContainText(toolNamesDictionary.Title1); + await expect(testToolItems.last()).toContainText(toolNamesDictionary.Title2); + }); + + test('should use capitalized tool name as translation key if toolbox title is missing', async ({ page }) => { + const toolNamesDictionary = { + TestTool: 'ТестТул', + }; + + // Create a test tool without title + await page.evaluate(() => { + // @ts-expect-error - Define TestTool in window for editor creation + window.TestTool = class { + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: '', + icon: '', + }; + } + + /** + * + */ + public render(): HTMLDivElement { + const wrapper = document.createElement('div'); + + wrapper.contentEditable = 'true'; + wrapper.innerHTML = 'Test tool content'; + + return wrapper; + } + + /** + * + */ + public save(): Record { + return {}; + } + }; + }); + + await createEditorWithI18n(page, { + tools: { + testTool: 'TestTool', + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(PLUS_BUTTON_SELECTOR).click(); + + const testToolItem = page.locator(`${POPOVER_SELECTOR} [data-item-name="testTool"]`); + + await expect(testToolItem).toBeVisible(); + await expect(testToolItem).toContainText(toolNamesDictionary.TestTool); + }); + }); + + test.describe('Block Tunes', () => { + test('should translate Delete button title', async ({ page }) => { + const blockTunesDictionary = { + delete: { + Delete: 'Удалить', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + blockTunes: blockTunesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + const deleteButton = page.locator(`${POPOVER_SELECTOR} [data-item-name="delete"]`); + + await expect(deleteButton).toBeVisible(); + await expect(deleteButton).toContainText(blockTunesDictionary.delete.Delete); + }); + + test('should translate Move up button title', async ({ page }) => { + const blockTunesDictionary = { + moveUp: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Move up': 'Переместить вверх', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + blockTunes: blockTunesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + ], + }, + }); + + const secondBlock = page.locator(BLOCK_SELECTOR).last(); + + await secondBlock.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + const moveUpButton = page.locator(`${POPOVER_SELECTOR} [data-item-name="move-up"]`); + + await expect(moveUpButton).toBeVisible(); + await expect(moveUpButton).toContainText(blockTunesDictionary.moveUp['Move up']); + }); + + test('should translate Move down button title', async ({ page }) => { + const blockTunesDictionary = { + moveDown: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Move down': 'Переместить вниз', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + blockTunes: blockTunesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'First block', + }, + }, + { + type: 'paragraph', + data: { + text: 'Second block', + }, + }, + ], + }, + }); + + const firstBlock = page.locator(BLOCK_SELECTOR).first(); + + await firstBlock.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + const moveDownButton = page.locator(`${POPOVER_SELECTOR} [data-item-name="move-down"]`); + + await expect(moveDownButton).toBeVisible(); + await expect(moveDownButton).toContainText(blockTunesDictionary.moveDown['Move down']); + }); + + test('should translate "Click to delete" confirmation message', async ({ page }) => { + const blockTunesDictionary = { + delete: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Click to delete': 'Нажмите для удаления', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + blockTunes: blockTunesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + const deleteButton = page.locator(`${POPOVER_SELECTOR} [data-item-name="delete"]`); + + await deleteButton.click(); + + // Wait for confirmation popover to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(100); + + // Check if confirmation message appears (it might be in a nested popover or notification) + const confirmationText = blockTunesDictionary.delete['Click to delete']; + const confirmationElement = page.locator(`text=${confirmationText}`).first(); + + // The confirmation might appear in different ways, so we check if it exists + const confirmationExists = await confirmationElement.count() > 0; + + expect(confirmationExists).toBeTruthy(); + }); + + test('should translate tool name in Convert To', async ({ page }) => { + const toolNamesDictionary = { + Heading: 'Заголовок', + }; + + // Create a simple header tool in the browser context + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * Creates a `SimpleHeader` instance with initial block data. + * + * @param root0 - Editor.js constructor arguments containing the block data. + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * Persists the heading content to the Editor.js data format. + * + * @param element - Heading element that contains the current block content. + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + + /** + * + */ + public static get conversionConfig(): { export: string; import: string } { + return { + export: 'text', + import: 'text', + }; + } + }; + }); + + await createEditorWithI18n(page, { + tools: { + header: 'SimpleHeader', + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + // Open "Convert to" menu + const convertToButton = page.locator(`${POPOVER_SELECTOR} [data-item-name="convert-to"]`); + + await expect(convertToButton).toBeVisible(); + await convertToButton.click(); + + // Check item in convert to menu is internationalized + const headerItem = page.locator(`${POPOVER_SELECTOR} .ce-popover--nested [data-item-name="header"]`); + + await expect(headerItem).toBeVisible(); + await expect(headerItem).toContainText(toolNamesDictionary.Heading); + }); + }); + + test.describe('UI Popover', () => { + test('should translate "Filter" search placeholder in toolbox', async ({ page }) => { + const uiDictionary = { + popover: { + Filter: 'Поиск', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + ui: uiDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(PLUS_BUTTON_SELECTOR).click(); + + const searchInput = page.locator(`${POPOVER_SELECTOR} input[type="search"], ${POPOVER_SELECTOR} input[placeholder*="Filter"]`); + + await expect(searchInput).toBeVisible(); + + const placeholder = await searchInput.getAttribute('placeholder'); + + expect(placeholder).toContain(uiDictionary.popover.Filter); + }); + + test('should translate "Nothing found" message in toolbox', async ({ page }) => { + const uiDictionary = { + popover: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Nothing found': 'Ничего не найдено', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + ui: uiDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(PLUS_BUTTON_SELECTOR).click(); + + const popover = page.locator(POPOVER_SELECTOR).first(); + const searchInput = popover.locator('input[type="search"]').first(); + + await expect(searchInput).toBeVisible(); + await searchInput.fill('nonexistenttool12345'); + + // Wait for "Nothing found" message to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for search results + await page.waitForTimeout(300); + + const nothingFoundMessage = popover.getByText(uiDictionary.popover['Nothing found']); + + await expect(nothingFoundMessage).toBeVisible(); + }); + + test('should translate "Filter" and "Nothing found" in block settings popover', async ({ page }) => { + const uiDictionary = { + popover: { + Filter: 'Поиск', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Nothing found': 'Ничего не найдено', + }, + }; + + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * + * @param root0 - root data + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * + * @param element - heading element + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + + /** + * + */ + public static get conversionConfig(): { export: string; import: string } { + return { + export: 'text', + import: 'text', + }; + } + }; + }); + + await createEditorWithI18n(page, { + i18n: { + messages: { + ui: uiDictionary, + }, + }, + tools: { + header: 'SimpleHeader', + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + await page.locator(SETTINGS_BUTTON_SELECTOR).click(); + + const popover = page.locator(POPOVER_SELECTOR).first(); + const searchInput = popover.getByRole('searchbox', { name: uiDictionary.popover.Filter }).first(); + + await expect(searchInput).toBeVisible(); + + const placeholder = await searchInput.getAttribute('placeholder'); + + expect(placeholder).toContain(uiDictionary.popover.Filter); + + await searchInput.fill('nonexistent12345'); + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for search results + await page.waitForTimeout(300); + + const nothingFoundMessage = popover.getByText(uiDictionary.popover['Nothing found']); + + await expect(nothingFoundMessage).toBeVisible(); + }); + + test('should translate "Filter" and "Nothing found" in inline toolbar popover', async ({ page }) => { + const uiDictionary = { + popover: { + Filter: 'Поиск', + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Nothing found': 'Ничего не найдено', + }, + }; + + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * + * @param root0 - root data + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * + * @param element - heading element + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + + /** + * + */ + public static get conversionConfig(): { export: string; import: string } { + return { + export: 'text', + import: 'text', + }; + } + }; + }); + + await resetEditor(page); + await page.evaluate( + async ({ holderId, uiDict }) => { + // @ts-expect-error - Get SimpleHeader from window + const SimpleHeader = window.SimpleHeader; + + const editor = new window.EditorJS({ + holder: holderId, + tools: { + header: SimpleHeader, + }, + i18n: { + messages: { + ui: uiDict, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text to select', + }, + }, + ], + }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, + { holderId: HOLDER_ID, + uiDict: uiDictionary } + ); + + const paragraph = page.locator(PARAGRAPH_SELECTOR).first(); + + await selectText(paragraph, 'Some text'); + + // Wait for inline toolbar to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(200); + + const inlinePopover = await openInlineToolbarPopover(page); + const convertToButton = inlinePopover.locator('[data-item-name="convert-to"]'); + + await expect(convertToButton).toBeVisible(); + await convertToButton.first().click(); + + const nestedPopover = page.locator(`${INLINE_TOOLBAR_SELECTOR} .ce-popover--nested`); + + await expect(nestedPopover).toHaveCount(1); + + const nestedPopoverContainer = nestedPopover.locator('.ce-popover__container').first(); + + await expect(nestedPopoverContainer).toBeVisible(); + + const searchInput = nestedPopover.getByRole('searchbox', { name: uiDictionary.popover.Filter }).first(); + + await expect(searchInput).toBeVisible(); + + const placeholder = await searchInput.getAttribute('placeholder'); + + expect(placeholder).toContain(uiDictionary.popover.Filter); + + await searchInput.fill('nonexistent12345'); + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for search results + await page.waitForTimeout(300); + + const nothingFoundMessage = nestedPopover.getByText(uiDictionary.popover['Nothing found']); + + await expect(nothingFoundMessage).toBeVisible(); + }); + }); + + test.describe('UI Toolbar Toolbox', () => { + test('should translate "Add" button tooltip', async ({ page }) => { + const uiDictionary = { + toolbar: { + toolbox: { + Add: 'Добавить', + }, + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + ui: uiDictionary, + }, + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + + const plusButton = page.locator(PLUS_BUTTON_SELECTOR); + + await expect(plusButton).toBeVisible(); + + const tooltipText = await getTooltipText(page, plusButton); + + expect(tooltipText).toContain(uiDictionary.toolbar.toolbox.Add); + }); + }); + + test.describe('UI Block Tunes Toggler', () => { + test('should translate "Click to tune" tooltip', async ({ page }) => { + const uiDictionary = { + blockTunes: { + toggler: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Click to tune': 'Нажмите, чтобы настроить', + }, + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + ui: uiDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const block = page.locator(BLOCK_SELECTOR).first(); + + await block.click(); + + const settingsButton = page.locator(SETTINGS_BUTTON_SELECTOR); + + await expect(settingsButton).toBeVisible(); + + const tooltipText = await getTooltipText(page, settingsButton); + + expect(tooltipText).toContain(uiDictionary.blockTunes.toggler['Click to tune']); + }); + }); + + test.describe('UI Inline Toolbar Converter', () => { + test('should translate "Convert to" label in inline toolbar', async ({ page }) => { + const uiDictionary = { + inlineToolbar: { + converter: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Convert to': 'Конвертировать в', + }, + }, + }; + + // Create a simple header tool in the browser context + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * Creates a `SimpleHeader` instance with initial block data. + * + * @param root0 - Editor.js constructor arguments containing the block data. + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * Persists the heading content to the Editor.js data format. + * + * @param element - Heading element that contains the current block content. + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + + /** + * + */ + public static get conversionConfig(): { export: string; import: string } { + return { + export: 'text', + import: 'text', + }; + } + }; + }); + + // Create editor with header tool + await resetEditor(page); + await page.evaluate( + async ({ holderId, uiDict }) => { + // @ts-expect-error - Get SimpleHeader from window + const SimpleHeader = window.SimpleHeader; + + const editor = new window.EditorJS({ + holder: holderId, + tools: { + header: SimpleHeader, + }, + i18n: { + messages: { + ui: uiDict, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + window.editorInstance = editor; + await editor.isReady; + }, + { holderId: HOLDER_ID, + uiDict: uiDictionary } + ); + + const paragraph = page.locator(PARAGRAPH_SELECTOR).first(); + + await selectText(paragraph, 'Some text'); + + // Wait for inline toolbar to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(200); + + const inlinePopover = await openInlineToolbarPopover(page); + + // Look for "Convert to" button/item in inline toolbar + const convertToButton = inlinePopover.locator('[data-item-name="convert-to"]'); + + await expect(convertToButton).toHaveCount(1); + const convertToTooltip = await getTooltipText(page, convertToButton.first()); + + expect(convertToTooltip).toContain(uiDictionary.inlineToolbar.converter['Convert to']); + }); + }); + + test.describe('Tools Translations', () => { + test('should translate "Add a link" placeholder for link tool', async ({ page }) => { + const toolsDictionary = { + link: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'Add a link': 'Вставьте ссылку', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + tools: toolsDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR).first(); + + await selectText(paragraph, 'Some text'); + + // Wait for inline toolbar to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(200); + + // Trigger link tool (Ctrl+K or Cmd+K) + await page.keyboard.press(`${LINK_TOOL_SHORTCUT_MODIFIER}+k`); + + // Wait for link input to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(200); + + const linkInput = page.locator('input[data-link-tool-input-opened], input[placeholder*="link" i]'); + + await expect(linkInput).toBeVisible(); + + const placeholder = await linkInput.getAttribute('placeholder'); + + expect(placeholder).toContain(toolsDictionary.link['Add a link']); + }); + + test('should translate stub tool message', async ({ page }) => { + const toolsDictionary = { + stub: { + // eslint-disable-next-line @typescript-eslint/naming-convention -- Dictionary keys must match i18n structure + 'The block can not be displayed correctly.': 'Блок не может быть отображен корректно.', + }, + }; + + await createEditorWithI18n(page, { + i18n: { + messages: { + tools: toolsDictionary, + }, + }, + data: { + blocks: [ + { + type: 'unknown-tool-type', + data: {}, + }, + ], + }, + }); + + // Stub block should be rendered with translated message + const stubMessage = page.locator(`text=${toolsDictionary.stub['The block can not be displayed correctly.']}`); + + await expect(stubMessage).toBeVisible(); + }); + }); + + test.describe('Inline Toolbar', () => { + test('should translate tool name in Convert To', async ({ page }) => { + const toolNamesDictionary = { + Heading: 'Заголовок', + }; + + // Create a simple header tool in the browser context + await page.evaluate(() => { + // @ts-expect-error - Define SimpleHeader in window for editor creation + window.SimpleHeader = class { + private data: { text: string }; + + /** + * Creates a `SimpleHeader` instance with initial block data. + * + * @param root0 - Editor.js constructor arguments containing the block data. + */ + constructor({ data }: { data: { text: string } }) { + this.data = data; + } + + /** + * + */ + public render(): HTMLHeadingElement { + const element = document.createElement('h1'); + + element.contentEditable = 'true'; + element.innerHTML = this.data.text; + + return element; + } + + /** + * Persists the heading content to the Editor.js data format. + * + * @param element - Heading element that contains the current block content. + */ + public save(element: HTMLHeadingElement): { text: string; level: number } { + return { + text: element.innerHTML, + level: 1, + }; + } + + /** + * + */ + public static get toolbox(): { title: string; icon: string } { + return { + title: 'Heading', + icon: '', + }; + } + + /** + * + */ + public static get conversionConfig(): { export: string; import: string } { + return { + export: 'text', + import: 'text', + }; + } + }; + }); + + await createEditorWithI18n(page, { + tools: { + header: 'SimpleHeader', + }, + i18n: { + messages: { + toolNames: toolNamesDictionary, + }, + }, + data: { + blocks: [ + { + type: 'paragraph', + data: { + text: 'Some text', + }, + }, + ], + }, + }); + + const paragraph = page.locator(PARAGRAPH_SELECTOR).first(); + + // Open Inline Toolbar + await selectText(paragraph, 'Some text'); + + // Wait for inline toolbar to appear + // eslint-disable-next-line playwright/no-wait-for-timeout -- Waiting for UI animation + await page.waitForTimeout(200); + + const inlinePopover = await openInlineToolbarPopover(page); + + // Open "Convert to" menu + const convertToButton = inlinePopover.locator('[data-item-name="convert-to"]'); + + await expect(convertToButton).toBeVisible(); + await convertToButton.click(); + + // Check item in convert to menu is internationalized + const nestedPopover = page.locator(`${INLINE_TOOLBAR_SELECTOR} .ce-popover--nested`).first(); + const headerItem = nestedPopover.locator('[data-item-name="header"]').first(); + + await expect(headerItem).toBeVisible(); + await expect(headerItem).toContainText(toolNamesDictionary.Heading); + }); + }); +}); +