Skip to content

Commit

Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Move data logic out of the inserter
Browse files Browse the repository at this point in the history
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.
noisysocks committed Jan 16, 2018

Unverified

This commit is not signed, but one or more authors requires that any commit attributed to them is signed.
1 parent af74fa6 commit cd1f9be
Showing 6 changed files with 504 additions and 298 deletions.
73 changes: 47 additions & 26 deletions editor/components/inserter/group.js
Original file line number Diff line number Diff line change
@@ -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 (
<button
role="menuitem"
key={ block.name === 'core/block' && block.initialAttributes ?
block.name + block.initialAttributes.ref :
block.name
}
key={ index }
className="editor-inserter__block"
onClick={ selectBlock( block ) }
ref={ bindReferenceNode( block.name ) }
tabIndex={ current === block.name || disabled ? null : '-1' }
disabled={ disabled }
onClick={ () => onSelectItem( item ) }
tabIndex={ isEqual( current, item ) || item.isDisabled ? null : '-1' }
disabled={ item.isDisabled }
>
<BlockIcon icon={ block.icon } />
{ block.title }
<BlockIcon icon={ item.icon } />
{ item.title }
</button>
);
}

/**
* 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 (
<NavigableMenu
@@ -83,7 +104,7 @@ export default class InserterGroup extends Component {
aria-labelledby={ labelledBy }
cycle={ false }
onNavigate={ this.onNavigate }>
{ blockTypes.map( this.renderItem, this ) }
{ items.map( this.renderItem, this ) }
</NavigableMenu>
);
}
15 changes: 5 additions & 10 deletions editor/components/inserter/index.js
Original file line number Diff line number Diff line change
@@ -82,17 +82,12 @@ class Inserter extends Component {
</IconButton>
) }
renderContent={ ( { onClose } ) => {
const onInsert = ( name, initialAttributes ) => {
onInsertBlock(
name,
initialAttributes,
insertionPoint
);

const onSelect = ( item ) => {
onInsertBlock( item, insertionPoint );
onClose();
};

return <InserterMenu onSelect={ onInsert } />;
return <InserterMenu onSelect={ onSelect } />;
} }
/>
);
@@ -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
) );
},
264 changes: 134 additions & 130 deletions editor/components/inserter/menu.js
Original file line number Diff line number Diff line change
@@ -3,7 +3,6 @@
*/
import {
filter,
find,
findIndex,
flow,
groupBy,
@@ -27,24 +26,24 @@ import {
withSpokenMessages,
withContext,
} from '@wordpress/components';
import { getCategories, getBlockTypes } from '@wordpress/blocks';
import { getCategories } from '@wordpress/blocks';
import { keycodes } from '@wordpress/utils';

/**
* Internal dependencies
*/
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.<string, Editor.InserterItem[]>} 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 (
<InserterGroup
blockTypes={ blockTypesInfo }
items={ items }
labelledBy={ labelledBy }
bindReferenceNode={ this.bindReferenceNode }
selectBlock={ this.selectBlock }
onSelectItem={ this.selectItem }
/>
);
}

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 && (
<div key={ category.slug }>
<div
className="editor-inserter__separator"
@@ -248,13 +236,19 @@ export class InserterMenu extends Component {
>
{ category.title }
</div>
{ this.renderBlocks( blockTypes, category.slug ) }
{ this.renderItems( items, category.slug ) }
</div>
);
}

renderCategories( visibleBlocksByCategory ) {
if ( isEmpty( visibleBlocksByCategory ) ) {
/**
* Renders multiple categories.
*
* @param {Object.<string, Editor.InserterItem[]>} visibleItemsByCategory Items to render, grouped by category slug.
* @returns {JSX.Element} Rendered categories.
*/
renderCategories( visibleItemsByCategory ) {
if ( isEmpty( visibleItemsByCategory ) ) {
return (
<span className="editor-inserter__no-results">
{ __( 'No blocks found' ) }
@@ -263,43 +257,48 @@ 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;
this.setState( { tab } );
}

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 (
<p className="editor-inserter__no-tab-content-message">
{ __( 'No saved blocks.' ) }
</p>
);
}

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 &&
<TabPanel className="editor-inserter__tabs" activeClass="is-active"
@@ -377,28 +378,31 @@ export class InserterMenu extends Component {
}
{ isSearching &&
<div role="menu" className="editor-inserter__search-results">
{ this.renderCategories( this.getVisibleBlocksByCategory( this.getBlockTypes() ) ) }
{ this.renderCategories( this.getVisibleItemsByCategory( items ) ) }
</div>
}
</TabbableContainer>
);
}
}

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 );
206 changes: 87 additions & 119 deletions editor/components/inserter/test/menu.js
Original file line number Diff line number Diff line change
@@ -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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ [] }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
@@ -110,108 +101,97 @@ describe( 'InserterMenu', () => {
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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [ advancedTextBlock ] }
items={ [] }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes={ false }
/>
);

const visibleBlocks = wrapper.find( '.editor-inserter__block' );
expect( visibleBlocks ).toHaveLength( 0 );
} );

it( 'should show filtered block types', () => {
it( 'should show the recently used items in the recent tab', () => {
const wrapper = mount(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [ textBlock, advancedTextBlock ] }
items={ items }
recentItems={ [ advancedTextItem, textItem, someOtherItem ] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes={ [ textBlock.name ] }
/>
);

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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [
// Actually recently used by user, thus present at the top.
advancedTextBlock,
// Blocks of category 'common' injected on SETUP_EDITOR.
// These have to be listed here in the order in which they
// are registered.
textBlock,
someOtherBlock,
] }
items={ items }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ items }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ items }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [ { name: moreBlock.name } ] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ items }
recentItems={ items }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
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(
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ items }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
wrapper.setState( { filterValue: 'text' } );
@@ -282,12 +254,10 @@ describe( 'InserterMenu', () => {
<InserterMenu
position={ 'top center' }
instanceId={ 1 }
blocks={ [] }
reusableBlocks={ [] }
recentlyUsedBlocks={ [] }
items={ items }
recentItems={ [] }
debouncedSpeak={ noop }
fetchReusableBlocks={ noop }
blockTypes
/>
);
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 ]
);
} );
} );
132 changes: 123 additions & 9 deletions editor/store/selectors.js
Original file line number Diff line number Diff line change
@@ -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 );
}

/**
112 changes: 108 additions & 4 deletions editor/store/test/selectors.js
Original file line number Diff line number Diff line change
@@ -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,15 +2252,115 @@ 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: {
recentlyUsedBlocks: [ 'core/deleted-block', 'core/paragraph', 'core/image' ],
},
};

expect( getRecentlyUsedBlocks( state ).map( ( block ) => block.name ) )
expect( getRecentInserterItems( state ).map( ( item ) => item.name ) )
.toEqual( [ 'core/paragraph', 'core/image' ] );
} );
} );

0 comments on commit cd1f9be

Please sign in to comment.