diff --git a/packages/block-editor/src/autocompleters/block.js b/packages/block-editor/src/autocompleters/block.js index fd25f90b1554d..b0afe27ac4f94 100644 --- a/packages/block-editor/src/autocompleters/block.js +++ b/packages/block-editor/src/autocompleters/block.js @@ -1,85 +1,38 @@ /** * External dependencies */ -import { once } from 'lodash'; +import { noop, map } from 'lodash'; /** * WordPress dependencies */ -import { select } from '@wordpress/data'; +import { useSelect } from '@wordpress/data'; import { createBlock } from '@wordpress/blocks'; +import { useMemo } from '@wordpress/element'; /** * Internal dependencies */ +import { searchBlockItems } from '../components/inserter/search-items'; +import useBlockTypesState from '../components/inserter/hooks/use-block-types-state'; +import { includeVariationsInInserterItems } from '../components/inserter/utils'; import BlockIcon from '../components/block-icon'; -/** @typedef {import('@wordpress/block-editor').WPEditorInserterItem} WPEditorInserterItem */ - -/** @typedef {import('@wordpress/components').WPCompleter} WPCompleter */ - -/** - * Returns the client ID of the parent where a newly inserted block would be - * placed. - * - * @return {string} Client ID of the parent where a newly inserted block would - * be placed. - */ -function defaultGetBlockInsertionParentClientId() { - return select( 'core/block-editor' ).getBlockInsertionPoint().rootClientId; -} - -/** - * Returns the inserter items for the specified parent block. - * - * @param {string} rootClientId Client ID of the block for which to retrieve - * inserter items. - * - * @return {Array} The inserter items for the specified - * parent. - */ -function defaultGetInserterItems( rootClientId ) { - return select( 'core/block-editor' ).getInserterItems( rootClientId ); -} - -/** - * Returns the name of the currently selected block. - * - * @return {string?} The name of the currently selected block or `null` if no - * block is selected. - */ -function defaultGetSelectedBlockName() { - const { getSelectedBlockClientId, getBlockName } = select( - 'core/block-editor' +const createBlocksFromInnerBlocksTemplate = ( innerBlocksTemplate ) => { + return map( + innerBlocksTemplate, + ( [ name, attributes, innerBlocks = [] ] ) => + createBlock( + name, + attributes, + createBlocksFromInnerBlocksTemplate( innerBlocks ) + ) ); - const selectedBlockClientId = getSelectedBlockClientId(); - return selectedBlockClientId ? getBlockName( selectedBlockClientId ) : null; -} +}; -/** - * Triggers a fetch of reusable blocks, once. - * - * TODO: Reusable blocks fetching should be reimplemented as a core-data entity - * resolver, not relying on `core/editor` (see #7119). The implementation here - * is imperfect in that the options result will not await the completion of the - * fetch request and thus will not include any reusable blocks. This has always - * been true, but relied upon the fact the user would be delayed in typing an - * autocompleter search query. Once implemented using resolvers, the status of - * this request could be subscribed to as part of a promised return value using - * the result of `hasFinishedResolution`. There is currently reliable way to - * determine that a reusable blocks fetch request has completed. - * - * @return {Promise} Promise resolving once reusable blocks fetched. - */ -const fetchReusableBlocks = once( () => { - const { __experimentalFetchReusableBlocks } = select( - 'core/block-editor' - ).getSettings(); +/** @typedef {import('@wordpress/block-editor').WPEditorInserterItem} WPEditorInserterItem */ - if ( __experimentalFetchReusableBlocks ) { - __experimentalFetchReusableBlocks(); - } -} ); +/** @typedef {import('@wordpress/components').WPCompleter} WPCompleter */ /** * Creates a blocks repeater for replacing the current block with a selected block type. @@ -91,46 +44,91 @@ const fetchReusableBlocks = once( () => { * * @return {WPCompleter} A blocks completer. */ -export function createBlockCompleter( { - // Allow store-based selectors to be overridden for unit test. - getBlockInsertionParentClientId = defaultGetBlockInsertionParentClientId, - getInserterItems = defaultGetInserterItems, - getSelectedBlockName = defaultGetSelectedBlockName, -} = {} ) { +function createBlockCompleter() { return { name: 'blocks', className: 'block-editor-autocompleters__block', triggerPrefix: '/', - options() { - fetchReusableBlocks(); - const selectedBlockName = getSelectedBlockName(); - return getInserterItems( getBlockInsertionParentClientId() ).filter( - // Avoid offering to replace the current block with a block of the same type. - ( inserterItem ) => selectedBlockName !== inserterItem.name + useItems( filterValue ) { + const { rootClientId, selectedBlockName } = useSelect( + ( select ) => { + const { + getSelectedBlockClientId, + getBlockName, + getBlockInsertionPoint, + } = select( 'core/block-editor' ); + const selectedBlockClientId = getSelectedBlockClientId(); + return { + selectedBlockName: selectedBlockClientId + ? getBlockName( selectedBlockClientId ) + : null, + rootClientId: getBlockInsertionPoint().rootClientId, + }; + }, + [] ); - }, - getOptionKeywords( inserterItem ) { - const { title, keywords = [], category } = inserterItem; - return [ category, ...keywords, title ]; - }, - getOptionLabel( inserterItem ) { - const { icon, title } = inserterItem; - return [ , title ]; + const [ items, categories, collections ] = useBlockTypesState( + rootClientId, + noop + ); + + const filteredItems = useMemo( () => { + return searchBlockItems( + items, + categories, + collections, + filterValue + ).filter( ( item ) => item.name !== selectedBlockName ); + }, [ + filterValue, + selectedBlockName, + items, + categories, + collections, + ] ); + + const options = useMemo( + () => + includeVariationsInInserterItems( filteredItems ).map( + ( blockItem ) => { + const { title, icon, isDisabled } = blockItem; + return { + key: `block-${ blockItem.id }`, + value: blockItem, + label: ( + <> + + { title } + + ), + isDisabled, + }; + } + ), + [ filteredItems ] + ); + + return [ options ]; }, allowContext( before, after ) { return ! ( /\S/.test( before ) || /\S/.test( after ) ); }, getOptionCompletion( inserterItem ) { - const { name, initialAttributes } = inserterItem; + const { name, initialAttributes, innerBlocks } = inserterItem; return { action: 'replace', - value: createBlock( name, initialAttributes ), + value: createBlock( + name, + initialAttributes, + createBlocksFromInnerBlocksTemplate( innerBlocks ) + ), }; }, - isOptionDisabled( inserterItem ) { - return inserterItem.isDisabled; - }, }; } diff --git a/packages/block-editor/src/autocompleters/test/block.js b/packages/block-editor/src/autocompleters/test/block.js deleted file mode 100644 index 11437cf171498..0000000000000 --- a/packages/block-editor/src/autocompleters/test/block.js +++ /dev/null @@ -1,146 +0,0 @@ -/** - * External dependencies - */ -import { shallow } from 'enzyme'; - -/** - * Internal dependencies - */ -import blockCompleter, { createBlockCompleter } from '../block'; -import '../../store'; - -describe( 'block', () => { - let originalFetch; - beforeEach( () => { - originalFetch = window.fetch; - window.fetch = ( url ) => { - if ( ! /\/wp\/v2\/types\/wp_block(\?|$)/.test( url ) ) { - throw new Error( 'Unhandled fetch ' + url ); - } - - return Promise.resolve( { - status: 200, - json: () => Promise.resolve( [] ), - } ); - }; - } ); - - afterEach( () => { - window.fetch = originalFetch; - } ); - - it( 'should retrieve block options for current insertion point', async () => { - const expectedOptions = [ {}, {}, {} ]; - const mockGetBlockInsertionParentClientId = jest.fn( - () => 'expected-insertion-point' - ); - const mockGetInserterItems = jest.fn( () => expectedOptions ); - - const completer = createBlockCompleter( { - getBlockInsertionParentClientId: mockGetBlockInsertionParentClientId, - getInserterItems: mockGetInserterItems, - getSelectedBlockName: () => 'non-existent-block-name', - } ); - - const actualOptions = completer.options(); - expect( mockGetBlockInsertionParentClientId ).toHaveBeenCalled(); - expect( mockGetInserterItems ).toHaveBeenCalledWith( - 'expected-insertion-point' - ); - expect( actualOptions ).toEqual( expectedOptions ); - } ); - - it( 'should exclude the currently selected block from the options', () => { - const option1 = { name: 'block-1' }; - const option2CurrentlySelected = { name: 'block-2-currently-selected' }; - const option3 = { name: 'block-3' }; - - const completer = createBlockCompleter( { - getBlockInsertionParentClientId: () => 'ignored', - getInserterItems: () => [ - option1, - option2CurrentlySelected, - option3, - ], - getSelectedBlockName: () => 'block-2-currently-selected', - } ); - - expect( completer.options() ).toEqual( [ option1, option3 ] ); - } ); - - it( 'should derive option keywords from block category, block keywords and block title', () => { - const inserterItemWithTitleAndKeywords = { - name: 'core/foo', - title: 'foo', - keywords: [ 'foo-keyword-1', 'foo-keyword-2' ], - category: 'design', - }; - const inserterItemWithTitleAndEmptyKeywords = { - name: 'core/bar', - title: 'bar', - // Intentionally empty keyword list - keywords: [], - category: 'text', - }; - const inserterItemWithTitleAndUndefinedKeywords = { - name: 'core/baz', - title: 'baz', - category: 'widgets', - // Intentionally omitted keyword list - }; - - expect( - blockCompleter.getOptionKeywords( inserterItemWithTitleAndKeywords ) - ).toEqual( [ 'design', 'foo-keyword-1', 'foo-keyword-2', 'foo' ] ); - expect( - blockCompleter.getOptionKeywords( - inserterItemWithTitleAndEmptyKeywords - ) - ).toEqual( [ 'text', 'bar' ] ); - expect( - blockCompleter.getOptionKeywords( - inserterItemWithTitleAndUndefinedKeywords - ) - ).toEqual( [ 'widgets', 'baz' ] ); - } ); - - it( 'should render a block option label', () => { - const labelComponents = shallow( -
- { blockCompleter.getOptionLabel( { - icon: 'expected-icon', - title: 'expected-text', - } ) } -
- ).children(); - - expect( labelComponents ).toHaveLength( 2 ); - expect( labelComponents.at( 0 ).name() ).toBe( 'BlockIcon' ); - expect( labelComponents.at( 0 ).prop( 'icon' ) ).toEqual( - 'expected-icon' - ); - expect( labelComponents.at( 1 ).text() ).toBe( 'expected-text' ); - } ); - - it( "should derive isOptionDisabled from the item's isDisabled", () => { - const disabledInserterItem = { - name: 'core/foo', - title: 'foo', - keywords: [ 'foo-keyword-1', 'foo-keyword-2' ], - isDisabled: true, - }; - const enabledInserterItem = { - name: 'core/bar', - title: 'bar', - keywords: [], - isDisabled: false, - }; - - expect( blockCompleter.isOptionDisabled( disabledInserterItem ) ).toBe( - true - ); - expect( blockCompleter.isOptionDisabled( enabledInserterItem ) ).toBe( - false - ); - } ); -} ); diff --git a/packages/block-editor/src/components/block-types-list/index.js b/packages/block-editor/src/components/block-types-list/index.js index b806457e71a40..60eb915c925a6 100644 --- a/packages/block-editor/src/components/block-types-list/index.js +++ b/packages/block-editor/src/components/block-types-list/index.js @@ -7,6 +7,7 @@ import { getBlockMenuDefaultClassName } from '@wordpress/blocks'; * Internal dependencies */ import InserterListItem from '../inserter-list-item'; +import { includeVariationsInInserterItems } from '../inserter/utils'; function BlockTypesList( { items = [], @@ -14,43 +15,7 @@ function BlockTypesList( { onHover = () => {}, children, } ) { - const normalizedItems = items.reduce( ( result, item ) => { - const { variations = [] } = item; - const hasDefaultVariation = variations.some( - ( { isDefault } ) => isDefault - ); - - // If there is no default inserter variation provided, - // then default block type is displayed. - if ( ! hasDefaultVariation ) { - result.push( item ); - } - - if ( variations.length ) { - result = result.concat( - variations.map( ( variation ) => { - return { - ...item, - id: `${ item.id }-${ variation.name }`, - icon: variation.icon || item.icon, - title: variation.title || item.title, - description: variation.description || item.description, - // If `example` is explicitly undefined for the variation, the preview will not be shown. - example: variation.hasOwnProperty( 'example' ) - ? variation.example - : item.example, - initialAttributes: { - ...item.initialAttributes, - ...variation.attributes, - }, - innerBlocks: variation.innerBlocks, - }; - } ) - ); - } - - return result; - }, [] ); + const normalizedItems = includeVariationsInInserterItems( items ); return ( /* diff --git a/packages/block-editor/src/components/inserter/utils.js b/packages/block-editor/src/components/inserter/utils.js new file mode 100644 index 0000000000000..4e8caf0b68494 --- /dev/null +++ b/packages/block-editor/src/components/inserter/utils.js @@ -0,0 +1,45 @@ +/** + * Normalizes an inserter block types list and includes variations as separate items. + * + * @param {Array} items Denormalized inserter items + * @return {Array} Normalized inserter items. + */ +export function includeVariationsInInserterItems( items ) { + return items.reduce( ( result, item ) => { + const { variations = [] } = item; + const hasDefaultVariation = variations.some( + ( { isDefault } ) => isDefault + ); + + // If there is no default inserter variation provided, + // then default block type is displayed. + if ( ! hasDefaultVariation ) { + result.push( item ); + } + + if ( variations.length ) { + result = result.concat( + variations.map( ( variation ) => { + return { + ...item, + id: `${ item.id }-${ variation.name }`, + icon: variation.icon || item.icon, + title: variation.title || item.title, + description: variation.description || item.description, + // If `example` is explicitly undefined for the variation, the preview will not be shown. + example: variation.hasOwnProperty( 'example' ) + ? variation.example + : item.example, + initialAttributes: { + ...item.initialAttributes, + ...variation.attributes, + }, + innerBlocks: variation.innerBlocks, + }; + } ) + ); + } + + return result; + }, [] ); +}