From 1936caaad65f25a52f383e2d5b224bb11315ef9a Mon Sep 17 00:00:00 2001 From: Jorge Costa Date: Mon, 18 Mar 2019 12:48:43 +0000 Subject: [PATCH] Add new action REPLACE_INNER_BLOCKS for InnerBlocks replacement (#14291) --- .../developers/data/data-core-block-editor.md | 11 + .../src/components/inner-blocks/index.js | 12 +- packages/block-editor/src/store/actions.js | 20 + packages/block-editor/src/store/reducer.js | 34 ++ .../block-editor/src/store/test/actions.js | 59 ++- .../block-editor/src/store/test/reducer.js | 391 ++++++++++++++++++ 6 files changed, 502 insertions(+), 25 deletions(-) diff --git a/docs/designers-developers/developers/data/data-core-block-editor.md b/docs/designers-developers/developers/data/data-core-block-editor.md index 03929e3671685d..fb3f337dd59cd2 100644 --- a/docs/designers-developers/developers/data/data-core-block-editor.md +++ b/docs/designers-developers/developers/data/data-core-block-editor.md @@ -980,6 +980,17 @@ specified client ID is to be removed. * selectPrevious: True if the previous block should be selected when a block is removed. +### replaceInnerBlocks + +Returns an action object used in signalling that the inner blocks with the +specified client ID should be replaced. + +*Parameters* + + * rootClientId: Client ID of the block whose InnerBlocks will re replaced. + * blocks: Block objects to insert as new InnerBlocks + * updateSelection: If true block selection will be updated. If false, block selection will not change. Defaults to true. + ### toggleBlockMode Returns an action object used to toggle the block editing mode between diff --git a/packages/block-editor/src/components/inner-blocks/index.js b/packages/block-editor/src/components/inner-blocks/index.js index 1dd6292ab324f0..b55802a0dfe90f 100644 --- a/packages/block-editor/src/components/inner-blocks/index.js +++ b/packages/block-editor/src/components/inner-blocks/index.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { pick, isEqual, map } from 'lodash'; +import { pick, isEqual } from 'lodash'; import classnames from 'classnames'; /** @@ -144,20 +144,14 @@ InnerBlocks = compose( [ } ), withDispatch( ( dispatch, ownProps ) => { const { - replaceBlocks, - insertBlocks, + replaceInnerBlocks, updateBlockListSettings, } = dispatch( 'core/block-editor' ); const { block, clientId, templateInsertUpdatesSelection = true } = ownProps; return { replaceInnerBlocks( blocks ) { - const clientIds = map( block.innerBlocks, 'clientId' ); - if ( clientIds.length ) { - replaceBlocks( clientIds, blocks ); - } else { - insertBlocks( blocks, undefined, clientId, templateInsertUpdatesSelection ); - } + replaceInnerBlocks( clientId, blocks, block.innerBlocks.length === 0 && templateInsertUpdatesSelection ); }, updateNestedSettings( settings ) { dispatch( updateBlockListSettings( clientId, settings ) ); diff --git a/packages/block-editor/src/store/actions.js b/packages/block-editor/src/store/actions.js index a2a1bc6e6e628c..d0040b2212b76e 100644 --- a/packages/block-editor/src/store/actions.js +++ b/packages/block-editor/src/store/actions.js @@ -420,6 +420,26 @@ export function removeBlock( clientId, selectPrevious ) { return removeBlocks( [ clientId ], selectPrevious ); } +/** + * Returns an action object used in signalling that the inner blocks with the + * specified client ID should be replaced. + * + * @param {string} rootClientId Client ID of the block whose InnerBlocks will re replaced. + * @param {Object[]} blocks Block objects to insert as new InnerBlocks + * @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 replaceInnerBlocks( rootClientId, blocks, updateSelection = true ) { + return { + type: 'REPLACE_INNER_BLOCKS', + rootClientId, + blocks, + updateSelection, + time: Date.now(), + }; +} + /** * Returns an action object used to toggle the block editing mode between * visual and HTML modes. diff --git a/packages/block-editor/src/store/reducer.js b/packages/block-editor/src/store/reducer.js index 007c5f4a8f6d4b..5e91c85f524bce 100644 --- a/packages/block-editor/src/store/reducer.js +++ b/packages/block-editor/src/store/reducer.js @@ -296,6 +296,38 @@ const withBlockReset = ( reducer ) => ( state, action ) => { return reducer( state, action ); }; +/** + * Higher-order reducer which targets the combined blocks reducer and handles + * the `REPLACE_INNER_BLOCKS` action. When dispatched, this action the state should become equivalent + * to the execution of a `REMOVE_BLOCKS` action containing all the child's of the root block followed by + * the execution of `INSERT_BLOCKS` with the new blocks. + * + * @param {Function} reducer Original reducer function. + * + * @return {Function} Enhanced reducer function. + */ +const withReplaceInnerBlocks = ( reducer ) => ( state, action ) => { + if ( action.type !== 'REPLACE_INNER_BLOCKS' ) { + return reducer( state, action ); + } + let stateAfterBlocksRemoval = state; + if ( state.order[ action.rootClientId ] ) { + stateAfterBlocksRemoval = reducer( stateAfterBlocksRemoval, { + type: 'REMOVE_BLOCKS', + clientIds: state.order[ action.rootClientId ], + } ); + } + let stateAfterInsert = stateAfterBlocksRemoval; + if ( action.blocks.length ) { + stateAfterInsert = reducer( stateAfterInsert, { + ...action, + type: 'INSERT_BLOCKS', + index: 0, + } ); + } + return stateAfterInsert; +}; + /** * Higher-order reducer which targets the combined blocks reducer and handles * the `SAVE_REUSABLE_BLOCK_SUCCESS` action. This action can't be handled by @@ -344,6 +376,7 @@ const withSaveReusableBlock = ( reducer ) => ( state, action ) => { export const blocks = flow( combineReducers, withInnerBlocksRemoveCascade, + withReplaceInnerBlocks, // needs to be after withInnerBlocksRemoveCascade withBlockReset, withSaveReusableBlock, withPersistentBlockChange, @@ -713,6 +746,7 @@ export function blockSelection( state = { end: action.clientId, initialPosition: action.initialPosition, }; + case 'REPLACE_INNER_BLOCKS': // REPLACE_INNER_BLOCKS and INSERT_BLOCKS should follow the same logic. case 'INSERT_BLOCKS': { if ( action.updateSelection ) { return { diff --git a/packages/block-editor/src/store/test/actions.js b/packages/block-editor/src/store/test/actions.js index 2ca6839dc57e22..225d36152e66a8 100644 --- a/packages/block-editor/src/store/test/actions.js +++ b/packages/block-editor/src/store/test/actions.js @@ -2,30 +2,31 @@ * Internal dependencies */ import { - replaceBlocks, - startTyping, - stopTyping, + clearSelectedBlock, enterFormattedText, exitFormattedText, - toggleSelection, + hideInsertionPoint, + insertBlock, + insertBlocks, + mergeBlocks, + multiSelect, + removeBlock, + removeBlocks, + replaceBlock, + replaceBlocks, + replaceInnerBlocks, resetBlocks, - updateBlockAttributes, - updateBlock, selectBlock, selectPreviousBlock, + showInsertionPoint, startMultiSelect, + startTyping, stopMultiSelect, - multiSelect, - clearSelectedBlock, - replaceBlock, - insertBlock, - insertBlocks, - showInsertionPoint, - hideInsertionPoint, - mergeBlocks, - removeBlocks, - removeBlock, + stopTyping, toggleBlockMode, + toggleSelection, + updateBlock, + updateBlockAttributes, updateBlockListSettings, } from '../actions'; import { select } from '../controls'; @@ -346,4 +347,30 @@ describe( 'actions', () => { } ); } ); } ); + + describe( 'replaceInnerBlocks', () => { + const block = { + clientId: 'ribs', + }; + + it( 'should return the REPLACE_INNER_BLOCKS action with default values set', () => { + expect( replaceInnerBlocks( 'root', [ block ] ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: true, + } ); + } ); + + it( 'should return the REPLACE_INNER_BLOCKS action with updateSelection false', () => { + expect( replaceInnerBlocks( 'root', [ block ], false ) ).toEqual( { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ block ], + rootClientId: 'root', + time: expect.any( Number ), + updateSelection: false, + } ); + } ); + } ); } ); diff --git a/packages/block-editor/src/store/test/reducer.js b/packages/block-editor/src/store/test/reducer.js index 778875b99ef599..62adf23f298fd8 100644 --- a/packages/block-editor/src/store/test/reducer.js +++ b/packages/block-editor/src/store/test/reducer.js @@ -162,6 +162,348 @@ describe( 'state', () => { unregisterBlockType( 'core/test-block' ); } ); + describe( 'replace inner blocks', () => { + beforeAll( () => { + registerBlockType( 'core/test-parent-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test parent block', + } ); + registerBlockType( 'core/test-child-block', { + save: noop, + edit: noop, + category: 'common', + title: 'test child block 1', + attributes: { + attr: { + type: 'boolean', + }, + attr2: { + type: 'string', + }, + }, + } ); + } ); + + afterAll( () => { + unregisterBlockType( 'core/test-parent-block' ); + unregisterBlockType( 'core/test-child-block' ); + } ); + it( 'can replace a child block', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': { + attr: true, + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child' ], + 'clicken-child': [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: expect.anything(), + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + + it( 'can insert a child block', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + }, + order: { + '': [ 'clicken' ], + clicken: [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: expect.anything(), + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: { + attr: false, + attr2: 'perfect', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + + it( 'can replace multiple child blocks', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'clicken-child-2': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': { + attr: true, + }, + 'clicken-child-2': { + attr2: 'ok', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child', 'clicken-child-2' ], + 'clicken-child': [], + 'clicken-child-2': [], + }, + } ); + + const newChildBlock1 = createBlock( 'core/test-child-block', { + attr: false, + attr2: 'perfect', + } ); + + const newChildBlock2 = createBlock( 'core/test-child-block', { + attr: true, + attr2: 'not-perfect', + } ); + + const newChildBlock3 = createBlock( 'core/test-child-block', { + attr2: 'hello', + } ); + + const { clientId: newChildBlockId1 } = newChildBlock1; + const { clientId: newChildBlockId2 } = newChildBlock2; + const { clientId: newChildBlockId3 } = newChildBlock3; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock1, newChildBlock2, newChildBlock3 ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: expect.anything(), + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId1 ]: { + clientId: newChildBlockId1, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId2 ]: { + clientId: newChildBlockId2, + name: 'core/test-child-block', + isValid: true, + }, + [ newChildBlockId3 ]: { + clientId: newChildBlockId3, + name: 'core/test-child-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId1 ]: { + attr: false, + attr2: 'perfect', + }, + [ newChildBlockId2 ]: { + attr: true, + attr2: 'not-perfect', + }, + [ newChildBlockId3 ]: { + attr2: 'hello', + }, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId1, newChildBlockId2, newChildBlockId3 ], + [ newChildBlockId1 ]: [], + [ newChildBlockId2 ]: [], + [ newChildBlockId3 ]: [], + }, + } ); + } ); + + it( 'can replace a child block that has other children', () => { + const existingState = deepFreeze( { + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + 'clicken-child': { + clientId: 'chicken-child', + name: 'core/test-child-block', + isValid: true, + }, + 'clicken-grand-child': { + clientId: 'chicken-child', + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + 'clicken-child': {}, + 'clicken-grand-child': {}, + }, + order: { + '': [ 'clicken' ], + clicken: [ 'clicken-child' ], + 'clicken-child': [ 'clicken-grand-child' ], + 'clicken-grand-child': [], + }, + } ); + + const newChildBlock = createBlock( 'core/test-block' ); + + const { clientId: newChildBlockId } = newChildBlock; + + const action = { + type: 'REPLACE_INNER_BLOCKS', + rootClientId: 'clicken', + blocks: [ newChildBlock ], + }; + + const state = blocks( existingState, action ); + + expect( state ).toEqual( { + isPersistentChange: expect.anything(), + byClientId: { + clicken: { + clientId: 'chicken', + name: 'core/test-parent-block', + isValid: true, + }, + [ newChildBlockId ]: { + clientId: newChildBlockId, + name: 'core/test-block', + isValid: true, + }, + }, + attributes: { + clicken: {}, + [ newChildBlockId ]: {}, + }, + order: { + '': [ 'clicken' ], + clicken: [ newChildBlockId ], + [ newChildBlockId ]: [], + }, + } ); + } ); + } ); + it( 'should return empty byClientId, attributes, order by default', () => { const state = blocks( undefined, {} ); @@ -1562,6 +1904,55 @@ describe( 'state', () => { expect( state ).toBe( original ); } ); + + it( 'should update the selection on inner blocks replace if updateSelection is true', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: true, + } ); + + expect( state ).toEqual( { + start: 'another-block', + end: 'another-block', + initialPosition: null, + isMultiSelecting: false, + } ); + } ); + + it( 'should not update the selection on inner blocks replace if updateSelection is false', () => { + const original = deepFreeze( { + start: 'chicken', + end: 'chicken', + initialPosition: null, + isMultiSelecting: false, + } ); + const newBlock = { + name: 'core/test-block', + clientId: 'another-block', + }; + + const state = blockSelection( original, { + type: 'REPLACE_INNER_BLOCKS', + blocks: [ newBlock ], + rootClientId: 'parent', + updateSelection: false, + } ); + + expect( state ).toBe( original ); + } ); } ); describe( 'preferences()', () => {