diff --git a/package.json b/package.json index 2235fc48..972ed829 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "serve": "vite --no-open", "build": "vite build --mode production", "build:test": "vite build --mode test", - "lint": "eslint . && tsc --noEmit", + "lint": "sh -c 'eslint .; ESLINT_EXIT=$?; tsc --noEmit; TSC_EXIT=$?; if [ $ESLINT_EXIT -ne 0 ] || [ $TSC_EXIT -ne 0 ]; then exit 1; fi'", "lint:fix": "eslint . --fix", "lint:tests": "eslint test/", "lint:types": "tsc --noEmit", diff --git a/src/components/inline-tools/inline-tool-italic.ts b/src/components/inline-tools/inline-tool-italic.ts index 526a5677..7914ce9f 100644 --- a/src/components/inline-tools/inline-tool-italic.ts +++ b/src/components/inline-tools/inline-tool-italic.ts @@ -50,7 +50,7 @@ export default class ItalicInlineTool implements InlineTool { /** * Elements */ - private nodes: {button: HTMLButtonElement} = { + private nodes: {button: HTMLButtonElement | null} = { button: null, }; @@ -58,12 +58,15 @@ export default class ItalicInlineTool implements InlineTool { * Create button for Inline Toolbar */ public render(): HTMLElement { - this.nodes.button = document.createElement('button') as HTMLButtonElement; - this.nodes.button.type = 'button'; - this.nodes.button.classList.add(this.CSS.button, this.CSS.buttonModifier); - this.nodes.button.innerHTML = IconItalic; + const button = document.createElement('button'); - return this.nodes.button; + button.type = 'button'; + button.classList.add(this.CSS.button, this.CSS.buttonModifier); + button.innerHTML = IconItalic; + + this.nodes.button = button; + + return button; } /** @@ -79,7 +82,9 @@ export default class ItalicInlineTool implements InlineTool { public checkState(): boolean { const isActive = document.queryCommandState(this.commandName); - this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive); + if (this.nodes.button) { + this.nodes.button.classList.toggle(this.CSS.buttonActive, isActive); + } return isActive; } diff --git a/src/components/modules/api/i18n.ts b/src/components/modules/api/i18n.ts index 25e35375..c9004184 100644 --- a/src/components/modules/api/i18n.ts +++ b/src/components/modules/api/i18n.ts @@ -13,7 +13,7 @@ export default class I18nAPI extends Module { * @param toolName - tool name * @param isTune - is tool a block tune */ - private static getNamespace(toolName, isTune): string { + private static getNamespace(toolName: string, isTune: boolean): string { if (isTune) { return `blockTunes.${toolName}`; } @@ -26,10 +26,10 @@ export default class I18nAPI extends Module { */ public get methods(): I18n { return { - t: (): string | undefined => { + t: (_dictKey?: string): string => { logLabeled('I18n.t() method can be accessed only from Tools', 'warn'); - return undefined; + return ''; }, }; } diff --git a/src/components/modules/api/saver.ts b/src/components/modules/api/saver.ts index ad0eac29..dc4fb271 100644 --- a/src/components/modules/api/saver.ts +++ b/src/components/modules/api/saver.ts @@ -24,15 +24,31 @@ export default class SaverAPI extends Module { * * @returns {OutputData} */ - public save(): Promise { + public async save(): Promise { const errorText = 'Editor\'s content can not be saved in read-only mode'; if (this.Editor.ReadOnly.isEnabled) { _.logLabeled(errorText, 'warn'); - return Promise.reject(new Error(errorText)); + throw new Error(errorText); } - return this.Editor.Saver.save(); + const savedData = await this.Editor.Saver.save(); + + if (savedData === undefined) { + const lastError = this.Editor.Saver.getLastSaveError?.(); + + if (lastError instanceof Error) { + throw lastError; + } + + const errorMessage = lastError !== undefined + ? String(lastError) + : 'Editor\'s content can not be saved because collecting data failed'; + + throw new Error(errorMessage); + } + + return savedData; } } diff --git a/src/components/modules/dragNDrop.ts b/src/components/modules/dragNDrop.ts index 410aa745..37daebd6 100644 --- a/src/components/modules/dragNDrop.ts +++ b/src/components/modules/dragNDrop.ts @@ -98,9 +98,12 @@ export default class DragNDrop extends Module { this.Editor.Caret.setToBlock(targetBlock, Caret.positions.END); } else { const lastBlock = BlockManager.lastBlock; - const blockToSet = BlockManager.setCurrentBlockByChildNode(lastBlock.holder) ?? lastBlock; - this.Editor.Caret.setToBlock(blockToSet, Caret.positions.END); + if (lastBlock) { + const blockToSet = BlockManager.setCurrentBlockByChildNode(lastBlock.holder) ?? lastBlock; + + this.Editor.Caret.setToBlock(blockToSet, Caret.positions.END); + } } const { dataTransfer } = dropEvent; diff --git a/src/components/modules/readonly.ts b/src/components/modules/readonly.ts index 536e9df5..e6406359 100644 --- a/src/components/modules/readonly.ts +++ b/src/components/modules/readonly.ts @@ -113,6 +113,12 @@ export default class ReadOnly extends Module { */ const savedBlocks = await this.Editor.Saver.save(); + if (savedBlocks === undefined) { + this.Editor.ModificationsObserver.enable(); + + return this.readOnlyEnabled; + } + await this.Editor.BlockManager.clear(); await this.Editor.Renderer.render(savedBlocks.blocks); diff --git a/src/components/modules/rectangleSelection.ts b/src/components/modules/rectangleSelection.ts index 137220e9..8877beb4 100644 --- a/src/components/modules/rectangleSelection.ts +++ b/src/components/modules/rectangleSelection.ts @@ -485,8 +485,8 @@ export default class RectangleSelection extends Module { const centerOfRedactor = widthOfRedactor / 2; const y = this.mouseY - window.pageYOffset; const elementUnderMouse = document.elementFromPoint(centerOfRedactor, y); - const lastBlockHolder = this.Editor.BlockManager.lastBlock.holder; - const contentElement = lastBlockHolder.querySelector('.' + Block.CSS.content); + const lastBlockHolder = this.Editor.BlockManager.lastBlock?.holder; + const contentElement = lastBlockHolder?.querySelector('.' + Block.CSS.content); const contentWidth = contentElement ? Number.parseInt(window.getComputedStyle(contentElement).width, 10) : 0; const centerOfBlock = contentWidth / 2; const leftPos = centerOfRedactor - centerOfBlock; diff --git a/src/components/modules/saver.ts b/src/components/modules/saver.ts index bea0d378..f0d5ec6a 100644 --- a/src/components/modules/saver.ts +++ b/src/components/modules/saver.ts @@ -6,12 +6,19 @@ * @version 2.0.0 */ import Module from '../__module'; -import type { OutputData, SanitizerConfig } from '../../../types'; +import type { BlockToolData, OutputData, SanitizerConfig } from '../../../types'; import type { SavedData, ValidatedData } from '../../../types/data-formats'; +import type { BlockTuneData } from '../../../types/block-tunes/block-tune-data'; import type Block from '../block'; import * as _ from '../utils'; import { sanitizeBlocks } from '../utils/sanitizer'; +type SaverValidatedData = ValidatedData & { + tunes?: Record; +}; + +type SanitizableBlockData = SaverValidatedData & Pick; + /** * @classdesc This method reduces all Blocks asyncronically and calls Block's save method to extract data * @typedef {Saver} Saver @@ -19,31 +26,42 @@ import { sanitizeBlocks } from '../utils/sanitizer'; * @property {string} json - Editor JSON output */ export default class Saver extends Module { + /** + * Stores the last error raised during save attempt + */ + private lastSaveError?: unknown; + /** * Composes new chain of Promises to fire them alternatelly * - * @returns {OutputData} + * @returns {OutputData | undefined} */ - public async save(): Promise { + public async save(): Promise { const { BlockManager, Tools } = this.Editor; const blocks = BlockManager.blocks; - const chainData = []; + const chainData: Array> = blocks.map((block: Block) => { + return this.getSavedData(block); + }); + + this.lastSaveError = undefined; try { - blocks.forEach((block: Block) => { - chainData.push(this.getSavedData(block)); - }); - - const extractedData = await Promise.all(chainData) as Array>; - const sanitizedData = await sanitizeBlocks( + const extractedData = await Promise.all(chainData); + const sanitizedData = this.sanitizeExtractedData( extractedData, - (name) => Tools.blockTools.get(name).sanitizeConfig, + (name) => Tools.blockTools.get(name)?.sanitizeConfig, this.config.sanitizer as SanitizerConfig ); return this.makeOutput(sanitizedData); - } catch (e) { - _.logLabeled(`Saving failed due to the Error %o`, 'error', e); + } catch (error: unknown) { + this.lastSaveError = error; + + const normalizedError = error instanceof Error ? error : new Error(String(error)); + + _.logLabeled(`Saving failed due to the Error %o`, 'error', normalizedError); + + return undefined; } } @@ -53,9 +71,18 @@ export default class Saver extends Module { * @param {Block} block - Editor's Tool * @returns {ValidatedData} - Tool's validated data */ - private async getSavedData(block: Block): Promise { + private async getSavedData(block: Block): Promise { const blockData = await block.save(); - const isValid = blockData && await block.validate(blockData.data); + const toolName = block.name; + + if (blockData === undefined) { + return { + tool: toolName, + isValid: false, + }; + } + + const isValid = await block.validate(blockData.data); return { ...blockData, @@ -69,8 +96,8 @@ export default class Saver extends Module { * @param {ValidatedData} allExtractedData - data extracted from Blocks * @returns {OutputData} */ - private makeOutput(allExtractedData): OutputData { - const blocks = []; + private makeOutput(allExtractedData: SaverValidatedData[]): OutputData { + const blocks: OutputData['blocks'] = []; allExtractedData.forEach(({ id, tool, data, tunes, isValid }) => { if (!isValid) { @@ -79,18 +106,29 @@ export default class Saver extends Module { return; } - /** If it was stub Block, get original data */ - if (tool === this.Editor.Tools.stubTool) { - blocks.push(data); + if (tool === undefined || data === undefined) { + _.log('Block skipped because saved data is missing required fields'); return; } - const output = { + /** If it was stub Block, get original data */ + if (tool === this.Editor.Tools.stubTool) { + if (this.isStubSavedData(data)) { + blocks.push(data); + } else { + _.log('Stub block data is malformed and was skipped'); + } + + return; + } + + const isTunesEmpty = tunes === undefined || _.isEmpty(tunes); + const output: OutputData['blocks'][number] = { id, type: tool, data, - ...!_.isEmpty(tunes) && { + ...!isTunesEmpty && { tunes, }, }; @@ -104,4 +142,82 @@ export default class Saver extends Module { version: _.getEditorVersion(), }; } + + /** + * Sanitizes extracted block data in-place + * + * @param extractedData - collection of saved block data + * @param getToolSanitizeConfig - resolver for tool-specific sanitize config + * @param globalSanitizer - global sanitizer config specified in editor settings + */ + private sanitizeExtractedData( + extractedData: SaverValidatedData[], + getToolSanitizeConfig: (toolName: string) => SanitizerConfig | undefined, + globalSanitizer: SanitizerConfig + ): SaverValidatedData[] { + const blocksToSanitize: Array<{ index: number; data: SanitizableBlockData }> = []; + + extractedData.forEach((blockData, index) => { + if (this.hasSanitizableData(blockData)) { + blocksToSanitize.push({ + index, + data: blockData, + }); + } + }); + + if (blocksToSanitize.length === 0) { + return extractedData; + } + + const sanitizedBlocks = sanitizeBlocks( + blocksToSanitize.map(({ data }) => data), + getToolSanitizeConfig, + globalSanitizer + ); + + const updatedData = extractedData.map((blockData) => ({ ...blockData })); + + blocksToSanitize.forEach(({ index }, sanitizedIndex) => { + const sanitized = sanitizedBlocks[sanitizedIndex]; + + updatedData[index] = { + ...updatedData[index], + data: sanitized.data, + }; + }); + + return updatedData; + } + + /** + * Checks whether block data contains fields required for sanitizing procedure + * + * @param blockData - data to check + */ + private hasSanitizableData(blockData: SaverValidatedData): blockData is SanitizableBlockData { + return blockData.data !== undefined && typeof blockData.tool === 'string'; + } + + /** + * Check that stub data matches OutputBlockData format + * + * @param data - saved stub data that should represent original block payload + */ + private isStubSavedData(data: BlockToolData): data is OutputData['blocks'][number] { + if (!_.isObject(data)) { + return false; + } + + const candidate = data as Record; + + return typeof candidate.type === 'string' && candidate.data !== undefined; + } + + /** + * Returns the last error raised during save attempt + */ + public getLastSaveError(): unknown { + return this.lastSaveError; + } } diff --git a/src/components/tools/base.ts b/src/components/tools/base.ts index fd58be7e..23f968a0 100644 --- a/src/components/tools/base.ts +++ b/src/components/tools/base.ts @@ -1,5 +1,5 @@ import type { Tool, ToolConstructable, ToolSettings } from '@/types/tools'; -import type { SanitizerConfig, API as ApiMethods } from '@/types'; +import type { SanitizerConfig, API as ApiMethods, ToolConfig } from '@/types'; import * as _ from '../utils'; import { ToolType } from '@/types/tools/adapters/tool-type'; import type { BaseToolAdapter as BaseToolAdapterInterface } from '@/types/tools/adapters/base-tool-adapter'; @@ -106,6 +106,11 @@ export enum InternalTuneSettings { export type ToolOptions = Omit; +type ToolPreparePayload = { + toolName: string; + config: ToolConfig; +}; + interface ConstructorOptions { name: string; constructable: ToolConstructable; @@ -123,7 +128,7 @@ export default abstract class BaseToolAdapter { - if (_.isFunction(this.constructable.prepare)) { - return this.constructable.prepare({ - toolName: this.name, - config: this.settings, - }); + const prepare = this.constructable.prepare; + + if (!_.isFunction(prepare)) { + return; } + + const payload: ToolPreparePayload = { + toolName: this.name, + config: this.settings, + }; + + return (prepare as (data: ToolPreparePayload) => void | Promise).call(this.constructable, payload); } /** diff --git a/test/unit/components/inline-tools/inline-tool-italic.test.ts b/test/unit/components/inline-tools/inline-tool-italic.test.ts new file mode 100644 index 00000000..6fbcc86c --- /dev/null +++ b/test/unit/components/inline-tools/inline-tool-italic.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { IconItalic } from '@codexteam/icons'; + +import ItalicInlineTool from '../../../../src/components/inline-tools/inline-tool-italic'; + +type DocumentCommandKey = 'execCommand' | 'queryCommandState'; + +const setDocumentCommand = ( + key: K, + implementation: Document[K] +): void => { + Object.defineProperty(document, key, { + configurable: true, + value: implementation, + writable: true, + }); +}; + +describe('ItalicInlineTool', () => { + let tool: ItalicInlineTool; + let execCommandMock: ReturnType>; + let queryCommandStateMock: ReturnType>; + + beforeEach(() => { + execCommandMock = vi.fn(() => true); + queryCommandStateMock = vi.fn(() => false); + + setDocumentCommand('execCommand', execCommandMock as Document['execCommand']); + setDocumentCommand('queryCommandState', queryCommandStateMock as Document['queryCommandState']); + + tool = new ItalicInlineTool(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exposes inline metadata and sanitizer config', () => { + expect(ItalicInlineTool.isInline).toBe(true); + expect(ItalicInlineTool.title).toBe('Italic'); + expect(ItalicInlineTool.sanitize).toStrictEqual({ i: {} }); + }); + + it('renders an inline toolbar button with italic icon', () => { + const element = tool.render(); + const button = element as HTMLButtonElement; + const expectedIcon = document.createElement('div'); + + expectedIcon.innerHTML = IconItalic; + + expect(button).toBeInstanceOf(HTMLButtonElement); + expect(button.type).toBe('button'); + expect(button.classList.contains('ce-inline-tool')).toBe(true); + expect(button.classList.contains('ce-inline-tool--italic')).toBe(true); + expect(button.innerHTML).toBe(expectedIcon.innerHTML); + }); + + it('executes italic command when surround is called', () => { + tool.surround(); + + expect(execCommandMock).toHaveBeenCalledWith('italic'); + }); + + it('synchronizes button active state with document command state', () => { + const button = tool.render(); + + queryCommandStateMock.mockReturnValue(true); + + expect(tool.checkState()).toBe(true); + expect(button.classList.contains('ce-inline-tool--active')).toBe(true); + + queryCommandStateMock.mockReturnValue(false); + + expect(tool.checkState()).toBe(false); + expect(button.classList.contains('ce-inline-tool--active')).toBe(false); + }); + + it('exposes CMD+I shortcut', () => { + expect(tool.shortcut).toBe('CMD+I'); + }); +}); + diff --git a/test/unit/components/modules/api/i18n.test.ts b/test/unit/components/modules/api/i18n.test.ts new file mode 100644 index 00000000..6ab4bfda --- /dev/null +++ b/test/unit/components/modules/api/i18n.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import I18nAPI from '../../../../../src/components/modules/api/i18n'; +import EventsDispatcher from '../../../../../src/components/utils/events'; + +import type { ModuleConfig } from '../../../../../src/types-internal/module-config'; +import type { EditorConfig } from '../../../../../types'; +import type { EditorEventMap } from '../../../../../src/components/events'; + +type EsModuleKey = '__esModule'; +type EsModule = T & { [K in EsModuleKey]: true }; +type WithEsModuleFlag = (moduleMock: T) => EsModule; + +/** + * + * @param moduleMock - The module object to add the ES module flag to + */ +const withEsModuleFlag: WithEsModuleFlag = vi.hoisted(() => { + return ((moduleMock: T): EsModule => { + return Object.defineProperty(moduleMock, '__esModule', { + configurable: true, + enumerable: true, + value: true, + }) as EsModule; + }); +}); + +const { logLabeledMock, translateMock } = vi.hoisted(() => { + return { + logLabeledMock: vi.fn(), + translateMock: vi.fn(), + }; +}); + +vi.mock('../../../../../src/components/utils', () => + withEsModuleFlag({ + logLabeled: logLabeledMock, + }) +); + +vi.mock('../../../../../src/components/i18n', () => + withEsModuleFlag({ + default: { + t: translateMock, + }, + }) +); + +const createI18nApi = (): I18nAPI => { + const eventsDispatcher = new EventsDispatcher(); + const moduleConfig: ModuleConfig = { + config: {} as EditorConfig, + eventsDispatcher, + }; + + return new I18nAPI(moduleConfig); +}; + +describe('I18nAPI', () => { + beforeEach(() => { + logLabeledMock.mockReset(); + translateMock.mockReset(); + }); + + it('warns and returns an empty string when calling global t()', () => { + const api = createI18nApi(); + + const result = api.methods.t('global'); + + expect(result).toBe(''); + expect(logLabeledMock).toHaveBeenCalledWith( + 'I18n.t() method can be accessed only from Tools', + 'warn' + ); + }); + + it('translates using tools namespace for regular tool', () => { + const api = createI18nApi(); + const methods = api.getMethodsForTool('paragraph', false); + + methods.t('label'); + + expect(translateMock).toHaveBeenCalledWith('tools.paragraph', 'label'); + }); + + it('translates using blockTunes namespace for block tune', () => { + const api = createI18nApi(); + const methods = api.getMethodsForTool('settings', true); + + methods.t('title'); + + expect(translateMock).toHaveBeenCalledWith('blockTunes.settings', 'title'); + }); +}); + diff --git a/test/unit/components/modules/api/saver.test.ts b/test/unit/components/modules/api/saver.test.ts new file mode 100644 index 00000000..ec88da2d --- /dev/null +++ b/test/unit/components/modules/api/saver.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import SaverAPI from '../../../../../src/components/modules/api/saver'; +import EventsDispatcher from '../../../../../src/components/utils/events'; +import * as utils from '../../../../../src/components/utils'; + +import type { ModuleConfig } from '../../../../../src/types-internal/module-config'; +import type { EditorConfig, OutputData } from '../../../../../types'; +import type { EditorEventMap } from '../../../../../src/components/events'; +import type { EditorModules } from '../../../../../src/types-internal/editor-modules'; + +const READ_ONLY_ERROR_TEXT = 'Editor\'s content can not be saved in read-only mode'; +const SAVE_FALLBACK_ERROR_TEXT = 'Editor\'s content can not be saved because collecting data failed'; + +type SaverSaveMock = ReturnType>>; +type SaverLastErrorMock = ReturnType>; + +type EditorStub = { + ReadOnly: { isEnabled: boolean }; + Saver: { + save: SaverSaveMock; + getLastSaveError?: SaverLastErrorMock; + }; +}; + +type EditorStubOverrides = { + ReadOnly?: Partial; + Saver?: Partial; +}; + +const createSaverApi = (overrides: EditorStubOverrides = {}): { saverApi: SaverAPI; editor: EditorStub } => { + const moduleConfig: ModuleConfig = { + config: {} as EditorConfig, + eventsDispatcher: new EventsDispatcher(), + }; + + const saverApi = new SaverAPI(moduleConfig); + + const editor: EditorStub = { + ReadOnly: { + isEnabled: false, + }, + Saver: { + save: vi.fn<[], Promise>().mockResolvedValue({ blocks: [] }), + getLastSaveError: vi.fn(), + }, + }; + + if (overrides.ReadOnly) { + Object.assign(editor.ReadOnly, overrides.ReadOnly); + } + + if (overrides.Saver) { + Object.assign(editor.Saver, overrides.Saver); + } + + saverApi.state = editor as unknown as EditorModules; + + return { saverApi, + editor }; +}; + +describe('SaverAPI', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('exposes a save method that proxies to the class method', async () => { + const { saverApi } = createSaverApi(); + const saveSpy = vi.spyOn(saverApi, 'save').mockResolvedValue({ blocks: [] }); + + await saverApi.methods.save(); + + expect(saveSpy).toHaveBeenCalledTimes(1); + }); + + it('throws and logs when editor is in read-only mode', async () => { + const logSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => undefined); + const { saverApi, editor } = createSaverApi({ + ReadOnly: { isEnabled: true }, + }); + + await expect(saverApi.save()).rejects.toThrow(READ_ONLY_ERROR_TEXT); + expect(editor.Saver.save).not.toHaveBeenCalled(); + expect(logSpy).toHaveBeenCalledWith(READ_ONLY_ERROR_TEXT, 'warn'); + }); + + it('returns saved data when saver succeeds', async () => { + const output: OutputData = { + blocks: [ + { + id: 'paragraph-1', + type: 'paragraph', + data: { text: 'Hello' }, + }, + ], + }; + const { saverApi, editor } = createSaverApi(); + + editor.Saver.save.mockResolvedValueOnce(output); + + await expect(saverApi.save()).resolves.toEqual(output); + expect(editor.Saver.save).toHaveBeenCalledTimes(1); + }); + + it('rethrows the last saver error when it is an Error instance', async () => { + const lastError = new Error('save crashed'); + const { saverApi, editor } = createSaverApi(); + + editor.Saver.save.mockResolvedValueOnce(undefined); + editor.Saver.getLastSaveError = vi.fn().mockReturnValue(lastError); + + await expect(saverApi.save()).rejects.toBe(lastError); + }); + + it('converts non-error last error values to strings', async () => { + const { saverApi, editor } = createSaverApi(); + + editor.Saver.save.mockResolvedValueOnce(undefined); + editor.Saver.getLastSaveError = vi.fn().mockReturnValue(404); + + await expect(saverApi.save()).rejects.toThrow('404'); + }); + + it('throws a fallback error when saver returns undefined without details', async () => { + const { saverApi, editor } = createSaverApi(); + + editor.Saver.save.mockResolvedValueOnce(undefined); + editor.Saver.getLastSaveError = undefined; + + await expect(saverApi.save()).rejects.toThrow(SAVE_FALLBACK_ERROR_TEXT); + }); +}); + + diff --git a/test/unit/components/modules/saver.test.ts b/test/unit/components/modules/saver.test.ts new file mode 100644 index 00000000..2f7fcba2 --- /dev/null +++ b/test/unit/components/modules/saver.test.ts @@ -0,0 +1,277 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; + +import Saver from '../../../../src/components/modules/saver'; +import type Block from '../../../../src/components/block'; +import type { EditorConfig, SanitizerConfig } from '../../../../types'; +import type { SavedData } from '../../../../types/data-formats'; +import * as sanitizer from '../../../../src/components/utils/sanitizer'; +import * as utils from '../../../../src/components/utils'; + +type BlockSaveResult = SavedData & { tunes?: Record }; + +interface BlockMock { + block: Block; + savedData: BlockSaveResult; + saveMock: Mock<[], Promise>; + validateMock: Mock<[BlockSaveResult['data']], Promise>; +} + +interface BlockMockOptions { + id: string; + tool: string; + data: SavedData['data']; + tunes?: Record; + isValid?: boolean; +} + +interface CreateSaverOptions { + blocks?: Block[]; + sanitizer?: SanitizerConfig; + stubTool?: string; + toolSanitizeConfigs?: Record; +} + +const createBlockMock = (options: BlockMockOptions): BlockMock => { + const savedData: BlockSaveResult = { + id: options.id, + tool: options.tool, + data: options.data, + time: 0, + ...(options.tunes ? { tunes: options.tunes } : {}), + }; + + const saveMock = vi.fn<[], Promise>().mockResolvedValue(savedData); + const validateMock = vi.fn<[BlockSaveResult['data']], Promise>() + .mockResolvedValue(options.isValid ?? true); + + const block = { + save: saveMock, + validate: validateMock, + } as unknown as Block; + + return { + block, + savedData, + saveMock, + validateMock, + }; +}; + +const createSaver = (options: CreateSaverOptions = {}): { saver: Saver } => { + const config: EditorConfig = { + sanitizer: options.sanitizer ?? ({} as SanitizerConfig), + }; + + const eventsDispatcher = { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + } as unknown as Saver['eventsDispatcher']; + + const saver = new Saver({ + config, + eventsDispatcher, + }); + + const stubTool = options.stubTool ?? 'stub'; + const toolConfigs: Record = { + ...(options.toolSanitizeConfigs ?? {}), + }; + + if (!toolConfigs[stubTool]) { + toolConfigs[stubTool] = {} as SanitizerConfig; + } + + const blockTools = new Map( + Object.entries(toolConfigs).map(([name, sanitizeConfig]) => [name, { sanitizeConfig } ]) + ); + + const editorState = { + BlockManager: { + blocks: options.blocks ?? [], + }, + Tools: { + blockTools, + stubTool, + }, + }; + + (saver as unknown as { state: Saver['Editor'] }).state = editorState as unknown as Saver['Editor']; + + return { saver }; +}; + +describe('Saver module', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + it('saves valid blocks, sanitizes data, and preserves stub data', async () => { + vi.useFakeTimers(); + const frozenDate = new Date('2024-05-01T10:00:00Z'); + + vi.setSystemTime(frozenDate); + + const version = 'test-version'; + + vi.spyOn(utils, 'getEditorVersion').mockReturnValue(version); + + const paragraphBlock = createBlockMock({ + id: 'block-1', + tool: 'paragraph', + data: { text: 'Hello' }, + tunes: { + alignment: 'left', + }, + }); + + const preservedData = { + type: 'legacy-tool', + data: { text: 'Keep me intact' }, + }; + + const stubToolName = 'stub-tool'; + + const stubBlock = createBlockMock({ + id: 'block-2', + tool: stubToolName, + data: preservedData, + }); + + const sanitizeBlocksSpy = vi.spyOn(sanitizer, 'sanitizeBlocks').mockImplementation((blocks, getConfig, globalSanitizer) => { + expect(typeof getConfig).toBe('function'); + + if (typeof getConfig !== 'function') { + throw new Error('Expected sanitize config resolver to be a function'); + } + + expect(getConfig('paragraph')).toEqual({ paragraph: { b: true } }); + expect(globalSanitizer).toEqual({ common: true }); + + return blocks.map((block) => { + if (block.tool === 'paragraph') { + return { + ...block, + data: { text: 'Hello' }, + }; + } + + return block; + }); + }); + + const { saver } = createSaver({ + blocks: [paragraphBlock.block, stubBlock.block], + sanitizer: { common: true }, + stubTool: stubToolName, + toolSanitizeConfigs: { + paragraph: { paragraph: { b: true } }, + [stubToolName]: {}, + }, + }); + + const result = await saver.save(); + + expect(paragraphBlock.saveMock).toHaveBeenCalledTimes(1); + expect(paragraphBlock.validateMock).toHaveBeenCalledWith(paragraphBlock.savedData.data); + expect(stubBlock.saveMock).toHaveBeenCalledTimes(1); + expect(sanitizeBlocksSpy).toHaveBeenCalledTimes(1); + + const [ blocksBeforeSanitize ] = sanitizeBlocksSpy.mock.calls[0]; + + expect(blocksBeforeSanitize).toEqual([ + expect.objectContaining({ + id: paragraphBlock.savedData.id, + tool: 'paragraph', + data: paragraphBlock.savedData.data, + tunes: paragraphBlock.savedData.tunes, + isValid: true, + }), + expect.objectContaining({ + id: stubBlock.savedData.id, + tool: 'stub-tool', + data: preservedData, + isValid: true, + }), + ]); + + expect(result).toEqual({ + time: frozenDate.valueOf(), + version, + blocks: [ + { + id: paragraphBlock.savedData.id, + type: 'paragraph', + data: { text: 'Hello' }, + tunes: paragraphBlock.savedData.tunes, + }, + preservedData, + ], + }); + }); + + it('skips invalid blocks and logs the reason', async () => { + const logSpy = vi.spyOn(utils, 'log').mockImplementation(() => undefined); + + const invalidBlock = createBlockMock({ + id: 'invalid', + tool: 'quote', + data: { text: 'Invalid' }, + isValid: false, + }); + + const validBlock = createBlockMock({ + id: 'valid', + tool: 'paragraph', + data: { text: 'Valid' }, + }); + + const sanitizeBlocksSpy = vi.spyOn(sanitizer, 'sanitizeBlocks').mockImplementation((blocks) => blocks); + + const { saver } = createSaver({ + blocks: [invalidBlock.block, validBlock.block], + toolSanitizeConfigs: { + quote: {}, + paragraph: {}, + }, + }); + + const result = await saver.save(); + + expect(result?.blocks).toEqual([ + { + id: 'valid', + type: 'paragraph', + data: { text: 'Valid' }, + }, + ]); + + expect(sanitizeBlocksSpy).toHaveBeenCalledTimes(1); + expect(logSpy).toHaveBeenCalledWith('Block «quote» skipped because saved data is invalid'); + }); + + it('logs a labeled error when saving fails', async () => { + const error = new Error('save failed'); + const logLabeledSpy = vi.spyOn(utils, 'logLabeled').mockImplementation(() => undefined); + const sanitizeBlocksSpy = vi.spyOn(sanitizer, 'sanitizeBlocks'); + + const failingBlock = { + save: vi.fn().mockRejectedValue(error), + validate: vi.fn(), + } as unknown as Block; + + const { saver } = createSaver({ + blocks: [ failingBlock ], + toolSanitizeConfigs: { + paragraph: {}, + }, + }); + + await expect(saver.save()).resolves.toBeUndefined(); + expect(logLabeledSpy).toHaveBeenCalledWith('Saving failed due to the Error %o', 'error', error); + expect(sanitizeBlocksSpy).not.toHaveBeenCalled(); + }); +}); + diff --git a/test/unit/tools/base.test.ts b/test/unit/tools/base.test.ts new file mode 100644 index 00000000..98066014 --- /dev/null +++ b/test/unit/tools/base.test.ts @@ -0,0 +1,273 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Tool, ToolConstructable } from '@/types/tools'; +import type { API } from '@/types'; +import type { ToolOptions } from '../../../src/components/tools/base'; +import BaseToolAdapter, { CommonInternalSettings, UserSettings } from '../../../src/components/tools/base'; +import { ToolType } from '@/types/tools/adapters/tool-type'; + +interface AdapterOptions { + name: string; + constructable: ToolConstructable; + config: ToolOptions; + api: API; + isDefault: boolean; + isInternal: boolean; + defaultPlaceholder?: string | false; +} + +interface TestToolAdapterOptions extends AdapterOptions { + toolType?: ToolType; +} + +/** + * Test-friendly adapter that lets specs interact with the base implementation. + */ +class TestToolAdapter extends BaseToolAdapter { + public type: ToolType; + + /** + * Creates a tool adapter instance preloaded with the supplied test options. + * + * @param options configuration bundle used to build the adapter for a spec + */ + constructor(options: TestToolAdapterOptions) { + super(options); + this.type = options.toolType ?? ToolType.Block; + } + + /** + * + */ + public create(): Tool { + return {} as Tool; + } +} + +const createConstructable = ( + overrides: Record = {} +): ToolConstructable & Record => { + /** + * + */ + class Constructable {} + + Object.assign(Constructable, overrides); + + return Constructable as unknown as ToolConstructable & Record; +}; + +const createToolOptions = (overrides: Partial = {}): ToolOptions => { + return { + [UserSettings.Config]: { + option1: 'value1', + }, + [UserSettings.Shortcut]: 'CTRL+SHIFT+B', + ...overrides, + }; +}; + +const createTool = ( + overrides: Partial = {} +): { + tool: TestToolAdapter; + options: TestToolAdapterOptions; +} => { + const options: TestToolAdapterOptions = { + name: overrides.name ?? 'baseTool', + constructable: overrides.constructable ?? createConstructable(), + config: overrides.config ?? createToolOptions(), + api: overrides.api ?? {} as API, + isDefault: overrides.isDefault ?? false, + isInternal: overrides.isInternal ?? false, + defaultPlaceholder: overrides.defaultPlaceholder ?? 'Default placeholder', + toolType: overrides.toolType ?? ToolType.Block, + }; + + return { + tool: new TestToolAdapter(options), + options, + }; +}; + +describe('BaseToolAdapter', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('settings', () => { + it('returns user configuration as-is', () => { + const customConfig: ToolOptions = { + [UserSettings.Config]: { + foo: 'bar', + }, + [UserSettings.Shortcut]: 'CMD+ALT+1', + }; + const { tool } = createTool({ + config: customConfig, + }); + + expect(tool.settings).toStrictEqual(customConfig[UserSettings.Config]); + }); + + it('adds default placeholder when default tool lacks placeholder', () => { + const { tool } = createTool({ + isDefault: true, + config: { + [UserSettings.Shortcut]: 'CMD+ALT+2', + } as ToolOptions, + }); + + expect(tool.settings).toStrictEqual({ + placeholder: 'Default placeholder', + }); + }); + + it('does not override user-defined placeholder', () => { + const placeholder = 'Custom placeholder'; + const { tool } = createTool({ + isDefault: true, + config: { + [UserSettings.Config]: { + placeholder, + }, + [UserSettings.Shortcut]: 'CMD+ALT+3', + } as ToolOptions, + }); + + expect(tool.settings).toStrictEqual({ + placeholder, + }); + }); + }); + + describe('lifecycle hooks', () => { + it('delegates prepare to constructable with tool context', async () => { + const prepare = vi.fn(); + const { tool } = createTool({ + constructable: createConstructable({ + prepare, + }), + }); + + await tool.prepare(); + + expect(prepare).toHaveBeenCalledWith({ + toolName: 'baseTool', + config: tool.settings, + }); + }); + + it('skips prepare when constructable method is absent', async () => { + const { tool } = createTool({ + constructable: createConstructable(), + }); + + const result = await tool.prepare(); + + expect(result).toBeUndefined(); + }); + + it('delegates reset to constructable when available', async () => { + const reset = vi.fn(); + const { tool } = createTool({ + constructable: createConstructable({ + reset, + }), + }); + + await tool.reset(); + + expect(reset).toHaveBeenCalledTimes(1); + }); + + it('skips reset when constructable method is absent', async () => { + const { tool } = createTool({ + constructable: createConstructable(), + }); + + const result = await tool.reset(); + + expect(result).toBeUndefined(); + }); + }); + + describe('shortcut', () => { + it('prefers user-defined shortcut value', () => { + const config: ToolOptions = { + [UserSettings.Config]: {}, + [UserSettings.Shortcut]: 'CMD+ALT+4', + }; + const { tool } = createTool({ + config, + }); + + expect(tool.shortcut).toBe(config[UserSettings.Shortcut]); + }); + + it('falls back to constructable shortcut', () => { + const constructableShortcut = 'CTRL+K'; + const { tool } = createTool({ + config: { + [UserSettings.Config]: {}, + } as ToolOptions, + constructable: createConstructable({ + [CommonInternalSettings.Shortcut]: constructableShortcut, + }), + }); + + expect(tool.shortcut).toBe(constructableShortcut); + }); + }); + + describe('sanitizeConfig', () => { + it('returns constructable sanitize configuration when provided', () => { + const sanitizeConfig = { + paragraph: { + b: true, + }, + }; + const { tool } = createTool({ + constructable: createConstructable({ + [CommonInternalSettings.SanitizeConfig]: sanitizeConfig, + }), + }); + + expect(tool.sanitizeConfig).toBe(sanitizeConfig); + }); + + it('returns empty object when sanitize configuration is missing', () => { + const { tool } = createTool({ + constructable: createConstructable(), + }); + + expect(tool.sanitizeConfig).toEqual({}); + }); + }); + + describe('type guards', () => { + it('identifies tool type correctly', () => { + const blockTool = createTool({ + toolType: ToolType.Block, + }).tool; + const inlineTool = createTool({ + toolType: ToolType.Inline, + }).tool; + const tuneTool = createTool({ + toolType: ToolType.Tune, + }).tool; + + expect(blockTool.isBlock()).toBe(true); + expect(blockTool.isInline()).toBe(false); + expect(blockTool.isTune()).toBe(false); + + expect(inlineTool.isInline()).toBe(true); + expect(inlineTool.isBlock()).toBe(false); + expect(inlineTool.isTune()).toBe(false); + + expect(tuneTool.isTune()).toBe(true); + expect(tuneTool.isBlock()).toBe(false); + expect(tuneTool.isInline()).toBe(false); + }); + }); +}); + diff --git a/tsconfig.json b/tsconfig.json index 201586ee..fa74d4f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,7 +23,9 @@ "@editorjs/editorjs/*": [ "./types/*" ], }, "esModuleInterop": true, - "allowArbitraryExtensions": true + "skipLibCheck": true, + "allowArbitraryExtensions": true, + "noEmit": true }, "include": [ "**/*.ts", @@ -31,5 +33,9 @@ "**/*.js", "**/*.json", "vite.config*.mjs" + ], + "exclude": [ + "dist", + "node_modules" ] } diff --git a/types/block-tunes/block-tune.d.ts b/types/block-tunes/block-tune.d.ts index c522c542..a0043665 100644 --- a/types/block-tunes/block-tune.d.ts +++ b/types/block-tunes/block-tune.d.ts @@ -1,6 +1,6 @@ -import {API, BlockAPI, SanitizerConfig, ToolConfig} from '../index'; +import {API, BlockAPI, ToolConfig} from '../index'; import { BlockTuneData } from './block-tune-data'; -import { MenuConfig } from '../tools'; +import { BaseToolConstructable, MenuConfig } from '../tools'; /** * Describes BLockTune blueprint @@ -33,23 +33,13 @@ export interface BlockTune { /** * Describes BlockTune class constructor function */ -export interface BlockTuneConstructable { +export interface BlockTuneConstructable extends BaseToolConstructable { /** * Flag show Tool is Block Tune */ isTune: boolean; - /** - * Tune's sanitize configuration - */ - sanitize?: SanitizerConfig; - - /** - * Shortcut for Tool - */ - shortcut?: string; - /** * @constructor * @@ -66,10 +56,5 @@ export interface BlockTuneConstructable { * Tune`s prepare method. Can be async * @param data */ - prepare?(): Promise | void; - - /** - * Tune`s reset method to clean up anything set by prepare. Can be async - */ - reset?(): void | Promise; + prepare?(data: { toolName: string; config: ToolConfig }): Promise | void; }