chore(api): blocks.update(id, data) method improved (#2443)

* add custom Chai assertion "be.calledWithBatchedEvents" for testing onchange

* chore(api): blocks.update(id, data) method improved

- `blocks.update(id, data)` now can accept partial data object — it will update only passed properties, others will remain the same.
- `blocks.update(id, data)` now will trigger onChange with only `block-change` event.
- `blocks.update(id, data)` will return a promise with BlockAPI object of changed block.

* fix tests

* Update blocks.cy.ts
This commit is contained in:
Peter Savchenko 2023-08-19 07:53:42 +03:00 committed by GitHub
parent b39996616c
commit 922dfd8741
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 314 additions and 150 deletions

View file

@ -3,8 +3,8 @@
### 2.28.0 ### 2.28.0
- `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id. - `New` - Block ids now displayed in DOM via a data-id attribute. Could be useful for plugins that want access a Block's element by id.
- `New` - The `blocks.convert(blockId, newType)` API method added. It allows to convert existed Block to a Block of another type. - `New` - The `blocks.convert(blockId, newType)` API method added. It allows to convert existing Block to a Block of another type.
- `New` - The `blocks.insertMany()` API method added. It allows to insert several Blocks to specified index. - `New` - The `blocks.insertMany()` API method added. It allows to insert several Blocks to the specified index.
- `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one. - `Improvement` - The Delete keydown at the end of the Block will now work opposite a Backspace at the start. Next Block will be removed (if empty) or merged with the current one.
- `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected. - `Improvement` - The Delete keydown will work like a Backspace when several Blocks are selected.
- `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current. - `Improvement` - If we have two empty Blocks, and press Backspace at the start of the second one, the previous will be removed instead of current.
@ -17,7 +17,11 @@
- `Improvement` - "I'm ready" log removed - `Improvement` - "I'm ready" log removed
- `Improvement` - The stub-block style simplified. - `Improvement` - The stub-block style simplified.
- `Improvement` - If some Block's tool will throw an error during construction, we will show Stub block instead of skipping it during render - `Improvement` - If some Block's tool will throw an error during construction, we will show Stub block instead of skipping it during render
- `Improvement` - Call of `blocks.clear()` now will trigger onChange will "block-removed" event for all removed blocks. - `Improvement` - Call of `blocks.clear()` now will trigger onChange with "block-removed" event for all removed blocks.
- `Improvement` - `BlockMutationType` and `BlockMutationEvent` types exported
- `Improvement` - `blocks.update(id, data)` now can accept partial data object — it will update only passed properties, others will remain the same.
- `Improvement` - `blocks.update(id, data)` now will trigger onChange with only `block-change` event.
- `Improvement` - `blocks.update(id, data)` will return a promise with BlockAPI object of changed block.
### 2.27.2 ### 2.27.2

View file

@ -220,6 +220,24 @@ export default class Blocks {
} }
} }
/**
* Replaces block under passed index with passed block
*
* @param index - index of existed block
* @param block - new block
*/
public replace(index: number, block: Block): void {
if (this.blocks[index] === undefined) {
throw Error('Incorrect index');
}
const prevBlock = this.blocks[index];
prevBlock.holder.replaceWith(block.holder);
this.blocks[index] = block;
}
/** /**
* Inserts several blocks at once * Inserts several blocks at once
* *

View file

@ -297,26 +297,19 @@ export default class BlocksAPI extends Module {
* @param id - id of the block to update * @param id - id of the block to update
* @param data - the new data * @param data - the new data
*/ */
public update = (id: string, data: BlockToolData): void => { public update = async (id: string, data: Partial<BlockToolData>): Promise<BlockAPIInterface> => {
const { BlockManager } = this.Editor; const { BlockManager } = this.Editor;
const block = BlockManager.getBlockById(id); const block = BlockManager.getBlockById(id);
if (!block) { if (block === undefined) {
_.log('blocks.update(): Block with passed id was not found', 'warn'); throw new Error(`Block with id "${id}" not found`);
return;
} }
const blockIndex = BlockManager.getBlockIndex(block); const updatedBlock = await BlockManager.update(block, data);
BlockManager.insert({ // we cast to any because our BlockAPI has no "new" signature
id: block.id, // eslint-disable-next-line @typescript-eslint/no-explicit-any
tool: block.name, return new (BlockAPI as any)(updatedBlock);
data,
index: blockIndex,
replace: true,
tunes: block.tunes,
});
}; };
/** /**

View file

@ -333,6 +333,36 @@ export default class BlockManager extends Module {
this._blocks.insertMany(blocks, index); this._blocks.insertMany(blocks, index);
} }
/**
* Update Block data.
*
* Currently we don't have an 'update' method in the Tools API, so we just create a new block with the same id and type
* Should not trigger 'block-removed' or 'block-added' events
*
* @param block - block to update
* @param data - new data
*/
public async update(block: Block, data: Partial<BlockToolData>): Promise<Block> {
const existingData = await block.data;
const newBlock = this.composeBlock({
id: block.id,
tool: block.name,
data: Object.assign({}, existingData, data),
tunes: block.tunes,
});
const blockIndex = this.getBlockIndex(block);
this._blocks.replace(blockIndex, newBlock);
this.blockDidMutated(BlockChangedMutationType, newBlock, {
index: blockIndex,
});
return newBlock;
}
/** /**
* Replace passed Block with the new one with specified Tool and data * Replace passed Block with the new one with specified Tool and data
* *

View file

@ -0,0 +1,16 @@
import { BlockMutationEvent, BlockMutationType } from '../../../../types';
/**
* Simplified version of the BlockMutationEvent with optional fields that could be used in tests
*/
export default interface PartialBlockMutationEvent {
/**
* Event type
*/
type?: BlockMutationType,
/**
* Details with partial properties
*/
detail?: Partial<BlockMutationEvent['detail']>
}

