Skip to content

Commit

Permalink
Support reusable blocks for multi-selection (#9732)
Browse files Browse the repository at this point in the history
* Support converting a multiselection to a reusable block

* Add e2e test for multi-selection reusable blocks

* Remove awareness of `core/template` server-side

* Fix unit tests

* Update the template block icon

* Tweaks per review
  • Loading branch information
youknowriad authored Sep 12, 2018
1 parent 7eb1252 commit 41d4223
Show file tree
Hide file tree
Showing 11 changed files with 191 additions and 36 deletions.
2 changes: 2 additions & 0 deletions block-library/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -89,6 +90,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
template,
textColumns,
verse,
video,
Expand Down
2 changes: 2 additions & 0 deletions packages/block-library/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -78,6 +79,7 @@ export const registerCoreBlocks = () => {
spacer,
subhead,
table,
template,
textColumns,
verse,
video,
Expand Down
31 changes: 31 additions & 0 deletions packages/block-library/src/template/index.js
Original file line number Diff line number Diff line change
@@ -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: <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="0" fill="none" width="24" height="24" /><g><path d="M19 3H5c-1.105 0-2 .895-2 2v14c0 1.105.895 2 2 2h14c1.105 0 2-.895 2-2V5c0-1.105-.895-2-2-2zM6 6h5v5H6V6zm4.5 13C9.12 19 8 17.88 8 16.5S9.12 14 10.5 14s2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5zm3-6l3-5 3 5h-6z" /></g></svg>,

supports: {
customClassName: false,
html: false,
inserter: false,
},

edit() {
return <InnerBlocks />;
},

save() {
return <InnerBlocks.Content />;
},
};
10 changes: 4 additions & 6 deletions packages/editor/src/components/block-settings-menu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,12 +106,10 @@ export function BlockSettingsMenu( { clientIds, onSelect } ) {
onToggle={ onClose }
/>
) }
{ count === 1 && (
<ReusableBlockConvertButton
clientId={ firstBlockClientId }
onToggle={ onClose }
/>
) }
<ReusableBlockConvertButton
clientIds={ clientIds }
onToggle={ onClose }
/>
<_BlockSettingsMenuPluginsExtension.Slot fillProps={ { clientIds, onClose } } />
<div className="editor-block-settings-menu__separator" />
{ count === 1 && (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { noop } from 'lodash';
import { noop, every, map } from 'lodash';

/**
* WordPress dependencies
Expand Down Expand Up @@ -48,35 +48,44 @@ 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,
} = dispatch( 'core/editor' );

return {
onConvertToStatic() {
convertBlockToStatic( clientId );
if ( clientIds.length !== 1 ) {
return;
}
convertBlockToStatic( clientIds[ 0 ] );
onToggle();
},
onConvertToReusable() {
convertBlockToReusable( clientId );
convertBlockToReusable( clientIds );
onToggle();
},
};
Expand Down
6 changes: 3 additions & 3 deletions packages/editor/src/store/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ),
};
}
/**
Expand Down
53 changes: 41 additions & 12 deletions packages/editor/src/store/effects/reusable-blocks.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
serialize,
createBlock,
isReusableBlock,
cloneBlock,
} from '@wordpress/blocks';
import { __ } from '@wordpress/i18n';

Expand All @@ -25,14 +26,15 @@ import {
createSuccessNotice,
createErrorNotice,
removeBlocks,
replaceBlock,
replaceBlocks,
receiveBlocks,
saveReusableBlock,
} from '../actions';
import {
getReusableBlock,
getBlock,
getBlocks,
getBlocksByClientId,
} from '../selectors';

/**
Expand Down Expand Up @@ -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( {
Expand Down Expand Up @@ -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 }`;
Expand Down Expand Up @@ -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 ) );
};

/**
Expand All @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/editor/src/store/test/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ],
} );
} );
} );
Expand Down
11 changes: 11 additions & 0 deletions test/e2e/specs/__snapshots__/reusable-blocks.test.js.snap
Original file line number Diff line number Diff line change
@@ -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`] = `
"<!-- wp:paragraph -->
<p>Hello there!</p>
<!-- /wp:paragraph -->
<!-- wp:paragraph -->
<p>Second paragraph</p>
<!-- /wp:paragraph -->"
`;
72 changes: 72 additions & 0 deletions test/e2e/specs/reusable-blocks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
newPost,
pressWithModifier,
searchForBlock,
getEditedPostContent,
META_KEY,
} from '../support/utils';

function waitForAndAcceptDialog() {
Expand Down Expand Up @@ -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();
} );
} );
3 changes: 2 additions & 1 deletion test/integration/full-content/full-content.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down

0 comments on commit 41d4223

Please sign in to comment.