From 022320940edbcf2261da70cbd9864ab33938de30 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Thu, 20 Jul 2023 23:27:18 +0300 Subject: [PATCH] feat(shortcuts): convert block by tools shortcut (#2419) * feat(conversion): allow to convert block using shortcut * display shortcuts in conversion toolbar * tests for the blocks.convert * tests for the toolbox shortcuts * Update CHANGELOG.md * Update toolbox.cy.ts * rm unused imports * firefox test fixed * test errors via to.throw --- .vscode/settings.json | 1 + docs/CHANGELOG.md | 3 + src/components/block/index.ts | 10 + src/components/modules/api/blocks.ts | 40 ++++ src/components/modules/blockManager.ts | 80 ++++++- src/components/modules/toolbar/conversion.ts | 100 ++------- src/components/tools/block.ts | 2 +- src/components/ui/toolbox.ts | 20 ++ src/components/utils/blocks.ts | 57 +++++ src/styles/conversion-toolbar.css | 17 +- test/cypress/fixtures/tools/ToolMock.ts | 54 +++++ test/cypress/tests/api/blocks.cy.ts | 218 +++++++++++++++++-- test/cypress/tests/ui/toolbox.cy.ts | 103 +++++++++ types/api/blocks.d.ts | 11 + types/configs/conversion-config.ts | 4 +- 15 files changed, 604 insertions(+), 116 deletions(-) create mode 100644 test/cypress/fixtures/tools/ToolMock.ts create mode 100644 test/cypress/tests/ui/toolbox.cy.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 424c8e58..5d01b0ef 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,6 +8,7 @@ "colspan", "contenteditable", "contentless", + "Convertable", "cssnano", "cssnext", "Debouncer", diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 20a73f68..6600d997 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -3,9 +3,12 @@ ### 2.28.0 - `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id. +- `New` - The `.convert(blockId, newType)` API method added - `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one. - `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected. - `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current. +- `Improvement` - Tools shortcuts could be used to convert one Block to another. +- `Improvement` - Tools shortcuts displayed in the Conversion Toolbar ### 2.27.2 diff --git a/src/components/block/index.ts b/src/components/block/index.ts index b452e5d0..d568f25d 100644 --- a/src/components/block/index.ts +++ b/src/components/block/index.ts @@ -25,6 +25,7 @@ import { TunesMenuConfigItem } from '../../../types/tools'; import { isMutationBelongsToElement } from '../utils/mutations'; import { EditorEventMap, FakeCursorAboutToBeToggled, FakeCursorHaveBeenSet, RedactorDomChanged } from '../events'; import { RedactorDomChangedPayload } from '../events/RedactorDomChanged'; +import { convertBlockDataToString } from '../utils/blocks'; /** * Interface describes Block class constructor argument @@ -723,6 +724,15 @@ export default class Block extends EventsDispatcher { }); } + /** + * Exports Block data as string using conversion config + */ + public async exportDataAsString(): Promise { + const blockData = await this.data; + + return convertBlockDataToString(blockData, this.tool.conversionConfig); + } + /** * Make default Block wrappers and put Tool`s content there * diff --git a/src/components/modules/api/blocks.ts b/src/components/modules/api/blocks.ts index c57b9e4c..81cf7a52 100644 --- a/src/components/modules/api/blocks.ts +++ b/src/components/modules/api/blocks.ts @@ -4,6 +4,7 @@ import * as _ from './../../utils'; import BlockAPI from '../../block/api'; import Module from '../../__module'; import Block from '../../block'; +import { capitalize } from './../../utils'; /** * @class BlocksAPI @@ -33,6 +34,7 @@ export default class BlocksAPI extends Module { insert: this.insert, update: this.update, composeBlockData: this.composeBlockData, + convert: this.convert, }; } @@ -311,4 +313,42 @@ export default class BlocksAPI extends Module { tunes: block.tunes, }); }; + + /** + * Converts block to another type. Both blocks should provide the conversionConfig. + * + * @param id - id of the existing block to convert. Should provide 'conversionConfig.export' method + * @param newType - new block type. Should provide 'conversionConfig.import' method + * @param dataOverrides - optional data overrides for the new block + * @throws Error if conversion is not possible + */ + private convert = (id: string, newType: string, dataOverrides?: BlockToolData): void => { + const { BlockManager, Tools } = this.Editor; + const blockToConvert = BlockManager.getBlockById(id); + + if (!blockToConvert) { + throw new Error(`Block with id "${id}" not found`); + } + + const originalBlockTool = Tools.blockTools.get(blockToConvert.name); + const targetBlockTool = Tools.blockTools.get(newType); + + if (!targetBlockTool) { + throw new Error(`Block Tool with type "${newType}" not found`); + } + + const originalBlockConvertable = originalBlockTool?.conversionConfig?.export !== undefined; + const targetBlockConvertable = targetBlockTool.conversionConfig?.import !== undefined; + + if (originalBlockConvertable && targetBlockConvertable) { + BlockManager.convert(blockToConvert, newType, dataOverrides); + } else { + const unsupportedBlockTypes = [ + !originalBlockConvertable ? capitalize(blockToConvert.name) : false, + !targetBlockConvertable ? capitalize(newType) : false, + ].filter(Boolean).join(' and '); + + throw new Error(`Conversion from "${blockToConvert.name}" to "${newType}" is not possible. ${unsupportedBlockTypes} tool(s) should provide a "conversionConfig"`); + } + }; } diff --git a/src/components/modules/blockManager.ts b/src/components/modules/blockManager.ts index ed614b24..67cae21e 100644 --- a/src/components/modules/blockManager.ts +++ b/src/components/modules/blockManager.ts @@ -18,6 +18,8 @@ import { BlockAddedMutationType } from '../../../types/events/block/BlockAdded'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import { BlockChangedMutationType } from '../../../types/events/block/BlockChanged'; import { BlockChanged } from '../events'; +import { clean } from '../utils/sanitizer'; +import { convertStringToBlockData } from '../utils/blocks'; /** * @typedef {BlockManager} BlockManager @@ -319,21 +321,19 @@ export default class BlockManager extends Module { } /** - * Replace current working block + * Replace passed Block with the new one with specified Tool and data * - * @param {object} options - replace options - * @param {string} options.tool — plugin name - * @param {BlockToolData} options.data — plugin data - * @returns {Block} + * @param block - block to replace + * @param newTool - new Tool name + * @param data - new Tool data */ - public replace({ - tool = this.config.defaultBlock, - data = {}, - }): Block { - return this.insert({ - tool, + public replace(block: Block, newTool: string, data: BlockToolData): void { + const blockIndex = this.getBlockIndex(block); + + this.insert({ + tool: newTool, data, - index: this.currentBlockIndex, + index: blockIndex, replace: true, }); } @@ -732,6 +732,62 @@ export default class BlockManager extends Module { }); } + /** + * Converts passed Block to the new Tool + * Uses Conversion Config + * + * @param blockToConvert - Block that should be converted + * @param targetToolName - name of the Tool to convert to + * @param blockDataOverrides - optional new Block data overrides + */ + public async convert(blockToConvert: Block, targetToolName: string, blockDataOverrides?: BlockToolData): Promise { + /** + * At first, we get current Block data + */ + const savedBlock = await blockToConvert.save(); + + if (!savedBlock) { + throw new Error('Could not convert Block. Failed to extract original Block data.'); + } + + /** + * Getting a class of the replacing Tool + */ + const replacingTool = this.Editor.Tools.blockTools.get(targetToolName); + + if (!replacingTool) { + throw new Error(`Could not convert Block. Tool «${targetToolName}» not found.`); + } + + /** + * Using Conversion Config "export" we get a stringified version of the Block data + */ + const exportedData = await blockToConvert.exportDataAsString(); + + /** + * Clean exported data with replacing sanitizer config + */ + const cleanData: string = clean( + exportedData, + replacingTool.sanitizeConfig + ); + + /** + * Now using Conversion Config "import" we compose a new Block data + */ + let newBlockData = convertStringToBlockData(cleanData, replacingTool.conversionConfig); + + /** + * Optional data overrides. + * Used for example, by the Multiple Toolbox Items feature, where a single Tool provides several Toolbox items with "data" overrides + */ + if (blockDataOverrides) { + newBlockData = Object.assign(newBlockData, blockDataOverrides); + } + + this.replace(blockToConvert, replacingTool.name, newBlockData); + } + /** * Sets current Block Index -1 which means unknown * and clear highlights diff --git a/src/components/modules/toolbar/conversion.ts b/src/components/modules/toolbar/conversion.ts index 754c7212..759e15d3 100644 --- a/src/components/modules/toolbar/conversion.ts +++ b/src/components/modules/toolbar/conversion.ts @@ -1,11 +1,9 @@ import Module from '../../__module'; import $ from '../../dom'; import * as _ from '../../utils'; -import { SavedData } from '../../../../types/data-formats'; import Flipper from '../../flipper'; import I18n from '../../i18n'; import { I18nInternalNS } from '../../i18n/namespace-internal'; -import { clean } from '../../utils/sanitizer'; import { ToolboxConfigEntry, BlockToolData } from '../../../../types'; /** @@ -34,6 +32,7 @@ export default class ConversionToolbar extends Module { conversionTool: 'ce-conversion-tool', conversionToolHidden: 'ce-conversion-tool--hidden', conversionToolIcon: 'ce-conversion-tool__icon', + conversionToolSecondaryLabel: 'ce-conversion-tool__secondary-label', conversionToolFocused: 'ce-conversion-tool--focused', conversionToolActive: 'ce-conversion-tool--active', @@ -179,90 +178,21 @@ export default class ConversionToolbar extends Module { * For that Tools must provide import/export methods * * @param {string} replacingToolName - name of Tool which replaces current - * @param blockDataOverrides - Block data overrides. Could be passed in case if Multiple Toolbox items specified + * @param blockDataOverrides - If this conversion fired by the one of multiple Toolbox items, extend converted data with this item's "data" overrides */ public async replaceWithBlock(replacingToolName: string, blockDataOverrides?: BlockToolData): Promise { - /** - * At first, we get current Block data - */ - const currentBlockTool = this.Editor.BlockManager.currentBlock.tool; - const savedBlock = await this.Editor.BlockManager.currentBlock.save() as SavedData; - const blockData = savedBlock.data; + const { BlockManager, BlockSelection, InlineToolbar, Caret } = this.Editor; - /** - * Getting a class of replacing Tool - */ - const replacingTool = this.Editor.Tools.blockTools.get(replacingToolName); + BlockManager.convert(this.Editor.BlockManager.currentBlock, replacingToolName, blockDataOverrides); - /** - * Export property can be: - * 1) Function — Tool defines which data to return - * 2) String — the name of saved property - * - * In both cases returning value must be a string - */ - let exportData = ''; - const exportProp = currentBlockTool.conversionConfig.export; - - if (_.isFunction(exportProp)) { - exportData = exportProp(blockData); - } else if (_.isString(exportProp)) { - exportData = blockData[exportProp]; - } else { - _.log('Conversion «export» property must be a string or function. ' + - 'String means key of saved data object to export. Function should export processed string to export.'); - - return; - } - - /** - * Clean exported data with replacing sanitizer config - */ - const cleaned: string = clean( - exportData, - replacingTool.sanitizeConfig - ); - - /** - * «import» property can be Function or String - * function — accept imported string and compose tool data object - * string — the name of data field to import - */ - let newBlockData = {}; - const importProp = replacingTool.conversionConfig.import; - - if (_.isFunction(importProp)) { - newBlockData = importProp(cleaned); - } else if (_.isString(importProp)) { - newBlockData[importProp] = cleaned; - } else { - _.log('Conversion «import» property must be a string or function. ' + - 'String means key of tool data to import. Function accepts a imported string and return composed tool data.'); - - return; - } - - /** - * If this conversion fired by the one of multiple Toolbox items, - * extend converted data with this item's "data" overrides - */ - if (blockDataOverrides) { - newBlockData = Object.assign(newBlockData, blockDataOverrides); - } - - this.Editor.BlockManager.replace({ - tool: replacingToolName, - data: newBlockData, - }); - this.Editor.BlockSelection.clearSelection(); + BlockSelection.clearSelection(); this.close(); - this.Editor.InlineToolbar.close(); + InlineToolbar.close(); - _.delay(() => { - this.Editor.Caret.setToBlock(this.Editor.BlockManager.currentBlock); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - }, 10)(); + window.requestAnimationFrame(() => { + Caret.setToBlock(this.Editor.BlockManager.currentBlock, Caret.positions.END); + }); } /** @@ -283,7 +213,7 @@ export default class ConversionToolbar extends Module { if (!conversionConfig || !conversionConfig.import) { return; } - tool.toolbox.forEach((toolboxItem) => + tool.toolbox?.forEach((toolboxItem) => this.addToolIfValid(name, toolboxItem) ); }); @@ -322,6 +252,16 @@ export default class ConversionToolbar extends Module { $.append(tool, icon); $.append(tool, $.text(I18n.t(I18nInternalNS.toolNames, toolboxItem.title || _.capitalize(toolName)))); + const shortcut = this.Editor.Tools.blockTools.get(toolName)?.shortcut; + + if (shortcut) { + const shortcutEl = $.make('span', ConversionToolbar.CSS.conversionToolSecondaryLabel, { + innerText: _.beautifyShortcut(shortcut), + }); + + $.append(tool, shortcutEl); + } + $.append(this.nodes.tools, tool); this.tools.push({ name: toolName, diff --git a/src/components/tools/block.ts b/src/components/tools/block.ts index 9e1edc17..c5a0f337 100644 --- a/src/components/tools/block.ts +++ b/src/components/tools/block.ts @@ -136,7 +136,7 @@ export default class BlockTool extends BaseTool { /** * Returns Tool conversion configuration */ - public get conversionConfig(): ConversionConfig { + public get conversionConfig(): ConversionConfig | undefined { return this.constructable[InternalBlockToolSettings.ConversionConfig]; } diff --git a/src/components/ui/toolbox.ts b/src/components/ui/toolbox.ts index 0d91c8ec..8ba53efb 100644 --- a/src/components/ui/toolbox.ts +++ b/src/components/ui/toolbox.ts @@ -307,6 +307,26 @@ export default class Toolbox extends EventsDispatcher { on: this.api.ui.nodes.redactor, handler: (event: KeyboardEvent) => { event.preventDefault(); + + const currentBlockIndex = this.api.blocks.getCurrentBlockIndex(); + const currentBlock = this.api.blocks.getBlockByIndex(currentBlockIndex); + + /** + * Try to convert current Block to shortcut's tool + * If conversion is not possible, insert a new Block below + */ + if (currentBlock) { + try { + this.api.blocks.convert(currentBlock.id, toolName); + + window.requestAnimationFrame(() => { + this.api.caret.setToBlock(currentBlockIndex, 'end'); + }); + + return; + } catch (error) {} + } + this.insertNewBlock(toolName); }, }); diff --git a/src/components/utils/blocks.ts b/src/components/utils/blocks.ts index e1e6209f..92a802ee 100644 --- a/src/components/utils/blocks.ts +++ b/src/components/utils/blocks.ts @@ -1,4 +1,7 @@ +import type { ConversionConfig } from '../../../types/configs/conversion-config'; +import type { BlockToolData } from '../../../types/tools/block-tool-data'; import type Block from '../block'; +import { isFunction, isString, log } from '../utils'; /** * Check if two blocks could be merged. @@ -13,3 +16,57 @@ import type Block from '../block'; export function areBlocksMergeable(targetBlock: Block, blockToMerge: Block): boolean { return targetBlock.mergeable && targetBlock.name === blockToMerge.name; } + +/** + * Using conversionConfig, convert block data to string. + * + * @param blockData - block data to convert + * @param conversionConfig - tool's conversion config + */ +export function convertBlockDataToString(blockData: BlockToolData, conversionConfig?: ConversionConfig ): string { + const exportProp = conversionConfig?.export; + + if (isFunction(exportProp)) { + return exportProp(blockData); + } else if (isString(exportProp)) { + return blockData[exportProp]; + } else { + /** + * Tool developer provides 'export' property, but it is not correct. Warn him. + */ + if (exportProp !== undefined) { + log('Conversion «export» property must be a string or function. ' + + 'String means key of saved data object to export. Function should export processed string to export.'); + } + + return ''; + } +} + +/** + * Using conversionConfig, convert string to block data. + * + * @param stringToImport - string to convert + * @param conversionConfig - tool's conversion config + */ +export function convertStringToBlockData(stringToImport: string, conversionConfig?: ConversionConfig): BlockToolData { + const importProp = conversionConfig?.import; + + if (isFunction(importProp)) { + return importProp(stringToImport); + } else if (isString(importProp)) { + return { + [importProp]: stringToImport, + }; + } else { + /** + * Tool developer provides 'import' property, but it is not correct. Warn him. + */ + if (importProp !== undefined) { + log('Conversion «import» property must be a string or function. ' + + 'String means key of tool data to import. Function accepts a imported string and return composed tool data.'); + } + + return {}; + } +} diff --git a/src/styles/conversion-toolbar.css b/src/styles/conversion-toolbar.css index 817ea544..49e400de 100644 --- a/src/styles/conversion-toolbar.css +++ b/src/styles/conversion-toolbar.css @@ -7,7 +7,7 @@ transition: transform 100ms ease, opacity 100ms ease; transform: translateY(-8px); left: -1px; - width: 150px; + width: 190px; margin-top: 5px; box-sizing: content-box; @@ -78,4 +78,19 @@ animation: bounceIn 0.75s 1; animation-fill-mode: forwards; } + + &__secondary-label { + color: var(--grayText); + font-size: 12px; + margin-left: auto; + white-space: nowrap; + letter-spacing: -0.1em; + padding-right: 5px; + margin-bottom: -2px; + opacity: 0.6; + + @media (--mobile){ + display: none; + } + } } diff --git a/test/cypress/fixtures/tools/ToolMock.ts b/test/cypress/fixtures/tools/ToolMock.ts new file mode 100644 index 00000000..0ef835f8 --- /dev/null +++ b/test/cypress/fixtures/tools/ToolMock.ts @@ -0,0 +1,54 @@ +import { BlockTool, BlockToolConstructorOptions } from '../../../../types'; + +/** + * Simple structure for Tool data + */ +interface MockToolData { + text: string; +} + +/** + * Common class for Tool mocking. + * Extend this class to create a mock for your Tool with specific properties. + */ +export default class ToolMock implements BlockTool { + /** + * Tool data + */ + private data: MockToolData; + + /** + * Creates new Tool instance + * + * @param options - tool constructor options + */ + constructor(options: BlockToolConstructorOptions) { + this.data = options.data; + } + + /** + * Renders a single content editable element as tools element + */ + public render(): HTMLElement { + const contenteditable = document.createElement('div'); + + if (this.data && this.data.text) { + contenteditable.innerHTML = this.data.text; + } + + contenteditable.contentEditable = 'true'; + + return contenteditable; + } + + /** + * Save method mock, returns block innerHTML + * + * @param block - element rendered by the render method + */ + public save(block: HTMLElement): MockToolData { + return { + text: block.innerHTML, + }; + } +} diff --git a/test/cypress/tests/api/blocks.cy.ts b/test/cypress/tests/api/blocks.cy.ts index ab3f2ac8..62c49fcc 100644 --- a/test/cypress/tests/api/blocks.cy.ts +++ b/test/cypress/tests/api/blocks.cy.ts @@ -1,4 +1,7 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ +import type EditorJS from '../../../../types/index'; +import { ConversionConfig, ToolboxConfig } from '../../../../types'; +import ToolMock from '../../fixtures/tools/ToolMock'; + /** * There will be described test cases of 'blocks.*' API */ @@ -16,18 +19,6 @@ describe('api.blocks', () => { ], }; - beforeEach(function () { - cy.createEditor({ - data: editorDataMock, - }).as('editorInstance'); - }); - - afterEach(function () { - if (this.editorInstance) { - this.editorInstance.destroy(); - } - }); - /** * api.blocks.getById(id) */ @@ -36,7 +27,11 @@ describe('api.blocks', () => { * Check that api.blocks.getByUd(id) returns the Block for existed id */ it('should return Block API for existed id', () => { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { const block = editor.blocks.getById(firstBlock.id); expect(block).not.to.be.undefined; @@ -48,7 +43,11 @@ describe('api.blocks', () => { * Check that api.blocks.getByUd(id) returns null for the not-existed id */ it('should return null for not-existed id', () => { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { expect(editor.blocks.getById('not-existed-id')).to.be.null; }); }); @@ -62,7 +61,11 @@ describe('api.blocks', () => { * Check if block is updated in DOM */ it('should update block in DOM', () => { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { const idToUpdate = firstBlock.id; const newBlockData = { text: 'Updated text', @@ -83,7 +86,11 @@ describe('api.blocks', () => { * Check if block's data is updated after saving */ it('should update block in saved data', () => { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { const idToUpdate = firstBlock.id; const newBlockData = { text: 'Updated text', @@ -91,7 +98,7 @@ describe('api.blocks', () => { editor.blocks.update(idToUpdate, newBlockData); - const output = await (editor as any).save(); + const output = await editor.save(); const text = output.blocks[0].data.text; expect(text).to.be.eq(newBlockData.text); @@ -102,7 +109,11 @@ describe('api.blocks', () => { * When incorrect id passed, editor should not update any block */ it('shouldn\'t update any block if not-existed id passed', () => { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { const idToUpdate = 'wrong-id-123'; const newBlockData = { text: 'Updated text', @@ -125,7 +136,11 @@ describe('api.blocks', () => { */ describe('.insert()', function () { it('should preserve block id if it is passed', function () { - cy.get('@editorInstance').then(async (editor: any) => { + cy.createEditor({ + data: editorDataMock, + }).as('editorInstance'); + + cy.get('@editorInstance').then(async (editor) => { const type = 'paragraph'; const data = { text: 'codex' }; const config = undefined; @@ -141,4 +156,167 @@ describe('api.blocks', () => { }); }); }); + + describe('.convert()', function () { + it('should convert a Block to another type if original Tool has "conversionConfig.export" and target Tool has "conversionConfig.import"', function () { + /** + * 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', + }; + } + } + + const existingBlock = { + id: 'test-id-123', + type: 'paragraph', + data: { + text: 'Some text', + }, + }; + + cy.createEditor({ + tools: { + convertableTool: { + class: ConvertableTool, + }, + }, + data: { + blocks: [ + existingBlock, + ], + }, + }).as('editorInstance'); + + /** + * Call the 'convert' api method + */ + cy.get('@editorInstance') + .then(async (editor) => { + const { convert } = editor.blocks; + + convert(existingBlock.id, 'convertableTool'); + }); + + // eslint-disable-next-line cypress/no-unnecessary-waiting, @typescript-eslint/no-magic-numbers -- wait for block to be converted + cy.wait(100); + + /** + * 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(existingBlock.data.text); + }); + }); + + it('should throw an error if nonexisting Block id passed', function () { + cy.createEditor({}).as('editorInstance'); + + /** + * Call the 'convert' api method with nonexisting Block id + */ + cy.get('@editorInstance') + .then(async (editor) => { + const fakeId = 'WRNG_ID'; + const { convert } = editor.blocks; + + const exec = (): void => convert(fakeId, 'convertableTool'); + + expect(exec).to.throw(`Block with id "${fakeId}" not found`); + }); + }); + + it('should throw an error if nonexisting Tool name passed', function () { + const existingBlock = { + id: 'test-id-123', + type: 'paragraph', + data: { + text: 'Some text', + }, + }; + + cy.createEditor({ + data: { + blocks: [ + existingBlock, + ], + }, + }).as('editorInstance'); + + /** + * Call the 'convert' api method with nonexisting tool name + */ + cy.get('@editorInstance') + .then(async (editor) => { + const nonexistingToolName = 'WRNG_TOOL_NAME'; + const { convert } = editor.blocks; + + const exec = (): void => convert(existingBlock.id, nonexistingToolName); + + expect(exec).to.throw(`Block Tool with type "${nonexistingToolName}" not found`); + }); + }); + + it('should throw an error if some tool does not provide "conversionConfig"', function () { + const existingBlock = { + id: 'test-id-123', + type: 'paragraph', + data: { + text: 'Some text', + }, + }; + + /** + * Mock of Tool without conversionConfig + */ + class ToolWithoutConversionConfig extends ToolMock {} + + cy.createEditor({ + tools: { + nonConvertableTool: { + class: ToolWithoutConversionConfig, + shortcut: 'CMD+SHIFT+H', + }, + }, + data: { + blocks: [ + existingBlock, + ], + }, + }).as('editorInstance'); + + /** + * Call the 'convert' api method with tool that does not provide "conversionConfig" + */ + cy.get('@editorInstance') + .then(async (editor) => { + const { convert } = editor.blocks; + + const exec = (): void => convert(existingBlock.id, 'nonConvertableTool'); + + expect(exec).to.throw(`Conversion from "paragraph" to "nonConvertableTool" is not possible. NonConvertableTool tool(s) should provide a "conversionConfig"`); + }); + }); + }); }); diff --git a/test/cypress/tests/ui/toolbox.cy.ts b/test/cypress/tests/ui/toolbox.cy.ts new file mode 100644 index 00000000..95ff5423 --- /dev/null +++ b/test/cypress/tests/ui/toolbox.cy.ts @@ -0,0 +1,103 @@ +import type EditorJS from '../../../../types/index'; +import { ConversionConfig, ToolboxConfig } from '../../../../types/index'; +import ToolMock from '../../fixtures/tools/ToolMock'; + +describe('Toolbox', function () { + describe('Shortcuts', function () { + it('should covert current Block to the Shortcuts\'s Block if both tools provides a "conversionConfig" ', function () { + /** + * 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('[data-cy=editorjs]') + .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'); + }); + }); + + it('should insert a Shortcuts\'s Block below the current if some (original or target) tool does not provide a "conversionConfig" ', function () { + /** + * 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('[data-cy=editorjs]') + .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'); + }); + }); + }); +}); diff --git a/types/api/blocks.d.ts b/types/api/blocks.d.ts index 0a90b031..b1aabcd5 100644 --- a/types/api/blocks.d.ts +++ b/types/api/blocks.d.ts @@ -130,4 +130,15 @@ export interface Blocks { * @param data - the new data */ update(id: string, data: BlockToolData): void; + + /** + * Converts block to another type. Both blocks should provide the conversionConfig. + * + * @param id - id of the existed block to convert. Should provide 'conversionConfig.export' method + * @param newType - new block type. Should provide 'conversionConfig.import' method + * @param dataOverrides - optional data overrides for the new block + * + * @throws Error if conversion is not possible + */ + convert(id: string, newType: string, dataOverrides?: BlockToolData): void; } diff --git a/types/configs/conversion-config.ts b/types/configs/conversion-config.ts index 0d614f06..b61aa478 100644 --- a/types/configs/conversion-config.ts +++ b/types/configs/conversion-config.ts @@ -12,7 +12,7 @@ export interface ConversionConfig { * 1. String — the key of Tool data object to fill it with imported string on render. * 2. Function — method that accepts importing string and composes Tool data to render. */ - import: ((data: string) => string) | string; + import?: ((data: string) => string) | string; /** * How to export this Tool to make other Block. @@ -22,5 +22,5 @@ export interface ConversionConfig { * 1. String — which property of saved Tool data should be used as exported string. * 2. Function — accepts saved Tool data and create a string to export */ - export: ((data: BlockToolData) => string) | string; + export?: ((data: BlockToolData) => string) | string; }