diff --git a/editor/components/inserter/group.js b/editor/components/inserter/group.js index 8126a55159f6b..4e3789238a13d 100644 --- a/editor/components/inserter/group.js +++ b/editor/components/inserter/group.js @@ -10,71 +10,92 @@ import { Component } from '@wordpress/element'; import { NavigableMenu } from '@wordpress/components'; import { BlockIcon } from '@wordpress/blocks'; -function deriveActiveBlocks( blocks ) { - return blocks.filter( ( block ) => ! block.disabled ); +/** + * Determines which items can be selected. These are the items that are not + * disabled. + * + * @param {Editor.InserterItem[]} items Items to filter. + * @returns {Editor.InserterItem[]} Items that can be selected. + */ +function deriveActiveItems( items ) { + return items.filter( ( item ) => ! item.isDisabled ); } export default class InserterGroup extends Component { + /** + * @inheritdoc + */ constructor() { super( ...arguments ); this.onNavigate = this.onNavigate.bind( this ); - this.activeBlocks = deriveActiveBlocks( this.props.blockTypes ); + this.activeItems = deriveActiveItems( this.props.items ); this.state = { - current: this.activeBlocks.length > 0 ? this.activeBlocks[ 0 ].name : null, + current: this.activeItems.length > 0 ? this.activeItems[ 0 ] : null, }; } + /** + * @inheritdoc + */ componentWillReceiveProps( nextProps ) { - if ( ! isEqual( this.props.blockTypes, nextProps.blockTypes ) ) { - this.activeBlocks = deriveActiveBlocks( nextProps.blockTypes ); + if ( ! isEqual( this.props.items, nextProps.items ) ) { + this.activeItems = deriveActiveItems( nextProps.items ); // Try and preserve any still valid selected state. - const current = find( this.activeBlocks, { name: this.state.current } ); + const current = find( this.activeItems, ( item ) => isEqual( item, this.state.current ) ); if ( ! current ) { this.setState( { - current: this.activeBlocks.length > 0 ? this.activeBlocks[ 0 ].name : null, + current: this.activeItems.length > 0 ? this.activeItems[ 0 ] : null, } ); } } } - renderItem( block ) { + /** + * Renders a single item. + * + * @param {Editor.InserterItem} item Item to render. + * @param {number} index Index of the item. + * @returns {JSX.Element} Rendered button. + */ + renderItem( item, index ) { const { current } = this.state; - const { selectBlock, bindReferenceNode } = this.props; - const { disabled } = block; + const { onSelectItem } = this.props; return ( ); } + /** + * Updates the currently selected item in response to a user navigating the + * menu with their keyboard. + * + * @param {number} index Index of the newly selected item. + */ onNavigate( index ) { - const { activeBlocks } = this; - const dest = activeBlocks[ index ]; + const { activeItems } = this; + const dest = activeItems[ index ]; if ( dest ) { this.setState( { - current: dest.name, + current: dest, } ); } } render() { - const { labelledBy, blockTypes } = this.props; + const { labelledBy, items } = this.props; return ( - { blockTypes.map( this.renderItem, this ) } + { items.map( this.renderItem, this ) } ); } diff --git a/editor/components/inserter/index.js b/editor/components/inserter/index.js index 51f385435f69b..e77ec98c034ea 100644 --- a/editor/components/inserter/index.js +++ b/editor/components/inserter/index.js @@ -82,17 +82,12 @@ class Inserter extends Component { ) } renderContent={ ( { onClose } ) => { - const onInsert = ( name, initialAttributes ) => { - onInsertBlock( - name, - initialAttributes, - insertionPoint - ); - + const onSelect = ( item ) => { + onInsertBlock( item, insertionPoint ); onClose(); }; - return ; + return ; } } /> ); @@ -108,9 +103,9 @@ export default compose( [ }; }, ( dispatch ) => ( { - onInsertBlock( name, initialAttributes, position ) { + onInsertBlock( item, position ) { dispatch( insertBlock( - createBlock( name, initialAttributes ), + createBlock( item.name, item.initialAttributes ), position ) ); }, diff --git a/editor/components/inserter/menu.js b/editor/components/inserter/menu.js index be59c24a1815f..ea5aec621d7bf 100644 --- a/editor/components/inserter/menu.js +++ b/editor/components/inserter/menu.js @@ -3,7 +3,6 @@ */ import { filter, - find, findIndex, flow, groupBy, @@ -27,7 +26,7 @@ import { withSpokenMessages, withContext, } from '@wordpress/components'; -import { getCategories, getBlockTypes } from '@wordpress/blocks'; +import { getCategories } from '@wordpress/blocks'; import { keycodes } from '@wordpress/utils'; /** @@ -35,16 +34,16 @@ import { keycodes } from '@wordpress/utils'; */ import './style.scss'; -import { getBlocks, getRecentlyUsedBlocks, getReusableBlocks } from '../../store/selectors'; +import { getInserterItems, getRecentInserterItems } from '../../store/selectors'; import { fetchReusableBlocks } from '../../store/actions'; import { default as InserterGroup } from './group'; -export const searchBlocks = ( blocks, searchTerm ) => { +export const searchItems = ( items, searchTerm ) => { const normalizedSearchTerm = searchTerm.toLowerCase().trim(); const matchSearch = ( string ) => string.toLowerCase().indexOf( normalizedSearchTerm ) !== -1; - return blocks.filter( ( block ) => - matchSearch( block.title ) || some( block.keywords, matchSearch ) + return items.filter( ( item ) => + matchSearch( item.title ) || some( item.keywords, matchSearch ) ); }; @@ -62,11 +61,10 @@ export class InserterMenu extends Component { tab: 'recent', }; this.filter = this.filter.bind( this ); - this.searchBlocks = this.searchBlocks.bind( this ); - this.getBlocksForTab = this.getBlocksForTab.bind( this ); - this.sortBlocks = this.sortBlocks.bind( this ); - this.bindReferenceNode = this.bindReferenceNode.bind( this ); - this.selectBlock = this.selectBlock.bind( this ); + this.searchItems = this.searchItems.bind( this ); + this.getItemsForTab = this.getItemsForTab.bind( this ); + this.sortItems = this.sortItems.bind( this ); + this.selectItem = this.selectItem.bind( this ); this.tabScrollTop = { recent: 0, blocks: 0, embeds: 0 }; this.switchTab = this.switchTab.bind( this ); @@ -77,8 +75,8 @@ export class InserterMenu extends Component { } componentDidUpdate( prevProps, prevState ) { - const searchResults = this.searchBlocks( this.getBlockTypes() ); - // Announce the blocks search results to screen readers. + const searchResults = this.searchItems( this.props.items ); + // Announce the search results to screen readers. if ( this.state.filterValue && !! searchResults.length ) { this.props.debouncedSpeak( sprintf( _n( '%d result found', @@ -94,152 +92,142 @@ export class InserterMenu extends Component { } } - isDisabledBlock( blockType ) { - return blockType.useOnce && find( this.props.blocks, ( { name } ) => blockType.name === name ); - } - - bindReferenceNode( nodeName ) { - return ( node ) => this.nodes[ nodeName ] = node; - } - filter( event ) { this.setState( { filterValue: event.target.value, } ); } - selectBlock( block ) { - return () => { - this.props.onSelect( block.name, block.initialAttributes ); - this.setState( { - filterValue: '', - } ); - }; - } - - getStaticBlockTypes() { - const { blockTypes } = this.props; - - // If all block types disabled, return empty set - if ( ! blockTypes ) { - return []; - } - - // Block types that are marked as private should not appear in the inserter - return getBlockTypes().filter( ( block ) => { - if ( block.isPrivate ) { - return false; - } - - // Block types defined as either `true` or array: - // - True: Allow - // - Array: Check block name within whitelist - return ( - ! Array.isArray( blockTypes ) || - includes( blockTypes, block.name ) - ); + /** + * Notify the parent component when an item is selected by the user. + * + * @param {Editor.InserterItem} item Selected inserter item. + */ + selectItem( item ) { + this.props.onSelect( item ); + this.setState( { + filterValue: '', } ); } - getReusableBlockTypes() { - const { reusableBlocks } = this.props; - - // Display reusable blocks that we've fetched in the inserter - return reusableBlocks.map( ( reusableBlock ) => ( { - name: 'core/block', - initialAttributes: { - ref: reusableBlock.id, - }, - title: reusableBlock.title, - icon: 'layout', - category: 'reusable-blocks', - } ) ); + /** + * Determines which items should be visible based on the current search + * query. + * + * @param {Editor.InserterItem[]} items Items to search. + * @returns {Editor.InserterItem[]} Items that should appear. + */ + searchItems( items ) { + return searchItems( items, this.state.filterValue ); } - getBlockTypes() { - return [ - ...this.getStaticBlockTypes(), - ...this.getReusableBlockTypes(), - ]; - } - - searchBlocks( blockTypes ) { - return searchBlocks( blockTypes, this.state.filterValue ); - } + /** + * Determines which items should appear in the currently selected tab. + * + * @param {string} tab Selected tab's slug, e.g. 'recent'. + * @returns {Editor.InserterItem[]} Items that should appear. + */ + getItemsForTab( tab ) { + const { items, recentItems } = this.props; - getBlocksForTab( tab ) { - const blockTypes = this.getBlockTypes(); - // if we're searching, use everything, otherwise just get the blocks visible in this tab + // If we're searching, use everything, otherwise just get the items visible in this tab if ( this.state.filterValue ) { - return blockTypes; + return items; } let predicate; switch ( tab ) { case 'recent': - return filter( this.props.recentlyUsedBlocks, - ( { name } ) => find( blockTypes, { name } ) ); + return recentItems; case 'blocks': - predicate = ( block ) => block.category !== 'embed' && block.category !== 'reusable-blocks'; + predicate = ( item ) => item.category !== 'embed' && item.category !== 'reusable-blocks'; break; case 'embeds': - predicate = ( block ) => block.category === 'embed'; + predicate = ( item ) => item.category === 'embed'; break; case 'saved': - predicate = ( block ) => block.category === 'reusable-blocks'; + predicate = ( item ) => item.category === 'reusable-blocks'; break; } - return filter( blockTypes, predicate ); + return filter( items, predicate ); } - sortBlocks( blockTypes ) { + /** + * Sorts the given items by the index of their category. + * + * @param {Editor.InserterItem[]} items Items to sort. + * @returns {Editor.InserterItem[]} Sorted items. + */ + sortItems( items ) { if ( 'recent' === this.state.tab && ! this.state.filterValue ) { - return blockTypes; + return items; } const getCategoryIndex = ( item ) => { return findIndex( getCategories(), ( category ) => category.slug === item.category ); }; - return sortBy( blockTypes, getCategoryIndex ); + return sortBy( items, getCategoryIndex ); } - groupByCategory( blockTypes ) { - return groupBy( blockTypes, ( blockType ) => blockType.category ); + /** + * Groups the given items by their category slug. + * + * @param {Editor.InserterItem[]} items Items to group. + * @returns {Object.} Grouped items. + */ + groupByCategory( items ) { + return groupBy( items, ( item ) => item.category ); } - getVisibleBlocksByCategory( blockTypes ) { + /** + * Determines which items should be visible based on the current state of the + * inserter. + * + * @param {Editor.InserterItem[]} items Items to filter. + * @returns {Editor.InserterItem[]} Visible inserter items. + */ + getVisibleItemsByCategory( items ) { return flow( - this.searchBlocks, - this.sortBlocks, + this.searchItems, + this.sortItems, this.groupByCategory - )( blockTypes ); + )( items ); } - renderBlocks( blockTypes, separatorSlug ) { + /** + * Renders multiple items. + * + * @param {Editor.InserterItem[]} items Items to render. + * @param {string} separatorSlug Slug of the category these items belong to. + * @returns {JSX.Element} Rendered item. + */ + renderItems( items, separatorSlug ) { const { instanceId } = this.props; const labelledBy = separatorSlug === undefined ? null : `editor-inserter__separator-${ separatorSlug }-${ instanceId }`; - const blockTypesInfo = blockTypes.map( ( blockType ) => ( - { ...blockType, disabled: this.isDisabledBlock( blockType ) } - ) ); - return ( ); } - renderCategory( category, blockTypes ) { + /** + * Renders a category. + * + * @param {Object} category Category to render. + * @param {Editor.InserterItem[]} items Items belonging to the category. + * @returns {JSX.Element} Rendered category. + */ + renderCategory( category, items ) { const { instanceId } = this.props; - return blockTypes && ( + return items && (
{ category.title }
- { this.renderBlocks( blockTypes, category.slug ) } + { this.renderItems( items, category.slug ) }
); } - renderCategories( visibleBlocksByCategory ) { - if ( isEmpty( visibleBlocksByCategory ) ) { + /** + * Renders multiple categories. + * + * @param {Object.} visibleItemsByCategory Items to render, grouped by category slug. + * @returns {JSX.Element} Rendered categories. + */ + renderCategories( visibleItemsByCategory ) { + if ( isEmpty( visibleItemsByCategory ) ) { return ( { __( 'No blocks found' ) } @@ -263,10 +257,15 @@ export class InserterMenu extends Component { } return getCategories().map( - ( category ) => this.renderCategory( category, visibleBlocksByCategory[ category.slug ] ) + ( category ) => this.renderCategory( category, visibleItemsByCategory[ category.slug ] ) ); } + /** + * Switch the currently selected tab. + * + * @param {string} tab Tab's slug, e.g. 'recent'. + */ switchTab( tab ) { // store the scrollTop of the tab switched from this.tabScrollTop[ this.state.tab ] = this.tabContainer.scrollTop; @@ -274,15 +273,15 @@ export class InserterMenu extends Component { } renderTabView( tab ) { - const blocksForTab = this.getBlocksForTab( tab ); + const itemsForTab = this.getItemsForTab( tab ); // If the Recent tab is selected, don't render category headers if ( 'recent' === tab ) { - return this.renderBlocks( blocksForTab ); + return this.renderItems( itemsForTab ); } // If the Saved tab is selected and we have no results, display a friendly message - if ( 'saved' === tab && blocksForTab.length === 0 ) { + if ( 'saved' === tab && itemsForTab.length === 0 ) { return (

{ __( 'No saved blocks.' ) } @@ -290,16 +289,16 @@ export class InserterMenu extends Component { ); } - const visibleBlocksByCategory = this.getVisibleBlocksByCategory( blocksForTab ); + const visibleItemsByCategory = this.getVisibleItemsByCategory( itemsForTab ); - // If our results have only blocks from one category, don't render category headers - const categories = Object.keys( visibleBlocksByCategory ); + // If our results have only items from one category, don't render category headers + const categories = Object.keys( visibleItemsByCategory ); if ( categories.length === 1 ) { const [ soleCategory ] = categories; - return this.renderBlocks( visibleBlocksByCategory[ soleCategory ] ); + return this.renderItems( visibleItemsByCategory[ soleCategory ] ); } - return this.renderCategories( visibleBlocksByCategory ); + return this.renderCategories( visibleItemsByCategory ); } // Passed to TabbableContainer, extending its event-handling logic @@ -323,8 +322,11 @@ export class InserterMenu extends Component { // Implicit `undefined` return: let the event propagate } + /** + * @inheritdoc + */ render() { - const { instanceId } = this.props; + const { instanceId, items } = this.props; const isSearching = this.state.filterValue; return ( @@ -340,7 +342,6 @@ export class InserterMenu extends Component { placeholder={ __( 'Search for a block' ) } className="editor-inserter__search" onChange={ this.filter } - ref={ this.bindReferenceNode( 'search' ) } /> { ! isSearching && - { this.renderCategories( this.getVisibleBlocksByCategory( this.getBlockTypes() ) ) } + { this.renderCategories( this.getVisibleItemsByCategory( items ) ) } } @@ -385,20 +386,23 @@ export class InserterMenu extends Component { } } -const connectComponent = connect( - ( state ) => { +export default compose( + withContext( 'editor' )( ( settings ) => { + const { blockTypes } = settings; + return { - recentlyUsedBlocks: getRecentlyUsedBlocks( state ), - blocks: getBlocks( state ), - reusableBlocks: getReusableBlocks( state ), + enabledBlockTypes: blockTypes, }; - }, - { fetchReusableBlocks } -); - -export default compose( - connectComponent, - withContext( 'editor' )( ( settings ) => pick( settings, 'blockTypes' ) ), + } ), + connect( + ( state, ownProps ) => { + return { + items: getInserterItems( state, ownProps.enabledBlockTypes ), + recentItems: getRecentInserterItems( state, ownProps.enabledBlockTypes ), + }; + }, + { fetchReusableBlocks } + ), withSpokenMessages, withInstanceId )( InserterMenu ); diff --git a/editor/components/inserter/test/menu.js b/editor/components/inserter/test/menu.js index 60dd6e4e9bc2d..1cdffc42d4928 100644 --- a/editor/components/inserter/test/menu.js +++ b/editor/components/inserter/test/menu.js @@ -4,99 +4,90 @@ import { mount } from 'enzyme'; import { noop } from 'lodash'; -/** - * WordPress dependencies - */ -import { registerBlockType, unregisterBlockType, getBlockTypes } from '@wordpress/blocks'; - /** * Internal dependencies */ -import { InserterMenu, searchBlocks } from '../menu'; +import { InserterMenu, searchItems } from '../menu'; -const textBlock = { +const textItem = { name: 'core/text-block', + initialAttributes: {}, title: 'Text', - save: noop, - edit: noop, category: 'common', + isDisabled: false, }; -const advancedTextBlock = { +const advancedTextItem = { name: 'core/advanced-text-block', + initialAttributes: {}, title: 'Advanced Text', - save: noop, - edit: noop, category: 'common', + isDisabled: false, }; -const someOtherBlock = { +const someOtherItem = { name: 'core/some-other-block', + initialAttributes: {}, title: 'Some Other Block', - save: noop, - edit: noop, category: 'common', + isDisabled: false, }; -const moreBlock = { +const moreItem = { name: 'core/more-block', + initialAttributes: {}, title: 'More', - save: noop, - edit: noop, category: 'layout', - useOnce: 'true', + isDisabled: true, }; -const youtubeBlock = { +const youtubeItem = { name: 'core-embed/youtube', + initialAttributes: {}, title: 'YouTube', - save: noop, - edit: noop, category: 'embed', keywords: [ 'google' ], + isDisabled: false, }; -const textEmbedBlock = { +const textEmbedItem = { name: 'core-embed/a-text-embed', + initialAttributes: {}, title: 'A Text Embed', - save: noop, - edit: noop, category: 'embed', + isDisabled: false, }; +const reusableItem = { + name: 'core/block', + initialAttributes: { ref: 123 }, + title: 'My reusable block', + category: 'reusable-blocks', + isDisabled: false, +}; + +const items = [ + textItem, + advancedTextItem, + someOtherItem, + moreItem, + youtubeItem, + textEmbedItem, + reusableItem, +]; + describe( 'InserterMenu', () => { // NOTE: Due to https://github.com/airbnb/enzyme/issues/1174, some of the selectors passed through to // wrapper.find have had to be strengthened (and the filterWhere strengthened also), otherwise two // results would be returned even though only one was in the DOM. - const unregisterAllBlocks = () => { - getBlockTypes().forEach( ( block ) => { - unregisterBlockType( block.name ); - } ); - }; - - afterEach( () => { - unregisterAllBlocks(); - } ); - - beforeEach( () => { - unregisterAllBlocks(); - registerBlockType( textBlock.name, textBlock ); - registerBlockType( advancedTextBlock.name, advancedTextBlock ); - registerBlockType( someOtherBlock.name, someOtherBlock ); - registerBlockType( moreBlock.name, moreBlock ); - registerBlockType( youtubeBlock.name, youtubeBlock ); - registerBlockType( textEmbedBlock.name, textEmbedBlock ); - } ); - it( 'should show the recent tab by default', () => { const wrapper = mount( { expect( visibleBlocks ).toHaveLength( 0 ); } ); - it( 'should show no blocks if all block types disabled', () => { + it( 'should show nothing if there are no items', () => { const wrapper = mount( ); @@ -128,90 +117,81 @@ describe( 'InserterMenu', () => { expect( visibleBlocks ).toHaveLength( 0 ); } ); - it( 'should show filtered block types', () => { + it( 'should show the recently used items in the recent tab', () => { const wrapper = mount( ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); - expect( visibleBlocks ).toHaveLength( 1 ); - expect( visibleBlocks.at( 0 ).text() ).toBe( 'Text' ); + expect( visibleBlocks ).toHaveLength( 3 ); + expect( visibleBlocks.at( 0 ).text() ).toBe( 'Advanced Text' ); + expect( visibleBlocks.at( 1 ).text() ).toBe( 'Text' ); + expect( visibleBlocks.at( 2 ).text() ).toBe( 'Some Other Block' ); } ); - it( 'should show the recently used blocks in the recent tab', () => { + it( 'should show items from the embed category in the embed tab', () => { const wrapper = mount( ); + const embedTab = wrapper.find( '.editor-inserter__tab' ) + .filterWhere( ( node ) => node.text() === 'Embeds' && node.name() === 'button' ); + embedTab.simulate( 'click' ); + + const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); + expect( activeCategory.text() ).toBe( 'Embeds' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); - expect( visibleBlocks ).toHaveLength( 3 ); - expect( visibleBlocks.at( 0 ).childAt( 0 ).name() ).toBe( 'BlockIcon' ); - expect( visibleBlocks.at( 0 ).text() ).toBe( 'Advanced Text' ); + expect( visibleBlocks ).toHaveLength( 2 ); + expect( visibleBlocks.at( 0 ).text() ).toBe( 'YouTube' ); + expect( visibleBlocks.at( 1 ).text() ).toBe( 'A Text Embed' ); } ); - it( 'should show blocks from the embed category in the embed tab', () => { + it( 'should show reusable items in the saved tab', () => { const wrapper = mount( ); const embedTab = wrapper.find( '.editor-inserter__tab' ) - .filterWhere( ( node ) => node.text() === 'Embeds' && node.name() === 'button' ); + .filterWhere( ( node ) => node.text() === 'Saved' && node.name() === 'button' ); embedTab.simulate( 'click' ); const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); - expect( activeCategory.text() ).toBe( 'Embeds' ); + expect( activeCategory.text() ).toBe( 'Saved' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); - expect( visibleBlocks ).toHaveLength( 2 ); - expect( visibleBlocks.at( 0 ).text() ).toBe( 'YouTube' ); - expect( visibleBlocks.at( 1 ).text() ).toBe( 'A Text Embed' ); + expect( visibleBlocks ).toHaveLength( 1 ); + expect( visibleBlocks.at( 0 ).text() ).toBe( 'My reusable block' ); } ); - it( 'should show all blocks except embeds in the blocks tab', () => { + it( 'should show all items except embeds and reusable blocks in the blocks tab', () => { const wrapper = mount( ); const blocksTab = wrapper.find( '.editor-inserter__tab' ) @@ -229,40 +209,32 @@ describe( 'InserterMenu', () => { expect( visibleBlocks.at( 3 ).text() ).toBe( 'More' ); } ); - it( 'should disable already used blocks with `usedOnce`', () => { + it( 'should disable items with `isDisabled`', () => { const wrapper = mount( ); - const blocksTab = wrapper.find( '.editor-inserter__tab' ) - .filterWhere( ( node ) => node.text() === 'Blocks' && node.name() === 'button' ); - blocksTab.simulate( 'click' ); - wrapper.update(); - const disabledBlocks = wrapper.find( '.editor-inserter__block[disabled]' ); + const disabledBlocks = wrapper.find( '.editor-inserter__block[disabled=true]' ); expect( disabledBlocks ).toHaveLength( 1 ); expect( disabledBlocks.at( 0 ).text() ).toBe( 'More' ); } ); - it( 'should allow searching for blocks', () => { + it( 'should allow searching for items', () => { const wrapper = mount( ); wrapper.setState( { filterValue: 'text' } ); @@ -282,12 +254,10 @@ describe( 'InserterMenu', () => { ); wrapper.setState( { filterValue: ' text' } ); @@ -303,18 +273,16 @@ describe( 'InserterMenu', () => { } ); } ); -describe( 'searchBlocks', () => { - it( 'should search blocks using the title ignoring case', () => { - const blocks = [ textBlock, advancedTextBlock, moreBlock, youtubeBlock, textEmbedBlock ]; - expect( searchBlocks( blocks, 'TEXT' ) ).toEqual( - [ textBlock, advancedTextBlock, textEmbedBlock ] +describe( 'searchItems', () => { + it( 'should search items using the title ignoring case', () => { + expect( searchItems( items, 'TEXT' ) ).toEqual( + [ textItem, advancedTextItem, textEmbedItem ] ); } ); - it( 'should search blocks using the keywords', () => { - const blocks = [ textBlock, advancedTextBlock, moreBlock, youtubeBlock, textEmbedBlock ]; - expect( searchBlocks( blocks, 'GOOGL' ) ).toEqual( - [ youtubeBlock ] + it( 'should search items using the keywords', () => { + expect( searchItems( items, 'GOOGL' ) ).toEqual( + [ youtubeItem ] ); } ); } ); diff --git a/editor/store/selectors.js b/editor/store/selectors.js index f05f794c07da3..4036afdc0068d 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -18,7 +18,7 @@ import createSelector from 'rememo'; /** * WordPress dependencies */ -import { serialize, getBlockType } from '@wordpress/blocks'; +import { serialize, getBlockType, getBlockTypes } from '@wordpress/blocks'; import { __ } from '@wordpress/i18n'; import { addQueryArgs } from '@wordpress/url'; @@ -1111,15 +1111,129 @@ export function getNotices( state ) { } /** - * Resolves the list of recently used block names into a list of block type settings. - * - * @param {Object} state Global application state - * - * @returns {Array} List of recently used blocks. + * An item that appears in the inserter. Inserting this item will create a new + * block. Inserter items encapsulate both regular blocks and reusable blocks. + * + * @typedef {Object} Editor.InserterItem + * @property {string} name The type of block to create. + * @property {Object} initialAttributes Attributes to pass to the newly created block. + * @property {string} title Title of the item, as it appears in the inserter. + * @property {string} icon Dashicon for the item, as it appears in the inserter. + * @property {string} category Block category that the item is associated with. + * @property {string[]} keywords Keywords that can be searched to find this item. + * @property {boolean} isDisabled Whether or not the user should be prevented from inserting this item. + */ + +/** + * Given a regular block type, constructs an item that appears in the inserter. + * + * @param {Object} state Global application state. + * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. + * @param {Object} blockType Block type, likely from getBlockType(). + * @returns {Editor.InserterItem} Item that appears in inserter. + */ +function buildInserterItemFromBlockType( state, enabledBlockTypes, blockType ) { + if ( ! enabledBlockTypes || ! blockType ) { + return null; + } + + const blockTypeIsDisabled = Array.isArray( enabledBlockTypes ) && ! enabledBlockTypes.includes( blockType.name ); + if ( blockTypeIsDisabled ) { + return null; + } + + if ( blockType.isPrivate ) { + return null; + } + + return { + name: blockType.name, + initialAttributes: {}, + title: blockType.title, + icon: blockType.icon, + category: blockType.category, + keywords: blockType.keywords, + isDisabled: !! blockType.useOnce && getBlocks( state ).some( block => block.name === blockType.name ), + }; +} + +/** + * Given a reusable block, constructs an item that appears in the inserter. + * + * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. + * @param {Object} reusableBlock Reusable block, likely from getReusableBlock(). + * @returns {Editor.InserterItem} Item that appears in inserter. */ -export function getRecentlyUsedBlocks( state ) { - // resolves the block names in the state to the block type settings - return compact( state.preferences.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ) ); +function buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock ) { + if ( ! enabledBlockTypes || ! reusableBlock ) { + return null; + } + + const blockTypeIsDisabled = Array.isArray( enabledBlockTypes ) && ! enabledBlockTypes.includes( 'core/block' ); + if ( blockTypeIsDisabled ) { + return null; + } + + const referencedBlockType = getBlockType( reusableBlock.type ); + if ( ! referencedBlockType ) { + return null; + } + + return { + name: 'core/block', + initialAttributes: { ref: reusableBlock.id }, + title: reusableBlock.title, + icon: referencedBlockType.icon, + category: 'reusable-blocks', + keywords: [], + isDisabled: false, + }; +} + +/** + * Determines the items that appear in the the inserter. Includes both static + * items (e.g. a regular block type) and dynamic items (e.g. a reusable block). + * + * @param {Object} state Global application state. + * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. + * @returns {Editor.InserterItem[]} Items that appear in inserter. + */ +export function getInserterItems( state, enabledBlockTypes = true ) { + if ( ! enabledBlockTypes ) { + return []; + } + + const staticItems = getBlockTypes().map( blockType => + buildInserterItemFromBlockType( state, enabledBlockTypes, blockType ) + ); + + const dynamicItems = getReusableBlocks( state ).map( reusableBlock => + buildInserterItemFromReusableBlock( enabledBlockTypes, reusableBlock ) + ); + + const items = [ ...staticItems, ...dynamicItems ]; + return compact( items ); +} + +/** + * Determines the items that appear in the 'Recent' tab of the inserter. + * + * @param {Object} state Global application state. + * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. + * @returns {Editor.InserterItem[]} Items that appear in the 'Recent' tab. + */ +export function getRecentInserterItems( state, enabledBlockTypes = true ) { + if ( ! enabledBlockTypes ) { + return []; + } + + const items = state.preferences.recentlyUsedBlocks.map( name => + buildInserterItemFromBlockType( state, enabledBlockTypes, getBlockType( name ) ) + ); + + // TODO: Merge in recently used reusable blocks + + return compact( items ); } /** diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 790932fa3ff2b..08cea88df1912 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -7,7 +7,7 @@ import moment from 'moment'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { registerBlockType, unregisterBlockType } from '@wordpress/blocks'; +import { registerBlockType, unregisterBlockType, getBlockTypes } from '@wordpress/blocks'; /** * Internal dependencies @@ -70,8 +70,9 @@ import { didPostSaveRequestFail, getSuggestedPostFormat, getNotices, + getInserterItems, getMostFrequentlyUsedBlocks, - getRecentlyUsedBlocks, + getRecentInserterItems, getMetaBoxes, getDirtyMetaBoxes, getMetaBox, @@ -97,6 +98,9 @@ describe( 'selectors', () => { save: ( props ) => props.attributes.text, category: 'common', title: 'test block', + icon: 'test', + keywords: [ 'testing' ], + useOnce: true, } ); } ); @@ -2248,7 +2252,107 @@ describe( 'selectors', () => { } ); } ); - describe( 'getRecentlyUsedBlocks', () => { + describe( 'getInserterItems', () => { + it( 'should list all non-private regular block types', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + }, + }, + reusableBlocks: { + data: {}, + }, + }; + + const blockTypes = getBlockTypes().filter( blockType => ! blockType.isPrivate ); + expect( getInserterItems( state ) ).toHaveLength( blockTypes.length ); + } ); + + it( 'should properly list a regular block type', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + }, + }, + reusableBlocks: { + data: {}, + }, + }; + + expect( getInserterItems( state, [ 'core/test-block' ] ) ).toEqual( [ + { + name: 'core/test-block', + initialAttributes: {}, + title: 'test block', + icon: 'test', + category: 'common', + keywords: [ 'testing' ], + isDisabled: false, + }, + ] ); + } ); + + it( 'should set isDisabled when a regular block type with useOnce has been used', () => { + const state = { + editor: { + present: { + blocksByUid: { + 1: { uid: 1, name: 'core/test-block', attributes: {} }, + }, + blockOrder: [ 1 ], + }, + }, + reusableBlocks: { + data: {}, + }, + }; + + const items = getInserterItems( state, [ 'core/test-block' ] ); + expect( items[ 0 ].isDisabled ).toBe( true ); + } ); + + it( 'should properly list reusable blocks', () => { + const state = { + editor: { + present: { + blocksByUid: {}, + blockOrder: [], + }, + }, + reusableBlocks: { + data: { + 123: { + id: 123, + title: 'My reusable block', + type: 'core/test-block', + }, + }, + }, + }; + + expect( getInserterItems( state, [ 'core/block' ] ) ).toEqual( [ + { + name: 'core/block', + initialAttributes: { ref: 123 }, + title: 'My reusable block', + icon: 'test', + category: 'reusable-blocks', + keywords: [], + isDisabled: false, + }, + ] ); + } ); + + it( 'should return nothing when all block types are disabled', () => { + expect( getInserterItems( {}, false ) ).toEqual( [] ); + } ); + } ); + + describe( 'getRecentInserterItems', () => { it( 'should return the most recently used blocks', () => { const state = { preferences: { @@ -2256,7 +2360,7 @@ describe( 'selectors', () => { }, }; - expect( getRecentlyUsedBlocks( state ).map( ( block ) => block.name ) ) + expect( getRecentInserterItems( state ).map( ( item ) => item.name ) ) .toEqual( [ 'core/paragraph', 'core/image' ] ); } ); } );