diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index d0040b2212b76e..6ebef33423b273 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { castArray } from 'lodash'; +import { castArray, first } from 'lodash'; /** * WordPress dependencies @@ -13,6 +13,25 @@ import { getDefaultBlockName, createBlock } from '@wordpress/blocks'; */ import { select } from './controls'; +/** + * Generator which will yield a default block insertion action if there + * are no other blocks at the root of the editor. This is expected to be used + * in actions which may result in no blocks remaining in the editor (removal, + * replacement, etc). + */ +function* ensureDefaultBlock() { + const count = yield select( + 'core/block-editor', + 'getBlockCount', + ); + + // To avoid a focus loss when removing the last block, assure there is + // always a default block if the last of the blocks have been removed. + if ( count === 0 ) { + yield insertDefaultBlock(); + } +} + /** * Returns an action object used in signalling that blocks state should be * reset to the specified array of blocks, taking precedence over any other @@ -201,16 +220,41 @@ export function toggleSelection( isSelectionEnabled = true ) { * * @param {(string|string[])} clientIds Block client ID(s) to replace. * @param {(Object|Object[])} blocks Replacement block(s). - * - * @return {Object} Action object. */ -export function replaceBlocks( clientIds, blocks ) { - return { +export function* replaceBlocks( clientIds, blocks ) { + clientIds = castArray( clientIds ); + blocks = castArray( blocks ); + const rootClientId = yield select( + 'core/block-editor', + 'getBlockRootClientId', + first( clientIds ) + ); + // Replace is valid if the new blocks can be inserted in the root block + // or if we had a block of the same type in the position of the block being replaced. + for ( let index = 0; index < blocks.length; index++ ) { + const block = blocks[ index ]; + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( ! canInsertBlock ) { + const clientIdToReplace = clientIds[ index ]; + const nameOfBlockToReplace = clientIdToReplace && + ( yield select( 'core/block-editor', 'getBlockName', clientIdToReplace ) ); + if ( ! nameOfBlockToReplace || ( nameOfBlockToReplace !== block.name ) ) { + return; + } + } + } + yield { type: 'REPLACE_BLOCKS', - clientIds: castArray( clientIds ), - blocks: castArray( blocks ), + clientIds, + blocks, time: Date.now(), }; + yield* ensureDefaultBlock(); } /** @@ -258,14 +302,48 @@ export const moveBlocksUp = createOnMove( 'MOVE_BLOCKS_UP' ); * * @return {Object} Action object. */ -export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { - return { +export function* moveBlockToPosition( clientId, fromRootClientId, toRootClientId, index ) { + const templateLock = yield select( + 'core/block-editor', + 'getTemplateLock', + fromRootClientId + ); + + // If locking is equal to all on the original clientId (fromRootClientId), + // it is not possible to move the block to any other position. + if ( templateLock === 'all' ) { + return; + } + + const action = { type: 'MOVE_BLOCK_TO_POSITION', fromRootClientId, toRootClientId, clientId, index, }; + // If moving inside the same root block the move is always possible. + if ( fromRootClientId === toRootClientId ) { + return action; + } + + const blockName = yield select( + 'core/block-editor', + 'getBlockName', + clientId + ); + + const canInsertBlock = yield select( + 'core/block-editor', + 'canInsertBlockType', + blockName, + toRootClientId + ); + + // If moving to other parent block, the move is possible if we can insert a block of the same type inside the new parent block. + if ( canInsertBlock ) { + return action; + } } /** @@ -279,8 +357,18 @@ export function moveBlockToPosition( clientId, fromRootClientId, toRootClientId, * * @return {Object} Action object. */ -export function insertBlock( block, index, rootClientId, updateSelection = true ) { - return insertBlocks( [ block ], index, rootClientId, updateSelection ); +export function insertBlock( + block, + index, + rootClientId, + updateSelection = true, +) { + return insertBlocks( + [ block ], + index, + rootClientId, + updateSelection + ); } /** @@ -292,17 +380,39 @@ export function insertBlock( block, index, rootClientId, updateSelection = true * @param {?string} rootClientId Optional root client ID of block list on which to insert. * @param {?boolean} updateSelection If true block selection will be updated. If false, block selection will not change. Defaults to true. * - * @return {Object} Action object. - */ -export function insertBlocks( blocks, index, rootClientId, updateSelection = true ) { - return { - type: 'INSERT_BLOCKS', - blocks: castArray( blocks ), - index, - rootClientId, - time: Date.now(), - updateSelection, - }; + * @return {Object} Action object. + */ +export function* insertBlocks( + blocks, + index, + rootClientId, + updateSelection = true +) { + blocks = castArray( blocks ); + const allowedBlocks = []; + for ( const block of blocks ) { + if ( block ) { + const isValid = yield select( + 'core/block-editor', + 'canInsertBlockType', + block.name, + rootClientId + ); + if ( isValid ) { + allowedBlocks.push( block ); + } + } + } + if ( allowedBlocks.length ) { + return { + type: 'INSERT_BLOCKS', + blocks: allowedBlocks, + index, + rootClientId, + time: Date.now(), + updateSelection, + }; + } } /** @@ -394,16 +504,9 @@ export function* removeBlocks( clientIds, selectPrevious = true ) { clientIds, }; - const count = yield select( - 'core/block-editor', - 'getBlockCount', - ); - // To avoid a focus loss when removing the last block, assure there is // always a default block if the last of the blocks have been removed. - if ( count === 0 ) { - yield insertDefaultBlock(); - } + yield* ensureDefaultBlock(); } /** diff --git a/packages/block-editor/src/store/effects.js b/packages/block-editor/src/store/effects.js index ca46e1afb8f4a6..d61ce67fe68df8 100644 --- a/packages/block-editor/src/store/effects.js +++ b/packages/block-editor/src/store/effects.js @@ -17,14 +17,12 @@ import { replaceBlocks, selectBlock, setTemplateValidity, - insertDefaultBlock, resetBlocks, } from './actions'; import { getBlock, getBlocks, getSelectedBlockCount, - getBlockCount, getTemplateLock, getTemplate, isValidTemplate, @@ -60,23 +58,6 @@ export function validateBlocksToTemplate( action, store ) { } } -/** - * Effect handler which will return a default block insertion action if there - * are no other blocks at the root of the editor. This is expected to be used - * in actions which may result in no blocks remaining in the editor (removal, - * replacement, etc). - * - * @param {Object} action Action which had initiated the effect handler. - * @param {Object} store Store instance. - * - * @return {?Object} Default block insert action, if no other blocks exist. - */ -export function ensureDefaultBlock( action, store ) { - if ( ! getBlockCount( store.getState() ) ) { - return insertDefaultBlock(); - } -} - export default { MERGE_BLOCKS( action, store ) { const { dispatch } = store; @@ -127,9 +108,6 @@ export default { RESET_BLOCKS: [ validateBlocksToTemplate, ], - REPLACE_BLOCKS: [ - ensureDefaultBlock, - ], MULTI_SELECT: ( action, { getState } ) => { const blockCount = getSelectedBlockCount( getState() ); diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 225d36152e66a8..bdbf4432b57de0 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -117,65 +117,299 @@ describe( 'actions', () => { } ); describe( 'replaceBlock', () => { - it( 'should return the REPLACE_BLOCKS action', () => { + it( 'should yield the REPLACE_BLOCKS action if the new block can be inserted in the destination root block', () => { const block = { clientId: 'ribs', + name: 'core/test-block', }; - expect( replaceBlock( [ 'chicken' ], block ) ).toEqual( { + const replaceBlockGenerator = replaceBlock( 'chicken', block ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-block', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( true ).value, + ).toEqual( { type: 'REPLACE_BLOCKS', clientIds: [ 'chicken' ], blocks: [ block ], time: expect.any( Number ), } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); } ); } ); describe( 'replaceBlocks', () => { - it( 'should return the REPLACE_BLOCKS action', () => { + it( 'should not yield the REPLACE_BLOCKS action if the replacement is not possible', () => { const blocks = [ { clientId: 'ribs', + name: 'core/test-block', } ]; - expect( replaceBlocks( [ 'chicken' ], blocks ) ).toEqual( { + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-block', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockName', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 'core/test-chicken' ), + ).toEqual( { + value: undefined, + done: true, + } ); + } ); + + it( 'should not yield the REPLACE_BLOCKS if the block being replaced and the replacement are of the same type', () => { + const blocks = [ { + clientId: 'ribs', + name: 'core/test-block', + } ]; + + const replaceBlockGenerator = replaceBlocks( [ 'chicken' ], blocks ); + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockRootClientId', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [ 'core/test-block', undefined ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'chicken' ], + selectorName: 'getBlockName', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 'core/test-block' ).value, + ).toEqual( { type: 'REPLACE_BLOCKS', clientIds: [ 'chicken' ], blocks, time: expect.any( Number ), } ); + + expect( + replaceBlockGenerator.next().value, + ).toEqual( { + args: [], + selectorName: 'getBlockCount', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + replaceBlockGenerator.next( 1 ), + ).toEqual( { + value: undefined, + done: true, + } ); } ); } ); describe( 'insertBlock', () => { - it( 'should return the INSERT_BLOCKS action', () => { + it( 'should yield the INSERT_BLOCKS action', () => { const block = { clientId: 'ribs', + name: 'core/test-block', }; const index = 5; - expect( insertBlock( block, index, 'testclientid' ) ).toEqual( { - type: 'INSERT_BLOCKS', - blocks: [ block ], - index, - rootClientId: 'testclientid', - time: expect.any( Number ), - updateSelection: true, + + const inserBlockGenerator = insertBlock( block, index, 'testclientid', true ); + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-block', 'testclientid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ block ], + index, + rootClientId: 'testclientid', + time: expect.any( Number ), + updateSelection: true, + }, } ); } ); } ); describe( 'insertBlocks', () => { - it( 'should return the INSERT_BLOCKS action', () => { - const blocks = [ { + it( 'should filter the allowed blocks in INSERT_BLOCKS action', () => { + const ribsBlock = { clientId: 'ribs', - } ]; - const index = 3; - expect( insertBlocks( blocks, index, 'testclientid' ) ).toEqual( { - type: 'INSERT_BLOCKS', - blocks, - index, - rootClientId: 'testclientid', - time: expect.any( Number ), - updateSelection: true, + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const chickenRibsBlock = { + clientId: 'chicken-ribs', + name: 'core/test-chicken-ribs', + }; + const blocks = [ + ribsBlock, + chickenBlock, + chickenRibsBlock, + ]; + + const inserBlockGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ).value + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( true ), + ).toEqual( { + done: true, + value: { + type: 'INSERT_BLOCKS', + blocks: [ ribsBlock, chickenRibsBlock ], + index: 5, + rootClientId: 'testrootid', + time: expect.any( Number ), + updateSelection: false, + }, + } ); + } ); + + it( 'does not yield INSERT_BLOCKS action if all the blocks are impossible to insert', () => { + const ribsBlock = { + clientId: 'ribs', + name: 'core/test-ribs', + }; + const chickenBlock = { + clientId: 'chicken', + name: 'core/test-chicken', + }; + const blocks = [ + ribsBlock, + chickenBlock, + ]; + + const inserBlockGenerator = insertBlocks( blocks, 5, 'testrootid', false ); + + expect( + inserBlockGenerator.next().value + ).toEqual( { + args: [ 'core/test-ribs', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ).value, + ).toEqual( { + args: [ 'core/test-chicken', 'testrootid' ], + selectorName: 'canInsertBlockType', + storeName: 'core/block-editor', + type: 'SELECT', + } ); + + expect( + inserBlockGenerator.next( false ), + ).toEqual( { + done: true, + value: undefined, } ); } ); } ); diff --git a/packages/block-editor/src/store/test/effects.js b/packages/block-editor/src/store/test/effects.js index 34300ed6d42ff8..cdcf5460775891 100644 --- a/packages/block-editor/src/store/test/effects.js +++ b/packages/block-editor/src/store/test/effects.js @@ -97,14 +97,19 @@ describe( 'effects', () => { expect( dispatch ).toHaveBeenCalledTimes( 2 ); expect( dispatch ).toHaveBeenCalledWith( selectBlock( 'chicken', -1 ) ); - expect( dispatch ).toHaveBeenCalledWith( { - ...replaceBlocks( [ 'chicken', 'ribs' ], [ { - clientId: 'chicken', - name: 'core/test-block', - attributes: { content: 'chicken ribs' }, - } ] ), - time: expect.any( Number ), - } ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); } ); it( 'should not merge the blocks have different types without transformation', () => { @@ -195,14 +200,19 @@ describe( 'effects', () => { expect( dispatch ).toHaveBeenCalledTimes( 2 ); // expect( dispatch ).toHaveBeenCalledWith( focusBlock( 'chicken', { offset: -1 } ) ); - expect( dispatch ).toHaveBeenCalledWith( { - ...replaceBlocks( [ 'chicken', 'ribs' ], [ { - clientId: 'chicken', - name: 'core/test-block', - attributes: { content: 'chicken ribs' }, - } ] ), - time: expect.any( Number ), - } ); + const expectedGenerator = replaceBlocks( [ 'chicken', 'ribs' ], [ { + clientId: 'chicken', + name: 'core/test-block', + attributes: { content: 'chicken ribs' }, + } ] ); + const lastCall = dispatch.mock.calls[ 1 ]; + expect( lastCall ).toHaveLength( 1 ); + const [ lastCallArgument ] = lastCall; + expect( + Array.from( lastCallArgument ) + ).toEqual( + Array.from( expectedGenerator ) + ); } ); } );