diff --git a/package-lock.json b/package-lock.json index d5bd6215c298cc..9742848c6011fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31146,9 +31146,9 @@ } }, "mkdirp-classic": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.2.tgz", - "integrity": "sha512-ejdnDQcR75gwknmMw/tx02AuRs8jCtqFoFqDZMjiNxsu85sRIJVXDKHuLYvUUPRBUtV2FpSZa9bL1BUa3BdR2g==", + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "dev": true }, "mkdirp-promise": { diff --git a/packages/block-editor/src/components/inserter/block-list.js b/packages/block-editor/src/components/inserter/block-list.js index 60e57c98ab35a1..a1aafcd54061de 100644 --- a/packages/block-editor/src/components/inserter/block-list.js +++ b/packages/block-editor/src/components/inserter/block-list.js @@ -31,8 +31,9 @@ import { compose } from '@wordpress/compose'; import BlockTypesList from '../block-types-list'; import ChildBlocks from './child-blocks'; import __experimentalInserterMenuExtension from '../inserter-menu-extension'; -import { searchItems } from './search-items'; +import { searchBlockItems } from './search-items'; import InserterPanel from './panel'; +import InserterNoResults from './no-results'; // Copied over from the Columns block. It seems like it should become part of public API. const createBlocksFromInnerBlocksTemplate = ( innerBlocksTemplate ) => { @@ -114,7 +115,7 @@ function InserterBlockList( { }; const filteredItems = useMemo( () => { - return searchItems( items, categories, collections, filterValue ); + return searchBlockItems( items, categories, collections, filterValue ); }, [ filterValue, items, categories, collections ] ); const childItems = useMemo( () => { @@ -282,19 +283,13 @@ function InserterBlockList( { { ( fills ) => { if ( fills.length ) { return ( - + { fills } ); } if ( ! hasItems ) { - return ( -

- { __( 'No blocks found.' ) } -

