From 7449074b46bcebb4724a4ffdfbb594ae2b3fc0de Mon Sep 17 00:00:00 2001 From: Bernie Reiter <96308+ockham@users.noreply.github.com> Date: Thu, 8 Feb 2024 18:30:15 +0100 Subject: [PATCH] Block Hooks: Set ignoredHookedBlocks metada attr upon insertion (#58553) In WP 6.5, we'll be enabling insertion of hooked blocks into modified templates/parts/patterns. This is implemented by adding a `metadata.ignoredHookedBlocks` attribute to the hooked block's anchor block upon editing. As a consequence, upon insertion of an anchor block, this attribute also needs to be added to that newly inserted instance. _A note on UX: It could be argued that ideally, insertion of a new anchor block should cause its hooked blocks to be inserted alongside with it. This could be a future addition but seems to be a bit more complicated to do. Regardless, the `metadata.ignoredHookedBlocks` attribute also would need to be set in that case, in order to avoid double insertion -- meaning that adding that attribute is required as a baseline anyway._ Co-authored-by: ockham Co-authored-by: gziolo --- .../reference-guides/data/data-core-blocks.md | 48 ++++++++ packages/block-editor/src/store/selectors.js | 10 +- packages/blocks/README.md | 14 +++ packages/blocks/src/api/index.js | 1 + packages/blocks/src/api/registration.js | 15 +++ packages/blocks/src/api/templates.js | 31 ++++- packages/blocks/src/api/test/templates.js | 80 ++++++++++++- packages/blocks/src/store/selectors.js | 62 ++++++++++ packages/blocks/src/store/test/selectors.js | 106 ++++++++++++++++++ 9 files changed, 364 insertions(+), 3 deletions(-) diff --git a/docs/reference-guides/data/data-core-blocks.md b/docs/reference-guides/data/data-core-blocks.md index 084c9c1d7a5fb..a25a521931e25 100644 --- a/docs/reference-guides/data/data-core-blocks.md +++ b/docs/reference-guides/data/data-core-blocks.md @@ -504,6 +504,54 @@ _Returns_ - `string?`: Name of the block for handling the grouping of blocks. +### getHookedBlocks + +Returns the hooked blocks for a given anchor block. + +Given an anchor block name, returns an object whose keys are relative positions, and whose values are arrays of block names that are hooked to the anchor block at that relative position. + +_Usage_ + +```js +import { store as blocksStore } from '@wordpress/blocks'; +import { useSelect } from '@wordpress/data'; + +const ExampleComponent = () => { + const hookedBlockNames = useSelect( + ( select ) => + select( blocksStore ).getHookedBlocks( 'core/navigation' ), + [] + ); + + return ( + + ); +}; +``` + +_Parameters_ + +- _state_ `Object`: Data state. +- _blockName_ `string`: Anchor block type name. + +_Returns_ + +- `Object`: Lists of hooked block names for each relative position. + ### getUnregisteredFallbackBlockName Returns the name of the block for handling unregistered blocks. diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 3475e2b5351c8..373611cd3bd8e 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -10,6 +10,7 @@ import { getBlockType, getBlockTypes, getBlockVariations, + getHookedBlocks, hasBlockSupport, getPossibleBlockTransformations, parse, @@ -1936,9 +1937,16 @@ const buildBlockTypeItem = blockType.name, 'inserter' ); + + const ignoredHookedBlocks = [ + ...new Set( Object.values( getHookedBlocks( id ) ).flat() ), + ]; + return { ...blockItemBase, - initialAttributes: {}, + initialAttributes: ignoredHookedBlocks.length + ? { metadata: { ignoredHookedBlocks } } + : {}, description: blockType.description, category: blockType.category, keywords: blockType.keywords, diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 8e6fdc9d900db..eda3ac629bef8 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -234,6 +234,20 @@ _Returns_ - `?string`: Block name. +### getHookedBlocks + +Returns the hooked blocks for a given anchor block. + +Given an anchor block name, returns an object whose keys are relative positions, and whose values are arrays of block names that are hooked to the anchor block at that relative position. + +_Parameters_ + +- _name_ `string`: Anchor block name. + +_Returns_ + +- `Object`: Lists of hooked block names for each relative position. + ### getPhrasingContentSchema Undocumented declaration. diff --git a/packages/blocks/src/api/index.js b/packages/blocks/src/api/index.js index 2ddeb3a60f0ab..92738c6e16fbc 100644 --- a/packages/blocks/src/api/index.js +++ b/packages/blocks/src/api/index.js @@ -124,6 +124,7 @@ export { getBlockTypes, getBlockSupport, hasBlockSupport, + getHookedBlocks, getBlockVariations, isReusableBlock, isTemplatePart, diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index 6633adf40050c..71e59949d51d1 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -550,6 +550,21 @@ export function hasBlockSupport( nameOrType, feature, defaultSupports ) { ); } +/** + * Returns the hooked blocks for a given anchor block. + * + * Given an anchor block name, returns an object whose keys are relative positions, + * and whose values are arrays of block names that are hooked to the anchor block + * at that relative position. + * + * @param {string} name Anchor block name. + * + * @return {Object} Lists of hooked block names for each relative position. + */ +export function getHookedBlocks( name ) { + return select( blocksStore ).getHookedBlocks( name ); +} + /** * Determines whether or not the given block is a reusable block. This is a * special block type that is used to point to a global block stored via the diff --git a/packages/blocks/src/api/templates.js b/packages/blocks/src/api/templates.js index bc76218892688..34e6954a9ff33 100644 --- a/packages/blocks/src/api/templates.js +++ b/packages/blocks/src/api/templates.js @@ -8,7 +8,7 @@ import { renderToString } from '@wordpress/element'; */ import { convertLegacyBlockNameAndAttributes } from './parser/convert-legacy-block'; import { createBlock } from './factory'; -import { getBlockType } from './registration'; +import { getBlockType, getHookedBlocks } from './registration'; /** * Checks whether a list of blocks matches a template by comparing the block names. @@ -115,6 +115,35 @@ export function synchronizeBlocksWithTemplate( blocks = [], template ) { normalizedAttributes ); + const ignoredHookedBlocks = [ + ...new Set( + Object.values( getHookedBlocks( blockName ) ).flat() + ), + ]; + + if ( ignoredHookedBlocks.length ) { + const { metadata = {}, ...otherAttributes } = blockAttributes; + const { + ignoredHookedBlocks: ignoredHookedBlocksFromTemplate = [], + ...otherMetadata + } = metadata; + + const newIgnoredHookedBlocks = [ + ...new Set( [ + ...ignoredHookedBlocks, + ...ignoredHookedBlocksFromTemplate, + ] ), + ]; + + blockAttributes = { + metadata: { + ignoredHookedBlocks: newIgnoredHookedBlocks, + ...otherMetadata, + }, + ...otherAttributes, + }; + } + // If a Block is undefined at this point, use the core/missing block as // a placeholder for a better user experience. if ( undefined === getBlockType( blockName ) ) { diff --git a/packages/blocks/src/api/test/templates.js b/packages/blocks/src/api/test/templates.js index 0a23505f0ac03..8ee031aedbeef 100644 --- a/packages/blocks/src/api/test/templates.js +++ b/packages/blocks/src/api/test/templates.js @@ -28,7 +28,11 @@ describe( 'templates', () => { beforeEach( () => { registerBlockType( 'core/test-block', { - attributes: {}, + attributes: { + metadata: { + type: 'object', + }, + }, save: noop, category: 'text', title: 'test block', @@ -132,6 +136,80 @@ describe( 'templates', () => { ] ); } ); + it( 'should set ignoredHookedBlocks metadata if a block has hooked blocks', () => { + registerBlockType( 'core/hooked-block', { + attributes: {}, + save: noop, + category: 'text', + title: 'hooked block', + blockHooks: { 'core/test-block': 'after' }, + } ); + + const template = [ + [ 'core/test-block' ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = []; + + expect( + synchronizeBlocksWithTemplate( blockList, template ) + ).toMatchObject( [ + { + name: 'core/test-block', + attributes: { + metadata: { + ignoredHookedBlocks: [ 'core/hooked-block' ], + }, + }, + }, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + + it( 'retains previously set ignoredHookedBlocks metadata', () => { + registerBlockType( 'core/hooked-block', { + attributes: {}, + save: noop, + category: 'text', + title: 'hooked block', + blockHooks: { 'core/test-block': 'after' }, + } ); + + const template = [ + [ + 'core/test-block', + { + metadata: { + ignoredHookedBlocks: [ 'core/other-hooked-block' ], + }, + }, + ], + [ 'core/test-block-2' ], + [ 'core/test-block-2' ], + ]; + const blockList = []; + + expect( + synchronizeBlocksWithTemplate( blockList, template ) + ).toMatchObject( [ + { + name: 'core/test-block', + attributes: { + metadata: { + ignoredHookedBlocks: [ + 'core/hooked-block', + 'core/other-hooked-block', + ], + }, + }, + }, + { name: 'core/test-block-2' }, + { name: 'core/test-block-2' }, + ] ); + } ); + it( 'should create nested blocks', () => { const template = [ [ 'core/test-block', {}, [ [ 'core/test-block-2' ] ] ], diff --git a/packages/blocks/src/store/selectors.js b/packages/blocks/src/store/selectors.js index b2b8ab8106f09..9eda135d0d699 100644 --- a/packages/blocks/src/store/selectors.js +++ b/packages/blocks/src/store/selectors.js @@ -106,6 +106,68 @@ export function getBlockType( state, name ) { return state.blockTypes[ name ]; } +/** + * Returns the hooked blocks for a given anchor block. + * + * Given an anchor block name, returns an object whose keys are relative positions, + * and whose values are arrays of block names that are hooked to the anchor block + * at that relative position. + * + * @param {Object} state Data state. + * @param {string} blockName Anchor block type name. + * + * @example + * ```js + * import { store as blocksStore } from '@wordpress/blocks'; + * import { useSelect } from '@wordpress/data'; + * + * const ExampleComponent = () => { + * const hookedBlockNames = useSelect( ( select ) => + * select( blocksStore ).getHookedBlocks( 'core/navigation' ), + * [] + * ); + * + * return ( + * + * ); + * }; + * ``` + * + * @return {Object} Lists of hooked block names for each relative position. + */ +export const getHookedBlocks = createSelector( + ( state, blockName ) => { + const hookedBlockTypes = getBlockTypes( state ).filter( + ( { blockHooks } ) => blockHooks && blockName in blockHooks + ); + + let hookedBlocks = {}; + for ( const blockType of hookedBlockTypes ) { + const relativePosition = blockType.blockHooks[ blockName ]; + hookedBlocks = { + ...hookedBlocks, + [ relativePosition ]: [ + ...( hookedBlocks[ relativePosition ] ?? [] ), + blockType.name, + ], + }; + } + return hookedBlocks; + }, + ( state ) => [ state.blockTypes ] +); + /** * Returns block styles by block name. * diff --git a/packages/blocks/src/store/test/selectors.js b/packages/blocks/src/store/test/selectors.js index 1fda11d72311a..0dcdde3c07bf1 100644 --- a/packages/blocks/src/store/test/selectors.js +++ b/packages/blocks/src/store/test/selectors.js @@ -12,6 +12,7 @@ import { getBlockVariations, getDefaultBlockVariation, getGroupingBlockName, + getHookedBlocks, isMatchingSearchTerm, getCategories, getActiveBlockVariation, @@ -228,6 +229,111 @@ describe( 'selectors', () => { } ); } ); + describe( 'getHookedBlocks', () => { + it( 'should return an empty object if state is empty', () => { + const state = { + blockTypes: {}, + }; + + expect( getHookedBlocks( state, 'anchor' ) ).toEqual( {} ); + } ); + + it( 'should return an empty object if the anchor block is not found', () => { + const state = { + blockTypes: { + anchor: { + name: 'anchor', + }, + hookedBlock: { + name: 'hookedBlock', + blockHooks: { + anchor: 'after', + }, + }, + }, + }; + + expect( getHookedBlocks( state, 'otherAnchor' ) ).toEqual( {} ); + } ); + + it( "should return the anchor block name even if the anchor block doesn't exist", () => { + const state = { + blockTypes: { + hookedBlock: { + name: 'hookedBlock', + blockHooks: { + anchor: 'after', + }, + }, + }, + }; + + expect( getHookedBlocks( state, 'anchor' ) ).toEqual( { + after: [ 'hookedBlock' ], + } ); + } ); + + it( 'should return an array with the hooked block names', () => { + const state = { + blockTypes: { + anchor: { + name: 'anchor', + }, + hookedBlock1: { + name: 'hookedBlock1', + blockHooks: { + anchor: 'after', + }, + }, + hookedBlock2: { + name: 'hookedBlock2', + blockHooks: { + anchor: 'before', + }, + }, + }, + }; + + expect( getHookedBlocks( state, 'anchor' ) ).toEqual( { + after: [ 'hookedBlock1' ], + before: [ 'hookedBlock2' ], + } ); + } ); + + it( 'should return an array with the hooked block names, even if multiple blocks are in the same relative position', () => { + const state = { + blockTypes: { + anchor: { + name: 'anchor', + }, + hookedBlock1: { + name: 'hookedBlock1', + blockHooks: { + anchor: 'after', + }, + }, + hookedBlock2: { + name: 'hookedBlock2', + blockHooks: { + anchor: 'before', + }, + }, + hookedBlock3: { + name: 'hookedBlock3', + blockHooks: { + anchor: 'after', + }, + }, + }, + }; + + expect( getHookedBlocks( state, 'anchor' ) ).toEqual( { + after: [ 'hookedBlock1', 'hookedBlock3' ], + before: [ 'hookedBlock2' ], + } ); + } ); + } ); + describe( 'Testing block variations selectors', () => { const blockName = 'block/name'; const createBlockVariationsState = ( variations ) => {