diff --git a/.eslintrc.js b/.eslintrc.js index 3dd7de35ba9606..51bca0f3bc059b 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,6 +108,7 @@ const restrictedImports = [ 'nth', 'omitBy', 'once', + 'orderby', 'overEvery', 'partial', 'partialRight', diff --git a/packages/block-editor/src/autocompleters/block.js b/packages/block-editor/src/autocompleters/block.js index e5bcd2e78dbbf0..9c1747fe867b62 100644 --- a/packages/block-editor/src/autocompleters/block.js +++ b/packages/block-editor/src/autocompleters/block.js @@ -1,8 +1,3 @@ -/** - * External dependencies - */ -import { orderBy } from 'lodash'; - /** * WordPress dependencies */ @@ -20,6 +15,7 @@ import { searchBlockItems } from '../components/inserter/search-items'; import useBlockTypesState from '../components/inserter/hooks/use-block-types-state'; import BlockIcon from '../components/block-icon'; import { store as blockEditorStore } from '../store'; +import { orderBy } from '../utils/sorting'; const noop = () => {}; const SHOWN_BLOCK_TYPES = 9; @@ -68,7 +64,7 @@ function createBlockCompleter() { collections, filterValue ) - : orderBy( items, [ 'frecency' ], [ 'desc' ] ); + : orderBy( items, 'frecency', 'desc' ); return initialFilteredItems .filter( ( item ) => item.name !== selectedBlockName ) diff --git a/packages/block-editor/src/components/block-list/block-list-context.native.js b/packages/block-editor/src/components/block-list/block-list-context.native.js index be028a25c9e419..b332a2d4ad5d85 100644 --- a/packages/block-editor/src/components/block-list/block-list-context.native.js +++ b/packages/block-editor/src/components/block-list/block-list-context.native.js @@ -1,12 +1,12 @@ /** - * External dependencies + * WordPress dependencies */ -import { orderBy } from 'lodash'; +import { createContext, useContext } from '@wordpress/element'; /** - * WordPress dependencies + * Internal dependencies */ -import { createContext, useContext } from '@wordpress/element'; +import { orderBy } from '../../utils/sorting'; export const DEFAULT_BLOCK_LIST_CONTEXT = { scrollRef: null, @@ -103,10 +103,7 @@ export function deleteBlockLayoutByClientId( data, clientId ) { */ function getBlockLayoutsOrderedByYCoord( data ) { // Only enabled for root level blocks. - // Using lodash orderBy due to hermes not having - // stable support for native .sort(). It will be - // supported in the React Native version 0.68.0. - return orderBy( data, [ 'y', 'asc' ] ); + return orderBy( Object.values( data ), 'y' ); } /** diff --git a/packages/block-editor/src/components/inserter/block-types-tab.js b/packages/block-editor/src/components/inserter/block-types-tab.js index b5ecb5ad212753..095b6cc7465918 100644 --- a/packages/block-editor/src/components/inserter/block-types-tab.js +++ b/packages/block-editor/src/components/inserter/block-types-tab.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { map, groupBy, orderBy } from 'lodash'; +import { map, groupBy } from 'lodash'; /** * WordPress dependencies @@ -17,6 +17,7 @@ import BlockTypesList from '../block-types-list'; import InserterPanel from './panel'; import useBlockTypesState from './hooks/use-block-types-state'; import InserterListbox from '../inserter-listbox'; +import { orderBy } from '../../utils/sorting'; const getBlockNamespace = ( item ) => item.name.split( '/' )[ 0 ]; @@ -42,7 +43,7 @@ export function BlockTypesTab( { ); const suggestedItems = useMemo( () => { - return orderBy( items, [ 'frecency' ], [ 'desc' ] ).slice( + return orderBy( items, 'frecency', 'desc' ).slice( 0, MAX_SUGGESTED_ITEMS ); diff --git a/packages/block-editor/src/components/inserter/search-results.js b/packages/block-editor/src/components/inserter/search-results.js index 53cd9df210fbfc..dfd7a3d73312d5 100644 --- a/packages/block-editor/src/components/inserter/search-results.js +++ b/packages/block-editor/src/components/inserter/search-results.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { orderBy, isEmpty } from 'lodash'; +import { isEmpty } from 'lodash'; /** * WordPress dependencies @@ -25,6 +25,7 @@ import usePatternsState from './hooks/use-patterns-state'; import useBlockTypesState from './hooks/use-block-types-state'; import { searchBlockItems, searchItems } from './search-items'; import InserterListbox from '../inserter-listbox'; +import { orderBy } from '../../utils/sorting'; const INITIAL_INSERTER_RESULTS = 9; /** @@ -91,7 +92,7 @@ function InserterSearchResults( { return []; } const results = searchBlockItems( - orderBy( blockTypes, [ 'frecency' ], [ 'desc' ] ), + orderBy( blockTypes, 'frecency', 'desc' ), blockTypeCategories, blockTypeCollections, filterValue diff --git a/packages/block-editor/src/components/rich-text/format-toolbar/index.js b/packages/block-editor/src/components/rich-text/format-toolbar/index.js index 12d14d24130685..7817fc284ae520 100644 --- a/packages/block-editor/src/components/rich-text/format-toolbar/index.js +++ b/packages/block-editor/src/components/rich-text/format-toolbar/index.js @@ -1,18 +1,20 @@ /** * External dependencies */ - -import { orderBy } from 'lodash'; import classnames from 'classnames'; /** * WordPress dependencies */ - import { __ } from '@wordpress/i18n'; import { ToolbarItem, DropdownMenu, Slot } from '@wordpress/components'; import { chevronDown } from '@wordpress/icons'; +/** + * Internal dependencies + */ +import { orderBy } from '../../../utils/sorting'; + const POPOVER_PROPS = { position: 'bottom right', variant: 'toolbar', diff --git a/packages/block-editor/src/store/selectors.js b/packages/block-editor/src/store/selectors.js index 3769c428a5c5bb..ee75068108e864 100644 --- a/packages/block-editor/src/store/selectors.js +++ b/packages/block-editor/src/store/selectors.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { map, find, orderBy } from 'lodash'; +import { map, find } from 'lodash'; import createSelector from 'rememo'; /** @@ -27,6 +27,7 @@ import deprecated from '@wordpress/deprecated'; * Internal dependencies */ import { mapRichTextSettings } from './utils'; +import { orderBy } from '../utils/sorting'; /** * A block selection object. diff --git a/packages/block-editor/src/utils/sorting.js b/packages/block-editor/src/utils/sorting.js new file mode 100644 index 00000000000000..84cf65a08e8274 --- /dev/null +++ b/packages/block-editor/src/utils/sorting.js @@ -0,0 +1,54 @@ +/** + * Recursive stable sorting comparator function. + * + * @param {string|Function} field Field to sort by. + * @param {Array} items Items to sort. + * @param {string} order Order, 'asc' or 'desc'. + * @return {Function} Comparison function to be used in a `.sort()`. + */ +const comparator = ( field, items, order ) => { + return ( a, b ) => { + let cmpA, cmpB; + + if ( typeof field === 'function' ) { + cmpA = field( a ); + cmpB = field( b ); + } else { + cmpA = a[ field ]; + cmpB = b[ field ]; + } + + if ( cmpA > cmpB ) { + return order === 'asc' ? 1 : -1; + } else if ( cmpB > cmpA ) { + return order === 'asc' ? -1 : 1; + } + + const orderA = items.findIndex( ( item ) => item === a ); + const orderB = items.findIndex( ( item ) => item === b ); + + // Stable sort: maintaining original array order + if ( orderA > orderB ) { + return 1; + } else if ( orderB > orderA ) { + return -1; + } + + return 0; + }; +}; + +/** + * Order items by a certain key. + * Supports decorator functions that allow complex picking of a comparison field. + * Sorts in ascending order by default, but supports descending as well. + * Stable sort - maintains original order of equal items. + * + * @param {Array} items Items to order. + * @param {string|Function} field Field to order by. + * @param {string} order Sorting order, `asc` or `desc`. + * @return {Array} Sorted items. + */ +export function orderBy( items, field, order = 'asc' ) { + return items.concat().sort( comparator( field, items, order ) ); +} diff --git a/packages/block-editor/src/utils/test/sorting.js b/packages/block-editor/src/utils/test/sorting.js new file mode 100644 index 00000000000000..f1038cda5809cc --- /dev/null +++ b/packages/block-editor/src/utils/test/sorting.js @@ -0,0 +1,49 @@ +/** + * Internal dependencies + */ +import { orderBy } from '../sorting'; + +describe( 'orderBy', () => { + it( 'should not mutate original input', () => { + const input = []; + expect( orderBy( input, 'x' ) ).not.toBe( input ); + } ); + + it( 'should sort items by a field when it is specified as a string', () => { + const input = [ { x: 2 }, { x: 1 }, { x: 3 } ]; + const expected = [ { x: 1 }, { x: 2 }, { x: 3 } ]; + expect( orderBy( input, 'x' ) ).toEqual( expected ); + } ); + + it( 'should support functions for picking the field', () => { + const input = [ { x: 2 }, { x: 1 }, { x: 3 } ]; + const expected = [ { x: 1 }, { x: 2 }, { x: 3 } ]; + expect( orderBy( input, ( item ) => item.x ) ).toEqual( expected ); + } ); + + it( 'should support sorting in a descending order', () => { + const input = [ { x: 2 }, { x: 1 }, { x: 3 } ]; + const expected = [ { x: 3 }, { x: 2 }, { x: 1 } ]; + expect( orderBy( input, 'x', 'desc' ) ).toEqual( expected ); + } ); + + it( 'should maintain original order of equal items', () => { + const a = { x: 1, a: 1 }; + const b = { x: 1, b: 2 }; + const c = { x: 0 }; + const d = { x: 1, b: 4 }; + const input = [ a, b, c, d ]; + const expected = [ c, a, b, d ]; + expect( orderBy( input, 'x' ) ).toEqual( expected ); + } ); + + it( 'should maintain original order of equal items in descencing order', () => { + const a = { x: 1, a: 1 }; + const b = { x: 1, b: 2 }; + const c = { x: 0 }; + const d = { x: 1, b: 4 }; + const input = [ a, b, c, d ]; + const expected = [ a, b, d, c ]; + expect( orderBy( input, 'x', 'desc' ) ).toEqual( expected ); + } ); +} );