From 6759f5513d6156a1683db37c80df8df1102bfe56 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 | 47 +++--- editor/components/inserter/index.js | 15 +- editor/components/inserter/menu.js | 199 ++++++++--------------- editor/components/inserter/test/menu.js | 206 ++++++++++-------------- editor/store/selectors.js | 132 +++++++++++++-- editor/store/test/selectors.js | 112 ++++++++++++- 6 files changed, 413 insertions(+), 298 deletions(-) diff --git a/editor/components/inserter/group.js b/editor/components/inserter/group.js index 8126a55159f6b2..ab9abf50137275 100644 --- a/editor/components/inserter/group.js +++ b/editor/components/inserter/group.js @@ -10,8 +10,8 @@ import { Component } from '@wordpress/element'; import { NavigableMenu } from '@wordpress/components'; import { BlockIcon } from '@wordpress/blocks'; -function deriveActiveBlocks( blocks ) { - return blocks.filter( ( block ) => ! block.disabled ); +function deriveActiveItems( items ) { + return items.filter( ( item ) => ! item.isDisabled ); } export default class InserterGroup extends Component { @@ -20,61 +20,56 @@ export default class InserterGroup extends Component { 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, }; } 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 ) { + renderItem( item, index ) { const { current } = this.state; - const { selectBlock, bindReferenceNode } = this.props; - const { disabled } = block; + const { onSelectItem } = this.props; return ( ); } 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 51f385435f69b8..e77ec98c034eae 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 be59c24a1815f5..600603c3f4956e 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,91 @@ 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 ) - ); + 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', - } ) ); + searchItems( items ) { + return searchItems( items, this.state.filterValue ); } - getBlockTypes() { - return [ - ...this.getStaticBlockTypes(), - ...this.getReusableBlockTypes(), - ]; - } - - searchBlocks( blockTypes ) { - return searchBlocks( blockTypes, this.state.filterValue ); - } + 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 ) { + 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 ); + groupByCategory( items ) { + return groupBy( items, ( item ) => item.category ); } - getVisibleBlocksByCategory( blockTypes ) { + getVisibleItemsByCategory( items ) { return flow( - this.searchBlocks, - this.sortBlocks, + this.searchItems, + this.sortItems, this.groupByCategory - )( blockTypes ); + )( items ); } - renderBlocks( blockTypes, separatorSlug ) { + 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 ) { + 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 ) ) { + renderCategories( visibleItemsByCategory ) { + if ( isEmpty( visibleItemsByCategory ) ) { return ( { __( 'No blocks found' ) } @@ -263,7 +200,7 @@ export class InserterMenu extends Component { } return getCategories().map( - ( category ) => this.renderCategory( category, visibleBlocksByCategory[ category.slug ] ) + ( category ) => this.renderCategory( category, visibleItemsByCategory[ category.slug ] ) ); } @@ -274,15 +211,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 +227,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 @@ -324,7 +261,7 @@ export class InserterMenu extends Component { } render() { - const { instanceId } = this.props; + const { instanceId, items } = this.props; const isSearching = this.state.filterValue; return ( @@ -340,7 +277,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 +321,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 60dd6e4e9bc2d1..1cdffc42d49280 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 f05f794c07da36..4036afdc0068d0 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 790932fa3ff2b6..08cea88df1912f 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' ] ); } ); } );