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