diff --git a/edit-post/components/header/header-toolbar/index.js b/edit-post/components/header/header-toolbar/index.js index c6b079efb3534..452656debf362 100644 --- a/edit-post/components/header/header-toolbar/index.js +++ b/edit-post/components/header/header-toolbar/index.js @@ -15,6 +15,7 @@ import { TableOfContents, EditorHistoryRedo, EditorHistoryUndo, + MultiBlocksSwitcher, NavigableToolbar, } from '@wordpress/editor'; @@ -33,6 +34,7 @@ function HeaderToolbar( { hasFixedToolbar, isLargeViewport } ) { + { hasFixedToolbar && isLargeViewport && (
diff --git a/editor/components/block-switcher/index.js b/editor/components/block-switcher/index.js new file mode 100644 index 0000000000000..0cd9392bf05c3 --- /dev/null +++ b/editor/components/block-switcher/index.js @@ -0,0 +1,119 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Dropdown, Dashicon, IconButton, Toolbar, NavigableMenu } from '@wordpress/components'; +import { getBlockType, getPossibleBlockTransformations, switchToBlockType, BlockIcon, withEditorSettings } from '@wordpress/blocks'; +import { compose } from '@wordpress/element'; +import { keycodes } from '@wordpress/utils'; +import { withSelect, withDispatch } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './style.scss'; + +/** + * Module Constants + */ +const { DOWN } = keycodes; + +export function BlockSwitcher( { blocks, onTransform, isLocked } ) { + const allowedBlocks = getPossibleBlockTransformations( blocks ); + + if ( isLocked || ! allowedBlocks.length ) { + return null; + } + + const sourceBlockName = blocks[ 0 ].name; + const blockType = getBlockType( sourceBlockName ); + + return ( + { + const openOnArrowDown = ( event ) => { + if ( ! isOpen && event.keyCode === DOWN ) { + event.preventDefault(); + event.stopPropagation(); + onToggle(); + } + }; + const label = __( 'Change block type' ); + + return ( + + } + onClick={ onToggle } + aria-haspopup="true" + aria-expanded={ isOpen } + label={ label } + tooltip={ label } + onKeyDown={ openOnArrowDown } + > + + + + ); + } } + renderContent={ ( { onClose } ) => ( +
+ + { __( 'Transform into:' ) } + + + { allowedBlocks.map( ( { name, title, icon } ) => ( + { + onTransform( blocks, name ); + onClose(); + } } + className="editor-block-switcher__menu-item" + icon={ ( + + + + ) } + role="menuitem" + > + { title } + + ) ) } + +
+ ) } + /> + ); +} + +export default compose( + withSelect( ( select, ownProps ) => { + return { + blocks: ownProps.uids.map( ( uid ) => select( 'core/editor' ).getBlock( uid ) ), + }; + } ), + withDispatch( ( dispatch, ownProps ) => ( { + onTransform( blocks, name ) { + dispatch( 'core/editor' ).replaceBlocks( + ownProps.uids, + switchToBlockType( blocks, name ) + ); + }, + } ) ), + withEditorSettings( ( settings ) => { + const { templateLock } = settings; + + return { + isLocked: !! templateLock, + }; + } ), +)( BlockSwitcher ); diff --git a/editor/components/block-switcher/multi-blocks-switcher.js b/editor/components/block-switcher/multi-blocks-switcher.js new file mode 100644 index 0000000000000..1dc4cc21974fe --- /dev/null +++ b/editor/components/block-switcher/multi-blocks-switcher.js @@ -0,0 +1,29 @@ +/** + * WordPress dependencies + */ +import { withSelect } from '@wordpress/data'; + +/** + * Internal dependencies + */ +import './style.scss'; +import BlockSwitcher from './'; + +export function MultiBlocksSwitcher( { isMultiBlockSelection, selectedBlockUids } ) { + if ( ! isMultiBlockSelection ) { + return null; + } + return ( + + ); +} + +export default withSelect( + ( select ) => { + const selectedBlockUids = select( 'core/editor' ).getMultiSelectedBlockUids(); + return { + isMultiBlockSelection: selectedBlockUids.length > 1, + selectedBlockUids, + }; + } +)( MultiBlocksSwitcher ); diff --git a/editor/components/block-switcher/style.scss b/editor/components/block-switcher/style.scss new file mode 100644 index 0000000000000..8a0936e267ca1 --- /dev/null +++ b/editor/components/block-switcher/style.scss @@ -0,0 +1,52 @@ +.editor-block-switcher { + position: relative; + + .components-toolbar { + border-left: none; + } +} + +.editor-block-switcher__toggle { + width: auto; + margin: 0; + padding: 8px; + border-radius: 0; + + &:focus:before { + top: -3px; + right: -3px; + bottom: -3px; + left: -3px; + } +} + +.editor-block-switcher__popover .components-popover__content { + width: 200px; +} + +.editor-block-switcher__menu { + box-shadow: $shadow-popover; + border: 1px solid $light-gray-500; + background: $white; + padding: 3px 3px 0; +} + +.editor-block-switcher__menu-title { + display: block; + padding: 6px; + color: $dark-gray-300; +} + +.editor-block-switcher__menu-item { + color: $dark-gray-500; + display: flex; + align-items: center; + width: 100%; + padding: 6px; + text-align: left; + + .editor-block-switcher__block-icon { + margin-right: 8px; + height: 20px; + } +} diff --git a/editor/components/block-switcher/test/__snapshots__/index.js.snap b/editor/components/block-switcher/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..3c19baa2d4fd1 --- /dev/null +++ b/editor/components/block-switcher/test/__snapshots__/index.js.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BlockSwitcher should render switcher with blocks 1`] = ` + +`; diff --git a/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap b/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap new file mode 100644 index 0000000000000..30fe0ea13763f --- /dev/null +++ b/editor/components/block-switcher/test/__snapshots__/multi-blocks-switcher.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MultiBlocksSwitcher should return a BlockSwitcher element matching the snapshot. 1`] = ` + +`; diff --git a/editor/components/block-switcher/test/index.js b/editor/components/block-switcher/test/index.js new file mode 100644 index 0000000000000..16d6ee1720725 --- /dev/null +++ b/editor/components/block-switcher/test/index.js @@ -0,0 +1,158 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { registerCoreBlocks } from '@wordpress/core-blocks'; +import { keycodes } from '@wordpress/utils'; + +/** + * Internal dependencies + */ +import { BlockSwitcher } from '../'; + +const { DOWN } = keycodes; + +describe( 'BlockSwitcher', () => { + const headingBlock1 = { + attributes: { + content: [ 'How are you?' ], + nodeName: 'H2', + }, + isValid: true, + name: 'core/heading', + originalContent: '

How are you?

', + uid: 'a1303fd6-3e60-4fff-a770-0e0ea656c5b9', + }; + + const textBlock = { + attributes: { + content: [ 'I am great!' ], + nodeName: 'P', + }, + isValid: true, + name: 'core/text', + originalContent: '

I am great!

', + uid: 'b1303fdb-3e60-43faf-a770-2e1ea656c5b8', + }; + + const headingBlock2 = { + attributes: { + content: [ 'I am the greatest!' ], + nodeName: 'H3', + }, + isValid: true, + name: 'core/text', + originalContent: '

I am the greatest!

', + uid: 'c2403fd2-4e63-5ffa-b71c-1e0ea656c5b0', + }; + + beforeAll( () => { + registerCoreBlocks(); + } ); + + test( 'should not render block switcher without blocks', () => { + const wrapper = shallow( ); + + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should render switcher with blocks', () => { + const blocks = [ + headingBlock1, + ]; + const wrapper = shallow( ); + + expect( wrapper ).toMatchSnapshot(); + } ); + + test( 'should not render block switcher with multi block of different types.', () => { + const blocks = [ + headingBlock1, + textBlock, + ]; + const wrapper = shallow( ); + + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should not render a component when the multi selected types of blocks match.', () => { + const blocks = [ + headingBlock1, + headingBlock2, + ]; + const wrapper = shallow( ); + + expect( wrapper.html() ).toBeNull(); + } ); + + describe( 'Dropdown', () => { + const blocks = [ + headingBlock1, + ]; + + const onTransformStub = jest.fn(); + const getDropdown = () => { + const blockSwitcher = shallow( ); + return blockSwitcher.find( 'Dropdown' ); + }; + + test( 'should dropdown exist', () => { + expect( getDropdown() ).toHaveLength( 1 ); + } ); + + describe( '.renderToggle', () => { + const onToggleStub = jest.fn(); + const mockKeyDown = { + preventDefault: () => {}, + stopPropagation: () => {}, + keyCode: DOWN, + }; + + afterEach( () => { + onToggleStub.mockReset(); + } ); + + test( 'should simulate a keydown event, which should call onToggle and open transform toggle.', () => { + const toggleClosed = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: false } ) ); + const iconButtonClosed = toggleClosed.find( 'IconButton' ); + + iconButtonClosed.simulate( 'keydown', mockKeyDown ); + + expect( onToggleStub ).toHaveBeenCalledTimes( 1 ); + } ); + + test( 'should simulate a click event, which should call onToggle.', () => { + const toggleOpen = shallow( getDropdown().props().renderToggle( { onToggle: onToggleStub, isOpen: true } ) ); + const iconButtonOpen = toggleOpen.find( 'IconButton' ); + + iconButtonOpen.simulate( 'keydown', mockKeyDown ); + + expect( onToggleStub ).toHaveBeenCalledTimes( 0 ); + } ); + } ); + + describe( '.renderContent', () => { + const onCloseStub = jest.fn(); + + const getIconButtons = () => { + const content = shallow( getDropdown().props().renderContent( { onClose: onCloseStub } ) ); + return content.find( 'IconButton' ); + }; + + test( 'should create the iconButtons for the chosen block. A heading block will have 3 items', () => { + expect( getIconButtons() ).toHaveLength( 3 ); + } ); + + test( 'should simulate the click event by closing the switcher and causing a block transform on iconButtons.', () => { + getIconButtons().first().simulate( 'click' ); + + expect( onCloseStub ).toHaveBeenCalledTimes( 1 ); + expect( onTransformStub ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + } ); +} ); diff --git a/editor/components/block-switcher/test/multi-blocks-switcher.js b/editor/components/block-switcher/test/multi-blocks-switcher.js new file mode 100644 index 0000000000000..37935f47788f6 --- /dev/null +++ b/editor/components/block-switcher/test/multi-blocks-switcher.js @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; + +/** + * Internal dependencies + */ +import { MultiBlocksSwitcher } from '../multi-blocks-switcher'; + +describe( 'MultiBlocksSwitcher', () => { + test( 'should return null when the selection is not a multi block selection.', () => { + const isMultiBlockSelection = false; + const selectedBlockUids = [ + 'an-uid', + ]; + const wrapper = shallow( + + ); + + expect( wrapper.html() ).toBeNull(); + } ); + + test( 'should return a BlockSwitcher element matching the snapshot.', () => { + const isMultiBlockSelection = true; + const selectedBlockUids = [ + 'an-uid', + 'another-uid', + ]; + const wrapper = shallow( + + ); + + expect( wrapper ).toMatchSnapshot(); + } ); +} ); diff --git a/editor/components/block-toolbar/index.js b/editor/components/block-toolbar/index.js index 72adadb2f67bd..75826e7cd5dc1 100644 --- a/editor/components/block-toolbar/index.js +++ b/editor/components/block-toolbar/index.js @@ -8,6 +8,7 @@ import { withSelect } from '@wordpress/data'; * Internal Dependencies */ import './style.scss'; +import BlockSwitcher from '../block-switcher'; function BlockToolbar( { block, mode } ) { if ( ! block || ! block.isValid || mode !== 'visual' ) { @@ -16,6 +17,7 @@ function BlockToolbar( { block, mode } ) { return (
+
diff --git a/editor/components/index.js b/editor/components/index.js index 7e65d7a1cb0b7..57bb528463ca8 100644 --- a/editor/components/index.js +++ b/editor/components/index.js @@ -63,6 +63,7 @@ export { default as CopyHandler } from './copy-handler'; export { default as DefaultBlockAppender } from './default-block-appender'; export { default as ErrorBoundary } from './error-boundary'; export { default as Inserter } from './inserter'; +export { default as MultiBlocksSwitcher } from './block-switcher/multi-blocks-switcher'; export { default as MultiSelectScrollIntoView } from './multi-select-scroll-into-view'; export { default as NavigableToolbar } from './navigable-toolbar'; export { default as ObserveTyping } from './observe-typing';