Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/scripts/compare-coverage.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,10 @@ export function processReports(pkg, headDir, baseDir) {

let delta = categories.branches.delta;

if (delta < 0) {
if (delta > 0) {
delta = `+${delta}% 🟢`;
} else if (delta > 0) {
delta = `-${delta}% 🔴`;
} else if (delta < 0) {
delta = `${delta}% 🔴`;
} else if (delta === 0) {
delta = `0% ⚪️`;
}
Expand Down
4 changes: 4 additions & 0 deletions .github/scripts/process-mutation.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,10 @@ function processMutationReport(artitfactName, reportPath, changedFilesPath, prNu

const metrics = getMetrics(obj);

if (metrics.total === 0) {
return `| ${packageName} | No mutants found. | |`;
}

const encodedPackageName = encodeURIComponent(packageName);
const dashboardUrl = `[Dashboard](https://dashboard.stryker-mutator.io/reports/github.com/editor-js/document-model/PR-${prNumber}?module=${encodedPackageName})`;

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/api/SelectionAPI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type { CoreConfigValidated } from '@editorjs/sdk';
// Mock dependencies before importing the module under test
jest.unstable_mockModule('../components/SelectionManager', () => ({
SelectionManager: jest.fn(() => ({
applyInlineToolForCurrentSelection: jest.fn(),
applyInlineTool: jest.fn(),
})),
}));

Expand Down Expand Up @@ -39,7 +39,10 @@ describe('SelectionAPI', () => {
});

expect(createInlineToolName).toHaveBeenCalledWith('bold');
expect(selectionManager.applyInlineToolForCurrentSelection).toHaveBeenCalledWith('inline:bold', { level: 1 });
expect(selectionManager.applyInlineTool).toHaveBeenCalledWith({
toolName: 'inline:bold',
data: { level: 1 },
});
});
});
});
25 changes: 22 additions & 3 deletions packages/core/src/api/SelectionAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import 'reflect-metadata';
import { inject, injectable } from 'inversify';

import { SelectionManager } from '../components/SelectionManager.js';
import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType } from '@editorjs/model';
import { Caret, CaretManagerEvents, createInlineToolName, EditorJSModel, EventType, Index } from '@editorjs/model';
import { CoreConfigValidated } from '@editorjs/sdk';
import { SelectionAPI as SelectionApiInterface } from '@editorjs/sdk';
import { TOKENS } from '../tokens.js';
Expand Down Expand Up @@ -33,14 +33,33 @@ export class SelectionAPI implements SelectionApiInterface {
this.#config = config;
}

/**
* Returns caret index for current user (or null)
* @returns Index of the caret for the current user or null
*/
public get caretIndex(): Index | null {
return this.#selectionManager.currentSelection;
}

/**
* Applies passed inline tool to the current selection
* @param params - methods parameters
* @param params.tool - Inline Tool name from the config to apply on the current selection
* @param [params.data] - Inline Tool data to apply to the current selection (e.g. link data)
* @param [params.caretIndex] - index where to apply the tool, by default equals current selection
* @param [params.action] - by default, method will flip the formatting. You can choose a specific action with this parameter
* @param [params.keepSelection] - if false, selection will be collapsed to the right. If true, selection will be restored to the caretIndex. True by default
* @param [params.userId] - id of a user to attribute the change to
*/
public applyInlineTool({ tool, data }: Parameters<SelectionApiInterface['applyInlineTool']>[0]): void {
this.#selectionManager.applyInlineToolForCurrentSelection(createInlineToolName(tool), data);
public applyInlineTool({ tool, data, caretIndex, userId, action, keepSelection }: Parameters<SelectionApiInterface['applyInlineTool']>[0]): void {
this.#selectionManager.applyInlineTool({
toolName: createInlineToolName(tool),
data,
userId,
caretIndex,
action,
keepSelection,
});
}

