diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index 65990d5951dfa9..d7ee1a135bacec 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -3,17 +3,14 @@ */ import { filter, - find, findIndex, flow, groupBy, isEmpty, map, - some, sortBy, without, includes, - deburr, } from 'lodash'; import scrollIntoView from 'dom-scroll-into-view'; import classnames from 'classnames'; @@ -34,7 +31,6 @@ import { Tip, } from '@wordpress/components'; import { - getCategories, isReusableBlock, createBlock, isUnmodifiedDefaultBlock, @@ -54,57 +50,12 @@ import BlockTypesList from '../block-types-list'; import BlockCard from '../block-card'; import ChildBlocks from './child-blocks'; import __experimentalInserterMenuExtension from '../inserter-menu-extension'; +import { searchItems } from './search-items'; const MAX_SUGGESTED_ITEMS = 9; const stopKeyPropagation = ( event ) => event.stopPropagation(); -/** - * Filters an item list given a search term. - * - * @param {Array} items Item list - * @param {string} searchTerm Search term. - * - * @return {Array} Filtered item list. - */ -export const searchItems = ( items, searchTerm ) => { - const normalizedSearchTerm = normalizeTerm( searchTerm ); - const matchSearch = ( string ) => normalizeTerm( string ).indexOf( normalizedSearchTerm ) !== -1; - const categories = getCategories(); - - return items.filter( ( item ) => { - const itemCategory = find( categories, { slug: item.category } ); - return matchSearch( item.title ) || some( item.keywords, matchSearch ) || ( itemCategory && matchSearch( itemCategory.title ) ); - } ); -}; - -/** - * Converts the search term into a normalized term. - * - * @param {string} term The search term to normalize. - * - * @return {string} The normalized search term. - */ -export const normalizeTerm = ( term ) => { - // Disregard diacritics. - // Input: "média" - term = deburr( term ); - - // Accommodate leading slash, matching autocomplete expectations. - // Input: "/media" - term = term.replace( /^\//, '' ); - - // Lowercase. - // Input: "MEDIA" - term = term.toLowerCase(); - - // Strip leading and trailing whitespace. - // Input: " media " - term = term.trim(); - - return term; -}; - export class InserterMenu extends Component { constructor() { super( ...arguments ); @@ -204,9 +155,9 @@ export class InserterMenu extends Component { } filter( filterValue = '' ) { - const { debouncedSpeak, items, rootChildBlocks } = this.props; + const { categories, debouncedSpeak, items, rootChildBlocks } = this.props; - const filteredItems = searchItems( items, filterValue ); + const filteredItems = searchItems( items, categories, filterValue ); const childItems = filter( filteredItems, ( { name } ) => includes( rootChildBlocks, name ) ); @@ -219,7 +170,7 @@ export class InserterMenu extends Component { const reusableItems = filter( filteredItems, { category: 'reusable' } ); const getCategoryIndex = ( item ) => { - return findIndex( getCategories(), ( category ) => category.slug === item.category ); + return findIndex( categories, ( category ) => category.slug === item.category ); }; const itemsPerCategory = flow( ( itemList ) => filter( itemList, ( item ) => item.category !== 'reusable' ), @@ -261,7 +212,7 @@ export class InserterMenu extends Component { } render() { - const { instanceId, onSelect, rootClientId, showInserterHelpPanel } = this.props; + const { categories, instanceId, onSelect, rootClientId, showInserterHelpPanel } = this.props; const { childItems, hoveredItem, @@ -330,7 +281,7 @@ export class InserterMenu extends Component { } - { map( getCategories(), ( category ) => { + { map( categories, ( category ) => { const categoryItems = itemsPerCategory[ category.slug ]; if ( ! categoryItems || ! categoryItems.length ) { return null; @@ -465,6 +416,7 @@ export default compose( getSettings, } = select( 'core/block-editor' ); const { + getCategories, getChildBlockNames, } = select( 'core/blocks' ); @@ -483,6 +435,7 @@ export default compose( } = getSettings(); return { + categories: getCategories(), rootChildBlocks: getChildBlockNames( destinationRootBlockName ), items: getInserterItems( destinationRootClientId ), showInserterHelpPanel: showInserterHelpPanel && showInserterHelpPanelSetting, diff --git a/packages/block-editor/src/components/inserter/search-items.js b/packages/block-editor/src/components/inserter/search-items.js new file mode 100644 index 00000000000000..6eb910d33df7fa --- /dev/null +++ b/packages/block-editor/src/components/inserter/search-items.js @@ -0,0 +1,86 @@ +/** + * External dependencies + */ +import { + deburr, + differenceWith, + find, + get, + words, +} from 'lodash'; + +/** + * Converts the search term into a list of normalized terms. + * + * @param {string} term The search term to normalize. + * + * @return {string[]} The normalized list of search terms. + */ +export const normalizeSearchTerm = ( term = '' ) => { + // Disregard diacritics. + // Input: "média" + term = deburr( term ); + + // Accommodate leading slash, matching autocomplete expectations. + // Input: "/media" + term = term.replace( /^\//, '' ); + + // Lowercase. + // Input: "MEDIA" + term = term.toLowerCase(); + + // Extract words. + return words( term ); +}; + +const removeMatchingTerms = ( unmatchedTerms, unprocessedTerms ) => { + return differenceWith( + unmatchedTerms, + normalizeSearchTerm( unprocessedTerms ), + ( unmatchedTerm, unprocessedTerm ) => unprocessedTerm.includes( unmatchedTerm ) + ); +}; + +/** + * Filters an item list given a search term. + * + * @param {Array} items Item list + * @param {Array} categories Available categories. + * @param {string} searchTerm Search term. + * + * @return {Array} Filtered item list. + */ +export const searchItems = ( items, categories, searchTerm ) => { + const normalizedTerms = normalizeSearchTerm( searchTerm ); + + if ( normalizedTerms.length === 0 ) { + return items; + } + + return items.filter( ( { title, category, keywords = [] } ) => { + let unmatchedTerms = removeMatchingTerms( + normalizedTerms, + title + ); + + if ( unmatchedTerms.length === 0 ) { + return true; + } + + unmatchedTerms = removeMatchingTerms( + unmatchedTerms, + keywords.join( ' ' ), + ); + + if ( unmatchedTerms.length === 0 ) { + return true; + } + + unmatchedTerms = removeMatchingTerms( + unmatchedTerms, + get( find( categories, { slug: category } ), [ 'title' ] ), + ); + + return unmatchedTerms.length === 0; + } ); +}; diff --git a/packages/block-editor/src/components/inserter/test/fixtures/index.js b/packages/block-editor/src/components/inserter/test/fixtures/index.js new file mode 100644 index 00000000000000..3161fcfa55bc63 --- /dev/null +++ b/packages/block-editor/src/components/inserter/test/fixtures/index.js @@ -0,0 +1,89 @@ +export const categories = [ + { slug: 'common', title: 'Common Blocks' }, + { slug: 'formatting', title: 'Formatting' }, + { slug: 'layout', title: 'Layout Elements' }, + { slug: 'widgets', title: 'Widgets' }, + { slug: 'embed', title: 'Embeds' }, + { slug: 'reusable', title: 'Reusable Blocks' }, +]; + +export const textItem = { + id: 'core/text-block', + name: 'core/text-block', + initialAttributes: {}, + title: 'Text', + category: 'common', + isDisabled: false, + utility: 1, +}; + +export const advancedTextItem = { + id: 'core/advanced-text-block', + name: 'core/advanced-text-block', + initialAttributes: {}, + title: 'Advanced Text', + category: 'common', + isDisabled: false, + utility: 1, +}; + +export const someOtherItem = { + id: 'core/some-other-block', + name: 'core/some-other-block', + initialAttributes: {}, + title: 'Some Other Block', + category: 'common', + isDisabled: false, + utility: 1, +}; + +export const moreItem = { + id: 'core/more-block', + name: 'core/more-block', + initialAttributes: {}, + title: 'More', + category: 'layout', + isDisabled: true, + utility: 0, +}; + +export const youtubeItem = { + id: 'core-embed/youtube', + name: 'core-embed/youtube', + initialAttributes: {}, + title: 'YouTube', + category: 'embed', + keywords: [ 'google', 'video' ], + isDisabled: false, + utility: 0, +}; + +export const textEmbedItem = { + id: 'core-embed/a-text-embed', + name: 'core-embed/a-text-embed', + initialAttributes: {}, + title: 'A Text Embed', + category: 'embed', + isDisabled: false, + utility: 0, +}; + +export const reusableItem = { + id: 'core/block/123', + name: 'core/block', + initialAttributes: { ref: 123 }, + title: 'My reusable block', + category: 'reusable', + isDisabled: false, + utility: 0, +}; + +export default [ + textItem, + advancedTextItem, + someOtherItem, + moreItem, + youtubeItem, + textEmbedItem, + reusableItem, +]; diff --git a/packages/block-editor/src/components/inserter/test/menu.js b/packages/block-editor/src/components/inserter/test/menu.js index ef9d2b10214a98..581c6b69aa8669 100644 --- a/packages/block-editor/src/components/inserter/test/menu.js +++ b/packages/block-editor/src/components/inserter/test/menu.js @@ -8,91 +8,12 @@ import ReactDOM from 'react-dom'; /** * Internal dependencies */ -import { InserterMenu, searchItems, normalizeTerm } from '../menu'; - -const textItem = { - id: 'core/text-block', - name: 'core/text-block', - initialAttributes: {}, - title: 'Text', - category: 'common', - isDisabled: false, - utility: 1, -}; - -const advancedTextItem = { - id: 'core/advanced-text-block', - name: 'core/advanced-text-block', - initialAttributes: {}, - title: 'Advanced Text', - category: 'common', - isDisabled: false, - utility: 1, -}; - -const someOtherItem = { - id: 'core/some-other-block', - name: 'core/some-other-block', - initialAttributes: {}, - title: 'Some Other Block', - category: 'common', - isDisabled: false, - utility: 1, -}; - -const moreItem = { - id: 'core/more-block', - name: 'core/more-block', - initialAttributes: {}, - title: 'More', - category: 'layout', - isDisabled: true, - utility: 0, -}; - -const youtubeItem = { - id: 'core-embed/youtube', - name: 'core-embed/youtube', - initialAttributes: {}, - title: 'YouTube', - category: 'embed', - keywords: [ 'google' ], - isDisabled: false, - utility: 0, -}; - -const textEmbedItem = { - id: 'core-embed/a-text-embed', - name: 'core-embed/a-text-embed', - initialAttributes: {}, - title: 'A Text Embed', - category: 'embed', - isDisabled: false, - utility: 0, -}; - -const reusableItem = { - id: 'core/block/123', - name: 'core/block', - initialAttributes: { ref: 123 }, - title: 'My reusable block', - category: 'reusable', - isDisabled: false, - utility: 0, -}; - -const items = [ - textItem, - advancedTextItem, - someOtherItem, - moreItem, - youtubeItem, - textEmbedItem, - reusableItem, -]; +import items, { categories } from './fixtures'; +import { InserterMenu } from '../menu'; const DEFAULT_PROPS = { position: 'top center', + categories, items, debouncedSpeak: noop, fetchReusableBlocks: noop, @@ -323,49 +244,3 @@ describe( 'InserterMenu', () => { assertNoResultsMessageNotToBePresent( element ); } ); } ); - -describe( 'searchItems', () => { - it( 'should search items using the title ignoring case', () => { - expect( searchItems( items, 'TEXT' ) ).toEqual( - [ textItem, advancedTextItem, textEmbedItem ] - ); - } ); - - it( 'should search items using the keywords', () => { - expect( searchItems( items, 'GOOGL' ) ).toEqual( - [ youtubeItem ] - ); - } ); - - it( 'should search items using the categories', () => { - expect( searchItems( items, 'LAYOUT' ) ).toEqual( - [ moreItem ] - ); - } ); - - it( 'should ignore a leading slash on a search term', () => { - expect( searchItems( items, '/GOOGL' ) ).toEqual( - [ youtubeItem ] - ); - } ); -} ); - -describe( 'normalizeTerm', () => { - it( 'should remove diacritics', () => { - expect( normalizeTerm( 'média' ) ).toEqual( - 'media' - ); - } ); - - it( 'should trim whitespace', () => { - expect( normalizeTerm( ' média ' ) ).toEqual( - 'media' - ); - } ); - - it( 'should convert to lowercase', () => { - expect( normalizeTerm( ' Média ' ) ).toEqual( - 'media' - ); - } ); -} ); diff --git a/packages/block-editor/src/components/inserter/test/search-items.js b/packages/block-editor/src/components/inserter/test/search-items.js new file mode 100644 index 00000000000000..e4f42c5e4a76c3 --- /dev/null +++ b/packages/block-editor/src/components/inserter/test/search-items.js @@ -0,0 +1,85 @@ +/** + * Internal dependencies + */ +import items, { + categories, + textItem, + advancedTextItem, + moreItem, + youtubeItem, + textEmbedItem, +} from './fixtures'; +import { + normalizeSearchTerm, + searchItems, +} from '../search-items'; + +describe( 'normalizeSearchTerm', () => { + it( 'should return an empty array when no words detected', () => { + expect( normalizeSearchTerm( ' - !? *** ' ) ).toEqual( + [] + ); + } ); + + it( 'should remove diacritics', () => { + expect( normalizeSearchTerm( 'média' ) ).toEqual( + [ 'media' ] + ); + } ); + + it( 'should trim whitespace', () => { + expect( normalizeSearchTerm( ' média ' ) ).toEqual( + [ 'media' ] + ); + } ); + + it( 'should convert to lowercase', () => { + expect( normalizeSearchTerm( ' Média ' ) ).toEqual( + [ 'media' ] + ); + } ); + + it( 'should extract only words', () => { + expect( normalizeSearchTerm( ' Média & Text Tag-Cloud > 123' ) ).toEqual( + [ 'media', 'text', 'tag', 'cloud', '123' ] + ); + } ); +} ); + +describe( 'searchItems', () => { + it( 'should return back all items when no terms detected', () => { + expect( searchItems( items, categories, ' - ? * ' ) ).toBe( + items + ); + } ); + + it( 'should search items using the title ignoring case', () => { + expect( searchItems( items, categories, 'TEXT' ) ).toEqual( + [ textItem, advancedTextItem, textEmbedItem ] + ); + } ); + + it( 'should search items using the keywords and partial terms', () => { + expect( searchItems( items, categories, 'GOOGL' ) ).toEqual( + [ youtubeItem ] + ); + } ); + + it( 'should search items using the categories', () => { + expect( searchItems( items, categories, 'LAYOUT' ) ).toEqual( + [ moreItem ] + ); + } ); + + it( 'should ignore a leading slash on a search term', () => { + expect( searchItems( items, categories, '/GOOGL' ) ).toEqual( + [ youtubeItem ] + ); + } ); + + it( 'should match words using the mix of the title, category and keywords', () => { + expect( searchItems( items, categories, 'youtube embed video' ) ).toEqual( + [ youtubeItem ] + ); + } ); +} );