From cd1f9be94ee80d5cc297d8c31f14e7a4b5e13f35 Mon Sep 17 00:00:00 2001 From: Robert Anderson Date: Tue, 16 Jan 2018 13:00:54 +1100 Subject: [PATCH] Move data logic out of the inserter Moves the logic that determines which items should appear in the inserter into dedicated selector functions. This way, the logic is easier to test and can be re-used. --- editor/components/inserter/group.js | 73 ++++--- editor/components/inserter/index.js | 15 +- editor/components/inserter/menu.js | 264 ++++++++++++------------ editor/components/inserter/test/menu.js | 206 ++++++++---------- editor/store/selectors.js | 132 +++++++++++- editor/store/test/selectors.js | 112 +++++++++- 6 files changed, 504 insertions(+), 298 deletions(-) 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' ] ); } ); } );