diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js index 8517c7bfc83e0..8e1b024d63e4c 100644 --- a/packages/components/src/autocomplete/index.js +++ b/packages/components/src/autocomplete/index.js @@ -7,16 +7,13 @@ import { escapeRegExp, find, map, debounce, deburr } from 'lodash'; /** * WordPress dependencies */ -import { Component, renderToString } from '@wordpress/element'; import { - ENTER, - ESCAPE, - UP, - DOWN, - LEFT, - RIGHT, - SPACE, -} from '@wordpress/keycodes'; + Component, + renderToString, + useLayoutEffect, + useState, +} from '@wordpress/element'; +import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT } from '@wordpress/keycodes'; import { __, _n, sprintf } from '@wordpress/i18n'; import { withInstanceId, compose } from '@wordpress/compose'; import { @@ -144,15 +141,150 @@ function getRange() { return selection.rangeCount ? selection.getRangeAt( 0 ) : null; } +const getAutoCompleterUI = ( autocompleter ) => { + const useItems = autocompleter.useItems + ? autocompleter.useItems + : ( filterValue ) => { + const [ items, setItems ] = useState( [] ); + /* + * We support both synchronous and asynchronous retrieval of completer options + * but internally treat all as async so we maintain a single, consistent code path. + * + * Because networks can be slow, and the internet is wonderfully unpredictable, + * we don't want two promises updating the state at once. This ensures that only + * the most recent promise will act on `optionsData`. This doesn't use the state + * because `setState` is batched, and so there's no guarantee that setting + * `activePromise` in the state would result in it actually being in `this.state` + * before the promise resolves and we check to see if this is the active promise or not. + */ + useLayoutEffect( () => { + const { options, isDebounced } = autocompleter; + const loadOptions = debounce( + () => { + const promise = Promise.resolve( + typeof options === 'function' + ? options( filterValue ) + : options + ).then( ( optionsData ) => { + if ( promise.canceled ) { + return; + } + const keyedOptions = optionsData.map( + ( optionData, optionIndex ) => ( { + key: `${ autocompleter.name }-${ optionIndex }`, + value: optionData, + label: autocompleter.getOptionLabel( + optionData + ), + keywords: autocompleter.getOptionKeywords + ? autocompleter.getOptionKeywords( + optionData + ) + : [], + isDisabled: autocompleter.isOptionDisabled + ? autocompleter.isOptionDisabled( + optionData + ) + : false, + } ) + ); + + // create a regular expression to filter the options + const search = new RegExp( + '(?:\\b|\\s|^)' + + escapeRegExp( filterValue ), + 'i' + ); + setItems( + filterOptions( search, keyedOptions ) + ); + } ); + + return promise; + }, + isDebounced ? 250 : 0 + ); + + const promise = loadOptions(); + + return () => { + loadOptions.cancel(); + if ( promise ) { + promise.canceled = true; + } + }; + }, [ filterValue ] ); + + return [ items ]; + }; + + function AutocompleterUI( { + filterValue, + instanceId, + listBoxId, + className, + selectedIndex, + onChangeOptions, + onSelect, + onReset, + } ) { + const [ items ] = useItems( filterValue ); + useLayoutEffect( () => { + onChangeOptions( items ); + }, [ items ] ); + + if ( ! items.length > 0 ) { + return null; + } + + return ( + +
+ { map( items, ( option, index ) => ( + + ) ) } +
+
+ ); + } + + return AutocompleterUI; +}; + export class Autocomplete extends Component { static getInitialState() { return { - search: /./, selectedIndex: 0, - suppress: undefined, - open: undefined, - query: undefined, filteredOptions: [], + filterValue: '', + autocompleter: null, + AutocompleterUI: null, }; } @@ -161,18 +293,18 @@ export class Autocomplete extends Component { this.select = this.select.bind( this ); this.reset = this.reset.bind( this ); - this.resetWhenSuppressed = this.resetWhenSuppressed.bind( this ); + this.onChangeOptions = this.onChangeOptions.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this ); - this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); this.state = this.constructor.getInitialState(); } insertCompletion( replacement ) { - const { open, query } = this.state; + const { autocompleter, filterValue } = this.state; const { record, onChange } = this.props; const end = record.start; - const start = end - open.triggerPrefix.length - query.length; + const start = + end - autocompleter.triggerPrefix.length - filterValue.length; const toInsert = create( { html: renderToString( replacement ) } ); onChange( insert( record, toInsert, start, end ) ); @@ -180,15 +312,15 @@ export class Autocomplete extends Component { select( option ) { const { onReplace } = this.props; - const { open, query } = this.state; - const { getOptionCompletion } = open || {}; + const { autocompleter, filterValue } = this.state; + const { getOptionCompletion } = autocompleter || {}; if ( option.isDisabled ) { return; } if ( getOptionCompletion ) { - const completion = getOptionCompletion( option.value, query ); + const completion = getOptionCompletion( option.value, filterValue ); const { action, value } = undefined === completion.action || @@ -212,13 +344,6 @@ export class Autocomplete extends Component { this.setState( this.constructor.getInitialState() ); } - resetWhenSuppressed() { - const { open, suppress } = this.state; - if ( open && suppress === open.idx ) { - this.reset(); - } - } - announce( filteredOptions ) { const { debouncedSpeak } = this.props; if ( ! debouncedSpeak ) { @@ -245,86 +370,23 @@ export class Autocomplete extends Component { /** * Load options for an autocompleter. * - * @param {WPCompleter} completer The autocompleter. - * @param {string} query The query, if any. + * @param {Array} filteredOptions */ - loadOptions( completer, query ) { - const { options } = completer; - - /* - * We support both synchronous and asynchronous retrieval of completer options - * but internally treat all as async so we maintain a single, consistent code path. - * - * Because networks can be slow, and the internet is wonderfully unpredictable, - * we don't want two promises updating the state at once. This ensures that only - * the most recent promise will act on `optionsData`. This doesn't use the state - * because `setState` is batched, and so there's no guarantee that setting - * `activePromise` in the state would result in it actually being in `this.state` - * before the promise resolves and we check to see if this is the active promise or not. - */ - const promise = ( this.activePromise = Promise.resolve( - typeof options === 'function' ? options( query ) : options - ).then( ( optionsData ) => { - if ( promise !== this.activePromise ) { - // Another promise has become active since this one was asked to resolve, so do nothing, - // or else we might end triggering a race condition updating the state. - return; - } - const keyedOptions = optionsData.map( - ( optionData, optionIndex ) => ( { - key: `${ completer.idx }-${ optionIndex }`, - value: optionData, - label: completer.getOptionLabel( optionData ), - keywords: completer.getOptionKeywords - ? completer.getOptionKeywords( optionData ) - : [], - isDisabled: completer.isOptionDisabled - ? completer.isOptionDisabled( optionData ) - : false, - } ) - ); - - const filteredOptions = filterOptions( - this.state.search, - keyedOptions - ); - const selectedIndex = - filteredOptions.length === this.state.filteredOptions.length - ? this.state.selectedIndex - : 0; - this.setState( { - [ 'options_' + completer.idx ]: keyedOptions, - filteredOptions, - selectedIndex, - } ); - this.announce( filteredOptions ); - } ) ); + onChangeOptions( filteredOptions ) { + const selectedIndex = + filteredOptions.length === this.state.filteredOptions.length + ? this.state.selectedIndex + : 0; + this.setState( { + filteredOptions, + selectedIndex, + } ); + this.announce( filteredOptions ); } handleKeyDown( event ) { - const { open, suppress, selectedIndex, filteredOptions } = this.state; - if ( ! open ) { - return; - } - if ( suppress === open.idx ) { - switch ( event.keyCode ) { - // cancel popup suppression on CTRL+SPACE - case SPACE: - const { ctrlKey, shiftKey, altKey, metaKey } = event; - if ( ctrlKey && ! ( shiftKey || altKey || metaKey ) ) { - this.setState( { suppress: undefined } ); - event.preventDefault(); - event.stopPropagation(); - } - break; - - // reset on cursor movement - case UP: - case DOWN: - case LEFT: - case RIGHT: - this.reset(); - } + const { autocompleter, selectedIndex, filteredOptions } = this.state; + if ( ! autocompleter ) { return; } if ( filteredOptions.length === 0 ) { @@ -347,7 +409,7 @@ export class Autocomplete extends Component { break; case ESCAPE: - this.setState( { suppress: open.idx } ); + this.setState( { autocompleter: null } ); break; case ENTER: @@ -380,12 +442,8 @@ export class Autocomplete extends Component { const textAfterSelection = getTextContent( slice( record, undefined, getTextContent( record ).length ) ); - const allCompleters = map( completers, ( completer, idx ) => ( { - ...completer, - idx, - } ) ); - const open = find( - allCompleters, + const autocompleter = find( + completers, ( { triggerPrefix, allowContext } ) => { const index = text.lastIndexOf( triggerPrefix ); @@ -409,78 +467,41 @@ export class Autocomplete extends Component { } ); - if ( ! open ) { + if ( ! autocompleter ) { this.reset(); return; } - const safeTrigger = escapeRegExp( open.triggerPrefix ); + const safeTrigger = escapeRegExp( autocompleter.triggerPrefix ); const match = text.match( new RegExp( `${ safeTrigger }(\\S*)$` ) ); const query = match && match[ 1 ]; - const { - open: wasOpen, - suppress: wasSuppress, - query: wasQuery, - } = this.state; - - if ( - open && - ( ! wasOpen || - open.idx !== wasOpen.idx || - query !== wasQuery ) - ) { - if ( open.isDebounced ) { - this.debouncedLoadOptions( open, query ); - } else { - this.loadOptions( open, query ); - } - } - // create a regular expression to filter the options - const search = open - ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) - : /./; - // filter the options we already have - const filteredOptions = open - ? filterOptions( - search, - this.state[ 'options_' + open.idx ] - ) - : []; - // check if we should still suppress the popover - const suppress = - open && wasSuppress === open.idx ? wasSuppress : undefined; - // update the state - if ( wasOpen || open ) { - this.setState( { - selectedIndex: 0, - filteredOptions, - suppress, - search, - open, - query, - } ); - } - // announce the count of filtered options but only if they have loaded - if ( open && this.state[ 'options_' + open.idx ] ) { - this.announce( filteredOptions ); - } + this.setState( { + autocompleter, + AutocompleterUI: + autocompleter !== this.state.autocompleter + ? getAutoCompleterUI( autocompleter ) + : this.state.AutocompleterUI, + filterValue: query, + } ); } } } - componentWillUnmount() { - this.debouncedLoadOptions.cancel(); - } - render() { const { children, instanceId, isSelected } = this.props; - const { open, suppress, selectedIndex, filteredOptions } = this.state; + const { + autocompleter, + selectedIndex, + filteredOptions, + AutocompleterUI, + filterValue, + } = this.state; const { key: selectedKey = '' } = filteredOptions[ selectedIndex ] || {}; - const { className, idx } = open || {}; - const isExpanded = suppress !== idx && filteredOptions.length > 0; + const { className } = autocompleter || {}; + const isExpanded = !! autocompleter && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; @@ -496,44 +517,17 @@ export class Autocomplete extends Component { activeId, onKeyDown: this.handleKeyDown, } ) } - { isExpanded && isSelected && ( - -
- { isExpanded && - map( filteredOptions, ( option, index ) => ( - - ) ) } -
-
+ { isSelected && AutocompleterUI && ( + ) } ); diff --git a/packages/e2e-tests/specs/editor/blocks/group.test.js b/packages/e2e-tests/specs/editor/blocks/group.test.js index 7ce9021d26bd4..ad2bc8596ed6c 100644 --- a/packages/e2e-tests/specs/editor/blocks/group.test.js +++ b/packages/e2e-tests/specs/editor/blocks/group.test.js @@ -23,6 +23,9 @@ describe( 'Group', () => { it( 'can be created using the slash inserter', async () => { await clickBlockAppender(); await page.keyboard.type( '/group' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Group')]` + ); await page.keyboard.press( 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); diff --git a/packages/e2e-tests/specs/editor/blocks/html.test.js b/packages/e2e-tests/specs/editor/blocks/html.test.js index 34be8f5a6299c..df2492aaa51bb 100644 --- a/packages/e2e-tests/specs/editor/blocks/html.test.js +++ b/packages/e2e-tests/specs/editor/blocks/html.test.js @@ -16,6 +16,9 @@ describe( 'HTML block', () => { // Create a Custom HTML block with the slash shortcut. await clickBlockAppender(); await page.keyboard.type( '/html' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Custom HTML')]` + ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '

Pythagorean theorem: ' ); await page.keyboard.press( 'Enter' ); diff --git a/packages/e2e-tests/specs/editor/blocks/spacer.test.js b/packages/e2e-tests/specs/editor/blocks/spacer.test.js index 4850d73570e96..258fc59a6d1ee 100644 --- a/packages/e2e-tests/specs/editor/blocks/spacer.test.js +++ b/packages/e2e-tests/specs/editor/blocks/spacer.test.js @@ -17,6 +17,9 @@ describe( 'Spacer', () => { // Create a spacer with the slash block shortcut. await clickBlockAppender(); await page.keyboard.type( '/spacer' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Spacer')]` + ); await page.keyboard.press( 'Enter' ); expect( await getEditedPostContent() ).toMatchSnapshot(); @@ -26,6 +29,9 @@ describe( 'Spacer', () => { // Create a spacer with the slash block shortcut. await clickBlockAppender(); await page.keyboard.type( '/spacer' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Spacer')]` + ); await page.keyboard.press( 'Enter' ); const resizableHandle = await page.$( diff --git a/packages/e2e-tests/specs/editor/various/embedding.test.js b/packages/e2e-tests/specs/editor/various/embedding.test.js index 9e30ec116f3c3..f81b756a7a373 100644 --- a/packages/e2e-tests/specs/editor/various/embedding.test.js +++ b/packages/e2e-tests/specs/editor/various/embedding.test.js @@ -151,6 +151,17 @@ const MOCK_RESPONSES = [ }, ]; +async function insertEmbed( URL ) { + await clickBlockAppender(); + await page.keyboard.type( '/embed' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Embed')]` + ); + await page.keyboard.press( 'Enter' ); + await page.keyboard.type( URL ); + await page.keyboard.press( 'Enter' ); +} + describe( 'Embedding content', () => { beforeEach( async () => { await setUpResponseMocking( MOCK_RESPONSES ); @@ -159,87 +170,49 @@ describe( 'Embedding content', () => { it( 'should render embeds in the correct state', async () => { // Valid embed. Should render valid figure element. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'https://twitter.com/notnownikki' ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/notnownikki' ); await page.waitForSelector( 'figure.wp-block-embed-twitter' ); // Valid provider; invalid content. Should render failed, edit state. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( - 'https://twitter.com/wooyaygutenberg123454312' - ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/wooyaygutenberg123454312' ); await page.waitForSelector( 'input[value="https://twitter.com/wooyaygutenberg123454312"]' ); // WordPress invalid content. Should render failed, edit state. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'https://wordpress.org/gutenberg/handbook/' ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://wordpress.org/gutenberg/handbook/' ); await page.waitForSelector( 'input[value="https://wordpress.org/gutenberg/handbook/"]' ); // Provider whose oembed API has gone wrong. Should render failed, edit // state. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'https://twitter.com/thatbunty' ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/thatbunty' ); await page.waitForSelector( 'input[value="https://twitter.com/thatbunty"]' ); // WordPress content that can be embedded. Should render valid figure // element. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( + await insertEmbed( 'https://wordpress.org/gutenberg/handbook/block-api/attributes/' ); - await page.keyboard.press( 'Enter' ); await page.waitForSelector( 'figure.wp-block-embed-wordpress' ); // Video content. Should render valid figure element, and include the // aspect ratio class. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( - 'https://www.youtube.com/watch?v=lXMskKTw3Bc' - ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://www.youtube.com/watch?v=lXMskKTw3Bc' ); await page.waitForSelector( 'figure.wp-block-embed-youtube.wp-embed-aspect-16-9' ); // Photo content. Should render valid figure element. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( 'https://cloudup.com/cQFlxqtY4ob' ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://cloudup.com/cQFlxqtY4ob' ); } ); it( 'should allow the user to convert unembeddable URLs to a paragraph with a link in it', async () => { // URL that can't be embedded. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( - 'https://twitter.com/wooyaygutenberg123454312' - ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/wooyaygutenberg123454312' ); // Wait for the request to fail and present an error. Since placeholder // has styles applied which depend on resize observer, wait for the @@ -254,25 +227,14 @@ describe( 'Embedding content', () => { } ); it( 'should retry embeds that could not be embedded with trailing slashes, without the trailing slashes', async () => { - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - // This URL can't be embedded, but without the trailing slash, it can. - await page.keyboard.type( 'https://twitter.com/notnownikki/' ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/notnownikki/' ); // The twitter block should appear correctly. await page.waitForSelector( 'figure.wp-block-embed-twitter' ); } ); it( 'should allow the user to try embedding a failed URL again', async () => { // URL that can't be embedded. - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( - 'https://twitter.com/wooyaygutenberg123454312' - ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( 'https://twitter.com/wooyaygutenberg123454312' ); // Wait for the request to fail and present an error. Since placeholder // has styles applied which depend on resize observer, wait for the @@ -313,11 +275,7 @@ describe( 'Embedding content', () => { // Start a new post, embed the previous post. await createNewPost(); - await clickBlockAppender(); - await page.keyboard.type( '/embed' ); - await page.keyboard.press( 'Enter' ); - await page.keyboard.type( postUrl ); - await page.keyboard.press( 'Enter' ); + await insertEmbed( postUrl ); // Check the block has become a WordPress block. await page.waitForSelector( '.wp-block-embed-wordpress' ); diff --git a/packages/e2e-tests/specs/editor/various/writing-flow.test.js b/packages/e2e-tests/specs/editor/various/writing-flow.test.js index fe9df94bb2fbb..d9a3b5c255df1 100644 --- a/packages/e2e-tests/specs/editor/various/writing-flow.test.js +++ b/packages/e2e-tests/specs/editor/various/writing-flow.test.js @@ -21,6 +21,9 @@ const addParagraphsAndColumnsDemo = async () => { await page.keyboard.type( 'First paragraph' ); await page.keyboard.press( 'Enter' ); await page.keyboard.type( '/columns' ); + await page.waitForXPath( + `//*[contains(@class, "components-autocomplete__result") and contains(@class, "is-selected") and contains(text(), 'Columns')]` + ); await page.keyboard.press( 'Enter' ); await page.click( ':focus [aria-label="Two columns; equal split"]' ); await page.click( ':focus .block-editor-button-block-appender' );