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 (
+
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' );