diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 92c065f4c9f0f..217eb2d656acb 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1382,10 +1382,28 @@ const canInsertBlockTypeUnmemoized = ( parentName ); + let hasBlockAllowedAncestor = true; + const blockAllowedAncestorBlocks = blockType.ancestor; + if ( blockAllowedAncestorBlocks ) { + const ancestors = [ + rootClientId, + ...getBlockParents( state, rootClientId ), + ]; + + hasBlockAllowedAncestor = some( ancestors, ( ancestorClientId ) => + checkAllowList( + blockAllowedAncestorBlocks, + getBlockName( state, ancestorClientId ) + ) + ); + } + const canInsert = - ( hasParentAllowedBlock === null && hasBlockAllowedParent === null ) || - hasParentAllowedBlock === true || - hasBlockAllowedParent === true; + hasBlockAllowedAncestor && + ( ( hasParentAllowedBlock === null && + hasBlockAllowedParent === null ) || + hasParentAllowedBlock === true || + hasBlockAllowedParent === true ); if ( ! canInsert ) { return canInsert; diff --git a/packages/block-editor/src/store/test/selectors.js b/packages/block-editor/src/store/test/selectors.js index 1a6d496df0e49..16e39a801428c 100644 --- a/packages/block-editor/src/store/test/selectors.js +++ b/packages/block-editor/src/store/test/selectors.js @@ -146,6 +146,41 @@ describe( 'selectors', () => { parent: [ 'core/post-content' ], } ); + registerBlockType( 'core/test-block-ancestor', { + save: ( props ) => props.attributes.text, + category: 'text', + title: 'Test Block required as ancestor', + icon: 'test', + keywords: [ 'testing' ], + } ); + + registerBlockType( 'core/test-block-parent', { + save: ( props ) => props.attributes.text, + category: 'text', + title: 'Test Block required as parent', + icon: 'test', + keywords: [ 'testing' ], + } ); + + registerBlockType( 'core/test-block-requires-ancestor', { + save: ( props ) => props.attributes.text, + category: 'text', + title: 'Test Block that requires ancestor', + icon: 'test', + keywords: [ 'testing' ], + ancestor: [ 'core/test-block-ancestor' ], + } ); + + registerBlockType( 'core/test-block-requires-ancestor-parent', { + save: ( props ) => props.attributes.text, + category: 'text', + title: 'Test Block that requires both ancestor and parent', + icon: 'test', + keywords: [ 'testing' ], + parent: [ 'core/test-block-parent' ], + ancestor: [ 'core/test-block-ancestor' ], + } ); + setFreeformContentHandlerName( 'core/test-freeform' ); cachedSelectors.forEach( ( { clear } ) => clear() ); @@ -158,6 +193,10 @@ describe( 'selectors', () => { unregisterBlockType( 'core/test-block-c' ); unregisterBlockType( 'core/test-freeform' ); unregisterBlockType( 'core/post-content-child' ); + unregisterBlockType( 'core/test-block-ancestor' ); + unregisterBlockType( 'core/test-block-parent' ); + unregisterBlockType( 'core/test-block-requires-ancestor' ); + unregisterBlockType( 'core/test-block-requires-ancestor-parent' ); setFreeformContentHandlerName( undefined ); } ); @@ -2475,6 +2514,198 @@ describe( 'selectors', () => { canInsertBlockType( state, 'core/post-content-child' ) ).toBe( true ); } ); + + it( 'should allow blocks to be inserted in a descendant of a required ancestor', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + }, + attributes: { + block1: {}, + block2: {}, + }, + parents: { + block2: 'block1', + }, + }, + blockListSettings: { + block1: {}, + block2: {}, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor', + 'block2' + ) + ).toBe( true ); + } ); + + it( 'should allow blocks to be inserted if both parent and ancestor restrictions are met', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + block3: { name: 'core/test-block-parent' }, + }, + attributes: { + block1: {}, + block2: {}, + block3: {}, + }, + parents: { + block2: 'block1', + block3: 'block2', + }, + }, + blockListSettings: { + block1: {}, + block2: {}, + block3: {}, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor-parent', + 'block3' + ) + ).toBe( true ); + } ); + + it( 'should deny blocks from being inserted outside a required ancestor', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + block3: { name: 'core/block' }, + }, + attributes: { + block1: {}, + block2: {}, + block3: {}, + }, + parents: { + block3: 'block2', + }, + }, + blockListSettings: { + block1: {}, + block2: {}, + block3: {}, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor', + 'block3' + ) + ).toBe( false ); + } ); + + it( 'should deny blocks from being inserted outside of a required ancestor, even if parent matches', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + block3: { name: 'core/test-block-parent' }, + }, + attributes: { + block1: {}, + block2: {}, + block3: {}, + }, + parents: { + block3: 'block2', + }, + }, + blockListSettings: { + block1: {}, + block2: {}, + block3: {}, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor-parent', + 'block3' + ) + ).toBe( false ); + } ); + + it( 'should deny blocks from being inserted inside ancestor if parent restricts allowedBlocks', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + }, + attributes: { + block1: {}, + block2: {}, + }, + parents: { + block2: 'block1', + }, + }, + blockListSettings: { + block1: {}, + block2: { + allowedBlocks: [], + }, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor', + 'block2' + ) + ).toBe( false ); + } ); + + it( 'should deny blocks from being inserted inside ancestor if parent restriction is not met', () => { + const state = { + blocks: { + byClientId: { + block1: { name: 'core/test-block-ancestor' }, + block2: { name: 'core/block' }, + }, + attributes: { + block1: {}, + block2: {}, + }, + parents: { + block2: 'block1', + }, + }, + blockListSettings: { + block1: {}, + block2: {}, + }, + settings: {}, + }; + expect( + canInsertBlockType( + state, + 'core/test-block-requires-ancestor-parent', + 'block2' + ) + ).toBe( false ); + } ); } ); describe( 'canInsertBlocks', () => { @@ -2681,6 +2912,8 @@ describe( 'selectors', () => { 'core/test-block-a', 'core/test-block-b', 'core/test-freeform', + 'core/test-block-ancestor', + 'core/test-block-parent', 'core/block/1', 'core/block/2', ] ); @@ -2695,6 +2928,8 @@ describe( 'selectors', () => { 'core/test-block-a', 'core/test-block-b', 'core/test-freeform', + 'core/test-block-ancestor', + 'core/test-block-parent', 'core/block/1', 'core/block/2', ] ); @@ -2727,6 +2962,7 @@ describe( 'selectors', () => { }, }, controlledInnerBlocks: {}, + parents: {}, }, preferences: { insertUsage: {}, @@ -2935,6 +3171,7 @@ describe( 'selectors', () => { }, }, controlledInnerBlocks: {}, + parents: {}, }, preferences: { insertUsage: {}, diff --git a/packages/block-library/src/comment-author-avatar/block.json b/packages/block-library/src/comment-author-avatar/block.json index e8793d77096bc..7e538aeb99223 100644 --- a/packages/block-library/src/comment-author-avatar/block.json +++ b/packages/block-library/src/comment-author-avatar/block.json @@ -4,7 +4,7 @@ "name": "core/comment-author-avatar", "title": "Comment Author Avatar", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays the avatar of the comment's author.", "textdomain": "default", "attributes": { diff --git a/packages/block-library/src/comment-author-name/block.json b/packages/block-library/src/comment-author-name/block.json index 82f3570a0e7d5..81c2653caad0d 100644 --- a/packages/block-library/src/comment-author-name/block.json +++ b/packages/block-library/src/comment-author-name/block.json @@ -4,7 +4,7 @@ "name": "core/comment-author-name", "title": "Comment Author Name", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays the name of the author of the comment.", "textdomain": "default", "attributes": { diff --git a/packages/block-library/src/comment-content/block.json b/packages/block-library/src/comment-content/block.json index d07a1272f7376..5979511eba9be 100644 --- a/packages/block-library/src/comment-content/block.json +++ b/packages/block-library/src/comment-content/block.json @@ -4,7 +4,7 @@ "name": "core/comment-content", "title": "Comment Content", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays the contents of a comment.", "textdomain": "default", "usesContext": [ "commentId" ], diff --git a/packages/block-library/src/comment-date/block.json b/packages/block-library/src/comment-date/block.json index 8a7f7d387f8bc..afffce71c48b1 100644 --- a/packages/block-library/src/comment-date/block.json +++ b/packages/block-library/src/comment-date/block.json @@ -4,7 +4,7 @@ "name": "core/comment-date", "title": "Comment Date", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays the date on which the comment was posted.", "textdomain": "default", "attributes": { diff --git a/packages/block-library/src/comment-edit-link/block.json b/packages/block-library/src/comment-edit-link/block.json index ccd4cc7d2be46..d2dd0fe226dc8 100644 --- a/packages/block-library/src/comment-edit-link/block.json +++ b/packages/block-library/src/comment-edit-link/block.json @@ -4,7 +4,7 @@ "name": "core/comment-edit-link", "title": "Comment Edit Link", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays a link to edit the comment in the WordPress Dashboard. This link is only visible to users with the edit comment capability.", "textdomain": "default", "usesContext": [ "commentId" ], diff --git a/packages/block-library/src/comment-reply-link/block.json b/packages/block-library/src/comment-reply-link/block.json index 6b4b237fa7905..ea61ca3553f9c 100644 --- a/packages/block-library/src/comment-reply-link/block.json +++ b/packages/block-library/src/comment-reply-link/block.json @@ -4,7 +4,7 @@ "name": "core/comment-reply-link", "title": "Comment Reply Link", "category": "theme", - "parent": [ "core/comment-template" ], + "ancestor": [ "core/comment-template" ], "description": "Displays a link to reply to a comment.", "textdomain": "default", "usesContext": [ "commentId" ], diff --git a/packages/blocks/src/api/registration.js b/packages/blocks/src/api/registration.js index eb61cf89d3d93..3181b842478c7 100644 --- a/packages/blocks/src/api/registration.js +++ b/packages/blocks/src/api/registration.js @@ -164,6 +164,17 @@ export function unstable__bootstrapServerSideBlockDefinitions( definitions ) { serverSideBlockDefinitions[ blockName ].apiVersion = definitions[ blockName ].apiVersion; } + // The `ancestor` prop is not included in the definitions shared + // from the server yet, so it needs to be polyfilled as well. + // @see https://github.com/WordPress/gutenberg/pull/39894 + if ( + serverSideBlockDefinitions[ blockName ].ancestor === + undefined && + definitions[ blockName ].ancestor + ) { + serverSideBlockDefinitions[ blockName ].ancestor = + definitions[ blockName ].ancestor; + } continue; } serverSideBlockDefinitions[ blockName ] = mapKeys( @@ -187,6 +198,7 @@ function getBlockSettingsFromMetadata( { textdomain, ...metadata } ) { 'title', 'category', 'parent', + 'ancestor', 'icon', 'description', 'keywords', diff --git a/packages/blocks/src/api/test/registration.js b/packages/blocks/src/api/test/registration.js index 35a17137ee473..bda9e864a7ed6 100644 --- a/packages/blocks/src/api/test/registration.js +++ b/packages/blocks/src/api/test/registration.js @@ -415,6 +415,42 @@ describe( 'blocks', () => { } ); } ); + // This test can be removed once the polyfill for ancestor gets removed. + it( 'should apply ancestor on the client when not set on the server', () => { + const blockName = 'core/test-block-with-ancestor'; + unstable__bootstrapServerSideBlockDefinitions( { + [ blockName ]: { + category: 'widgets', + }, + } ); + unstable__bootstrapServerSideBlockDefinitions( { + [ blockName ]: { + ancestor: 'core/test-block-ancestor', + category: 'ignored', + }, + } ); + + const blockType = { + title: 'block title', + }; + registerBlockType( blockName, blockType ); + expect( getBlockType( blockName ) ).toEqual( { + ancestor: 'core/test-block-ancestor', + name: blockName, + save: expect.any( Function ), + title: 'block title', + category: 'widgets', + icon: { src: BLOCK_ICON_DEFAULT }, + attributes: {}, + providesContext: {}, + usesContext: [], + keywords: [], + supports: {}, + styles: [], + variations: [], + } ); + } ); + it( 'should validate the icon', () => { const blockType = { save: noop, diff --git a/schemas/json/block.json b/schemas/json/block.json index 7efbb55f41056..99217d43c376f 100644 --- a/schemas/json/block.json +++ b/schemas/json/block.json @@ -55,6 +55,13 @@ "type": "string" } }, + "ancestor": { + "type": "array", + "description": "The `ancestor` property makes a block available inside the specified block types at any position of the ancestor block subtree. That allows, for example, to place a ‘Comment Content’ block inside a ‘Column’ block, as long as ‘Column’ is somewhere within a ‘Comment Template’ block.", + "items": { + "type": "string" + } + }, "icon": { "type": "string", "description": "An icon property should be specified to make it easier to identify a block. These can be any of WordPress’ Dashicons (slug serving also as a fallback in non-js contexts)."