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
106 changes: 106 additions & 0 deletions packages/core/src/api/BlocksAPI.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ jest.unstable_mockModule('@editorjs/model', () => ({
EditorJSModel: jest.fn(),
createBlockId: jest.fn(id => id),
createDataKey: jest.fn(key => key),
createBlockTuneName: jest.fn(name => name),
}));

const { BlocksManager } = await import('../components/BlockManager');
Expand Down Expand Up @@ -219,4 +220,109 @@ describe('BlocksAPI', () => {
});
});
});

describe('.getTuneData()', () => {
it('should return the tune data for the given block and tune name', () => {
const tuneData = { align: 'left' };
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = new EditorJSModel('userId', { identifier: 'docId' }) as any;

model.getBlockSerialized = jest.fn().mockReturnValue({ tunes: { myTune: tuneData } });

const api = new BlocksAPI(
blocksManager,
{ defaultBlock } as CoreConfigValidated,
model
);

const result = api.getTuneData({ block: 0,
tuneName: 'myTune' });

expect(result).toEqual(tuneData);
expect(model.getBlockSerialized).toHaveBeenCalledWith(0);
});

it('should return an empty object when the tune has no data', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = new EditorJSModel('userId', { identifier: 'docId' }) as any;

model.getBlockSerialized = jest.fn().mockReturnValue({ tunes: {} });

const api = new BlocksAPI(
blocksManager,
{ defaultBlock } as CoreConfigValidated,
model
);

const result = api.getTuneData({ block: 0,
tuneName: 'missingTune' });

expect(result).toEqual({});
});

it('should return an empty object when the block has no tunes', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = new EditorJSModel('userId', { identifier: 'docId' }) as any;

model.getBlockSerialized = jest.fn().mockReturnValue({});

const api = new BlocksAPI(
blocksManager,
{ defaultBlock } as CoreConfigValidated,
model
);

const result = api.getTuneData({ block: 0,
tuneName: 'anyTune' });

expect(result).toEqual({});
});
});

describe('.updateTuneData()', () => {
it('should call model.updateTuneData with userId, block, tuneName, and data', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = new EditorJSModel('userId', { identifier: 'docId' }) as any;

model.updateTuneData = jest.fn();

const api = new BlocksAPI(
blocksManager,
{
defaultBlock,
userId: 'user1',
} as CoreConfigValidated,
model
);

api.updateTuneData({ block: 1,
tuneName: 'align',
data: { align: 'center' } });

expect(model.updateTuneData).toHaveBeenCalledWith('user1', 1, 'align', { align: 'center' });
});

it('should use the provided userId instead of the config userId', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const model = new EditorJSModel('userId', { identifier: 'docId' }) as any;

model.updateTuneData = jest.fn();

const api = new BlocksAPI(
blocksManager,
{
defaultBlock,
userId: 'defaultUser',
} as CoreConfigValidated,
model
);

api.updateTuneData({ block: 0,
tuneName: 'align',
data: {},
userId: 'overrideUser' });

expect(model.updateTuneData).toHaveBeenCalledWith('overrideUser', 0, 'align', {});
});
});
});
23 changes: 23 additions & 0 deletions packages/core/src/api/BlocksAPI.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
BlockId,
BlockIndexOrId,
createBlockId,
createBlockTuneName,
createDataKey,
EditorDocumentSerialized,
EditorJSModel,
Expand Down Expand Up @@ -228,4 +229,26 @@ export class BlocksAPI implements BlocksApiInterface {
}: Parameters<BlocksApiInterface['convert']>[0]): void {
this.#blocksManager.convertBlock(block as BlockIndexOrId, createDataKey(key), newType, userId, dataOverrides);
}

/**
* Returns the serialized data for the given tune on a block
* @param params - getTuneData parameters
* @param params.block - index or id of the block
* @param params.tuneName - tune to read data from
*/
public getTuneData({ block, tuneName }: Parameters<BlocksApiInterface['getTuneData']>[0]): Record<string, unknown> {
return this.#model.getBlockSerialized(block as BlockIndexOrId).tunes?.[tuneName] ?? {};
}