View file

@ -0,0 +1,59 @@
/* global chai */
// because this file is imported from cypress/support/e2e.js
// that means all other spec files will have this assertion plugin
// available to them because the supportFile is bundled and served
// prior to any spec files loading
import PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent';
/**
* Chai plugin for checking if passed onChange method is called with an array of passed events
*
* @param _chai - Chai instance
*/
const beCalledWithBatchedEvents = (_chai): void => {
/**
* Check if passed onChange method is called with an array of passed events
*
* @param expectedEvents - batched events to check
*/
function assertToBeCalledWithBatchedEvents(expectedEvents: PartialBlockMutationEvent[]): void {
/**
* EditorJS API is passed as the first parameter of the onChange callback
*/
const EditorJSApiMock = Cypress.sinon.match.any;
const $onChange = this._obj;
this.assert(
$onChange.calledOnce,
'expected #{this} to be called once',
'expected #{this} to not be called once'
);
this.assert(
$onChange.calledWithMatch(
EditorJSApiMock,
Cypress.sinon.match((events: PartialBlockMutationEvent[]) => {
expect(events).to.be.an('array');
return events.every((event, index) => {
const eventToCheck = expectedEvents[index];
return expect(event).to.containSubset(eventToCheck);
});
})
),
'expected #{this} to be called with #{exp}, but it was called with #{act}',
'expected #{this} to not be called with #{exp}, but it was called with #{act} ',
expectedEvents
);
}
_chai.Assertion.addMethod('calledWithBatchedEvents', assertToBeCalledWithBatchedEvents);
};
/**
* registers our assertion function "beCalledWithBatchedEvents" with Chai
*/
chai.use(beCalledWithBatchedEvents);

View file

@ -1,9 +1,9 @@
// in cypress/support/index.d.ts
// load type definitions that come with Cypress module // load type definitions that come with Cypress module
/// <reference types="cypress" /> /// <reference types="cypress" />
import type { EditorConfig, OutputData } from './../../../types/index'; import type { EditorConfig, OutputData } from './../../../types/index';
import type EditorJS from '../../../types/index' import type EditorJS from '../../../types/index'
import PartialBlockMutationEvent from '../fixtures/types/PartialBlockMutationEvent';
declare global { declare global {
namespace Cypress { namespace Cypress {
@ -65,6 +65,22 @@ declare global {
interface ApplicationWindow { interface ApplicationWindow {
EditorJS: typeof EditorJS EditorJS: typeof EditorJS
} }
/**
* Extends Cypress assertion Chainer interface with the new assertion methods
*/
interface Chainer<Subject> {
/**
* Custom Chai assertion that checks if given onChange method is called with an array of passed events
*
* @example
* ```
* cy.get('@onChange').should('be.calledWithBatchedEvents', [{ type: 'block-added', detail: { index: 0 }}])
* expect(onChange).to.be.calledWithBatchedEvents([{ type: 'block-added', detail: { index: 0 }}])
* ```
*/
(chainer: 'be.calledWithBatchedEvents', expectedEvents: PartialBlockMutationEvent[]): Chainable<Subject>;
}
} }
/** /**
@ -76,6 +92,17 @@ declare global {
* "containSubset" object properties matcher * "containSubset" object properties matcher
*/ */
containSubset(subset: any): Assertion; containSubset(subset: any): Assertion;
/**
* Custom Chai assertion that checks if given onChange method is called with an array of passed events
*
* @example
* ```
* cy.get('@onChange').should('be.calledWithBatchedEvents', [{ type: 'block-added', detail: { index: 0 }}])
* expect(onChange).to.be.calledWithBatchedEvents([{ type: 'block-added', detail: { index: 0 }}])
* ```
*/
calledWithBatchedEvents(expectedEvents: PartialBlockMutationEvent[]): Assertion;
} }
} }
} }

