diff --git a/editor/components/inserter-with-shortcuts/index.js b/editor/components/inserter-with-shortcuts/index.js index 39d1a409820af..ced6faf7d569f 100644 --- a/editor/components/inserter-with-shortcuts/index.js +++ b/editor/components/inserter-with-shortcuts/index.js @@ -17,7 +17,7 @@ import { __, sprintf } from '@wordpress/i18n'; */ import './style.scss'; import Inserter from '../inserter'; -import { getFrequentInserterItems } from '../../store/selectors'; +import { getFrecentInserterItems } from '../../store/selectors'; import { replaceBlocks } from '../../store/actions'; function InserterWithShortcuts( { items, isLocked, onToggle, onInsert } ) { @@ -62,7 +62,7 @@ export default compose( } ), connect( ( state, { enabledBlockTypes } ) => ( { - items: getFrequentInserterItems( state, enabledBlockTypes, 3 ), + items: getFrecentInserterItems( state, enabledBlockTypes, 3 ), } ), ( dispatch, { uid, layout } ) => ( { onInsert( { name, initialAttributes } ) { diff --git a/editor/components/inserter/menu.js b/editor/components/inserter/menu.js index 0ff036a934241..0d30cfbcb60e8 100644 --- a/editor/components/inserter/menu.js +++ b/editor/components/inserter/menu.js @@ -35,7 +35,7 @@ import { keycodes } from '@wordpress/utils'; import './style.scss'; import NoBlocks from './no-blocks'; -import { getInserterItems, getRecentInserterItems } from '../../store/selectors'; +import { getInserterItems, getFrecentInserterItems } from '../../store/selectors'; import { fetchReusableBlocks } from '../../store/actions'; import { default as InserterGroup } from './group'; import BlockPreview from '../block-preview'; @@ -60,7 +60,7 @@ export class InserterMenu extends Component { this.nodes = {}; this.state = { filterValue: '', - tab: 'recent', + tab: 'frequent', selectedItem: null, }; this.filter = this.filter.bind( this ); @@ -69,7 +69,7 @@ export class InserterMenu extends Component { this.sortItems = this.sortItems.bind( this ); this.selectItem = this.selectItem.bind( this ); - this.tabScrollTop = { recent: 0, blocks: 0, embeds: 0 }; + this.tabScrollTop = { frequent: 0, blocks: 0, embeds: 0 }; this.switchTab = this.switchTab.bind( this ); this.previewItem = this.previewItem.bind( this ); } @@ -118,7 +118,7 @@ export class InserterMenu extends Component { } getItemsForTab( tab ) { - const { items, recentItems } = this.props; + const { items, frecentItems } = this.props; // If we're searching, use everything, otherwise just get the items visible in this tab if ( this.state.filterValue ) { @@ -127,8 +127,8 @@ export class InserterMenu extends Component { let predicate; switch ( tab ) { - case 'recent': - return recentItems; + case 'frequent': + return frecentItems; case 'blocks': predicate = ( item ) => item.category !== 'embed' && item.category !== 'reusable-blocks'; @@ -147,7 +147,7 @@ export class InserterMenu extends Component { } sortItems( items ) { - if ( 'recent' === this.state.tab && ! this.state.filterValue ) { + if ( 'frequent' === this.state.tab && ! this.state.filterValue ) { return items; } @@ -220,8 +220,8 @@ export class InserterMenu extends Component { renderTabView( tab ) { const itemsForTab = this.getItemsForTab( tab ); - // If the Recent tab is selected, don't render category headers - if ( 'recent' === tab ) { + // If the Frequent tab is selected, don't render category headers + if ( 'frequent' === tab ) { return this.renderItems( itemsForTab ); } @@ -248,7 +248,7 @@ export class InserterMenu extends Component { // Passed to TabbableContainer, extending its event-handling logic eventToOffset( event ) { - // If a tab (Recent, Blocks, …) is focused, pressing the down arrow + // If a tab (Frequent, Blocks, …) is focused, pressing the down arrow // moves focus to the selected panel below. if ( event.keyCode === keycodes.DOWN && @@ -291,8 +291,8 @@ export class InserterMenu extends Component { onSelect={ this.switchTab } tabs={ [ { - name: 'recent', - title: __( 'Recent' ), + name: 'frequent', + title: __( 'Frequent' ), className: 'editor-inserter__tab', }, { @@ -344,7 +344,7 @@ export default compose( ( state, ownProps ) => { return { items: getInserterItems( state, ownProps.enabledBlockTypes ), - recentItems: getRecentInserterItems( state, ownProps.enabledBlockTypes ), + frecentItems: getFrecentInserterItems( state, ownProps.enabledBlockTypes ), }; }, { fetchReusableBlocks } diff --git a/editor/components/inserter/test/menu.js b/editor/components/inserter/test/menu.js index 57a5be1f0b7d3..04dad4f28a25c 100644 --- a/editor/components/inserter/test/menu.js +++ b/editor/components/inserter/test/menu.js @@ -88,13 +88,13 @@ describe( 'InserterMenu', () => { // 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. - it( 'should show the recent tab by default', () => { + it( 'should show the frequent tab by default', () => { const wrapper = mount( { ); const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); - expect( activeCategory.text() ).toBe( 'Recent' ); + expect( activeCategory.text() ).toBe( 'Frequent' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); expect( visibleBlocks ).toHaveLength( 0 ); @@ -114,7 +114,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ [] } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -124,13 +124,13 @@ describe( 'InserterMenu', () => { expect( visibleBlocks ).toHaveLength( 0 ); } ); - it( 'should show the recently used items in the recent tab', () => { + it( 'should show the frequently used items in the frequent tab', () => { const wrapper = mount( @@ -149,7 +149,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -173,7 +173,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -196,7 +196,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -222,7 +222,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ items } + frecentItems={ items } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -239,7 +239,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> @@ -262,7 +262,7 @@ describe( 'InserterMenu', () => { position={ 'top center' } instanceId={ 1 } items={ items } - recentItems={ [] } + frecentItems={ [] } debouncedSpeak={ noop } fetchReusableBlocks={ noop } /> diff --git a/editor/store/defaults.js b/editor/store/defaults.js index 27a94b9581467..eeea12bb320a0 100644 --- a/editor/store/defaults.js +++ b/editor/store/defaults.js @@ -1,4 +1,3 @@ export const PREFERENCES_DEFAULTS = { - recentInserts: [], insertUsage: {}, }; diff --git a/editor/store/reducer.js b/editor/store/reducer.js index 5b03ccddd6382..6d9a0be3eba6d 100644 --- a/editor/store/reducer.js +++ b/editor/store/reducer.js @@ -656,17 +656,12 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { id += '/' + block.attributes.ref; } - const isSameAsInsert = ( { name, ref } ) => name === insert.name && ref === insert.ref; - return { ...prevState, - recentInserts: [ - insert, - ...reject( prevState.recentInserts, isSameAsInsert ), - ], insertUsage: { ...prevState.insertUsage, [ id ]: { + time: Date.now(), count: prevState.insertUsage[ id ] ? prevState.insertUsage[ id ].count + 1 : 1, insert, }, @@ -678,7 +673,6 @@ export function preferences( state = PREFERENCES_DEFAULTS, action ) { return { ...state, insertUsage: omitBy( state.insertUsage, ( { insert } ) => insert.ref === action.id ), - recentInserts: reject( state.recentInserts, insert => insert.ref === action.id ), }; } diff --git a/editor/store/selectors.js b/editor/store/selectors.js index 14ce95bed18fb..85ccdccb73735 100644 --- a/editor/store/selectors.js +++ b/editor/store/selectors.js @@ -1186,20 +1186,11 @@ function getItemsFromInserts( state, inserts, enabledBlockTypes = true, maximum } /** - * Determines the items that appear in the 'Recent' tab of the inserter. + * Returns a list of items which the user is likely to want to insert. These + * are ordered by 'frecency', which is a heuristic that combines block usage + * frequency and recency. * - * @param {Object} state Global application state. - * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. - * @param {number} maximum Number of items to return. - * - * @return {Editor.InserterItem[]} Items that appear in the 'Recent' tab. - */ -export function getRecentInserterItems( state, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) { - return getItemsFromInserts( state, state.preferences.recentInserts, enabledBlockTypes, maximum ); -} - -/** - * Determines the items that appear in the inserter with shortcuts based on the block usage + * https://en.wikipedia.org/wiki/Frecency * * @param {Object} state Global application state. * @param {string[]|boolean} enabledBlockTypes Enabled block types, or true/false to enable/disable all types. @@ -1207,9 +1198,23 @@ export function getRecentInserterItems( state, enabledBlockTypes = true, maximum * * @return {Editor.InserterItem[]} Items that appear in the 'Recent' tab. */ -export function getFrequentInserterItems( state, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) { +export function getFrecentInserterItems( state, enabledBlockTypes = true, maximum = MAX_RECENT_BLOCKS ) { + const calculateFrecency = ( time, count ) => { + const duration = Date.now() - time; + switch ( true ) { + case duration < 3600: + return count * 4; + case duration < ( 24 * 3600 ): + return count * 2; + case duration < ( 7 * 24 * 3600 ): + return count / 2; + default: + return count / 4; + } + }; + const sortedInserts = values( state.preferences.insertUsage ) - .sort( ( a, b ) => b.count - a.count ) + .sort( ( a, b ) => calculateFrecency( b.time, b.count ) - calculateFrecency( a.time, a.count ) ) .map( ( { insert } ) => insert ); return getItemsFromInserts( state, sortedInserts, enabledBlockTypes, maximum ); } diff --git a/editor/store/test/reducer.js b/editor/store/test/reducer.js index 81df4129511e2..c9e1523ca56dc 100644 --- a/editor/store/test/reducer.js +++ b/editor/store/test/reducer.js @@ -1196,13 +1196,12 @@ describe( 'state', () => { const state = preferences( undefined, {} ); expect( state ).toEqual( { - recentInserts: [], insertUsage: {}, } ); } ); it( 'should record recently used blocks', () => { - const state = preferences( deepFreeze( { recentInserts: [], insertUsage: {} } ), { + const state = preferences( deepFreeze( { insertUsage: {} } ), { type: 'INSERT_BLOCKS', blocks: [ { uid: 'bacon', @@ -1211,11 +1210,9 @@ describe( 'state', () => { } ); expect( state ).toEqual( { - recentInserts: [ - { name: 'core-embed/twitter' }, - ], insertUsage: { 'core-embed/twitter': { + time: expect.any( Number ), count: 1, insert: { name: 'core-embed/twitter' }, }, @@ -1223,9 +1220,9 @@ describe( 'state', () => { } ); const twoRecentBlocks = preferences( deepFreeze( { - recentInserts: [], insertUsage: { 'core-embed/twitter': { + time: expect.any( Number ), count: 1, insert: { name: 'core-embed/twitter' }, }, @@ -1243,16 +1240,14 @@ describe( 'state', () => { } ); expect( twoRecentBlocks ).toEqual( { - recentInserts: [ - { name: 'core/block', ref: 123 }, - { name: 'core-embed/twitter' }, - ], insertUsage: { 'core-embed/twitter': { + time: expect.any( Number ), count: 2, insert: { name: 'core-embed/twitter' }, }, 'core/block/123': { + time: expect.any( Number ), count: 1, insert: { name: 'core/block', ref: 123 }, }, @@ -1262,13 +1257,9 @@ describe( 'state', () => { it( 'should remove recorded reusable blocks that are deleted', () => { const initialState = { - recentInserts: [ - { name: 'core-embed/twitter' }, - { name: 'core/block', ref: 123 }, - { name: 'core/block', ref: 456 }, - ], insertUsage: { 'core/block/123': { + time: 1000, count: 1, insert: { name: 'core/block', ref: 123 }, }, @@ -1281,10 +1272,6 @@ describe( 'state', () => { } ); expect( state ).toEqual( { - recentInserts: [ - { name: 'core-embed/twitter' }, - { name: 'core/block', ref: 456 }, - ], insertUsage: {}, } ); } ); diff --git a/editor/store/test/selectors.js b/editor/store/test/selectors.js index 9cb79e54dc334..7ebea03c57564 100644 --- a/editor/store/test/selectors.js +++ b/editor/store/test/selectors.js @@ -71,8 +71,7 @@ const { getStateBeforeOptimisticTransaction, isPublishingPost, getInserterItems, - getRecentInserterItems, - getFrequentInserterItems, + getFrecentInserterItems, POST_UPDATE_TRANSACTION_ID, } = selectors; @@ -2379,89 +2378,20 @@ describe( 'selectors', () => { } ); } ); - describe( 'getRecentInserterItems', () => { + describe( 'getFrecentInserterItems', () => { beforeAll( () => { registerCoreBlocks(); } ); - it( 'should return the 9 most recently used blocks', () => { - const state = { - preferences: { - recentInserts: [ - { name: 'core/deleted-block' }, // Deleted blocks should be filtered out - { name: 'core/block', ref: 456 }, // Deleted reusable blocks should be filtered out - { name: 'core/paragraph' }, - { name: 'core/block', ref: 123 }, - { name: 'core/image' }, - { name: 'core/quote' }, - { name: 'core/gallery' }, - { name: 'core/heading' }, - { name: 'core/list' }, - { name: 'core/video' }, - { name: 'core/audio' }, - { name: 'core/code' }, - ], - }, - editor: { - present: { - blockOrder: [], - }, - }, - reusableBlocks: { - data: { - 123: { id: 123, type: 'core/test-block' }, - }, - }, - }; - - expect( getRecentInserterItems( state ) ).toMatchObject( [ - { name: 'core/paragraph', initialAttributes: {} }, - { name: 'core/block', initialAttributes: { ref: 123 } }, - { name: 'core/image', initialAttributes: {} }, - { name: 'core/quote', initialAttributes: {} }, - { name: 'core/gallery', initialAttributes: {} }, - { name: 'core/heading', initialAttributes: {} }, - { name: 'core/list', initialAttributes: {} }, - { name: 'core/video', initialAttributes: {} }, - { name: 'core/audio', initialAttributes: {} }, - ] ); - } ); - - it( 'should pad list out with blocks from the common category', () => { - const state = { - preferences: { - recentInserts: [ - { name: 'core/paragraph' }, - ], - }, - editor: { - present: { - blockOrder: [], - }, - }, - }; - - // We should get back 8 items with no duplicates - const items = getRecentInserterItems( state ); - const blockNames = items.map( item => item.name ); - expect( union( blockNames ) ).toHaveLength( 9 ); - } ); - } ); - - describe( 'getFrequentInserterItems', () => { - beforeAll( () => { - registerCoreBlocks(); - } ); - - it( 'should return the 8 most recently used blocks', () => { + it( 'should return the most frecently used blocks', () => { const state = { preferences: { insertUsage: { - 'core/deleted-block': { count: 10, insert: { name: 'core/deleted-block' } }, // Deleted blocks should be filtered out - 'core/block/456': { count: 4, insert: { name: 'core/block', ref: 456 } }, // Deleted reusable blocks should be filtered out - 'core/image': { count: 3, insert: { name: 'core/image' } }, - 'core/block/123': { count: 5, insert: { name: 'core/block', ref: 123 } }, - 'core/paragraph': { count: 2, insert: { name: 'core/paragraph' } }, + 'core/deleted-block': { time: 1000, count: 10, insert: { name: 'core/deleted-block' } }, // Deleted blocks should be filtered out + 'core/block/456': { time: 1000, count: 4, insert: { name: 'core/block', ref: 456 } }, // Deleted reusable blocks should be filtered out + 'core/image': { time: 1000, count: 3, insert: { name: 'core/image' } }, + 'core/block/123': { time: 1000, count: 5, insert: { name: 'core/block', ref: 123 } }, + 'core/paragraph': { time: 1000, count: 2, insert: { name: 'core/paragraph' } }, }, }, editor: { @@ -2476,7 +2406,7 @@ describe( 'selectors', () => { }, }; - expect( getFrequentInserterItems( state, true, 3 ) ).toMatchObject( [ + expect( getFrecentInserterItems( state, true, 3 ) ).toMatchObject( [ { name: 'core/block', initialAttributes: { ref: 123 } }, { name: 'core/image', initialAttributes: {} }, { name: 'core/paragraph', initialAttributes: {} }, @@ -2487,7 +2417,7 @@ describe( 'selectors', () => { const state = { preferences: { insertUsage: { - 'core/image': { count: 2, insert: { name: 'core/paragraph' } }, + 'core/image': { time: 1000, count: 2, insert: { name: 'core/paragraph' } }, }, }, editor: { @@ -2498,7 +2428,7 @@ describe( 'selectors', () => { }; // We should get back 4 items with no duplicates - const items = getFrequentInserterItems( state, true, 4 ); + const items = getFrecentInserterItems( state, true, 4 ); const blockNames = items.map( item => item.name ); expect( union( blockNames ) ).toHaveLength( 4 ); } );