From 8f006528295c237f07ab5b396408f6b8dd88415c Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Tue, 4 Jul 2017 11:14:22 +0100 Subject: [PATCH 1/5] Simple "recent blocks" implementation Refactored code that generates and sorts visible blocks so that the order of recent blocks can be arbitrary, and recent blocks appear in their own category. --- editor/inserter/menu.js | 106 ++++++++++++++++++++++++++-------------- editor/selectors.js | 18 ++++++- editor/state.js | 26 ++++++++++ editor/test/state.js | 43 +++++++++++++++- 4 files changed, 154 insertions(+), 39 deletions(-) diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js index 7a91f440de8f0..afd0fbdbc49c8 100644 --- a/editor/inserter/menu.js +++ b/editor/inserter/menu.js @@ -18,6 +18,7 @@ import { getCategories, getBlockTypes } from 'blocks'; */ import './style.scss'; import { showInsertionPoint, hideInsertionPoint } from '../actions'; +import { getRecentlyUsedBlocks } from '../selectors'; class InserterMenu extends Component { constructor() { @@ -29,11 +30,13 @@ class InserterMenu extends Component { tab: 'recent', }; this.filter = this.filter.bind( this ); - this.isShownBlock = this.isShownBlock.bind( this ); this.setSearchFocus = this.setSearchFocus.bind( this ); this.onKeyDown = this.onKeyDown.bind( this ); - this.getVisibleBlocks = this.getVisibleBlocks.bind( this ); - this.sortBlocksByCategory = this.sortBlocksByCategory.bind( this ); + this.applySearchFilter = this.applySearchFilter.bind( this ); + this.getBlocksForCurrentTab = this.getBlocksForCurrentTab.bind( this ); + this.sortBlocks = this.sortBlocks.bind( this ); + this.addRecentBlocks = this.addRecentBlocks.bind( this ); + this.filterRecentForSearch = this.filterRecentForSearch.bind( this ); } componentDidMount() { @@ -44,10 +47,6 @@ class InserterMenu extends Component { document.removeEventListener( 'keydown', this.onKeyDown ); } - isShownBlock( block ) { - return block.title.toLowerCase().indexOf( this.state.filterValue.toLowerCase() ) !== -1; - } - bindReferenceNode( nodeName ) { return ( node ) => this.nodes[ nodeName ] = node; } @@ -58,9 +57,9 @@ class InserterMenu extends Component { } ); } - selectBlock( name ) { + selectBlock( blockType ) { return () => { - this.props.onSelect( name ); + this.props.onSelect( blockType.name ); this.setState( { filterValue: '', currentFocus: null, @@ -68,11 +67,31 @@ class InserterMenu extends Component { }; } - getVisibleBlocks( blockTypes ) { - return filter( blockTypes, this.isShownBlock ); + applySearchFilter( blockTypes ) { + const matchesSearch = ( block ) => block.title.toLowerCase().indexOf( this.state.filterValue.toLowerCase() ) !== -1; + return filter( blockTypes, matchesSearch ); + } + + getBlocksForCurrentTab() { + // if we're searching, use everything, otherwise just get the blocks visible in this tab + if ( this.state.filterValue ) { + return getBlockTypes(); + } + switch ( this.state.tab ) { + case 'recent': + return this.props.recentlyUsedBlocks; + case 'blocks': + return filter( getBlockTypes(), ( block ) => block.category !== 'embed' ); + case 'embeds': + return filter( getBlockTypes(), ( block ) => block.category === 'embed' ); + } } - sortBlocksByCategory( blockTypes ) { + sortBlocks( blockTypes ) { + if ( 'recent' === this.state.tab && ! this.state.filterValue ) { + return blockTypes; + } + const getCategoryIndex = ( item ) => { return findIndex( getCategories(), ( category ) => category.slug === item.category ); }; @@ -80,15 +99,27 @@ class InserterMenu extends Component { return sortBy( blockTypes, getCategoryIndex ); } + addRecentBlocks( blocksByCategory ) { + blocksByCategory.recent = this.props.recentlyUsedBlocks; + return blocksByCategory; + } + + filterRecentForSearch( blocksByCategory ) { + blocksByCategory.recent = this.applySearchFilter( blocksByCategory.recent ); + return blocksByCategory; + } + groupByCategory( blockTypes ) { return groupBy( blockTypes, ( blockType ) => blockType.category ); } getVisibleBlocksByCategory( blockTypes ) { return flow( - this.getVisibleBlocks, - this.sortBlocksByCategory, - this.groupByCategory + this.applySearchFilter, + this.sortBlocks, + this.groupByCategory, + this.addRecentBlocks, + this.filterRecentForSearch )( blockTypes ); } @@ -137,9 +168,9 @@ class InserterMenu extends Component { focusNext() { const sortedByCategory = flow( - this.getVisibleBlocks, - this.sortBlocksByCategory, - )( getBlockTypes() ); + this.applySearchFilter, + this.sortBlocks, + )( this.getBlocksForCurrentTab() ); // If the block list is empty return early. if ( ! sortedByCategory.length ) { @@ -152,9 +183,9 @@ class InserterMenu extends Component { focusPrevious() { const sortedByCategory = flow( - this.getVisibleBlocks, - this.sortBlocksByCategory, - )( getBlockTypes() ); + this.applySearchFilter, + this.sortBlocks, + )( this.getBlocksForCurrentTab() ); // If the block list is empty return early. if ( ! sortedByCategory.length ) { @@ -231,7 +262,7 @@ class InserterMenu extends Component { role="menuitem" key={ block.name } className="editor-inserter__block" - onClick={ this.selectBlock( block.name ) } + onClick={ this.selectBlock( block ) } ref={ this.bindReferenceNode( block.name ) } tabIndex="-1" onMouseEnter={ this.props.showInsertionPoint } @@ -245,12 +276,13 @@ class InserterMenu extends Component { switchTab( tab ) { this.setState( { tab: tab } ); + this.setSearchFocus(); } render() { const { position, instanceId } = this.props; - const visibleBlocksByCategory = this.getVisibleBlocksByCategory( getBlockTypes() ); const isSearching = this.state.filterValue; + const visibleBlocksByCategory = this.getVisibleBlocksByCategory( this.getBlocksForCurrentTab() ); /* eslint-disable jsx-a11y/no-autofocus */ return ( @@ -272,19 +304,15 @@ class InserterMenu extends Component {
{ this.state.tab === 'recent' && ! isSearching &&
- { getCategories() - .map( ( category ) => category.slug === 'common' && !! visibleBlocksByCategory[ category.slug ] && ( -
- { visibleBlocksByCategory[ category.slug ].map( ( block ) => this.getBlockItem( block ) ) } -
- ) ) - } +
+ { visibleBlocksByCategory.recent.map( ( block ) => this.getBlockItem( block ) ) } +
} { this.state.tab === 'blocks' && ! isSearching && @@ -375,7 +403,11 @@ class InserterMenu extends Component { } const connectComponent = connect( - undefined, + ( state ) => { + return { + recentlyUsedBlocks: getRecentlyUsedBlocks( state ), + }; + }, { showInsertionPoint, hideInsertionPoint } ); diff --git a/editor/selectors.js b/editor/selectors.js index a6c4175177ac8..48e75b611617b 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -2,12 +2,13 @@ * External dependencies */ import moment from 'moment'; -import { first, last, get, values } from 'lodash'; +import { first, last, get, values, sortBy } from 'lodash'; import createSelector from 'rememo'; /** * Internal dependencies */ +import { getBlockType } from 'blocks'; import { addQueryArgs } from './utils/url'; /** @@ -668,3 +669,18 @@ export function getSuggestedPostFormat( state ) { export function getNotices( state ) { return values( state.notices ); } + +/** + * Resolves the list of recently used block names into a list of block type settings. + * + * @param {Object} state Global application state + * @return {Array} List of recently used blocks + */ +export function getRecentlyUsedBlocks( state ) { + // resolves the block names in the state to the block type settings, + // and orders by title so they don't jump around as much in the recent tab + return sortBy( + state.editor.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ), + ( blockType ) => blockType.title + ); +} diff --git a/editor/state.js b/editor/state.js index 97c41a0226c43..8acf1d7afc8fe 100644 --- a/editor/state.js +++ b/editor/state.js @@ -6,6 +6,11 @@ import { combineReducers, applyMiddleware, createStore } from 'redux'; import refx from 'refx'; import { reduce, keyBy, first, last, omit, without, flowRight } from 'lodash'; +/** + * WordPress dependencies + */ +import { getBlockTypes } from 'blocks'; + /** * Internal dependencies */ @@ -219,6 +224,27 @@ export const editor = combineUndoableReducers( { return state; }, + + recentlyUsedBlocks( state = [], action ) { + const maxRecent = 8; + switch ( action.type ) { + case 'SETUP_NEW_POST': + // This is where we initially populate the recently used blocks, + // for now this inserts blocks from the common category. + return getBlockTypes() + .filter( ( blockType ) => 'common' === blockType.category ) + .slice( 0, maxRecent ) + .map( ( blockType ) => blockType.name ); + case 'INSERT_BLOCK': + // This is where we record the block usage so it can show up in + // the recent blocks. + return [ + action.block.name, + ...without( state, action.block.name ), + ].slice( 0, maxRecent ); + } + return state; + }, }, { resetTypes: [ 'RESET_BLOCKS' ] } ); /** diff --git a/editor/test/state.js b/editor/test/state.js index 9e6786ba1923e..d4fe949addccd 100644 --- a/editor/test/state.js +++ b/editor/test/state.js @@ -29,7 +29,11 @@ import { describe( 'state', () => { describe( 'editor()', () => { beforeAll( () => { - registerBlockType( 'core/test-block', { save: noop } ); + registerBlockType( 'core/test-block', { + save: noop, + edit: noop, + category: 'common', + } ); } ); afterAll( () => { @@ -78,6 +82,43 @@ describe( 'state', () => { expect( state.blockOrder ).toEqual( [ 'chicken', 'ribs' ] ); } ); + it( 'should record recently used blocks', () => { + const original = editor( undefined, {} ); + expect( original.recentlyUsedBlocks ).to.not.include( 'core-embed/twitter' ); + + const state = editor( original, { + type: 'INSERT_BLOCK', + block: { + uid: 'bacon', + name: 'core-embed/twitter', + }, + } ); + + expect( state.recentlyUsedBlocks ).to.eql( [ 'core-embed/twitter' ] ); + + const twoRecentBlocks = editor( state, { + type: 'INSERT_BLOCK', + block: { + uid: 'eggs', + name: 'core-embed/youtube', + }, + } ); + + expect( twoRecentBlocks.recentlyUsedBlocks ).to.eql( [ 'core-embed/youtube', 'core-embed/twitter' ] ); + } ); + + it( 'should populate recently used blocks with the common category', () => { + const initial = editor( undefined, { + type: 'SETUP_NEW_POST', + edits: { + status: 'draft', + title: 'post title', + }, + } ); + + expect( initial.recentlyUsedBlocks ).to.have.eql( [ 'core/test-block' ] ); + } ); + it( 'should replace the block', () => { const original = editor( undefined, { type: 'RESET_BLOCKS', From ead53098ea0190fce082c4609a70068932e8063b Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Tue, 11 Jul 2017 16:02:19 +0100 Subject: [PATCH 2/5] Fixed up tests, removed ordering recent blocks by title --- editor/selectors.js | 10 +++------- editor/state.js | 11 ++++++----- editor/test/state.js | 21 ++++++++++----------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/editor/selectors.js b/editor/selectors.js index 48e75b611617b..9dc5711bceb1a 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -2,7 +2,7 @@ * External dependencies */ import moment from 'moment'; -import { first, last, get, values, sortBy } from 'lodash'; +import { first, last, get, values } from 'lodash'; import createSelector from 'rememo'; /** @@ -677,10 +677,6 @@ export function getNotices( state ) { * @return {Array} List of recently used blocks */ export function getRecentlyUsedBlocks( state ) { - // resolves the block names in the state to the block type settings, - // and orders by title so they don't jump around as much in the recent tab - return sortBy( - state.editor.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ), - ( blockType ) => blockType.title - ); + // resolves the block names in the state to the block type settings + return state.editor.recentlyUsedBlocks.map( blockType => getBlockType( blockType ) ); } diff --git a/editor/state.js b/editor/state.js index 8acf1d7afc8fe..b53c47893c0f1 100644 --- a/editor/state.js +++ b/editor/state.js @@ -235,13 +235,14 @@ export const editor = combineUndoableReducers( { .filter( ( blockType ) => 'common' === blockType.category ) .slice( 0, maxRecent ) .map( ( blockType ) => blockType.name ); - case 'INSERT_BLOCK': + case 'INSERT_BLOCKS': // This is where we record the block usage so it can show up in // the recent blocks. - return [ - action.block.name, - ...without( state, action.block.name ), - ].slice( 0, maxRecent ); + let newState = [ ...state ]; + action.blocks.forEach( ( block ) => { + newState = [ block.name, ...without( newState, block.name ) ]; + } ); + return newState.slice( 0, maxRecent ); } return state; }, diff --git a/editor/test/state.js b/editor/test/state.js index d4fe949addccd..78bf877208a81 100644 --- a/editor/test/state.js +++ b/editor/test/state.js @@ -84,27 +84,26 @@ describe( 'state', () => { it( 'should record recently used blocks', () => { const original = editor( undefined, {} ); - expect( original.recentlyUsedBlocks ).to.not.include( 'core-embed/twitter' ); - const state = editor( original, { - type: 'INSERT_BLOCK', - block: { + type: 'INSERT_BLOCKS', + blocks: [ { uid: 'bacon', name: 'core-embed/twitter', - }, + } ], } ); - expect( state.recentlyUsedBlocks ).to.eql( [ 'core-embed/twitter' ] ); + expect( state.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/twitter' ); const twoRecentBlocks = editor( state, { - type: 'INSERT_BLOCK', - block: { + type: 'INSERT_BLOCKS', + blocks: [ { uid: 'eggs', name: 'core-embed/youtube', - }, + } ], } ); - expect( twoRecentBlocks.recentlyUsedBlocks ).to.eql( [ 'core-embed/youtube', 'core-embed/twitter' ] ); + expect( twoRecentBlocks.recentlyUsedBlocks[ 0 ] ).toEqual( 'core-embed/youtube' ); + expect( twoRecentBlocks.recentlyUsedBlocks[ 1 ] ).toEqual( 'core-embed/twitter' ); } ); it( 'should populate recently used blocks with the common category', () => { @@ -116,7 +115,7 @@ describe( 'state', () => { }, } ); - expect( initial.recentlyUsedBlocks ).to.have.eql( [ 'core/test-block' ] ); + expect( initial.recentlyUsedBlocks ).toEqual( expect.arrayContaining( [ 'core/test-block', 'core/text' ] ) ); } ); it( 'should replace the block', () => { From e4553469adf67edd3a72e0c692efc7ff493e10cb Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Tue, 11 Jul 2017 18:40:38 +0100 Subject: [PATCH 3/5] Removed redundant search method, clarified some other method names --- editor/inserter/menu.js | 25 +++++++++---------------- 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js index afd0fbdbc49c8..284d2b7f06dc5 100644 --- a/editor/inserter/menu.js +++ b/editor/inserter/menu.js @@ -32,11 +32,10 @@ class InserterMenu extends Component { this.filter = this.filter.bind( this ); this.setSearchFocus = this.setSearchFocus.bind( this ); this.onKeyDown = this.onKeyDown.bind( this ); - this.applySearchFilter = this.applySearchFilter.bind( this ); + this.searchBlocks = this.searchBlocks.bind( this ); this.getBlocksForCurrentTab = this.getBlocksForCurrentTab.bind( this ); this.sortBlocks = this.sortBlocks.bind( this ); this.addRecentBlocks = this.addRecentBlocks.bind( this ); - this.filterRecentForSearch = this.filterRecentForSearch.bind( this ); } componentDidMount() { @@ -57,9 +56,9 @@ class InserterMenu extends Component { } ); } - selectBlock( blockType ) { + selectBlock( name ) { return () => { - this.props.onSelect( blockType.name ); + this.props.onSelect( name ); this.setState( { filterValue: '', currentFocus: null, @@ -67,7 +66,7 @@ class InserterMenu extends Component { }; } - applySearchFilter( blockTypes ) { + searchBlocks( blockTypes ) { const matchesSearch = ( block ) => block.title.toLowerCase().indexOf( this.state.filterValue.toLowerCase() ) !== -1; return filter( blockTypes, matchesSearch ); } @@ -104,22 +103,16 @@ class InserterMenu extends Component { return blocksByCategory; } - filterRecentForSearch( blocksByCategory ) { - blocksByCategory.recent = this.applySearchFilter( blocksByCategory.recent ); - return blocksByCategory; - } - groupByCategory( blockTypes ) { return groupBy( blockTypes, ( blockType ) => blockType.category ); } getVisibleBlocksByCategory( blockTypes ) { return flow( - this.applySearchFilter, + this.searchBlocks, this.sortBlocks, this.groupByCategory, - this.addRecentBlocks, - this.filterRecentForSearch + this.addRecentBlocks )( blockTypes ); } @@ -168,7 +161,7 @@ class InserterMenu extends Component { focusNext() { const sortedByCategory = flow( - this.applySearchFilter, + this.searchBlocks, this.sortBlocks, )( this.getBlocksForCurrentTab() ); @@ -183,7 +176,7 @@ class InserterMenu extends Component { focusPrevious() { const sortedByCategory = flow( - this.applySearchFilter, + this.searchBlocks, this.sortBlocks, )( this.getBlocksForCurrentTab() ); @@ -262,7 +255,7 @@ class InserterMenu extends Component { role="menuitem" key={ block.name } className="editor-inserter__block" - onClick={ this.selectBlock( block ) } + onClick={ this.selectBlock( block.name ) } ref={ this.bindReferenceNode( block.name ) } tabIndex="-1" onMouseEnter={ this.props.showInsertionPoint } From 9286b687da864342f6365a2be3b60342619368f5 Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Tue, 11 Jul 2017 22:12:42 +0100 Subject: [PATCH 4/5] Remove focus search on switching tab --- editor/inserter/menu.js | 1 - 1 file changed, 1 deletion(-) diff --git a/editor/inserter/menu.js b/editor/inserter/menu.js index 284d2b7f06dc5..b847eca5e1053 100644 --- a/editor/inserter/menu.js +++ b/editor/inserter/menu.js @@ -269,7 +269,6 @@ class InserterMenu extends Component { switchTab( tab ) { this.setState( { tab: tab } ); - this.setSearchFocus(); } render() { From 1ef430807779a03df0e9e30e913454b363a56d9c Mon Sep 17 00:00:00 2001 From: Nicola Heald Date: Tue, 11 Jul 2017 22:21:53 +0100 Subject: [PATCH 5/5] Fix dependency declaration comment --- editor/selectors.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/editor/selectors.js b/editor/selectors.js index 9dc5711bceb1a..6e71424290b7d 100644 --- a/editor/selectors.js +++ b/editor/selectors.js @@ -6,9 +6,13 @@ import { first, last, get, values } from 'lodash'; import createSelector from 'rememo'; /** - * Internal dependencies + * WordPress dependencies */ import { getBlockType } from 'blocks'; + +/** + * Internal dependencies + */ import { addQueryArgs } from './utils/url'; /**