View file

@ -16,6 +16,11 @@ installLogsCollector();
*/ */
import './commands'; import './commands';
/**
* File with custom assertions
*/
import './e2e';
import chaiSubset from 'chai-subset'; import chaiSubset from 'chai-subset';
/** /**

View file

@ -114,13 +114,17 @@ describe('api.blocks', () => {
text: 'Updated text', text: 'Updated text',
}; };
editor.blocks.update(idToUpdate, newBlockData); editor.blocks.update(idToUpdate, newBlockData)
.catch(error => {
cy.get('[data-cy=editorjs]') expect(error.message).to.be.eq(`Block with id "${idToUpdate}" not found`);
.get('div.ce-block') })
.invoke('text') .finally(() => {
.then(blockText => { cy.get('[data-cy=editorjs]')
expect(blockText).to.be.eq(firstBlock.data.text); .get('div.ce-block')
.invoke('text')
.then(blockText => {
expect(blockText).to.be.eq(firstBlock.data.text);
});
}); });
}); });
}); });
@ -161,7 +165,7 @@ describe('api.blocks', () => {
{ {
type: 'paragraph', type: 'paragraph',
data: { text: 'first block' }, data: { text: 'first block' },
} },
], ],
}, },
}).then((editor) => { }).then((editor) => {

View file

@ -7,32 +7,11 @@ import { BlockRemovedMutationType } from '../../../types/events/block/BlockRemov
import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved'; import { BlockMovedMutationType } from '../../../types/events/block/BlockMoved';
import type EditorJS from '../../../types/index'; import type EditorJS from '../../../types/index';
/** /**
* EditorJS API is passed as the first parameter of the onChange callback * EditorJS API is passed as the first parameter of the onChange callback
*/ */
const EditorJSApiMock = Cypress.sinon.match.any; const EditorJSApiMock = Cypress.sinon.match.any;
/**
* Check if passed onChange method is called with an array of passed events
*
* @param $onChange - editor onChange spy
* @param expectedEvents - batched events to check
*/
function beCalledWithBatchedEvents($onChange, expectedEvents): void {
expect($onChange).to.be.calledOnce;
expect($onChange).to.be.calledWithMatch(
EditorJSApiMock,
Cypress.sinon.match((events) => {
return events.every((event, index) => {
const eventToCheck = expectedEvents[index];
return expect(event).to.containSubset(eventToCheck);
});
})
);
}
/** /**
* @todo Add checks that correct block API object is passed to onChange * @todo Add checks that correct block API object is passed to onChange
* @todo Add cases for native inputs changes * @todo Add cases for native inputs changes
@ -105,22 +84,20 @@ describe('onChange callback', () => {
.type('change') .type('change')
.type('{enter}'); .type('{enter}');
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ {
{ type: BlockChangedMutationType,
type: BlockChangedMutationType, detail: {
detail: { index: 0,
index: 0,
},
}, },
{ },
type: BlockAddedMutationType, {
detail: { type: BlockAddedMutationType,
index: 1, detail: {
}, index: 1,
}, },
]); },
}); ]);
}); });
it('should filter out similar events on batching', () => { it('should filter out similar events on batching', () => {
@ -243,37 +220,35 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=delimiter]') .get('div.ce-popover-item[data-item-name=delimiter]')
.click(); .click();
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, detail: {
detail: { index: 0,
index: 0, target: {
target: { name: 'paragraph',
name: 'paragraph',
},
}, },
}, },
{ },
type: BlockAddedMutationType, {
detail: { type: BlockAddedMutationType,
index: 0, detail: {
target: { index: 0,
name: 'delimiter', target: {
}, name: 'delimiter',
}, },
}, },
{ },
type: BlockAddedMutationType, {
detail: { type: BlockAddedMutationType,
index: 1, detail: {
target: { index: 1,
name: 'paragraph', target: {
}, name: 'paragraph',
}, },
}, },
]); },
}); ]);
}); });
it('should be fired on block replacement for both of blocks', () => { it('should be fired on block replacement for both of blocks', () => {
@ -291,28 +266,26 @@ describe('onChange callback', () => {
.get('div.ce-popover-item[data-item-name=header]') .get('div.ce-popover-item[data-item-name=header]')
.click(); .click();
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, detail: {
detail: { index: 0,
index: 0, target: {
target: { name: 'paragraph',
name: 'paragraph',
},
}, },
}, },
{ },
type: BlockAddedMutationType, {
detail: { type: BlockAddedMutationType,
index: 0, detail: {
target: { index: 0,
name: 'header', target: {
}, name: 'header',
}, },
}, },
]); },
}); ]);
}); });
it('should be fired on tune modifying', () => { it('should be fired on tune modifying', () => {
@ -375,28 +348,26 @@ describe('onChange callback', () => {
.get('div[data-item-name=delete]') .get('div[data-item-name=delete]')
.click(); .click();
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ /**
/** * "block-removed" fired since we have deleted a block
* "block-removed" fired since we have deleted a block */
*/ {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, detail: {
detail: { index: 0,
index: 0,
},
}, },
/** },
* "block-added" fired since we have deleted the last block, so the new one is created /**
*/ * "block-added" fired since we have deleted the last block, so the new one is created
{ */
type: BlockAddedMutationType, {
detail: { type: BlockAddedMutationType,
index: 0, detail: {
}, index: 0,
}, },
]); },
}); ]);
}); });
it('should be fired when block is moved', () => { it('should be fired when block is moved', () => {
@ -594,19 +565,17 @@ describe('onChange callback', () => {
cy.wrap(editor.blocks.clear()); cy.wrap(editor.blocks.clear());
}); });
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, },
}, {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, },
}, {
{ type: BlockAddedMutationType,
type: BlockAddedMutationType, },
}, ]);
]);
});
}); });
it('should be called on blocks.render() on non-empty editor with removed blocks', () => { it('should be called on blocks.render() on non-empty editor with removed blocks', () => {
@ -639,15 +608,52 @@ describe('onChange callback', () => {
})); }));
}); });
cy.get('@onChange').should(($callback) => { cy.get('@onChange').should('be.calledWithBatchedEvents', [
return beCalledWithBatchedEvents($callback, [ {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, },
}, {
{ type: BlockRemovedMutationType,
type: BlockRemovedMutationType, },
}, ]);
]); });
});
it('should be called on blocks.update() with "block-changed" event', () => {
const block = {
id: 'bwnFX5LoX7',
type: 'paragraph',
data: {
text: 'The first block mock.',
},
};
const config = {
data: {
blocks: [
block,
],
},
onChange: (api, event): void => {
console.log('something changed', event);
},
};
cy.spy(config, 'onChange').as('onChange');
cy.createEditor(config)
.then((editor) => {
editor.blocks.update(block.id, {
text: 'Updated text',
});
cy.get('@onChange').should('be.calledWithMatch', EditorJSApiMock, Cypress.sinon.match({
type: BlockChangedMutationType,
detail: {
index: 0,
target: {
id: block.id,
},
},
}));
});
}); });
}); });

