diff --git a/block-library/index.js b/block-library/index.js
index b76b3ce0bee70..ba1faf30c115b 100644
--- a/block-library/index.js
+++ b/block-library/index.js
@@ -41,6 +41,7 @@ import * as shortcode from '../packages/block-library/src/shortcode';
import * as spacer from '../packages/block-library/src/spacer';
import * as subhead from '../packages/block-library/src/subhead';
import * as table from '../packages/block-library/src/table';
+import * as template from '../packages/block-library/src/template';
import * as textColumns from '../packages/block-library/src/text-columns';
import * as verse from '../packages/block-library/src/verse';
import * as video from '../packages/block-library/src/video';
@@ -89,6 +90,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
+ template,
textColumns,
verse,
video,
diff --git a/packages/block-library/src/index.js b/packages/block-library/src/index.js
index 1ed6b33539d4c..6d8ef11b8770e 100644
--- a/packages/block-library/src/index.js
+++ b/packages/block-library/src/index.js
@@ -38,6 +38,7 @@ import * as shortcode from './shortcode';
import * as spacer from './spacer';
import * as subhead from './subhead';
import * as table from './table';
+import * as template from './template';
import * as textColumns from './text-columns';
import * as verse from './verse';
import * as video from './video';
@@ -78,6 +79,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
+ template,
textColumns,
verse,
video,
diff --git a/packages/block-library/src/template/index.js b/packages/block-library/src/template/index.js
new file mode 100644
index 0000000000000..0cac89683d955
--- /dev/null
+++ b/packages/block-library/src/template/index.js
@@ -0,0 +1,31 @@
+/**
+ * WordPress dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { InnerBlocks } from '@wordpress/editor';
+
+export const name = 'core/template';
+
+export const settings = {
+ title: __( 'Reusable Template' ),
+
+ category: 'reusable',
+
+ description: __( 'Template block used as a container.' ),
+
+ icon: ,
+
+ supports: {
+ customClassName: false,
+ html: false,
+ inserter: false,
+ },
+
+ edit() {
+ return ;
+ },
+
+ save() {
+ return ;
+ },
+};
diff --git a/packages/editor/src/components/block-settings-menu/index.js b/packages/editor/src/components/block-settings-menu/index.js
index 0f37fb9448194..ec4bbd60db53f 100644
--- a/packages/editor/src/components/block-settings-menu/index.js
+++ b/packages/editor/src/components/block-settings-menu/index.js
@@ -106,12 +106,10 @@ export function BlockSettingsMenu( { clientIds, onSelect } ) {
onToggle={ onClose }
/>
) }
- { count === 1 && (
-
- ) }
+
<_BlockSettingsMenuPluginsExtension.Slot fillProps={ { clientIds, onClose } } />
{ count === 1 && (
diff --git a/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js b/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js
index 8178ef66162fb..b3473238ece27 100644
--- a/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js
+++ b/packages/editor/src/components/block-settings-menu/reusable-block-convert-button.js
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
-import { noop } from 'lodash';
+import { noop, every, map } from 'lodash';
/**
* WordPress dependencies
@@ -48,23 +48,29 @@ export function ReusableBlockConvertButton( {
}
export default compose( [
- withSelect( ( select, { clientId } ) => {
+ withSelect( ( select, { clientIds } ) => {
const { getBlock, getReusableBlock } = select( 'core/editor' );
const { getFallbackBlockName } = select( 'core/blocks' );
- const block = getBlock( clientId );
- if ( ! block ) {
- return { isVisible: false };
- }
+ const blocks = map( clientIds, ( clientId ) => getBlock( clientId ) );
+
+ // Hide 'Add to Reusable Blocks' on Classic blocks. Showing it causes a
+ // confusing UX, because of its similarity to the 'Convert to Blocks' button.
+ const isVisible = (
+ every( blocks, ( block ) => !! block ) &&
+ ( blocks.length !== 1 || blocks[ 0 ].name !== getFallbackBlockName() )
+ );
return {
- // Hide 'Add to Reusable Blocks' on Classic blocks. Showing it causes a
- // confusing UX, because of its similarity to the 'Convert to Blocks' button.
- isVisible: block.name !== getFallbackBlockName(),
- isStaticBlock: ! isReusableBlock( block ) || ! getReusableBlock( block.attributes.ref ),
+ isStaticBlock: isVisible && (
+ blocks.length !== 1 ||
+ ! isReusableBlock( blocks[ 0 ] ) ||
+ ! getReusableBlock( blocks[ 0 ].attributes.ref )
+ ),
+ isVisible,
};
} ),
- withDispatch( ( dispatch, { clientId, onToggle = noop } ) => {
+ withDispatch( ( dispatch, { clientIds, onToggle = noop } ) => {
const {
convertBlockToReusable,
convertBlockToStatic,
@@ -72,11 +78,14 @@ export default compose( [
return {
onConvertToStatic() {
- convertBlockToStatic( clientId );
+ if ( clientIds.length !== 1 ) {
+ return;
+ }
+ convertBlockToStatic( clientIds[ 0 ] );
onToggle();
},
onConvertToReusable() {
- convertBlockToReusable( clientId );
+ convertBlockToReusable( clientIds );
onToggle();
},
};
diff --git a/packages/editor/src/store/actions.js b/packages/editor/src/store/actions.js
index 52da09f02eeb3..d9d4e3186b73c 100644
--- a/packages/editor/src/store/actions.js
+++ b/packages/editor/src/store/actions.js
@@ -691,14 +691,14 @@ export function convertBlockToStatic( clientId ) {
/**
* Returns an action object used to convert a static block into a reusable block.
*
- * @param {string} clientId The client ID of the block to detach.
+ * @param {string} clientIds The client IDs of the block to detach.
*
* @return {Object} Action object.
*/
-export function convertBlockToReusable( clientId ) {
+export function convertBlockToReusable( clientIds ) {
return {
type: 'CONVERT_BLOCK_TO_REUSABLE',
- clientId,
+ clientIds: castArray( clientIds ),
};
}
/**
diff --git a/packages/editor/src/store/effects/reusable-blocks.js b/packages/editor/src/store/effects/reusable-blocks.js
index 27dd4e86696f7..d1cb383469328 100644
--- a/packages/editor/src/store/effects/reusable-blocks.js
+++ b/packages/editor/src/store/effects/reusable-blocks.js
@@ -13,6 +13,7 @@ import {
serialize,
createBlock,
isReusableBlock,
+ cloneBlock,
} from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';
@@ -25,7 +26,7 @@ import {
createSuccessNotice,
createErrorNotice,
removeBlocks,
- replaceBlock,
+ replaceBlocks,
receiveBlocks,
saveReusableBlock,
} from '../actions';
@@ -33,6 +34,7 @@ import {
getReusableBlock,
getBlock,
getBlocks,
+ getBlocksByClientId,
} from '../selectors';
/**
@@ -68,10 +70,19 @@ export const fetchReusableBlocks = async ( action, store ) => {
const reusableBlockOrBlocks = await result;
dispatch( receiveReusableBlocksAction( map(
castArray( reusableBlockOrBlocks ),
- ( reusableBlock ) => ( {
- reusableBlock,
- parsedBlock: parse( reusableBlock.content )[ 0 ],
- } )
+ ( reusableBlock ) => {
+ const parsedBlocks = parse( reusableBlock.content );
+ if ( parsedBlocks.length === 1 ) {
+ return {
+ reusableBlock,
+ parsedBlock: parsedBlocks[ 0 ],
+ };
+ }
+ return {
+ reusableBlock,
+ parsedBlock: createBlock( 'core/template', {}, parsedBlocks ),
+ };
+ }
) ) );
dispatch( {
@@ -105,8 +116,8 @@ export const saveReusableBlocks = async ( action, store ) => {
const { dispatch } = store;
const state = store.getState();
const { clientId, title, isTemporary } = getReusableBlock( state, id );
- const { name, attributes, innerBlocks } = getBlock( state, clientId );
- const content = serialize( createBlock( name, attributes, innerBlocks ) );
+ const reusableBlock = getBlock( state, clientId );
+ const content = serialize( reusableBlock.name === 'core/template' ? reusableBlock.innerBlocks : reusableBlock );
const data = isTemporary ? { title, content } : { id, title, content };
const path = isTemporary ? `/wp/v2/${ postType.rest_base }` : `/wp/v2/${ postType.rest_base }/${ id }`;
@@ -215,8 +226,13 @@ export const convertBlockToStatic = ( action, store ) => {
const oldBlock = getBlock( state, action.clientId );
const reusableBlock = getReusableBlock( state, oldBlock.attributes.ref );
const referencedBlock = getBlock( state, reusableBlock.clientId );
- const newBlock = createBlock( referencedBlock.name, referencedBlock.attributes );
- store.dispatch( replaceBlock( oldBlock.clientId, newBlock ) );
+ let newBlocks;
+ if ( referencedBlock.name === 'core/template' ) {
+ newBlocks = referencedBlock.innerBlocks.map( ( innerBlock ) => cloneBlock( innerBlock ) );
+ } else {
+ newBlocks = [ createBlock( referencedBlock.name, referencedBlock.attributes ) ];
+ }
+ store.dispatch( replaceBlocks( oldBlock.clientId, newBlocks ) );
};
/**
@@ -227,8 +243,21 @@ export const convertBlockToStatic = ( action, store ) => {
*/
export const convertBlockToReusable = ( action, store ) => {
const { getState, dispatch } = store;
+ let parsedBlock;
+ if ( action.clientIds.length === 1 ) {
+ parsedBlock = getBlock( getState(), action.clientIds[ 0 ] );
+ } else {
+ parsedBlock = createBlock(
+ 'core/template',
+ {},
+ getBlocksByClientId( getState(), action.clientIds )
+ );
+
+ // This shouldn't be necessary but at the moment
+ // we expect the content of the shared blocks to live in the blocks state.
+ dispatch( receiveBlocks( [ parsedBlock ] ) );
+ }
- const parsedBlock = getBlock( getState(), action.clientId );
const reusableBlock = {
id: uniqueId( 'reusable' ),
clientId: parsedBlock.clientId,
@@ -242,8 +271,8 @@ export const convertBlockToReusable = ( action, store ) => {
dispatch( saveReusableBlock( reusableBlock.id ) );
- dispatch( replaceBlock(
- parsedBlock.clientId,
+ dispatch( replaceBlocks(
+ action.clientIds,
createBlock( 'core/block', {
ref: reusableBlock.id,
layout: parsedBlock.attributes.layout,
diff --git a/packages/editor/src/store/test/actions.js b/packages/editor/src/store/test/actions.js
index 1346a5477aa43..124d8eb6f9abe 100644
--- a/packages/editor/src/store/test/actions.js
+++ b/packages/editor/src/store/test/actions.js
@@ -508,7 +508,7 @@ describe( 'actions', () => {
const clientId = '358b59ee-bab3-4d6f-8445-e8c6971a5605';
expect( convertBlockToReusable( clientId ) ).toEqual( {
type: 'CONVERT_BLOCK_TO_REUSABLE',
- clientId,
+ clientIds: [ clientId ],
} );
} );
} );
diff --git a/test/e2e/specs/__snapshots__/reusable-blocks.test.js.snap b/test/e2e/specs/__snapshots__/reusable-blocks.test.js.snap
new file mode 100644
index 0000000000000..fb2a458cef30e
--- /dev/null
+++ b/test/e2e/specs/__snapshots__/reusable-blocks.test.js.snap
@@ -0,0 +1,11 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Reusable Blocks multi-selection reusable block can be converted back to regular blocks 1`] = `
+"
+Hello there!
+
+
+
+Second paragraph
+"
+`;
diff --git a/test/e2e/specs/reusable-blocks.test.js b/test/e2e/specs/reusable-blocks.test.js
index a1360c35743ce..28b13590a463a 100644
--- a/test/e2e/specs/reusable-blocks.test.js
+++ b/test/e2e/specs/reusable-blocks.test.js
@@ -6,6 +6,8 @@ import {
newPost,
pressWithModifier,
searchForBlock,
+ getEditedPostContent,
+ META_KEY,
} from '../support/utils';
function waitForAndAcceptDialog() {
@@ -200,4 +202,74 @@ describe( 'Reusable Blocks', () => {
);
expect( items ).toHaveLength( 0 );
} );
+
+ it( 'can be created from multiselection', async () => {
+ await newPost();
+
+ // Insert a Two paragraphs block
+ await insertBlock( 'Paragraph' );
+ await page.keyboard.type( 'Hello there!' );
+ await page.keyboard.press( 'Enter' );
+ await page.keyboard.type( 'Second paragraph' );
+
+ // Select all the blocks
+ await pressWithModifier( META_KEY, 'a' );
+ await pressWithModifier( META_KEY, 'a' );
+
+ // Trigger isTyping = false
+ await page.mouse.move( 200, 300, { steps: 10 } );
+ await page.mouse.move( 250, 350, { steps: 10 } );
+
+ // Convert block to a reusable block
+ await page.waitForSelector( 'button[aria-label="More options"]' );
+ await page.click( 'button[aria-label="More options"]' );
+ const convertButton = await page.waitForXPath( '//button[text()="Add to Reusable Blocks"]' );
+ await convertButton.click();
+
+ // Wait for creation to finish
+ await page.waitForXPath(
+ '//*[contains(@class, "components-notice") and contains(@class, "is-success")]/*[text()="Block created."]'
+ );
+
+ // Select all of the text in the title field by triple-clicking on it. We
+ // triple-click because, on Mac, Mod+A doesn't work. This step can be removed
+ // when https://github.com/WordPress/gutenberg/issues/7972 is fixed
+ await page.click( '.reusable-block-edit-panel__title', { clickCount: 3 } );
+
+ // Give the reusable block a title
+ await page.keyboard.type( 'Multi-selection reusable block' );
+
+ // Save the reusable block
+ const [ saveButton ] = await page.$x( '//button[text()="Save"]' );
+ await saveButton.click();
+
+ // Wait for saving to finish
+ await page.waitForXPath( '//button[text()="Edit"]' );
+
+ // Check that we have a reusable block on the page
+ const block = await page.$( '.editor-block-list__block[data-type="core/block"]' );
+ expect( block ).not.toBeNull();
+
+ // Check that its title is displayed
+ const title = await page.$eval(
+ '.reusable-block-edit-panel__info',
+ ( element ) => element.innerText
+ );
+ expect( title ).toBe( 'Multi-selection reusable block' );
+ } );
+
+ it( 'multi-selection reusable block can be converted back to regular blocks', async () => {
+ // Insert the reusable block we edited above
+ await insertBlock( 'Multi-selection reusable block' );
+
+ // Convert block to a regular block
+ await page.click( 'button[aria-label="More options"]' );
+ const convertButton = await page.waitForXPath(
+ '//button[text()="Convert to Regular Block"]'
+ );
+ await convertButton.click();
+
+ // Check that we have two paragraph blocks on the page
+ expect( await getEditedPostContent() ).toMatchSnapshot();
+ } );
} );
diff --git a/test/integration/full-content/full-content.spec.js b/test/integration/full-content/full-content.spec.js
index 5c169185accae..1e81908e68719 100644
--- a/test/integration/full-content/full-content.spec.js
+++ b/test/integration/full-content/full-content.spec.js
@@ -223,7 +223,8 @@ describe( 'full post content fixture', () => {
.map( ( block ) => block.name )
// We don't want tests for each oembed provider, which all have the same
// `save` functions and attributes.
- .filter( ( name ) => name.indexOf( 'core-embed' ) !== 0 )
+ // The `core/template` is not worth testing here because it's never saved, it's covered better in e2e tests.
+ .filter( ( name ) => name.indexOf( 'core-embed' ) !== 0 && name !== 'core/template' )
.forEach( ( name ) => {
const nameToFilename = name.replace( /\//g, '__' );
const foundFixtures = fileBasenames