/**
Expand Down
145 changes: 128 additions & 17 deletions packages/core/src/components/SelectionManager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { jest } from '@jest/globals';
import type { CoreConfigValidated } from '@editorjs/sdk';
// @ts-expect-error - TS don't import types via import() so have to import them here as well
import type { CaretManagerEvents, InlineFragment, InlineToolName, EventType, Index } from '@editorjs/model';
import type { CaretManagerEvents, InlineFragment, InlineToolName, EventType, Index, FormattingAction } from '@editorjs/model';

// Register ESM mocks before importing the module under test
jest.unstable_mockModule('@editorjs/model', () => {
Expand All @@ -27,10 +27,17 @@ jest.unstable_mockModule('@editorjs/model', () => {
serialized: { blocks: [] },
}));

class IndexBuilderMock {
public from = jest.fn(() => this);
public addTextRange = jest.fn(() => this);
public build = jest.fn(() => ({ getTextSegments: jest.fn(() => []) }));
}

return {
EditorJSModel,
CaretManagerCaretUpdatedEvent: caretManagerCaretUpdatedEvent,
Index: { parse: jest.fn() },
IndexBuilder: IndexBuilderMock,
EventType: eventType,
createInlineToolData: (data: Record<string, unknown>) => data,
createInlineToolName: (name: string) => name,
Expand Down Expand Up @@ -111,7 +118,7 @@ describe('SelectionManager', () => {
expect(SelectionChangedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({
index: null,
fragments: [],
availableInlineTools: expect.any(Map),
availableInlineTools: expect.any(Array),
}));
expect(eventBus.dispatchEvent).toHaveBeenCalled();
});
Expand Down Expand Up @@ -205,26 +212,26 @@ describe('SelectionManager', () => {

caretEventsListener(event);

const callArg = (SelectionChangedCoreEvent as jest.MockedClass<typeof SelectionChangedCoreEvent>).mock.calls[0][0] as { availableInlineTools: Map<string, unknown> };
const callArg = ((SelectionChangedCoreEvent as jest.MockedClass<typeof SelectionChangedCoreEvent>).mock.calls[0][0] as unknown) as { availableInlineTools: unknown[] };

expect(callArg.availableInlineTools.has('italic')).toBe(true);
expect(callArg.availableInlineTools).toContain(facadeMock);
});
});

describe('.applyInlineToolForCurrentSelection()', () => {
describe('.applyInlineTool()', () => {
it('should throw when caret is not set', () => {
jest.spyOn(model, 'getCaret').mockReturnValue(undefined);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow();
});

it('should throw when caret index is null', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: null } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow();
});

Expand All @@ -234,7 +241,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
Comment on lines 221 to +244
}).toThrow();
});

Expand All @@ -249,8 +256,8 @@ describe('SelectionManager', () => {
(toolsManager as unknown as { inlineTools: Map<unknown, unknown> }).inlineTools = new Map();

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
}).toThrow('SelectionManager[applyInlineToolForCurrentSelection]: tool bold is not attached');
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('SelectionManager[applyInlineTool]: tool bold is not attached');
});

it('should call model.format when tool getFormattingOptions returns Format action', () => {
Expand All @@ -269,10 +276,14 @@ describe('SelectionManager', () => {
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: jest.fn(),
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });

expect(mockFormat).toHaveBeenCalled();
});
Expand All @@ -293,10 +304,14 @@ describe('SelectionManager', () => {
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: jest.fn(),
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });

expect(mockUnformat).toHaveBeenCalled();
});
Expand All @@ -315,7 +330,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('TextRange of the index should be defined');
});

Expand All @@ -333,7 +348,7 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('BlockIndex should be defined');
});

Expand All @@ -351,8 +366,104 @@ describe('SelectionManager', () => {
jest.spyOn(model, 'getCaret').mockReturnValue({ index: indexMock } as unknown as ReturnType<typeof model.getCaret>);

expect(() => {
selectionManager.applyInlineToolForCurrentSelection('bold' as InlineToolName);
selectionManager.applyInlineTool({ toolName: 'bold' as InlineToolName });
}).toThrow('DataKey of the index should be defined');
});

it('should collapse selection to end when keepSelection is false', () => {
const caretUpdateMock = jest.fn();
const toolMock = {
getFormattingOptions: jest.fn(() => ({ action: 'format',
range: [0, 3] })),
};
const facadeMock = { create: jest.fn(() => toolMock) };

(toolsManager as unknown as { inlineTools: Map<unknown, unknown> }).inlineTools = new Map([['bold', facadeMock]]);

const indexMock = {
getTextSegments: jest.fn(() => [{ blockIndex: 0,
dataKey: 'text',
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: caretUpdateMock,
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

selectionManager.applyInlineTool({
toolName: 'bold' as InlineToolName,
keepSelection: false,
});

expect(caretUpdateMock).toHaveBeenCalledWith(expect.objectContaining({ getTextSegments: expect.any(Function) }));
});

it('should override tool action when action parameter is provided', () => {
const mockUnformat = jest.spyOn(model, 'unformat').mockImplementation(() => undefined);
const toolMock = {
getFormattingOptions: jest.fn(() => ({ action: 'format',
range: [0, 3] })),
};
const facadeMock = { create: jest.fn(() => toolMock) };

(toolsManager as unknown as { inlineTools: Map<unknown, unknown> }).inlineTools = new Map([['bold', facadeMock]]);

const indexMock = {
getTextSegments: jest.fn(() => [{ blockIndex: 0,
dataKey: 'text',
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: jest.fn(),
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

// Tool suggests Format, but we override with Unformat
selectionManager.applyInlineTool({
toolName: 'bold' as InlineToolName,
action: 'unformat' as FormattingAction,
});

expect(mockUnformat).toHaveBeenCalled();
});

it('should not update caret for non-current userId', () => {
const caretUpdateMock = jest.fn();
const toolMock = {
getFormattingOptions: jest.fn(() => ({ action: 'format',
range: [0, 3] })),
};
const facadeMock = { create: jest.fn(() => toolMock) };

(toolsManager as unknown as { inlineTools: Map<unknown, unknown> }).inlineTools = new Map([['bold', facadeMock]]);

const indexMock = {
getTextSegments: jest.fn(() => [{ blockIndex: 0,
dataKey: 'text',
textRange: [0, 3] }]),
};

jest.spyOn(model, 'getCaret')
.mockReturnValue({
index: indexMock,
update: caretUpdateMock,
} as unknown as ReturnType<typeof model.getCaret>);
jest.spyOn(model, 'getFragments').mockReturnValue([]);

// Apply tool with a different userId
selectionManager.applyInlineTool({
toolName: 'bold' as InlineToolName,
userId: 'another-user',
});

// Caret should not be updated for non-current user
expect(caretUpdateMock).not.toHaveBeenCalled();
});
});
});
Loading