View file

@ -134,9 +134,9 @@ export interface Blocks {
* Updates block data by id * Updates block data by id
* *
* @param id - id of the block to update * @param id - id of the block to update
* @param data - the new data * @param data - the new data. Can be partial.
*/ */
update(id: string, data: BlockToolData): void; update(id: string, data: Partial<BlockToolData>): Promise<BlockAPI>;
/** /**
* Converts block to another type. Both blocks should provide the conversionConfig. * Converts block to another type. Both blocks should provide the conversionConfig.

4
types/index.d.ts vendored
View file

@ -31,7 +31,7 @@ import {
} from './api'; } from './api';
import { OutputData } from './data-formats'; import { OutputData } from './data-formats';
import { BlockMutationEventMap } from './events/block'; import { BlockMutationEvent, BlockMutationEventMap, BlockMutationType } from './events/block';
import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded'; import { BlockAddedMutationType, BlockAddedEvent } from './events/block/BlockAdded';
import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged'; import { BlockChangedMutationType, BlockChangedEvent } from './events/block/BlockChanged';
import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved'; import { BlockMovedMutationType, BlockMovedEvent } from './events/block/BlockMoved';
@ -85,6 +85,8 @@ export { OutputData, OutputBlockData} from './data-formats/output-data';
export { BlockId } from './data-formats/block-id'; export { BlockId } from './data-formats/block-id';
export { BlockAPI } from './api' export { BlockAPI } from './api'
export { export {
BlockMutationType,
BlockMutationEvent,
BlockMutationEventMap, BlockMutationEventMap,
BlockAddedMutationType, BlockAddedMutationType,
BlockAddedEvent, BlockAddedEvent,