- ); + return ; } return null; } } diff --git a/packages/block-editor/src/components/inserter/block-patterns.js b/packages/block-editor/src/components/inserter/block-patterns.js index 56288df4be228a..d739e5f249fda0 100644 --- a/packages/block-editor/src/components/inserter/block-patterns.js +++ b/packages/block-editor/src/components/inserter/block-patterns.js @@ -18,6 +18,8 @@ import { __, sprintf, _x } from '@wordpress/i18n'; import BlockPreview from '../block-preview'; import useAsyncList from './use-async-list'; import InserterPanel from './panel'; +import { searchItems } from './search-items'; +import InserterNoResults from './no-results'; function BlockPattern( { pattern, onClick } ) { const { content } = pattern; @@ -30,13 +32,16 @@ function BlockPattern( { pattern, onClick } ) { onClick={ () => onClick( pattern, blocks ) } onKeyDown={ ( event ) => { if ( ENTER === event.keyCode || SPACE === event.keyCode ) { - onClick( blocks ); + onClick( pattern, blocks ); } } } tabIndex={ 0 } aria-label={ pattern.title } > +
+ { pattern.title } +
); } @@ -47,8 +52,12 @@ function BlockPatternPlaceholder() { ); } -function BlockPatterns( { patterns, onInsert } ) { - const currentShownPatterns = useAsyncList( patterns ); +function BlockPatterns( { patterns, onInsert, filterValue } ) { + const filteredPatterns = useMemo( + () => searchItems( patterns, filterValue ), + [ filterValue, patterns ] + ); + const currentShownPatterns = useAsyncList( filteredPatterns ); const { createSuccessNotice } = useDispatch( 'core/notices' ); const onClickPattern = useCallback( ( pattern, blocks ) => { onInsert( map( blocks, ( block ) => cloneBlock( block ) ) ); @@ -64,20 +73,28 @@ function BlockPatterns( { patterns, onInsert } ) { ); }, [] ); - return ( - - { patterns.map( ( pattern, index ) => + return !! filteredPatterns.length ? ( + + { filteredPatterns.map( ( pattern, index ) => currentShownPatterns[ index ] === pattern ? ( ) : ( - + ) ) } + ) : ( + ); } diff --git a/packages/block-editor/src/components/inserter/menu.js b/packages/block-editor/src/components/inserter/menu.js index 6e446717f37517..3d9864fc14a3e3 100644 --- a/packages/block-editor/src/components/inserter/menu.js +++ b/packages/block-editor/src/components/inserter/menu.js @@ -73,10 +73,7 @@ function InserterMenu( { hideInsertionPoint, } = useDispatch( 'core/block-editor' ); const hasPatterns = - ! destinationRootClientId && - !! patterns && - !! patterns.length && - ! filterValue; + ! destinationRootClientId && !! patterns && !! patterns.length; const onKeyDown = ( event ) => { if ( includes( @@ -165,7 +162,11 @@ function InserterMenu( { const patternsTab = (
- +
); diff --git a/packages/block-editor/src/components/inserter/no-results.js b/packages/block-editor/src/components/inserter/no-results.js new file mode 100644 index 00000000000000..3ca6569dc14ea4 --- /dev/null +++ b/packages/block-editor/src/components/inserter/no-results.js @@ -0,0 +1,19 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Icon, blockDefault } from '@wordpress/icons'; + +function InserterNoResults() { + return ( +
+ +

{ __( 'No results found.' ) }

+
+ ); +} + +export default InserterNoResults; diff --git a/packages/block-editor/src/components/inserter/search-items.js b/packages/block-editor/src/components/inserter/search-items.js index 23bae5d9b3860d..83b4ba3364dae8 100644 --- a/packages/block-editor/src/components/inserter/search-items.js +++ b/packages/block-editor/src/components/inserter/search-items.js @@ -5,7 +5,6 @@ import { deburr, differenceWith, find, - get, intersectionWith, isEmpty, words, @@ -44,94 +43,98 @@ const removeMatchingTerms = ( unmatchedTerms, unprocessedTerms ) => { ); }; +export const searchBlockItems = ( + items, + categories, + collections, + searchTerm +) => { + const normalizedSearchTerms = normalizeSearchTerm( searchTerm ); + if ( normalizedSearchTerms.length === 0 ) { + return items; + } + + return searchItems( items, searchTerm, { + getCategory: ( item ) => + find( categories, { slug: item.category } )?.title, + getCollection: ( item ) => + collections[ item.name.split( '/' )[ 0 ] ]?.title, + getVariations: ( item ) => + ( item.variations || [] ).map( ( variation ) => variation.title ), + } ).map( ( item ) => { + if ( isEmpty( item.variations ) ) { + return item; + } + + const matchedVariations = item.variations.filter( ( variation ) => { + return ( + intersectionWith( + normalizedSearchTerms, + normalizeSearchTerm( variation.title ), + ( termToMatch, labelTerm ) => + labelTerm.includes( termToMatch ) + ).length > 0 + ); + } ); + // When no variations matched, fallback to all variations. + if ( isEmpty( matchedVariations ) ) { + return item; + } + + return { + ...item, + variations: matchedVariations, + }; + } ); +}; + /** * Filters an item list given a search term. * * @param {Array} items Item list - * @param {Array} categories Available categories. - * @param {Array} collections Available collections. * @param {string} searchTerm Search term. - * - * @return {Array} Filtered item list. + * @param {Object} config Search Config. + * @return {Array} Filtered item list. */ -export const searchItems = ( items, categories, collections, searchTerm ) => { +export const searchItems = ( items, searchTerm, config = {} ) => { const normalizedSearchTerms = normalizeSearchTerm( searchTerm ); - if ( normalizedSearchTerms.length === 0 ) { return items; } - return items - .filter( - ( { name, title, category, keywords = [], variations = [] } ) => { - let unmatchedTerms = removeMatchingTerms( - normalizedSearchTerms, - 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' ] ) - ); - - const itemCollection = collections[ name.split( '/' )[ 0 ] ]; - if ( itemCollection ) { - unmatchedTerms = removeMatchingTerms( - unmatchedTerms, - itemCollection.title - ); - } - - if ( unmatchedTerms.length === 0 ) { - return true; - } - - unmatchedTerms = removeMatchingTerms( - unmatchedTerms, - variations - .map( ( variation ) => variation.title ) - .join( ' ' ) - ); - - return unmatchedTerms.length === 0; - } - ) - .map( ( item ) => { - if ( isEmpty( item.variations ) ) { - return item; - } - - const matchedVariations = item.variations.filter( ( variation ) => { - return ( - intersectionWith( - normalizedSearchTerms, - normalizeSearchTerm( variation.title ), - ( termToMatch, labelTerm ) => - labelTerm.includes( termToMatch ) - ).length > 0 - ); - } ); - // When no partterns matched, fallback to all variations. - if ( isEmpty( matchedVariations ) ) { - return item; - } - - return { - ...item, - variations: matchedVariations, - }; - } ); + const defaultGetTitle = ( item ) => item.title; + const defaultGetKeywords = ( item ) => item.keywords || []; + const defaultGetCategory = ( item ) => item.category; + const defaultGetCollection = () => null; + const defaultGetVariations = () => []; + const { + getTitle = defaultGetTitle, + getKeywords = defaultGetKeywords, + getCategory = defaultGetCategory, + getCollection = defaultGetCollection, + getVariations = defaultGetVariations, + } = config; + + return items.filter( ( item ) => { + const title = getTitle( item ); + const keywords = getKeywords( item ); + const category = getCategory( item ); + const collection = getCollection( item ); + const variations = getVariations( item ); + + const terms = [ + title, + ...keywords, + category, + collection, + ...variations, + ].join( ' ' ); + + const unmatchedTerms = removeMatchingTerms( + normalizedSearchTerms, + terms + ); + + return unmatchedTerms.length === 0; + } ); }; diff --git a/packages/block-editor/src/components/inserter/style.scss b/packages/block-editor/src/components/inserter/style.scss index 000f3c2cf99bd4..4815fa5866bd52 100644 --- a/packages/block-editor/src/components/inserter/style.scss +++ b/packages/block-editor/src/components/inserter/style.scss @@ -170,11 +170,15 @@ $block-inserter-tabs-height: 44px; } .block-editor-inserter__no-results { - font-style: italic; - padding: 24px; + padding: $grid-unit-40; + margin-top: $grid-unit-40 * 2; text-align: center; } +.block-editor-inserter__no-results-icon { + fill: $light-gray-800; +} + .block-editor-inserter__child-blocks { padding: 0 $grid-unit-20; } @@ -262,3 +266,9 @@ $block-inserter-tabs-height: 44px; min-height: 100px; } } + +.block-editor-inserter__patterns-item-title { + padding: $grid-unit-05; + font-size: 12px; + text-align: center; +} diff --git a/packages/block-editor/src/components/inserter/test/block-list.js b/packages/block-editor/src/components/inserter/test/block-list.js index 65b70a70902100..c83df96520e099 100644 --- a/packages/block-editor/src/components/inserter/test/block-list.js +++ b/packages/block-editor/src/components/inserter/test/block-list.js @@ -55,7 +55,7 @@ const assertNoResultsMessageToBePresent = ( element ) => { const noResultsMessage = element.querySelector( '.block-editor-inserter__no-results' ); - expect( noResultsMessage.textContent ).toEqual( 'No blocks found.' ); + expect( noResultsMessage.textContent ).toEqual( 'No results found.' ); }; const assertNoResultsMessageNotToBePresent = ( element ) => { @@ -81,7 +81,9 @@ describe( 'InserterMenu', () => { collections, items: noItems, } ) ); - const element = initializeMenuDefaultStateAndReturnElement(); + const element = initializeMenuDefaultStateAndReturnElement( { + filterValue: 'random', + } ); const visibleBlocks = element.querySelector( '.block-editor-block-types-list__item' ); diff --git a/packages/block-editor/src/components/inserter/test/search-items.js b/packages/block-editor/src/components/inserter/test/search-items.js index 01c0bace323283..f4b24b14a28a46 100644 --- a/packages/block-editor/src/components/inserter/test/search-items.js +++ b/packages/block-editor/src/components/inserter/test/search-items.js @@ -10,7 +10,7 @@ import items, { youtubeItem, textEmbedItem, } from './fixtures'; -import { normalizeSearchTerm, searchItems } from '../search-items'; +import { normalizeSearchTerm, searchBlockItems } from '../search-items'; describe( 'normalizeSearchTerm', () => { it( 'should return an empty array when no words detected', () => { @@ -36,45 +36,50 @@ describe( 'normalizeSearchTerm', () => { } ); } ); -describe( 'searchItems', () => { +describe( 'searchBlockItems', () => { it( 'should return back all items when no terms detected', () => { - expect( searchItems( items, categories, collections, ' - ? * ' ) ).toBe( - items - ); + expect( + searchBlockItems( items, categories, collections, ' - ? * ' ) + ).toBe( items ); } ); it( 'should search items using the title ignoring case', () => { expect( - searchItems( items, categories, collections, 'TEXT' ) + searchBlockItems( items, categories, collections, 'TEXT' ) ).toEqual( [ textItem, advancedTextItem, textEmbedItem ] ); } ); it( 'should search items using the keywords and partial terms', () => { expect( - searchItems( items, categories, collections, 'GOOGL' ) + searchBlockItems( items, categories, collections, 'GOOGL' ) ).toEqual( [ youtubeItem ] ); } ); it( 'should search items using the categories', () => { expect( - searchItems( items, categories, collections, 'LAYOUT' ) + searchBlockItems( items, categories, collections, 'LAYOUT' ) ).toEqual( [ moreItem ] ); } ); it( 'should ignore a leading slash on a search term', () => { expect( - searchItems( items, categories, collections, '/GOOGL' ) + searchBlockItems( items, categories, collections, '/GOOGL' ) ).toEqual( [ youtubeItem ] ); } ); it( 'should match words using the mix of the title, category and keywords', () => { expect( - searchItems( items, categories, collections, 'youtube embed video' ) + searchBlockItems( + items, + categories, + collections, + 'youtube embed video' + ) ).toEqual( [ youtubeItem ] ); } ); it( 'should match words using also variations and return all matched variations', () => { - const filteredItems = searchItems( + const filteredItems = searchBlockItems( items, categories, collections, @@ -86,7 +91,7 @@ describe( 'searchItems', () => { } ); it( 'should match words using also variations and filter out unmatched variations', () => { - const filteredItems = searchItems( + const filteredItems = searchBlockItems( items, categories, collections, diff --git a/packages/block-editor/src/components/inserter/use-async-list.js b/packages/block-editor/src/components/inserter/use-async-list.js index ab546f08b2c896..3189cd3a008684 100644 --- a/packages/block-editor/src/components/inserter/use-async-list.js +++ b/packages/block-editor/src/components/inserter/use-async-list.js @@ -4,9 +4,39 @@ import { useEffect, useReducer } from '@wordpress/element'; import { createQueue } from '@wordpress/priority-queue'; +/** + * Returns the first items from list that are present on state. + * + * @param {Array} list New array. + * @param {Array} state Current state. + * @return {Array} First items present iin state. + */ +function getFirstItemsPresentInState( list, state ) { + const firstItems = []; + + for ( let i = 0; i < list.length; i++ ) { + const item = list[ i ]; + if ( ! state.includes( item ) ) { + break; + } + + firstItems.push( item ); + } + + return firstItems; +} + +/** + * Reducer keeping track of a list of appended items. + * + * @param {Array} state Current state + * @param {Object} action Action + * + * @return {Array} update state. + */ function listReducer( state, action ) { - if ( action.type === 'reset' && state.length !== 0 ) { - return []; + if ( action.type === 'reset' ) { + return action.list; } if ( action.type === 'append' ) { @@ -16,20 +46,32 @@ function listReducer( state, action ) { return state; } +/** + * React hook returns an array which items get asynchronously appended from a source array. + * This behavior is useful if we want to render a list of items asynchronously for performance reasons. + * + * @param {Array} list Source array. + * @return {Array} Async array. + */ function useAsyncList( list ) { const [ current, dispatch ] = useReducer( listReducer, [] ); useEffect( () => { - dispatch( { type: 'reset' } ); + // On reset, we keep the first items that were previously rendered. + const firstItems = getFirstItemsPresentInState( list, current ); + dispatch( { + type: 'reset', + list: firstItems, + } ); const asyncQueue = createQueue(); - const append = ( index = 0 ) => () => { + const append = ( index ) => () => { if ( list.length <= index ) { return; } dispatch( { type: 'append', item: list[ index ] } ); asyncQueue.add( {}, append( index + 1 ) ); }; - asyncQueue.add( {}, append( 0 ) ); + asyncQueue.add( {}, append( firstItems.length ) ); return () => asyncQueue.reset(); }, [ list ] ); diff --git a/packages/e2e-tests/specs/editor/various/adding-blocks.test.js b/packages/e2e-tests/specs/editor/various/adding-blocks.test.js index 65fd287650b462..6e222ff24e5b79 100644 --- a/packages/e2e-tests/specs/editor/various/adding-blocks.test.js +++ b/packages/e2e-tests/specs/editor/various/adding-blocks.test.js @@ -116,7 +116,7 @@ describe( 'adding blocks', () => { ) ); await page.keyboard.type( 'para' ); - await pressKeyTimes( 'Tab', 4 ); + await pressKeyTimes( 'Tab', 2 ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( 'Second paragraph' );