/**
* Updates tune data for the given block and tune name
* @param params - updateTuneData parameters
* @param params.block - index or id of the block
* @param params.tuneName - selects which set of registered settings to modify
* @param params.data - new tune data (merged into existing data)
* @param [params.userId] - user id to attribute the change to
*/
public updateTuneData({ block, tuneName, data, userId = this.#config.userId }: Parameters<BlocksApiInterface['updateTuneData']>[0]): void {
this.#model.updateTuneData(userId, block as BlockIndexOrId, createBlockTuneName(tuneName), data);
}
}
4 changes: 4 additions & 0 deletions packages/core/src/components/BlockRenderer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ jest.unstable_mockModule('../tools/ToolsManager', () => ({
create: jest.fn(() => ({ render: jest.fn(() => Promise.resolve({})) }))
})),
},
blockTunes: new Map(),
})),
}));

Expand Down Expand Up @@ -84,6 +85,9 @@ describe('BlockRenderer (unit, mocked deps)', () => {
const adapter: EditorJSAdapterPlugin = {
createBlockToolAdapter: jest.fn(() => ({})),
destroyBlockToolAdapter: jest.fn(),
createBlockTuneAdapter: jest.fn(() => ({})),
getBlockTuneAdapter: jest.fn(() => undefined),
destroyBlockTuneAdapters: jest.fn(),
} as unknown as EditorJSAdapterPlugin;

new BlockRenderer(
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/components/BlockRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,12 @@ export class BlockRenderer {
throw new Error(`[BlockRenderer] Block Tool ${data.name} not found`);
}

const blockToolAdapter = this.#adapter.createBlockToolAdapter(createBlockId(data.id), tool.name);
const blockId = createBlockId(data.id);
const blockToolAdapter = this.#adapter.createBlockToolAdapter(blockId, tool.name);

for (const tuneName of this.#toolsManager.blockTunes.keys()) {
this.#adapter.createBlockTuneAdapter(blockId, tuneName);
}

const block = tool.create({
adapter: blockToolAdapter,
Expand Down Expand Up @@ -147,7 +152,10 @@ export class BlockRenderer {
throw new Error('[BlockRenderer] Block index should be defined. Probably something wrong with the Editor Model. Please, report this issue');
}

this.#adapter.destroyBlockToolAdapter(createBlockId(data.id));
const removedBlockId = createBlockId(data.id);

this.#adapter.destroyBlockToolAdapter(removedBlockId);
this.#adapter.destroyBlockTuneAdapters(removedBlockId);

this.#eventBus.dispatchEvent(new BlockRemovedCoreEvent({
tool: data.name,
Expand Down
129 changes: 129 additions & 0 deletions packages/core/src/components/BlockTunesManager.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/* eslint-disable jsdoc/require-jsdoc, @typescript-eslint/naming-convention */

import { jest } from '@jest/globals';
import type { EditorAPI } from '../api/index.js';

let blockSelectedListener: (event: CustomEvent) => void;

jest.unstable_mockModule('@editorjs/sdk', () => ({
BlockSelectedCoreEvent: jest.fn(function (this: { detail: unknown }, payload: unknown) {
this.detail = payload;
}),
BlockSelectedUIEvent: jest.fn(),
CoreEventType: { BlockSelected: 'block:selected' },
EventBus: jest.fn(() => ({
addEventListener: jest.fn((name: string, handler: (e: CustomEvent) => void) => {
if (name === 'ui:blocks:block-selected') {
blockSelectedListener = handler;
}
}),
dispatchEvent: jest.fn(),
})),
EditorJSAdapterPlugin: jest.fn(),
}));

const fakeFacade = { create: jest.fn(() => ({ render: jest.fn() })) };

jest.unstable_mockModule('../tools/ToolsManager', () => ({
default: jest.fn(() => ({
blockTunes: new Map([
['moveUp', fakeFacade],
]),
})),
}));

jest.unstable_mockModule('../api/index.js', () => ({
EditorAPI: jest.fn(),
}));

const { BlockSelectedCoreEvent, EventBus } = await import('@editorjs/sdk');
const ToolsManager = (await import('../tools/ToolsManager')).default;
const { BlockTunesManager } = await import('./BlockTunesManager.js');

describe('BlockTunesManager', () => {
const mockBlockId = 'block-id-42';

const mockApi = {
blocks: {
getIdByIndex: jest.fn(() => mockBlockId),
},
};

const mockTuneAdapter = {};

const mockAdapter = {
getBlockTuneAdapter: jest.fn(() => mockTuneAdapter),
createBlockTuneAdapter: jest.fn(() => mockTuneAdapter),
createBlockToolAdapter: jest.fn(),
destroyBlockToolAdapter: jest.fn(),
destroyBlockTuneAdapters: jest.fn(),
};

const eventBus = new EventBus();
// @ts-expect-error — mocked instance
const toolsManager = new ToolsManager();

new BlockTunesManager(
eventBus,
toolsManager,
mockApi as unknown as EditorAPI,
mockAdapter as never
);

beforeEach(() => {
jest.clearAllMocks();
mockApi.blocks.getIdByIndex.mockReturnValue(mockBlockId);
mockAdapter.getBlockTuneAdapter.mockReturnValue(mockTuneAdapter);
});

it('should emit BlockSelectedCoreEvent with tune instances when a block is selected', () => {
blockSelectedListener({ detail: { index: 2,
block: {} } } as unknown as CustomEvent);

expect(mockApi.blocks.getIdByIndex).toHaveBeenCalledWith(2);
expect(BlockSelectedCoreEvent).toHaveBeenCalledWith(expect.objectContaining({
index: 2,
blockId: mockBlockId,
availableBlockTunes: expect.any(Map),
}));
expect(eventBus.dispatchEvent).toHaveBeenCalled();
});

it('should not emit when blockId is undefined', () => {
mockApi.blocks.getIdByIndex.mockReturnValue(undefined as unknown as string);

blockSelectedListener({ detail: { index: 99,
block: {} } } as unknown as CustomEvent);

expect(eventBus.dispatchEvent).not.toHaveBeenCalled();
});

it('should call facade.create() for each registered tune with blockId, api, and adapter', () => {
blockSelectedListener({ detail: { index: 0,
block: {} } } as unknown as CustomEvent);

expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, mockTuneAdapter);
});

it('should use an existing adapter when getBlockTuneAdapter returns one', () => {
const existingAdapter = { existing: true };

mockAdapter.getBlockTuneAdapter.mockReturnValue(existingAdapter);

blockSelectedListener({ detail: { index: 0,
block: {} } } as unknown as CustomEvent);

expect(mockAdapter.createBlockTuneAdapter).not.toHaveBeenCalled();
expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, existingAdapter);
});

it('should create a new adapter when getBlockTuneAdapter returns undefined', () => {
mockAdapter.getBlockTuneAdapter.mockReturnValue(undefined as never);

blockSelectedListener({ detail: { index: 0,
block: {} } } as unknown as CustomEvent);

expect(mockAdapter.createBlockTuneAdapter).toHaveBeenCalled();
expect(fakeFacade.create).toHaveBeenCalledWith({}, mockBlockId, mockApi, mockTuneAdapter);
});
});
58 changes: 58 additions & 0 deletions packages/core/src/components/BlockTunesManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import 'reflect-metadata';
import { inject, injectable } from 'inversify';
import {
BlockSelectedCoreEvent,
BlockSelectedUIEvent,
EditorJSAdapterPlugin,
EventBus
} from '@editorjs/sdk';
import ToolsManager from '../tools/ToolsManager.js';
import { EditorAPI } from '../api/index.js';
import { TOKENS } from '../tokens.js';

/**
* BlockTunesManager listens for block selection events, instantiates tune instances
* for the selected block, and emits a BlockSelectedCoreEvent so the UI can render them.
*/
@injectable()
export class BlockTunesManager {
/**
* @param eventBus - event bus to listen for block selection events on
* @param toolsManager - provides the registered block tune facades
* @param api - editor API
* @param adapter - adapter plugin used to create/get block tune adapters
*/
constructor(
eventBus: EventBus,
toolsManager: ToolsManager,
api: EditorAPI,
@inject(TOKENS.Adapter) adapter: EditorJSAdapterPlugin
) {
eventBus.addEventListener('ui:blocks:block-selected', (event: BlockSelectedUIEvent) => {
const { index } = event.detail;
const blockId = api.blocks.getIdByIndex(index);

if (blockId === undefined) {
return;
}

const availableBlockTunes = new Map(
Array.from(toolsManager.blockTunes.entries()).map(([name, facade]) => {
const tuneAdapter = adapter.getBlockTuneAdapter(blockId, name)
?? adapter.createBlockTuneAdapter(blockId, name);

return [
name,
facade.create({}, blockId, api, tuneAdapter),
];
})
);

eventBus.dispatchEvent(new BlockSelectedCoreEvent({
index,
blockId,
availableBlockTunes,
}));
});
}
}
Loading