diff --git a/docs/designers-developers/developers/block-api/block-registration.md b/docs/designers-developers/developers/block-api/block-registration.md index 8a2d6c26a7dabf..7986be28bee4bc 100644 --- a/docs/designers-developers/developers/block-api/block-registration.md +++ b/docs/designers-developers/developers/block-api/block-registration.md @@ -311,6 +311,40 @@ transforms: { ``` {% end %} +In addition to accepting an array of known block types, the `blocks` option also accepts a "wildcard" (`"*"`). This allows for transformations which apply to _all_ block types (eg: all blocks can transform into `core/group`): + +{% codetabs %} +{% ES5 %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ '*' ], // wildcard - match any block + transform: function( attributes, innerBlocks ) { + // transform logic here + }, + }, + ], +}, +``` +{% ESNext %} +```js +transforms: { + from: [ + { + type: 'block', + blocks: [ '*' ], // wildcard - match any block + transform: ( attributes, innerBlocks ) => { + // transform logic here + }, + }, + ], +}, +``` +{% end %} + + A block with innerBlocks can also be transformed from and to another block with innerBlocks. {% codetabs %} diff --git a/packages/block-editor/src/components/block-actions/index.js b/packages/block-editor/src/components/block-actions/index.js index 3b3f8032432e8b..c10d03a5c060f0 100644 --- a/packages/block-editor/src/components/block-actions/index.js +++ b/packages/block-editor/src/components/block-actions/index.js @@ -8,13 +8,15 @@ import { castArray, first, last, every } from 'lodash'; */ import { compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; -import { cloneBlock, hasBlockSupport } from '@wordpress/blocks'; +import { cloneBlock, hasBlockSupport, switchToBlockType } from '@wordpress/blocks'; function BlockActions( { onDuplicate, onRemove, onInsertBefore, onInsertAfter, + onGroup, + onUngroup, isLocked, canDuplicate, children, @@ -24,6 +26,8 @@ function BlockActions( { onRemove, onInsertAfter, onInsertBefore, + onGroup, + onUngroup, isLocked, canDuplicate, } ); @@ -65,6 +69,7 @@ export default compose( [ multiSelect, removeBlocks, insertDefaultBlock, + replaceBlocks, } = dispatch( 'core/block-editor' ); return { @@ -107,6 +112,39 @@ export default compose( [ insertDefaultBlock( {}, rootClientId, lastSelectedIndex + 1 ); } }, + onGroup() { + if ( ! blocks.length ) { + return; + } + + // Activate the `transform` on `core/group` which does the conversion + const newBlocks = switchToBlockType( blocks, 'core/group' ); + + if ( ! newBlocks ) { + return; + } + replaceBlocks( + clientIds, + newBlocks + ); + }, + + onUngroup() { + if ( ! blocks.length ) { + return; + } + + const innerBlocks = blocks[ 0 ].innerBlocks; + + if ( ! innerBlocks.length ) { + return; + } + + replaceBlocks( + clientIds, + innerBlocks + ); + }, }; } ), ] )( BlockActions ); diff --git a/packages/block-library/src/group/index.js b/packages/block-library/src/group/index.js index 9bab779f5863b5..386ab20a80955c 100644 --- a/packages/block-library/src/group/index.js +++ b/packages/block-library/src/group/index.js @@ -2,6 +2,7 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; +import { createBlock } from '@wordpress/blocks'; /** * Internal dependencies @@ -25,6 +26,45 @@ export const settings = { anchor: true, html: false, }, + + transforms: { + from: [ + { + type: 'block', + isMultiBlock: true, + blocks: [ '*' ], + convert( blocks ) { + // Avoid transforming a single `core/group` Block + if ( blocks.length === 1 && blocks[ 0 ].name === 'core/group' ) { + return; + } + + const alignments = [ 'wide', 'full' ]; + + // Determine the widest setting of all the blocks to be grouped + const widestAlignment = blocks.reduce( ( result, block ) => { + const { align } = block.attributes; + return alignments.indexOf( align ) > alignments.indexOf( result ) ? align : result; + }, undefined ); + + // Clone the Blocks to be Grouped + // Failing to create new block references causes the original blocks + // to be replaced in the switchToBlockType call thereby meaning they + // are removed both from their original location and within the + // new group block. + const groupInnerBlocks = blocks.map( ( block ) => { + return createBlock( block.name, block.attributes, block.innerBlocks ); + } ); + + return createBlock( 'core/group', { + align: widestAlignment, + }, groupInnerBlocks ); + }, + }, + + ], + }, + edit, save, }; diff --git a/packages/blocks/CHANGELOG.md b/packages/blocks/CHANGELOG.md index b13c58601e1e99..df5b183e688d45 100644 --- a/packages/blocks/CHANGELOG.md +++ b/packages/blocks/CHANGELOG.md @@ -3,6 +3,8 @@ ### New Feature - Added a default implementation for `save` setting in `registerBlockType` which saves no markup in the post content. +- Added wildcard block transforms which allows for transforming all/any blocks in another block. +- Added `convert()` method option to `transforms` definition. It receives complete block object(s) as it's argument(s). It is now preferred over the older `transform()` (note that `transform()` is still fully supported). ## 6.1.0 (2019-03-06) diff --git a/packages/blocks/README.md b/packages/blocks/README.md index 9bc199a54452f2..b7a8e5fd70c9dd 100644 --- a/packages/blocks/README.md +++ b/packages/blocks/README.md @@ -679,7 +679,7 @@ _Parameters_ _Returns_ -- `Array`: Array of blocks. +- `?Array`: Array of blocks or null. # **synchronizeBlocksWithTemplate** diff --git a/packages/blocks/src/api/factory.js b/packages/blocks/src/api/factory.js index c7bd01f2c699c4..304329d0be657f 100644 --- a/packages/blocks/src/api/factory.js +++ b/packages/blocks/src/api/factory.js @@ -11,6 +11,7 @@ import { filter, first, flatMap, + has, uniq, isFunction, isEmpty, @@ -117,26 +118,41 @@ const isPossibleTransformForSource = ( transform, direction, blocks ) => { return false; } - // If multiple blocks are selected, only multi block transforms are allowed. + // If multiple blocks are selected, only multi block transforms + // or wildcard transforms are allowed. const isMultiBlock = blocks.length > 1; - const isValidForMultiBlocks = ! isMultiBlock || transform.isMultiBlock; + const firstBlockName = first( blocks ).name; + const isValidForMultiBlocks = isWildcardBlockTransform( transform ) || ! isMultiBlock || transform.isMultiBlock; if ( ! isValidForMultiBlocks ) { return false; } + // Check non-wildcard transforms to ensure that transform is valid + // for a block selection of multiple blocks of different types + if ( ! isWildcardBlockTransform( transform ) && ! every( blocks, { name: firstBlockName } ) ) { + return false; + } + // Only consider 'block' type transforms as valid. const isBlockType = transform.type === 'block'; if ( ! isBlockType ) { return false; } - // Check if the transform's block name matches the source block only if this is a transform 'from'. + // Check if the transform's block name matches the source block (or is a wildcard) + // only if this is a transform 'from'. const sourceBlock = first( blocks ); - const hasMatchingName = direction !== 'from' || transform.blocks.indexOf( sourceBlock.name ) !== -1; + const hasMatchingName = direction !== 'from' || transform.blocks.indexOf( sourceBlock.name ) !== -1 || isWildcardBlockTransform( transform ); if ( ! hasMatchingName ) { return false; } + // Don't allow single 'core/group' blocks to be transformed into + // a 'core/group' block. + if ( ! isMultiBlock && isContainerGroupBlock( sourceBlock.name ) && isContainerGroupBlock( transform.blockName ) ) { + return false; + } + // If the transform has a `isMatch` function specified, check that it returns true. if ( isFunction( transform.isMatch ) ) { const attributes = transform.isMultiBlock ? blocks.map( ( block ) => block.attributes ) : sourceBlock.attributes; @@ -171,7 +187,9 @@ const getBlockTypesForPossibleFromTransforms = ( blocks ) => { return !! findTransform( fromTransforms, - ( transform ) => isPossibleTransformForSource( transform, 'from', blocks ) + ( transform ) => { + return isPossibleTransformForSource( transform, 'from', blocks ); + } ); }, ); @@ -199,7 +217,9 @@ const getBlockTypesForPossibleToTransforms = ( blocks ) => { // filter all 'to' transforms to find those that are possible. const possibleTransforms = filter( transformsTo, - ( transform ) => isPossibleTransformForSource( transform, 'to', blocks ) + ( transform ) => { + return transform && isPossibleTransformForSource( transform, 'to', blocks ); + } ); // Build a list of block names using the possible 'to' transforms. @@ -212,6 +232,45 @@ const getBlockTypesForPossibleToTransforms = ( blocks ) => { return blockNames.map( ( name ) => getBlockType( name ) ); }; +/** + * Determines whether transform is a "block" type + * and if so whether it is a "wildcard" transform + * ie: targets "any" block type + * + * @param {Object} t the Block transform object + * + * @return {boolean} whether transform is a wildcard transform + */ +export const isWildcardBlockTransform = ( t ) => t && t.type === 'block' && Array.isArray( t.blocks ) && t.blocks.includes( '*' ); + +/** + * Determines whether the given Block is the core Block which + * acts as a container Block for other Blocks as part of the + * Grouping mechanics + * + * @param {string} name the name of the Block to test against + * + * @return {boolean} whether or not the Block is the container Block type + */ +export const isContainerGroupBlock = ( name ) => name === 'core/group'; + +/** + * Determines whether the provided Blocks are of the same type + * (eg: all `core/paragraph`). + * + * @param {Array} blocksArray the Block definitions + * + * @return {boolean} whether or not the given Blocks pass the criteria + */ +export const isBlockSelectionOfSameType = ( blocksArray = [] ) => { + if ( ! blocksArray.length ) { + return false; + } + const sourceName = blocksArray[ 0 ].name; + + return every( blocksArray, [ 'name', sourceName ] ); +}; + /** * Returns an array of block types that the set of blocks received as argument * can be transformed into. @@ -225,12 +284,6 @@ export function getPossibleBlockTransformations( blocks ) { return []; } - const sourceBlock = first( blocks ); - const isMultiBlock = blocks.length > 1; - if ( isMultiBlock && ! every( blocks, { name: sourceBlock.name } ) ) { - return []; - } - const blockTypesForFromTransforms = getBlockTypesForPossibleFromTransforms( blocks ); const blockTypesForToTransforms = getBlockTypesForPossibleToTransforms( blocks ); @@ -313,7 +366,7 @@ export function getBlockTransforms( direction, blockTypeOrName ) { * @param {Array|Object} blocks Blocks array or block object. * @param {string} name Block name. * - * @return {Array} Array of blocks. + * @return {?Array} Array of blocks or null. */ export function switchToBlockType( blocks, name ) { const blocksArray = castArray( blocks ); @@ -321,7 +374,10 @@ export function switchToBlockType( blocks, name ) { const firstBlock = blocksArray[ 0 ]; const sourceName = firstBlock.name; - if ( isMultiBlock && ! every( blocksArray, ( block ) => ( block.name === sourceName ) ) ) { + // Unless it's a `core/group` Block then for multi block selections + // check that all Blocks are of the same type otherwise + // we can't run a conversion + if ( ! isContainerGroupBlock( name ) && isMultiBlock && ! isBlockSelectionOfSameType( blocksArray ) ) { return null; } @@ -329,14 +385,15 @@ export function switchToBlockType( blocks, name ) { // transformation. const transformationsFrom = getBlockTransforms( 'from', name ); const transformationsTo = getBlockTransforms( 'to', sourceName ); + const transformation = findTransform( transformationsTo, - ( t ) => t.type === 'block' && t.blocks.indexOf( name ) !== -1 && ( ! isMultiBlock || t.isMultiBlock ) + ( t ) => t.type === 'block' && ( ( isWildcardBlockTransform( t ) ) || t.blocks.indexOf( name ) !== -1 ) && ( ! isMultiBlock || t.isMultiBlock ) ) || findTransform( transformationsFrom, - ( t ) => t.type === 'block' && t.blocks.indexOf( sourceName ) !== -1 && ( ! isMultiBlock || t.isMultiBlock ) + ( t ) => t.type === 'block' && ( ( isWildcardBlockTransform( t ) ) || t.blocks.indexOf( sourceName ) !== -1 ) && ( ! isMultiBlock || t.isMultiBlock ) ); // Stop if there is no valid transformation. @@ -345,11 +402,18 @@ export function switchToBlockType( blocks, name ) { } let transformationResults; + if ( transformation.isMultiBlock ) { - transformationResults = transformation.transform( - blocksArray.map( ( currentBlock ) => currentBlock.attributes ), - blocksArray.map( ( currentBlock ) => currentBlock.innerBlocks ) - ); + if ( has( transformation, 'convert' ) ) { + transformationResults = transformation.convert( blocksArray ); + } else { + transformationResults = transformation.transform( + blocksArray.map( ( currentBlock ) => currentBlock.attributes ), + blocksArray.map( ( currentBlock ) => currentBlock.innerBlocks ), + ); + } + } else if ( has( transformation, 'convert' ) ) { + transformationResults = transformation.convert( firstBlock ); } else { transformationResults = transformation.transform( firstBlock.attributes, firstBlock.innerBlocks ); } diff --git a/packages/blocks/src/api/test/factory.js b/packages/blocks/src/api/test/factory.js index 11249b08d36ced..3e0d42c103bb9a 100644 --- a/packages/blocks/src/api/test/factory.js +++ b/packages/blocks/src/api/test/factory.js @@ -2,7 +2,7 @@ * External dependencies */ import deepFreeze from 'deep-freeze'; -import { noop } from 'lodash'; +import { noop, times } from 'lodash'; /** * Internal dependencies @@ -14,6 +14,9 @@ import { switchToBlockType, getBlockTransforms, findTransform, + isWildcardBlockTransform, + isContainerGroupBlock, + isBlockSelectionOfSameType, } from '../factory'; import { getBlockType, @@ -776,6 +779,81 @@ describe( 'block factory', () => { expect( isMatch ).toHaveBeenCalledWith( [ { value: 'ribs' }, { value: 'halloumi' } ] ); } ); + + describe( 'wildcard block transforms', () => { + beforeEach( () => { + registerBlockType( 'core/group', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + transform: noop, + } ], + }, + save: noop, + category: 'common', + title: 'A block that groups other blocks.', + } ); + } ); + + it( 'should should show wildcard "from" transformation as available for multiple blocks of the same type', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/image-block', defaultBlockSettings ); + + const textBlocks = times( 4, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( textBlocks ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + + it( 'should should show wildcard "from" transformation as available for multiple blocks of different types', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + registerBlockType( 'core/image-block', defaultBlockSettings ); + + const textBlocks = times( 2, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const imageBlocks = times( 2, ( index ) => { + return createBlock( 'core/image-block', { + value: `imageBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( [ ...textBlocks, ...imageBlocks ] ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + + it( 'should should show wildcard "from" transformation as available for single blocks', () => { + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const blocks = times( 1, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const availableBlocks = getPossibleBlockTransformations( blocks ); + + expect( availableBlocks ).toHaveLength( 1 ); + expect( availableBlocks[ 0 ].name ).toBe( 'core/group' ); + } ); + } ); } ); describe( 'switchToBlockType()', () => { @@ -1222,6 +1300,94 @@ describe( 'block factory', () => { expect( transformedBlocks[ 1 ].innerBlocks ).toHaveLength( 1 ); expect( transformedBlocks[ 1 ].innerBlocks[ 0 ].attributes.value ).toBe( 'after1' ); } ); + + it( 'should pass entire block object(s) to the "convert" method if defined', () => { + registerBlockType( 'core/test-group-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + isMultiBlock: true, + convert( blocks ) { + const groupInnerBlocks = blocks.map( ( { name, attributes, innerBlocks } ) => { + return createBlock( name, attributes, innerBlocks ); + } ); + + return createBlock( 'core/test-group-block', {}, groupInnerBlocks ); + }, + } ], + }, + save: noop, + category: 'common', + title: 'Test Group Block', + } ); + + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const numOfBlocksToGroup = 4; + const blocks = times( numOfBlocksToGroup, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const transformedBlocks = switchToBlockType( blocks, 'core/test-group-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( transformedBlocks[ 0 ].name ).toBe( 'core/test-group-block' ); + expect( transformedBlocks[ 0 ].innerBlocks ).toHaveLength( numOfBlocksToGroup ); + } ); + + it( 'should prefer "convert" method over "transform" method when running a transformation', () => { + const convertSpy = jest.fn( ( blocks ) => { + const groupInnerBlocks = blocks.map( ( { name, attributes, innerBlocks } ) => { + return createBlock( name, attributes, innerBlocks ); + } ); + + return createBlock( 'core/test-group-block', {}, groupInnerBlocks ); + } ); + const transformSpy = jest.fn(); + + registerBlockType( 'core/test-group-block', { + attributes: { + value: { + type: 'string', + }, + }, + transforms: { + from: [ { + type: 'block', + blocks: [ '*' ], + isMultiBlock: true, + convert: convertSpy, + transform: transformSpy, + } ], + }, + save: noop, + category: 'common', + title: 'Test Group Block', + } ); + + registerBlockType( 'core/text-block', defaultBlockSettings ); + + const numOfBlocksToGroup = 4; + const blocks = times( numOfBlocksToGroup, ( index ) => { + return createBlock( 'core/text-block', { + value: `textBlock${ index + 1 }`, + } ); + } ); + + const transformedBlocks = switchToBlockType( blocks, 'core/test-group-block' ); + + expect( transformedBlocks ).toHaveLength( 1 ); + expect( convertSpy.mock.calls ).toHaveLength( 1 ); + expect( transformSpy.mock.calls ).toHaveLength( 0 ); + } ); } ); describe( 'getBlockTransforms', () => { @@ -1336,4 +1502,107 @@ describe( 'block factory', () => { expect( transform ).toBe( null ); } ); } ); + + describe( 'isWildcardBlockTransform', () => { + it( 'should return true for transforms with type of block and "*" alias as blocks', () => { + const validWildcardBlockTransform = { + type: 'block', + blocks: [ + 'core/some-other-block-first', // unlikely to happen but... + '*', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( validWildcardBlockTransform ) ).toBe( true ); + } ); + + it( 'should return false for transforms with a type which is not "block"', () => { + const invalidWildcardBlockTransform = { + type: 'file', + blocks: [ + '*', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + + it( 'should return false for transforms which do not include "*" alias in "block" array', () => { + const invalidWildcardBlockTransform = { + type: 'block', + blocks: [ + 'core/some-block', + 'core/another-block', + ], + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + + it( 'should return false for transforms which do not provide an array as the "blocks" option', () => { + const invalidWildcardBlockTransform = { + type: 'block', + blocks: noop, + blockName: 'core/test-block', + }; + + expect( isWildcardBlockTransform( invalidWildcardBlockTransform ) ).toBe( false ); + } ); + } ); + + describe( 'isContainerGroupBlock', () => { + it( 'should return true when passed block name matches "core/group"', () => { + expect( isContainerGroupBlock( 'core/group' ) ).toBe( true ); + } ); + + it( 'should return false when passed block name does not match "core/group"', () => { + expect( isContainerGroupBlock( 'core/some-test-name' ) ).toBe( false ); + } ); + } ); + + describe( 'isBlockSelectionOfSameType', () => { + it( 'should return false when all blocks do not match the name of the first block', () => { + const blocks = [ + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/another-block', + }, + { + name: 'core/test-block', + }, + ]; + + expect( isBlockSelectionOfSameType( blocks ) ).toBe( false ); + } ); + + it( 'should return true when all blocks match the name of the first block', () => { + const blocks = [ + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + { + name: 'core/test-block', + }, + ]; + + expect( isBlockSelectionOfSameType( blocks ) ).toBe( true ); + } ); + } ); } ); diff --git a/packages/e2e-tests/fixtures/block-transforms.js b/packages/e2e-tests/fixtures/block-transforms.js index 79d4d7bc804566..913953d69d2b3e 100644 --- a/packages/e2e-tests/fixtures/block-transforms.js +++ b/packages/e2e-tests/fixtures/block-transforms.js @@ -1,131 +1,166 @@ export const EXPECTED_TRANSFORMS = { core__archives: { originalBlock: 'Archives', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__archives__showPostCounts: { originalBlock: 'Archives', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__audio: { originalBlock: 'Audio', availableTransforms: [ 'File', + 'Group', ], }, core__button__center: { originalBlock: 'Button', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__calendar: { originalBlock: 'Calendar', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__media-text': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Image', ], }, 'core__media-text__image-alt-no-align': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Image', ], }, 'core__media-text__image-fill-no-focal-point-selected': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Image', ], }, 'core__media-text__image-fill-with-focal-point-selected': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Image', ], }, 'core__media-text__is-stacked-on-mobile': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Video', ], }, 'core__media-text__media-right-custom-width': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Video', ], }, 'core__media-text__vertical-align-bottom': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Image', ], }, 'core__media-text__video': { originalBlock: 'Media & Text', availableTransforms: [ + 'Group', 'Video', ], }, core__categories: { originalBlock: 'Categories', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__code: { originalBlock: 'Code', availableTransforms: [ + 'Group', 'Preformatted', ], }, core__columns: { originalBlock: 'Columns', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__cover: { availableTransforms: [ + 'Group', 'Image', ], originalBlock: 'Cover', }, core__cover__video: { availableTransforms: [ + 'Group', 'Video', ], originalBlock: 'Cover', }, 'core__cover__video-overlay': { availableTransforms: [ + 'Group', 'Video', ], originalBlock: 'Cover', }, core__embed: { originalBlock: 'Embed', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__file__new-window': { originalBlock: 'File', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__file__no-download-button': { originalBlock: 'File', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__file__no-text-link': { originalBlock: 'File', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__gallery: { originalBlock: 'Gallery', availableTransforms: [ + 'Group', 'Image', ], }, core__gallery__columns: { originalBlock: 'Gallery', availableTransforms: [ + 'Group', 'Image', ], }, @@ -137,6 +172,7 @@ export const EXPECTED_TRANSFORMS = { originalBlock: 'Heading', availableTransforms: [ 'Quote', + 'Group', 'Paragraph', ], }, @@ -144,12 +180,15 @@ export const EXPECTED_TRANSFORMS = { originalBlock: 'Heading', availableTransforms: [ 'Quote', + 'Group', 'Paragraph', ], }, core__html: { originalBlock: 'Custom HTML', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__image: { originalBlock: 'Image', @@ -157,6 +196,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -166,6 +206,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -175,6 +216,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -184,6 +226,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -193,6 +236,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -202,6 +246,7 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, @@ -211,43 +256,59 @@ export const EXPECTED_TRANSFORMS = { 'Gallery', 'Cover', 'File', + 'Group', 'Media & Text', ], }, 'core__latest-comments': { originalBlock: 'Latest Comments', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__latest-posts': { originalBlock: 'Latest Posts', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__latest-posts__displayPostDate': { originalBlock: 'Latest Posts', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__legacy-widget': { originalBlock: 'Legacy Widget (Experimental)', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__list__ul: { originalBlock: 'List', availableTransforms: [ + 'Group', 'Paragraph', 'Quote', ], }, core__more: { originalBlock: 'More', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__more__custom-text-teaser': { originalBlock: 'More', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__nextpage: { originalBlock: 'Page Break', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__paragraph__align-right': { originalBlock: 'Paragraph', @@ -255,6 +316,7 @@ export const EXPECTED_TRANSFORMS = { 'Heading', 'List', 'Quote', + 'Group', 'Preformatted', 'Verse', ], @@ -262,6 +324,7 @@ export const EXPECTED_TRANSFORMS = { core__preformatted: { originalBlock: 'Preformatted', availableTransforms: [ + 'Group', 'Paragraph', ], }, @@ -269,18 +332,21 @@ export const EXPECTED_TRANSFORMS = { originalBlock: 'Pullquote', availableTransforms: [ 'Quote', + 'Group', ], }, 'core__pullquote__multi-paragraph': { originalBlock: 'Pullquote', availableTransforms: [ 'Quote', + 'Group', ], }, 'core__quote__style-1': { originalBlock: 'Quote', availableTransforms: [ 'List', + 'Group', 'Paragraph', 'Heading', 'Pullquote', @@ -290,6 +356,7 @@ export const EXPECTED_TRANSFORMS = { originalBlock: 'Quote', availableTransforms: [ 'List', + 'Group', 'Paragraph', 'Heading', 'Pullquote', @@ -297,44 +364,62 @@ export const EXPECTED_TRANSFORMS = { }, core__rss: { originalBlock: 'RSS', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__search: { originalBlock: 'Search', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__search__custom-text': { originalBlock: 'Search', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__separator: { originalBlock: 'Separator', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__shortcode: { originalBlock: 'Shortcode', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__spacer: { originalBlock: 'Spacer', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, core__table: { originalBlock: 'Table', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__tag-cloud': { originalBlock: 'Tag Cloud', - availableTransforms: [], + availableTransforms: [ + 'Group', + ], }, 'core__tag-cloud__showTagCounts': { originalBlock: 'Tag Cloud', availableTransforms: [ + 'Group', ], }, core__verse: { originalBlock: 'Verse', availableTransforms: [ + 'Group', 'Paragraph', ], }, @@ -343,6 +428,7 @@ export const EXPECTED_TRANSFORMS = { availableTransforms: [ 'Cover', 'File', + 'Group', 'Media & Text', ], }, diff --git a/packages/e2e-tests/specs/__snapshots__/block-grouping.test.js.snap b/packages/e2e-tests/specs/__snapshots__/block-grouping.test.js.snap new file mode 100644 index 00000000000000..af571b2010ed25 --- /dev/null +++ b/packages/e2e-tests/specs/__snapshots__/block-grouping.test.js.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Block Grouping Group creation creates a group from multiple blocks of different types via block transforms 1`] = ` +" +
+

Group Heading

+ + + +
\\"\\"/
+ + + +

Some paragraph

+
+" +`; + +exports[`Block Grouping Group creation creates a group from multiple blocks of the same type via block transforms 1`] = ` +" +
+

First Paragraph

+ + + +

Second Paragraph

+ + + +

Third Paragraph

+
+" +`; + +exports[`Block Grouping Group creation creates a group from multiple blocks of the same type via options toolbar 1`] = ` +" +
+

First Paragraph

+ + + +

Second Paragraph

+ + + +

Third Paragraph

+
+" +`; + +exports[`Block Grouping Group creation groups and ungroups multiple blocks of different types via options toolbar 1`] = ` +" +
+

Group Heading

+ + + +
\\"\\"/
+ + + +

Some paragraph

+
+" +`; + +exports[`Block Grouping Group creation groups and ungroups multiple blocks of different types via options toolbar 2`] = ` +" +

Group Heading

+ + + +
\\"\\"/
+ + + +

Some paragraph

+" +`; + +exports[`Block Grouping Preserving selected blocks attributes preserves width alignment settings of selected blocks 1`] = ` +" +
+

Group Heading

+ + + +
\\"\\"/
+ + + +
\\"\\"/
+ + + +

Some paragraph

+
+" +`; diff --git a/packages/e2e-tests/specs/__snapshots__/block-transforms.test.js.snap b/packages/e2e-tests/specs/__snapshots__/block-transforms.test.js.snap index ce9dfc0be7e555..25e50d75416a43 100644 --- a/packages/e2e-tests/specs/__snapshots__/block-transforms.test.js.snap +++ b/packages/e2e-tests/specs/__snapshots__/block-transforms.test.js.snap @@ -96,6 +96,14 @@ exports[`Block transforms correctly transform block Image in fixture core__image " `; +exports[`Block transforms correctly transform block Image in fixture core__image into the Group block 1`] = ` +" +
+
\\"\\"/
+
+" +`; + exports[`Block transforms correctly transform block Image in fixture core__image into the Media & Text block 1`] = ` "
\\"\\"/
@@ -352,6 +360,14 @@ exports[`Block transforms correctly transform block Media & Text in fixture core " `; +exports[`Block transforms correctly transform block Paragraph in fixture core__paragraph__align-right into the Group block 1`] = ` +" +
+

... like this one, which is separate from the above and right aligned.

+
+" +`; + exports[`Block transforms correctly transform block Paragraph in fixture core__paragraph__align-right into the Heading block 1`] = ` "

... like this one, which is separate from the above and right aligned.

diff --git a/packages/e2e-tests/specs/block-deletion.test.js b/packages/e2e-tests/specs/block-deletion.test.js index 194ff847328c18..02cc0a0f5720ee 100644 --- a/packages/e2e-tests/specs/block-deletion.test.js +++ b/packages/e2e-tests/specs/block-deletion.test.js @@ -8,6 +8,7 @@ import { createNewPost, isInDefaultBlock, pressKeyWithModifier, + pressKeyTimes, insertBlock, } from '@wordpress/e2e-test-utils'; @@ -22,10 +23,37 @@ const addThreeParagraphsToNewPost = async () => { await page.keyboard.press( 'Enter' ); }; -const clickOnBlockSettingsMenuItem = async ( buttonLabel ) => { +/** + * Due to an issue with the Popover component not being scrollable + * under certain conditions, Pupeteer cannot "see" the "Remove Block" + * button. This is a workaround until that issue is resolved. + * + * see: https://github.com/WordPress/gutenberg/pull/14908#discussion_r284725956 + */ +const clickOnBlockSettingsMenuRemoveBlockButton = async () => { await clickBlockToolbarButton( 'More options' ); - const itemButton = ( await page.$x( `//*[contains(@class, "block-editor-block-settings-menu__popover")]//button[contains(text(), '${ buttonLabel }')]` ) )[ 0 ]; - await itemButton.click(); + + let isRemoveButton = false; + + let numButtons = await page.$$eval( '.block-editor-block-toolbar button', ( btns ) => btns.length ); + + // Limit by the number of buttons available + while ( --numButtons ) { + await page.keyboard.press( 'Tab' ); + + isRemoveButton = await page.evaluate( () => { + return document.activeElement.innerText.includes( 'Remove Block' ); + } ); + + // Stop looping once we find the button + if ( isRemoveButton ) { + await pressKeyTimes( 'Enter', 1 ); + break; + } + } + + // Makes failures more explicit + await expect( isRemoveButton ).toBe( true ); }; describe( 'block deletion -', () => { @@ -39,7 +67,8 @@ describe( 'block deletion -', () => { // Press Escape to show the block toolbar await page.keyboard.press( 'Escape' ); - await clickOnBlockSettingsMenuItem( 'Remove Block' ); + await clickOnBlockSettingsMenuRemoveBlockButton(); + expect( await getEditedPostContent() ).toMatchSnapshot(); // Type additional text and assert that caret position is correct by comparing to snapshot. @@ -121,7 +150,7 @@ describe( 'deleting all blocks', () => { await page.keyboard.press( 'Escape' ); - await clickOnBlockSettingsMenuItem( 'Remove Block' ); + await clickOnBlockSettingsMenuRemoveBlockButton(); // There is a default block: expect( await page.$$( '.block-editor-block-list__block' ) ).toHaveLength( 1 ); diff --git a/packages/e2e-tests/specs/block-grouping.test.js b/packages/e2e-tests/specs/block-grouping.test.js new file mode 100644 index 00000000000000..57300ec2b9de66 --- /dev/null +++ b/packages/e2e-tests/specs/block-grouping.test.js @@ -0,0 +1,176 @@ +/** + * WordPress dependencies + */ +import { + insertBlock, + createNewPost, + clickBlockToolbarButton, + pressKeyWithModifier, + getEditedPostContent, + transformBlockTo, + getAllBlocks, + getAvailableBlockTransforms, +} from '@wordpress/e2e-test-utils'; + +async function insertBlocksOfSameType() { + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'First Paragraph' ); + + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Second Paragraph' ); + + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Third Paragraph' ); +} + +async function insertBlocksOfMultipleTypes() { + await insertBlock( 'Heading' ); + await page.keyboard.type( 'Group Heading' ); + + await insertBlock( 'Image' ); + + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Some paragraph' ); +} + +describe( 'Block Grouping', () => { + beforeEach( async () => { + // Posts are auto-removed at the end of each test run + await createNewPost(); + } ); + + describe( 'Group creation', () => { + it( 'creates a group from multiple blocks of the same type via block transforms', async () => { + // Creating test blocks + await insertBlocksOfSameType(); + + // Multiselect via keyboard. + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + await transformBlockTo( 'Group' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'creates a group from multiple blocks of different types via block transforms', async () => { + // Creating test blocks + await insertBlocksOfMultipleTypes(); + + // Multiselect via keyboard. + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + await transformBlockTo( 'Group' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'creates a group from multiple blocks of the same type via options toolbar', async () => { + // Creating test blocks + await insertBlocksOfSameType(); + + // Multiselect via keyboard. + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + await clickBlockToolbarButton( 'More options' ); + + const groupButton = await page.waitForXPath( '//button[text()="Group"]' ); + await groupButton.click(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + + it( 'groups and ungroups multiple blocks of different types via options toolbar', async () => { + // Creating test blocks + await insertBlocksOfMultipleTypes(); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + // Group + await clickBlockToolbarButton( 'More options' ); + const groupButton = await page.waitForXPath( '//button[text()="Group"]' ); + await groupButton.click(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + + // UnGroup + await clickBlockToolbarButton( 'More options' ); + const unGroupButton = await page.waitForXPath( '//button[text()="Ungroup"]' ); + await unGroupButton.click(); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + } ); + + describe( 'Container Block availability', () => { + beforeEach( async () => { + // Disable the Group block + await page.evaluate( () => { + const { dispatch } = wp.data; + dispatch( 'core/edit-post' ).hideBlockTypes( [ 'core/group' ] ); + } ); + + // Create a Group + await insertBlocksOfMultipleTypes(); + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + } ); + + afterAll( async () => { + // Re-enable the Group block + await page.evaluate( () => { + const { dispatch } = wp.data; + dispatch( 'core/edit-post' ).showBlockTypes( [ 'core/group' ] ); + } ); + } ); + + it( 'does not show group transform if container block is disabled', async () => { + const availableTransforms = await getAvailableBlockTransforms(); + + expect( + availableTransforms + ).not.toContain( 'Group' ); + } ); + + it( 'does not show group option in the options toolbar if container block is disabled ', async () => { + await clickBlockToolbarButton( 'More options' ); + + const blockOptionsDropdownHTML = await page.evaluate( () => document.querySelector( '.block-editor-block-settings-menu__content' ).innerHTML ); + + expect( blockOptionsDropdownHTML ).not.toContain( 'Group' ); + } ); + } ); + + describe( 'Preserving selected blocks attributes', () => { + it( 'preserves width alignment settings of selected blocks', async () => { + await insertBlock( 'Heading' ); + await page.keyboard.type( 'Group Heading' ); + + // Full width image + await insertBlock( 'Image' ); + await clickBlockToolbarButton( 'Full width' ); + + // Wide width image) + await insertBlock( 'Image' ); + await clickBlockToolbarButton( 'Wide width' ); + + await insertBlock( 'Paragraph' ); + await page.keyboard.type( 'Some paragraph' ); + + await pressKeyWithModifier( 'primary', 'a' ); + await pressKeyWithModifier( 'primary', 'a' ); + + await transformBlockTo( 'Group' ); + + const allBlocks = await getAllBlocks(); + + // We expect Group block align setting to match that + // of the widest of it's "child" innerBlocks + expect( allBlocks[ 0 ].attributes.align ).toBe( 'full' ); + + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); + } ); +} ); diff --git a/packages/e2e-tests/specs/block-switcher.test.js b/packages/e2e-tests/specs/block-switcher.test.js index 0d2fcf2adfe9e3..9282e83dc855f1 100644 --- a/packages/e2e-tests/specs/block-switcher.test.js +++ b/packages/e2e-tests/specs/block-switcher.test.js @@ -27,6 +27,7 @@ describe( 'adding blocks', () => { expect( await getAvailableBlockTransforms() ).toEqual( [ + 'Group', 'Paragraph', 'Quote', ] ); @@ -50,6 +51,7 @@ describe( 'adding blocks', () => { expect( await getAvailableBlockTransforms() ).toEqual( [ + 'Group', 'Paragraph', ] ); } ); @@ -60,6 +62,7 @@ describe( 'adding blocks', () => { ( [ 'core/quote', 'core/paragraph', + 'core/group', ] ).map( ( block ) => wp.blocks.unregisterBlockType( block ) ); } ); diff --git a/packages/e2e-tests/specs/block-transforms.test.js b/packages/e2e-tests/specs/block-transforms.test.js index a16a7a1ec10035..89d14bf524491f 100644 --- a/packages/e2e-tests/specs/block-transforms.test.js +++ b/packages/e2e-tests/specs/block-transforms.test.js @@ -153,7 +153,12 @@ describe( 'Block transforms', () => { ) ); - it.each( testTable )( + // As Group is available as a transform on *all* blocks this would create a lot of + // tests which would impact on the performance of the e2e test suite. + // To avoid this, we remove `core/group` from test table for all but 2 block types. + const testTableWithSomeGroupsFiltered = testTable.filter( ( transform ) => ( transform[ 2 ] !== 'Group' || transform[ 1 ] === 'core__paragraph__align-right' || transform[ 1 ] === 'core__image' ) ); + + it.each( testTableWithSomeGroupsFiltered )( 'block %s in fixture %s into the %s block', async ( originalBlock, fixture, destinationBlock ) => { const { content } = transformStructure[ fixture ]; diff --git a/packages/editor/src/components/convert-to-group-buttons/convert-button.js b/packages/editor/src/components/convert-to-group-buttons/convert-button.js new file mode 100644 index 00000000000000..6a7299ed259c98 --- /dev/null +++ b/packages/editor/src/components/convert-to-group-buttons/convert-button.js @@ -0,0 +1,127 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { MenuItem } from '@wordpress/components'; +import { _x } from '@wordpress/i18n'; +import { switchToBlockType } from '@wordpress/blocks'; +import { withSelect, withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import { Group, Ungroup } from './icons'; + +export function ConvertToGroupButton( { + onConvertToGroup, + onConvertFromGroup, + isGroupable = false, + isUngroupable = false, +} ) { + return ( + + { isGroupable && ( + + { _x( 'Group', 'verb' ) } + + ) } + { isUngroupable && ( + + { _x( 'Ungroup', 'Ungrouping blocks from within a Group block back into individual blocks within the Editor ' ) } + + ) } + + ); +} + +export default compose( [ + withSelect( ( select, { clientIds } ) => { + const { + getBlocksByClientId, + canInsertBlockType, + } = select( 'core/block-editor' ); + + const containerBlockAvailable = canInsertBlockType( 'core/group' ); + + const blocksSelection = getBlocksByClientId( clientIds ); + + const isSingleContainerBlock = blocksSelection.length === 1 && blocksSelection[ 0 ] && blocksSelection[ 0 ].name === 'core/group'; + + // Do we have + // 1. Container block available to be inserted? + // 2. One or more blocks selected + // (we allow single Blocks to become groups unless + // they are a soltiary group block themselves) + const isGroupable = ( + containerBlockAvailable && + blocksSelection.length && + ! isSingleContainerBlock + ); + + // Do we have a single Group Block selected? + const isUngroupable = isSingleContainerBlock; + + return { + isGroupable, + isUngroupable, + blocksSelection, + }; + } ), + withDispatch( ( dispatch, { clientIds, onToggle = noop, blocksSelection = [] } ) => { + const { + replaceBlocks, + } = dispatch( 'core/block-editor' ); + + return { + onConvertToGroup() { + if ( ! blocksSelection.length ) { + return; + } + + // Activate the `transform` on `core/group` which does the conversion + const newBlocks = switchToBlockType( blocksSelection, 'core/group' ); + + if ( newBlocks ) { + replaceBlocks( + clientIds, + newBlocks + ); + } + + onToggle(); + }, + onConvertFromGroup() { + if ( ! blocksSelection.length ) { + return; + } + + const innerBlocks = blocksSelection[ 0 ].innerBlocks; + + if ( ! innerBlocks.length ) { + return; + } + + replaceBlocks( + clientIds, + innerBlocks + ); + + onToggle(); + }, + }; + } ), +] )( ConvertToGroupButton ); diff --git a/packages/editor/src/components/convert-to-group-buttons/icons.js b/packages/editor/src/components/convert-to-group-buttons/icons.js new file mode 100644 index 00000000000000..8ca249c2fa7ee6 --- /dev/null +++ b/packages/editor/src/components/convert-to-group-buttons/icons.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { Icon, SVG, Path } from '@wordpress/components'; + +const GroupSVG = + + +; + +export const Group = ; + +const UngroupSVG = + + +; + +export const Ungroup = ; + diff --git a/packages/editor/src/components/convert-to-group-buttons/index.js b/packages/editor/src/components/convert-to-group-buttons/index.js new file mode 100644 index 00000000000000..a276ed4434bcab --- /dev/null +++ b/packages/editor/src/components/convert-to-group-buttons/index.js @@ -0,0 +1,33 @@ +/** + * WordPress dependencies + */ +import { Fragment } from '@wordpress/element'; +import { __experimentalBlockSettingsMenuPluginsExtension } from '@wordpress/block-editor'; +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import ConvertToGroupButton from './convert-button'; + +function ConvertToGroupButtons( { clientIds } ) { + return ( + <__experimentalBlockSettingsMenuPluginsExtension> + { ( { onClose } ) => ( + + + + ) } + + ); +} + +export default withSelect( ( select ) => { + const { getSelectedBlockClientIds } = select( 'core/block-editor' ); + return { + clientIds: getSelectedBlockClientIds(), + }; +} )( ConvertToGroupButtons ); diff --git a/packages/editor/src/components/provider/index.js b/packages/editor/src/components/provider/index.js index 601a44c0260cfe..e897931daf6c42 100644 --- a/packages/editor/src/components/provider/index.js +++ b/packages/editor/src/components/provider/index.js @@ -21,6 +21,7 @@ import { decodeEntities } from '@wordpress/html-entities'; */ import { mediaUpload } from '../../utils'; import ReusableBlocksButtons from '../reusable-blocks-buttons'; +import ConvertToGroupButtons from '../convert-to-group-buttons'; const fetchLinkSuggestions = async ( search ) => { const posts = await apiFetch( { @@ -159,6 +160,7 @@ class EditorProvider extends Component { > { children } + ); }