fix: all typescript errors

This commit is contained in:
JackUait 2025-11-15 03:13:55 +03:00
commit b677b63eeb
16 changed files with 1077 additions and 67 deletions

View file

@ -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",

View file

@ -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;
}

View file

@ -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 '';
},
};
}

View file

@ -24,15 +24,31 @@ export default class SaverAPI extends Module {
*
* @returns {OutputData}
*/
public save(): Promise<OutputData> {
public async save(): Promise<OutputData> {
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;
}
}

View file

@ -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;

View file

@ -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);

View file

@ -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;

View file

@ -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<string, BlockTuneData>;
};
type SanitizableBlockData = SaverValidatedData & Pick<SavedData, 'data' | 'tool'>;
/**
* @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<OutputData> {
public async save(): Promise<OutputData | undefined> {
const { BlockManager, Tools } = this.Editor;
const blocks = BlockManager.blocks;
const chainData = [];
const chainData: Array<Promise<SaverValidatedData>> = 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<Pick<SavedData, 'data' | 'tool'>>;
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<ValidatedData> {
private async getSavedData(block: Block): Promise<SaverValidatedData> {
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<string, unknown>;
return typeof candidate.type === 'string' && candidate.data !== undefined;
}
/**
* Returns the last error raised during save attempt
*/
public getLastSaveError(): unknown {
return this.lastSaveError;
}
}

View file

@ -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<ToolSettings, 'class'>;
type ToolPreparePayload = {
toolName: string;
config: ToolConfig;
};
interface ConstructorOptions {
name: string;
constructable: ToolConstructable;
@ -123,7 +128,7 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
/**
* Tool type: Block, Inline or Tune
*/
public type: Type;
public abstract type: Type;
/**
* Tool name specified in EditorJS config
@ -185,8 +190,8 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
/**
* Returns Tool user configuration
*/
public get settings(): ToolOptions {
const config = this.config[UserSettings.Config] || {};
public get settings(): ToolConfig {
const config = (this.config[UserSettings.Config] ?? {}) as ToolConfig;
if (this.isDefault && !('placeholder' in config) && this.defaultPlaceholder) {
config.placeholder = this.defaultPlaceholder;
@ -208,12 +213,18 @@ export default abstract class BaseToolAdapter<Type extends ToolType = ToolType,
* Calls Tool's prepare method
*/
public prepare(): void | Promise<void> {
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<void>).call(this.constructable, payload);
}
/**

View file

@ -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 = <K extends DocumentCommandKey>(
key: K,
implementation: Document[K]
): void => {
Object.defineProperty(document, key, {
configurable: true,
value: implementation,
writable: true,
});
};
describe('ItalicInlineTool', () => {
let tool: ItalicInlineTool;
let execCommandMock: ReturnType<typeof vi.fn<[], boolean>>;
let queryCommandStateMock: ReturnType<typeof vi.fn<[], boolean>>;
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');
});
});

View file

@ -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 extends object> = T & { [K in EsModuleKey]: true };
type WithEsModuleFlag = <T extends object>(moduleMock: T) => EsModule<T>;
/**
*
* @param moduleMock - The module object to add the ES module flag to
*/
const withEsModuleFlag: WithEsModuleFlag = vi.hoisted(() => {
return (<T extends object>(moduleMock: T): EsModule<T> => {
return Object.defineProperty(moduleMock, '__esModule', {
configurable: true,
enumerable: true,
value: true,
}) as EsModule<T>;
});
});
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<EditorEventMap>();
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');
});
});

View file

@ -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<typeof vi.fn<[], Promise<OutputData | undefined>>>;
type SaverLastErrorMock = ReturnType<typeof vi.fn<[], unknown>>;
type EditorStub = {
ReadOnly: { isEnabled: boolean };
Saver: {
save: SaverSaveMock;
getLastSaveError?: SaverLastErrorMock;
};
};
type EditorStubOverrides = {
ReadOnly?: Partial<EditorStub['ReadOnly']>;
Saver?: Partial<EditorStub['Saver']>;
};
const createSaverApi = (overrides: EditorStubOverrides = {}): { saverApi: SaverAPI; editor: EditorStub } => {
const moduleConfig: ModuleConfig = {
config: {} as EditorConfig,
eventsDispatcher: new EventsDispatcher<EditorEventMap>(),
};
const saverApi = new SaverAPI(moduleConfig);
const editor: EditorStub = {
ReadOnly: {
isEnabled: false,
},
Saver: {
save: vi.fn<[], Promise<OutputData | undefined>>().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);
});
});

View file

@ -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<string, unknown> };
interface BlockMock {
block: Block;
savedData: BlockSaveResult;
saveMock: Mock<[], Promise<BlockSaveResult>>;
validateMock: Mock<[BlockSaveResult['data']], Promise<boolean>>;
}
interface BlockMockOptions {
id: string;
tool: string;
data: SavedData['data'];
tunes?: Record<string, unknown>;
isValid?: boolean;
}
interface CreateSaverOptions {
blocks?: Block[];
sanitizer?: SanitizerConfig;
stubTool?: string;
toolSanitizeConfigs?: Record<string, SanitizerConfig>;
}
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<BlockSaveResult>>().mockResolvedValue(savedData);
const validateMock = vi.fn<[BlockSaveResult['data']], Promise<boolean>>()
.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<string, SanitizerConfig> = {
...(options.toolSanitizeConfigs ?? {}),
};
if (!toolConfigs[stubTool]) {
toolConfigs[stubTool] = {} as SanitizerConfig;
}
const blockTools = new Map<string, { sanitizeConfig?: SanitizerConfig }>(
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: '<b>Hello</b>' },
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();
});
});

View file

@ -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<ToolType, Tool> {
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<string, unknown> = {}
): ToolConstructable & Record<string, unknown> => {
/**
*
*/
class Constructable {}
Object.assign(Constructable, overrides);
return Constructable as unknown as ToolConstructable & Record<string, unknown>;
};
const createToolOptions = (overrides: Partial<ToolOptions> = {}): ToolOptions => {
return {
[UserSettings.Config]: {
option1: 'value1',
},
[UserSettings.Shortcut]: 'CTRL+SHIFT+B',
...overrides,
};
};
const createTool = (
overrides: Partial<TestToolAdapterOptions> = {}
): {
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);
});
});
});

View file

@ -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"
]
}

View file

@ -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> | void;
/**
* Tune`s reset method to clean up anything set by prepare. Can be async
*/
reset?(): void | Promise<void>;
prepare?(data: { toolName: string; config: ToolConfig }): Promise<void> | void;
}