diff --git a/packages/block-editor/src/components/link-control/README.md b/packages/block-editor/src/components/link-control/README.md index 4b590885ae8be5..ad82cae83d2b64 100644 --- a/packages/block-editor/src/components/link-control/README.md +++ b/packages/block-editor/src/components/link-control/README.md @@ -25,7 +25,7 @@ Default properties include: - Type: `Array` - Required: No -- Default: +- Default: ``` [ { @@ -54,7 +54,7 @@ Value change handler, called with the updated value if the user selects a new li onChange={ ( nextValue ) => { console.log( `The selected item URL: ${ nextValue.url }.` ); } -/> +/> ``` ### showInitialSuggestions @@ -71,3 +71,60 @@ Whether to present initial suggestions immediately. - Required: No If passed as either `true` or `false`, controls the internal editing state of the component to respective show or not show the URL input field. + + +### createSuggestion + +- Type: `function` +- Required: No + +Used to handle the dynamic creation of new suggestions within the Link UI. When +the prop is provided, an option is added to the end of all search +results requests which when clicked will call `createSuggestion` callback +(passing the current value of the search ``) in +order to afford the parent component the opportunity to dynamically create a new +link `value` (see above). + +This is often used to allow on-the-fly creation of new entities (eg: `Posts`, +`Pages`) based on the text the user has entered into the link search UI. For +example, the Navigation Block uses this to create Pages on demand. + +When called, `createSuggestion` may return either a new suggestion directly or a `Promise` which resolves to a +new suggestion. Suggestions have the following shape: + +```js +{ + id: // unique identifier + type: // "url", "page", "post"...etc + title: // "My new suggestion" + url: // any string representing the URL value +} +``` + +#### Example +```jsx +// Promise example + { + // Hard coded values. These could be dynamically created by calling out to an API which creates an entity (eg: https://developer.wordpress.org/rest-api/reference/pages/#create-a-page). + return { + id: 1234, + type: 'page', + title: inputText, + url: '/some-url-here' + } + }} +/> + +// Non-Promise example + ( + { + id: 1234, + type: 'page', + title: inputText, + url: '/some-url-here' + } + )} +/> +``` \ No newline at end of file diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 3ef9c75fa8f261..dcd9c54dbd49d4 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -7,7 +7,12 @@ import { noop, startsWith } from 'lodash'; /** * WordPress dependencies */ -import { Button, ExternalLink, VisuallyHidden } from '@wordpress/components'; +import { + Button, + ExternalLink, + Spinner, + VisuallyHidden, +} from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; import { useRef, @@ -15,6 +20,7 @@ import { useState, Fragment, useEffect, + createElement, } from '@wordpress/element'; import { safeDecodeURI, @@ -33,7 +39,39 @@ import { focus } from '@wordpress/dom'; import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchItem from './search-item'; import LinkControlSearchInput from './search-input'; +import LinkControlSearchCreate from './search-create-button'; + +// Used as a unique identifier for the "Create" option within search results. +// Used to help distinguish the "Create" suggestion within the search results in +// order to handle it as a unique case. +const CREATE_TYPE = '__CREATE__'; + +/** + * Creates a wrapper around a promise which allows it to be programmatically + * cancelled. + * See: https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html + * + * @param {Promise} promise the Promise to make cancelable + */ +const makeCancelable = ( promise ) => { + let hasCanceled_ = false; + + const wrappedPromise = new Promise( ( resolve, reject ) => { + promise.then( + ( val ) => + hasCanceled_ ? reject( { isCanceled: true } ) : resolve( val ), + ( error ) => + hasCanceled_ ? reject( { isCanceled: true } ) : reject( error ) + ); + } ); + return { + promise: wrappedPromise, + cancel() { + hasCanceled_ = true; + }, + }; +}; /** * Default properties associated with a link control value. * @@ -70,18 +108,33 @@ import LinkControlSearchInput from './search-input'; /** @typedef {(nextValue:WPLinkControlValue)=>void} WPLinkControlOnChangeProp */ +/** + * Properties associated with a search suggestion used within the LinkControl. + * + * @typedef WPLinkControlSuggestion + * + * @property {string} id Identifier to use to uniquely identify the suggestion. + * @property {string} type Identifies the type of the suggestion (eg: `post`, + * `page`, `url`...etc) + * @property {string} title Human-readable label to show in user interface. + * @property {string} url A URL for the suggestion. + */ + +/** @typedef {(title:string)=>WPLinkControlSuggestion} WPLinkControlCreateSuggestionProp */ + /** * @typedef WPLinkControlProps * - * @property {(WPLinkControlSetting[])=} settings An array of settings objects. Each object will used to - * render a `ToggleControl` for that setting. - * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the - * internal editing state of the component to respective - * show or not show the URL input field. - * @property {WPLinkControlValue=} value Current link value. - * @property {WPLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if - * the user selects a new link or updates settings. - * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately. + * @property {(WPLinkControlSetting[])=} settings An array of settings objects. Each object will used to + * render a `ToggleControl` for that setting. + * @property {boolean=} forceIsEditingLink If passed as either `true` or `false`, controls the + * internal editing state of the component to respective + * show or not show the URL input field. + * @property {WPLinkControlValue=} value Current link value. + * @property {WPLinkControlOnChangeProp=} onChange Value change handler, called with the updated value if + * the user selects a new link or updates settings. + * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately. + * @property {WPLinkControlCreateSuggestionProp=} createSuggestion Handler to manage creation of link value from suggestion. */ /** @@ -97,7 +150,11 @@ function LinkControl( { onChange = noop, showInitialSuggestions, forceIsEditingLink, + createSuggestion, } ) { + const cancelableOnCreate = useRef(); + const cancelableCreateSuggestion = useRef(); + const wrapperNode = useRef(); const instanceId = useInstanceId( LinkControl ); const [ inputValue, setInputValue ] = useState( @@ -108,7 +165,10 @@ function LinkControl( { ? forceIsEditingLink : ! value || ! value.url ); + const [ isResolvingLink, setIsResolvingLink ] = useState( false ); + const [ errorMessage, setErrorMessage ] = useState( null ); const isEndingEditWithFocus = useRef( false ); + const { fetchSearchSuggestions } = useSelect( ( select ) => { const { getSettings } = select( 'core/block-editor' ); return { @@ -154,6 +214,21 @@ function LinkControl( { isEndingEditWithFocus.current = false; }, [ isEditingLink ] ); + /** + * Handles cancelling any pending Promises that have been made cancelable. + */ + useEffect( () => { + return () => { + // componentDidUnmount + if ( cancelableOnCreate.current ) { + cancelableOnCreate.current.cancel(); + } + if ( cancelableCreateSuggestion.current ) { + cancelableCreateSuggestion.current.cancel(); + } + }; + }, [] ); + /** * onChange LinkControlSearchInput event handler * @@ -182,7 +257,7 @@ function LinkControl( { return Promise.resolve( [ { - id: '-1', + id: val, title: val, url: type === 'URL' ? prependHTTP( val ) : val, type, @@ -191,7 +266,7 @@ function LinkControl( { }; const handleEntitySearch = async ( val, args ) => { - const results = await Promise.all( [ + let results = await Promise.all( [ fetchSearchSuggestions( val, { ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), } ), @@ -203,9 +278,35 @@ function LinkControl( { // If it's potentially a URL search then concat on a URL search suggestion // just for good measure. That way once the actual results run out we always // have a URL option to fallback on. - return couldBeURL && ! args.isInitialSuggestions - ? results[ 0 ].concat( results[ 1 ] ) - : results[ 0 ]; + results = + couldBeURL && ! args.isInitialSuggestions + ? results[ 0 ].concat( results[ 1 ] ) + : results[ 0 ]; + + // Here we append a faux suggestion to represent a "CREATE" option. This + // is detected in the rendering of the search results and handled as a + // special case. This is currently necessary because the suggestions + // dropdown will only appear if there are valid suggestions and + // therefore unless the create option is a suggestion it will not + // display in scenarios where there are no results returned from the + // API. In addition promoting CREATE to a first class suggestion affords + // the a11y benefits afforded by `URLInput` to all suggestions (eg: + // keyboard handling, ARIA roles...etc). + // + // Note also that the value of the `title` and `url` properties must correspond + // to the text value of the ``. This is because `title` is used + // when creating the suggestion. Similarly `url` is used when using keyboard to select + // the suggestion (the
`onSubmit` handler falls-back to `url`). + return isURLLike( val ) + ? results + : results.concat( { + // the `id` prop is intentionally ommitted here because it + // is never exposed as part of the component's public API. + // see: https://github.com/WordPress/gutenberg/pull/19775#discussion_r378931316. + title: val, // must match the existing ``s text value + url: val, // must match the existing ``s text value + type: CREATE_TYPE, + } ); }; /** @@ -220,21 +321,80 @@ function LinkControl( { setIsEditingLink( false ); } + /** + * Determines whether a given value could be a URL. Note this does not + * guarantee the value is a URL only that it looks like it might be one. For + * example, just because a string has `www.` in it doesn't make it a URL, + * but it does make it highly likely that it will be so in the context of + * creating a link it makes sense to treat it like one. + * + * @param {string} val the candidate for being URL-like (or not). + * @return {boolean} whether or not the value is potentially a URL. + */ + function isURLLike( val ) { + const isInternal = startsWith( val, '#' ); + return isURL( val ) || ( val && val.includes( 'www.' ) ) || isInternal; + } + // Effects const getSearchHandler = useCallback( ( val, args ) => { - const isInternal = startsWith( val, '#' ); - - const handleManualEntry = - isInternal || isURL( val ) || ( val && val.includes( 'www.' ) ); - - return handleManualEntry + return isURLLike( val ) ? handleDirectEntry( val, args ) : handleEntitySearch( val, args ); }, [ handleDirectEntry, fetchSearchSuggestions ] ); + const handleOnCreate = async ( suggestionTitle ) => { + setIsResolvingLink( true ); + setErrorMessage( null ); + + try { + // Make cancellable in order that we can avoid setting State + // if the component unmounts during the call to `createSuggestion` + cancelableCreateSuggestion.current = makeCancelable( + // Using Promise.resolve to allow createSuggestion to return a + // non-Promise based value. + Promise.resolve( createSuggestion( suggestionTitle ) ) + ); + + const newSuggestion = await cancelableCreateSuggestion.current + .promise; + + // ******** + // NOTE: if the above Promise rejects then code below here will never run + // ******** + setIsResolvingLink( false ); + + // Only set link if request is resolved, otherwise enable edit mode. + if ( newSuggestion ) { + onChange( newSuggestion ); + stopEditing(); + } else { + setIsEditingLink( true ); + } + } catch ( error ) { + if ( error && error.isCanceled ) { + return; // bail if canceled to avoid setting state + } + + setErrorMessage( + error.message || + __( + 'An unknown error occurred during creation. Please try again.' + ) + ); + setIsResolvingLink( false ); + setIsEditingLink( true ); + } + }; + + const handleSelectSuggestion = ( suggestion, _value = {} ) => { + setIsEditingLink( false ); + onChange( { ..._value, ...suggestion } ); + }; + // Render Components const renderSearchResults = ( { suggestionsListProps, @@ -251,22 +411,34 @@ function LinkControl( { } ); - const manualLinkEntryTypes = [ 'url', 'mailto', 'tel', 'internal' ]; - const searchResultsLabelId = isInitialSuggestions - ? `block-editor-link-control-search-results-label-${ instanceId }` - : undefined; - const labelText = isInitialSuggestions - ? __( 'Recently updated' ) - : sprintf( __( 'Search results for %s' ), inputValue ); + const directLinkEntryTypes = [ 'url', 'mailto', 'tel', 'internal' ]; + const isSingleDirectEntryResult = + suggestions.length === 1 && + directLinkEntryTypes.includes( + suggestions[ 0 ].type.toLowerCase() + ); + const shouldShowCreateSuggestion = + createSuggestion && + ! isSingleDirectEntryResult && + ! isInitialSuggestions; + // According to guidelines aria-label should be added if the label // itself is not visible. // See: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role - const ariaLabel = isInitialSuggestions ? undefined : labelText; - const SearchResultsLabel = ( + const searchResultsLabelId = `block-editor-link-control-search-results-label-${ instanceId }`; + const labelText = isInitialSuggestions + ? __( 'Recently updated' ) + : sprintf( __( 'Search results for "%s"' ), inputValue ); + + // VisuallyHidden rightly doesn't accept custom classNames + // so we conditionally render it as a wrapper to visually hide the label + // when that is required. + const searchResultsLabel = createElement( + isInitialSuggestions ? Fragment : VisuallyHidden, + {}, // empty props { labelText } @@ -274,36 +446,65 @@ function LinkControl( { return (
- { isInitialSuggestions ? ( - SearchResultsLabel - ) : ( - { SearchResultsLabel } - ) } - + { searchResultsLabel }
- { suggestions.map( ( suggestion, index ) => ( - { - onChange( { ...value, ...suggestion } ); - stopEditing(); - } } - isSelected={ index === selectedSuggestion } - isURL={ manualLinkEntryTypes.includes( - suggestion.type.toLowerCase() - ) } - searchTerm={ inputValue } - /> - ) ) } + { suggestions.map( ( suggestion, index ) => { + if ( + shouldShowCreateSuggestion && + CREATE_TYPE === suggestion.type + ) { + return ( + { + await handleOnCreate( + suggestion.title + ); + } } + // Intentionally only using `type` here as + // the constant is enough to uniquely + // identify the single "CREATE" suggestion. + key={ suggestion.type } + itemProps={ buildSuggestionItemProps( + suggestion, + index + ) } + isSelected={ index === selectedSuggestion } + /> + ); + } + + // If we're not handling "Create" suggestions above then + // we don't want them in the main results so exit early + if ( CREATE_TYPE === suggestion.type ) { + return null; + } + + return ( + { + stopEditing(); + onChange( { ...value, ...suggestion } ); + } } + isSelected={ index === selectedSuggestion } + isURL={ directLinkEntryTypes.includes( + suggestion.type.toLowerCase() + ) } + searchTerm={ inputValue } + /> + ); + } ) }
); @@ -315,28 +516,35 @@ function LinkControl( { ref={ wrapperNode } className="block-editor-link-control" > - { isEditingLink || ! value ? ( + { isResolvingLink && ( +
+ { __( 'Creating' ) }… +
+ ) } + + { ( isEditingLink || ! value ) && ! isResolvingLink && ( { - onChange( { ...value, ...suggestion } ); - stopEditing(); + onSelect={ async ( suggestion ) => { + if ( CREATE_TYPE === suggestion.type ) { + await handleOnCreate( inputValue ); + } else { + handleSelectSuggestion( suggestion, value ); + stopEditing(); + } } } renderSuggestions={ renderSearchResults } fetchSuggestions={ getSearchHandler } showInitialSuggestions={ showInitialSuggestions } + errorMessage={ errorMessage } /> - ) : ( + ) } + + { value && ! isEditingLink && ! isResolvingLink && ( -

- { __( 'Currently selected' ) }: -

{ + if ( ! searchTerm ) { + return null; + } + + return ( + + ); +}; + +export default LinkControlSearchCreate; diff --git a/packages/block-editor/src/components/link-control/search-input.js b/packages/block-editor/src/components/link-control/search-input.js index 442b53d6ce095b..ceb1e46df66f20 100644 --- a/packages/block-editor/src/components/link-control/search-input.js +++ b/packages/block-editor/src/components/link-control/search-input.js @@ -3,8 +3,7 @@ */ import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Button } from '@wordpress/components'; -import { LEFT, RIGHT, UP, DOWN, BACKSPACE, ENTER } from '@wordpress/keycodes'; +import { Button, Notice } from '@wordpress/components'; import { keyboardReturn } from '@wordpress/icons'; /** @@ -12,24 +11,6 @@ import { keyboardReturn } from '@wordpress/icons'; */ import { URLInput } from '../'; -const handleLinkControlOnKeyDown = ( event ) => { - const { keyCode } = event; - - if ( [ LEFT, DOWN, RIGHT, UP, BACKSPACE, ENTER ].indexOf( keyCode ) > -1 ) { - // Stop the key event from propagating up to ObserveTyping.startTypingInTextField. - event.stopPropagation(); - } -}; - -const handleLinkControlOnKeyPress = ( event ) => { - const { keyCode } = event; - - event.stopPropagation(); - - if ( keyCode === ENTER ) { - } -}; - const LinkControlSearchInput = ( { value, onChange, @@ -37,9 +18,17 @@ const LinkControlSearchInput = ( { renderSuggestions, fetchSuggestions, showInitialSuggestions, + errorMessage, } ) => { const [ selectedSuggestion, setSelectedSuggestion ] = useState(); + /** + * Handles the user moving between different suggestions. Does not handle + * choosing an individual item. + * + * @param {string} selection the url of the selected suggestion. + * @param {Object} suggestion the suggestion object. + */ const selectItemHandler = ( selection, suggestion ) => { onChange( selection ); setSelectedSuggestion( suggestion ); @@ -56,31 +45,38 @@ const LinkControlSearchInput = ( { return ( - { - if ( event.keyCode === ENTER ) { - return; +
+ -
-
+ + { errorMessage && ( + + { errorMessage } + + ) } ); }; diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 959e1f30c11262..9cc009e6c0ff6f 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -5,6 +5,12 @@ $block-editor-link-control-number-of-actions: 1; min-width: $modal-min-width; } +// Provides positioning context for reset button. Without this then when an +// error notice is displayed the input's reset button is incorrectly positioned. +.block-editor-link-control__search-input-wrapper { + position: relative; +} + // LinkControl popover. .block-editor-link-control .block-editor-link-control__search-input { // Specificity override. @@ -15,7 +21,7 @@ $block-editor-link-control-number-of-actions: 1; margin: $grid-size-large; padding-right: ( $icon-button-size * $block-editor-link-control-number-of-actions ); // width of reset and submit buttons position: relative; - border: 1px solid #e1e1e1; + border: 1px solid $light-gray-500; border-radius: $radius-round-rectangle; /* Fonts smaller than 16px causes mobile safari to zoom. */ @@ -31,6 +37,10 @@ $block-editor-link-control-number-of-actions: 1; } } +.block-editor-link-control__search-error { + margin: -$grid-size-large/2 $grid-size-large $grid-size-large; // negative margin to bring the error a bit closer to the button +} + .block-editor-link-control__search-actions { position: absolute; /* @@ -87,7 +97,7 @@ $block-editor-link-control-number-of-actions: 1; .block-editor-link-control__search-results { margin: 0; - padding: $grid-size-large/2 $grid-size-large $grid-size-large; + padding: $grid-size-large/2 $grid-size-large $grid-size-large/2; max-height: 200px; overflow-y: auto; // allow results list to scroll @@ -112,14 +122,14 @@ $block-editor-link-control-number-of-actions: 1; &:hover, &:focus { - background-color: #e9e9e9; + background-color: $light-gray-300; } &.is-selected { - background: #f2f2f2; + background: $light-gray-200; .block-editor-link-control__search-item-type { - background: #fff; + background: $white; } } @@ -135,6 +145,7 @@ $block-editor-link-control-number-of-actions: 1; .block-editor-link-control__search-item-header { display: block; margin-right: $grid-size-xlarge; + margin-top: 2px; } .block-editor-link-control__search-item-icon { @@ -150,6 +161,10 @@ $block-editor-link-control-number-of-actions: 1; white-space: nowrap; } + .block-editor-link-control__search-item-title mark { + color: $dark-gray-900; + } + .block-editor-link-control__search-item-title { display: block; margin-bottom: 0.2em; @@ -157,7 +172,7 @@ $block-editor-link-control-number-of-actions: 1; mark { font-weight: 700; - color: #000; + color: $black; background-color: transparent; } @@ -168,7 +183,7 @@ $block-editor-link-control-number-of-actions: 1; .block-editor-link-control__search-item-info { display: block; - color: #999; + color: $dark-gray-300; font-size: 0.9em; line-height: 1.3; } @@ -178,18 +193,47 @@ $block-editor-link-control-number-of-actions: 1; padding: 3px 8px; margin-left: auto; font-size: 0.9em; - background-color: #f3f4f5; + background-color: $light-gray-200; border-radius: 2px; } } +.block-editor-link-control__loading { + margin: $grid-size-large; // when only loading control is shown it requires it's own spacing. + display: flex; + align-items: center; + + .components-spinner { + margin-top: 0; + } +} + +// Separate Create button when following other suggestions. +.components-button + .block-editor-link-control__search-create { + margin-top: 20px; + overflow: visible; + padding: 12px 15px; + + // Create fake border. We cannot use border because the button has a border + // radius applied to it + &::before { + content: ""; + position: absolute; + top: -#{$block-selected-child-margin*2}; + left: 0; + display: block; + width: 100%; + border-top: 1px solid $light-gray-500; + } +} + // Specificity overide .block-editor-link-control__search-results div[role="menu"] > .block-editor-link-control__search-item.block-editor-link-control__search-item { padding: 10px; } .block-editor-link-control__settings { - border-top: 1px solid #e1e1e1; + border-top: 1px solid $light-gray-500; margin: 0; padding: $grid-size-large $grid-size-xlarge; diff --git a/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap b/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap index 9049884131f8e4..db2b039353fb7d 100644 --- a/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap +++ b/packages/block-editor/src/components/link-control/test/__snapshots__/index.js.snap @@ -1,3 +1,3 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Basic rendering should render 1`] = `""`; +exports[`Basic rendering should render 1`] = `""`; diff --git a/packages/block-editor/src/components/link-control/test/index.js b/packages/block-editor/src/components/link-control/test/index.js index 8cbff65f45d38d..77f10013a6c857 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -214,6 +214,7 @@ describe( 'Searching for a link', () => { // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = getSearchResults(); @@ -261,6 +262,7 @@ describe( 'Searching for a link', () => { // fetchFauxEntitySuggestions resolves on next "tick" of event loop await eventLoopTick(); + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. const searchResultElements = getSearchResults(); @@ -517,6 +519,474 @@ describe( 'Default search suggestions', () => { } ); } ); +describe( 'Creating Entities (eg: Posts, Pages)', () => { + const noResults = []; + beforeEach( () => { + // Force returning empty results for existing Pages. Doing this means that the only item + // shown should be "Create Page" suggestion because there will be no search suggestions + // and our input does not conform to a direct entry schema (eg: a URL). + mockFetchSearchSuggestions.mockImplementation( () => + Promise.resolve( noResults ) + ); + } ); + it.each( [ + [ 'HelloWorld', 'without spaces' ], + [ 'Hello World', 'with spaces' ], + ] )( + 'should allow creating a link for a valid Entity title "%s" (%s)', + async ( entityNameText ) => { + let resolver; + let resolvedEntity; + + const createSuggestion = ( title ) => + new Promise( ( resolve ) => { + resolver = resolve; + resolvedEntity = { + title, + id: 123, + url: '/?p=123', + type: 'page', + }; + } ); + + const LinkControlConsumer = () => { + const [ link, setLink ] = useState( null ); + + return ( + { + setLink( suggestion ); + } } + createSuggestion={ createSuggestion } + /> + ); + }; + + act( () => { + render( , container ); + } ); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // Simulate searching for a term + act( () => { + Simulate.change( searchInput, { + target: { value: entityNameText }, + } ); + } ); + + await eventLoopTick(); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + expect( createButton ).not.toBeNull(); + expect( createButton.innerHTML ).toEqual( + expect.stringContaining( entityNameText ) + ); + + // No need to wait in this test because we control the Promise + // resolution manually via the `resolver` reference + act( () => { + Simulate.click( createButton ); + } ); + + await eventLoopTick(); + + // Check for loading indicator + const loadingIndicator = container.querySelector( + '.block-editor-link-control__loading' + ); + const currentLinkLabel = container.querySelector( + '[aria-label="Currently selected"]' + ); + + expect( currentLinkLabel ).toBeNull(); + expect( loadingIndicator.innerHTML ).toEqual( + expect.stringContaining( 'Creating' ) + ); + + // Resolve the `createSuggestion` promise + await act( async () => { + resolver( resolvedEntity ); + } ); + + await eventLoopTick(); + + const currentLink = container.querySelector( + '[aria-label="Currently selected"]' + ); + + const currentLinkHTML = currentLink.innerHTML; + + expect( currentLinkHTML ).toEqual( + expect.stringContaining( entityNameText ) + ); + expect( currentLinkHTML ).toEqual( + expect.stringContaining( '/?p=123' ) + ); + } + ); + + it( 'should allow createSuggestion prop to return a non-Promise value', async () => { + const LinkControlConsumer = () => { + const [ link, setLink ] = useState( null ); + + return ( + { + setLink( suggestion ); + } } + createSuggestion={ ( title ) => ( { + title, + id: 123, + url: '/?p=123', + type: 'page', + } ) } + /> + ); + }; + + act( () => { + render( , container ); + } ); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // Simulate searching for a term + act( () => { + Simulate.change( searchInput, { + target: { value: 'Some new page to create' }, + } ); + } ); + + await eventLoopTick(); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + await act( async () => { + Simulate.click( createButton ); + } ); + + await eventLoopTick(); + + const currentLink = container.querySelector( + '[aria-label="Currently selected"]' + ); + + const currentLinkHTML = currentLink.innerHTML; + + expect( currentLinkHTML ).toEqual( + expect.stringContaining( 'Some new page to create' ) + ); + expect( currentLinkHTML ).toEqual( + expect.stringContaining( '/?p=123' ) + ); + } ); + + it( 'should allow creation of entities via the keyboard', async () => { + const entityNameText = 'A new page to be created'; + + const LinkControlConsumer = () => { + const [ link, setLink ] = useState( null ); + + return ( + { + setLink( suggestion ); + } } + createSuggestion={ ( title ) => + Promise.resolve( { + title, + id: 123, + url: '/?p=123', + type: 'page', + } ) + } + /> + ); + }; + + act( () => { + render( , container ); + } ); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // Simulate searching for a term + act( () => { + Simulate.change( searchInput, { + target: { value: entityNameText }, + } ); + } ); + + await eventLoopTick(); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + const form = container.querySelector( 'form' ); + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + // Step down into the search results, highlighting the first result item + act( () => { + Simulate.keyDown( searchInput, { keyCode: DOWN } ); + } ); + + act( () => { + Simulate.keyDown( createButton, { keyCode: ENTER } ); + } ); + + await act( async () => { + Simulate.submit( form ); + } ); + + await eventLoopTick(); + + const currentLink = container.querySelector( + '[aria-label="Currently selected"]' + ); + + const currentLinkHTML = currentLink.innerHTML; + + expect( currentLinkHTML ).toEqual( + expect.stringContaining( entityNameText ) + ); + } ); + + describe( 'Do not show create option', () => { + it.each( [ [ undefined ], [ null ], [ false ] ] )( + 'should not show not show an option to create an entity when "createSuggestion" handler is %s', + async ( handler ) => { + act( () => { + render( + , + container + ); + } ); + // Await the initial suggestions to be fetched + await eventLoopTick(); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + // Verify input has no value + expect( searchInput.value ).toBe( '' ); + expect( createButton ).toBeFalsy(); // shouldn't exist! + } + ); + + it( 'should not show not show an option to create an entity when input is empty', async () => { + act( () => { + render( + , + container + ); + } ); + // Await the initial suggestions to be fetched + await eventLoopTick(); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + // Verify input has no value + expect( searchInput.value ).toBe( '' ); + expect( createButton ).toBeFalsy(); // shouldn't exist! + } ); + + it.each( [ + 'https://wordpress.org', + 'www.wordpress.org', + 'mailto:example123456@wordpress.org', + 'tel:example123456@wordpress.org', + '#internal-anchor', + ] )( + 'should not show option to "Create Page" when text is a form of direct entry (eg: %s)', + async ( inputText ) => { + act( () => { + render( + , + container + ); + } ); + + // Search Input UI + const searchInput = container.querySelector( + 'input[aria-label="URL"]' + ); + + // Simulate searching for a term + act( () => { + Simulate.change( searchInput, { + target: { value: inputText }, + } ); + } ); + + await eventLoopTick(); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + const searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + + const createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + expect( createButton ).toBeFalsy(); // shouldn't exist! + } + ); + } ); + + describe( 'Error handling', () => { + it( 'should display human-friendly, perceivable error notice and re-show create button and search input if page creation request fails', async () => { + const searchText = 'This page to be created'; + let searchInput; + + const throwsError = () => { + throw new Error( 'API response returned invalid entity.' ); // this can be any error and msg + }; + + const createSuggestion = () => Promise.reject( throwsError() ); + + act( () => { + render( + , + container + ); + } ); + + // Search Input UI + searchInput = container.querySelector( 'input[aria-label="URL"]' ); + + // Simulate searching for a term + act( () => { + Simulate.change( searchInput, { + target: { value: searchText }, + } ); + } ); + + await eventLoopTick(); + + // TODO: select these by aria relationship to autocomplete rather than arbitrary selector. + let searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + let createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + await act( async () => { + Simulate.click( createButton ); + } ); + + await eventLoopTick(); + + searchInput = container.querySelector( 'input[aria-label="URL"]' ); + + // This is a Notice component + // we allow selecting by className here as an edge case because the + // a11y is handled via `speak`. + // See: https://github.com/WordPress/gutenberg/tree/master/packages/a11y#speak. + const errorNotice = container.querySelector( + '.block-editor-link-control__search-error' + ); + + // Catch the error in the test to avoid test failures + expect( throwsError ).toThrow( Error ); + + // Check human readable error notice is perceivable + expect( errorNotice ).not.toBeFalsy(); + expect( errorNotice.innerHTML ).toEqual( + expect.stringContaining( + 'API response returned invalid entity' + ) + ); + + // Verify input is repopulated with original search text + expect( searchInput ).not.toBeFalsy(); + expect( searchInput.value ).toBe( searchText ); + + // Verify search results are re-shown and create button is available. + searchResultElements = container.querySelectorAll( + '[role="listbox"] [role="option"]' + ); + createButton = first( + Array.from( searchResultElements ).filter( ( result ) => + result.innerHTML.includes( 'New page' ) + ) + ); + + expect( createButton ).not.toBeFalsy(); // shouldn't exist! + } ); + } ); +} ); + describe( 'Selecting links', () => { it( 'should display a selected link corresponding to the provided "currentLink" prop', () => { const selectedLink = first( fauxEntitySuggestions ); diff --git a/packages/block-editor/src/components/url-input/index.js b/packages/block-editor/src/components/url-input/index.js index 1f43cf341ebeb2..f9c6eeb4b94e66 100644 --- a/packages/block-editor/src/components/url-input/index.js +++ b/packages/block-editor/src/components/url-input/index.js @@ -425,6 +425,7 @@ class URLInput extends Component { } ) } > { if ( isLinkOpen && url ) { - // Close the link. - setIsLinkOpen( false ); - // Does this look like a URL and have something TLD-ish? if ( isURL( prependHTTP( label ) ) && @@ -114,6 +113,21 @@ function NavigationLinkEdit( { selection.addRange( range ); } + async function handleCreatePage( pageTitle ) { + const type = 'page'; + const page = await saveEntityRecord( 'postType', type, { + title: pageTitle, + status: 'publish', + } ); + + return { + id: page.id, + type, + title: page.title.rendered, + url: page.link, + }; + } + return ( @@ -226,6 +240,11 @@ function NavigationLinkEdit( { className="wp-block-navigation-link__inline-link-input" value={ link } showInitialSuggestions={ true } + createSuggestion={ + userCanCreatePages + ? handleCreatePage + : undefined + } onChange={ ( { title: newTitle = '', url: newURL = '', @@ -320,12 +339,18 @@ export default compose( [ !! navigationBlockAttributes.showSubmenuIcon && hasDescendants; const isParentOfSelectedBlock = hasSelectedInnerBlock( clientId, true ); + const userCanCreatePages = select( 'core' ).canUser( + 'create', + 'pages' + ); + return { isParentOfSelectedBlock, hasDescendants, showSubmenuIcon, textColor: navigationBlockAttributes.textColor, backgroundColor: navigationBlockAttributes.backgroundColor, + userCanCreatePages, rgbTextColor: getColorObjectByColorSlug( colors, navigationBlockAttributes.textColor, @@ -339,7 +364,9 @@ export default compose( [ }; } ), withDispatch( ( dispatch, ownProps, registry ) => { + const { saveEntityRecord } = dispatch( 'core' ); return { + saveEntityRecord, insertLinkBlock() { const { clientId } = ownProps; diff --git a/packages/e2e-tests/specs/experiments/__snapshots__/navigation.test.js.snap b/packages/e2e-tests/specs/experiments/__snapshots__/navigation.test.js.snap index b13411df2531c4..9bef34b8e46192 100644 --- a/packages/e2e-tests/specs/experiments/__snapshots__/navigation.test.js.snap +++ b/packages/e2e-tests/specs/experiments/__snapshots__/navigation.test.js.snap @@ -2,18 +2,24 @@ exports[`Navigation allows a navigation menu to be created from an empty menu using a mixture of internal and external links 1`] = ` " - + - + " `; exports[`Navigation allows a navigation menu to be created using existing pages 1`] = ` " - + - + - + +" +`; + +exports[`Navigation allows pages to be created from the navigation block and their links added to menu 1`] = ` +" + " `; diff --git a/packages/e2e-tests/specs/experiments/navigation.test.js b/packages/e2e-tests/specs/experiments/navigation.test.js index fd3ab2202f636a..aabda57d9d4087 100644 --- a/packages/e2e-tests/specs/experiments/navigation.test.js +++ b/packages/e2e-tests/specs/experiments/navigation.test.js @@ -15,7 +15,7 @@ async function mockPagesResponse( pages ) { const mappedPages = pages.map( ( { title, slug }, index ) => ( { id: index + 1, type: 'page', - link: `https://this/is/a/test/url/${ slug }`, + link: `https://this/is/a/test/page/${ slug }`, title: { rendered: title, raw: title, @@ -41,7 +41,7 @@ async function mockSearchResponse( items ) { subtype: 'page', title, type: 'post', - url: `https://this/is/a/test/url/${ slug }`, + url: `https://this/is/a/test/search/${ slug }`, } ) ); await setUpResponseMocking( [ @@ -54,20 +54,57 @@ async function mockSearchResponse( items ) { ] ); } -async function updateActiveNavigationLink( { url, label } ) { +async function mockCreatePageResponse( title, slug ) { + const page = { + id: 1, + title: { raw: title, rendered: title }, + type: 'page', + link: `https://this/is/a/test/create/page/${ slug }`, + slug, + }; + + await setUpResponseMocking( [ + { + match: ( request ) => + request.url().includes( `rest_route` ) && + request.url().includes( `pages` ) && + request.method() === 'POST', + onRequestMatch: createJSONResponse( page ), + }, + ] ); +} + +/** + * Interacts with the LinkControl to perform a search and select a returned suggestion + * @param {string} url What will be typed in the search input + * @param {string} label What the resulting label will be in the creating Navigation Link Block after the block is created. + * @param {string} type What kind of suggestion should be clicked, ie. 'url', 'create', or 'entity' + */ +async function updateActiveNavigationLink( { url, label, type } ) { + const typeClasses = { + create: 'block-editor-link-control__search-create', + entity: 'is-entity', + url: 'is-url', + }; + if ( url ) { await page.type( 'input[placeholder="Search or type url"]', url ); + + const suggestionPath = `//button[contains(@class, 'block-editor-link-control__search-item') and contains(@class, '${ typeClasses[ type ] }')]/span/span[@class='block-editor-link-control__search-item-title']/mark[text()="${ url }"]`; + // Wait for the autocomplete suggestion item to appear. - await page.waitForXPath( - `//span[@class="block-editor-link-control__search-item-title"]/mark[text()="${ url }"]` - ); - // Navigate to the first suggestion. - await page.keyboard.press( 'ArrowDown' ); - // Select the suggestion. - await page.keyboard.press( 'Enter' ); + await page.waitForXPath( suggestionPath ); + // Set the suggestion + const [ suggestion ] = await page.$x( suggestionPath ); + + // Select it (so we're clicking the right one, even if it's further down the list) + await suggestion.click(); } if ( label ) { + // Wait for rich text editor input to be focused before we start typing the label + await page.waitForSelector( ':focus.rich-text' ); + // With https://github.com/WordPress/gutenberg/pull/19686, we're auto-selecting the label if the label is URL-ish. // In this case, it means we have to select and delete the label if it's _not_ the url. if ( label !== url ) { @@ -143,6 +180,7 @@ describe( 'Navigation', () => { await updateActiveNavigationLink( { url: 'https://wordpress.org', label: 'WP', + type: 'url', } ); // Move the mouse to reveal the block movers. Without this the test seems to fail. @@ -156,8 +194,17 @@ describe( 'Navigation', () => { // After adding a new block, search input should be shown immediately. // Verify that Escape would close the popover. // Regression: https://github.com/WordPress/gutenberg/pull/19885 + // Wait for URL input to be focused + await page.waitForSelector( + 'input.block-editor-url-input__input:focus' + ); + + // After adding a new block, search input should be shown immediately. const isInURLInput = await page.evaluate( - () => !! document.activeElement.closest( '.block-editor-url-input' ) + () => + !! document.activeElement.matches( + 'input.block-editor-url-input__input' + ) ); expect( isInURLInput ).toBe( true ); await page.keyboard.press( 'Escape' ); @@ -176,16 +223,96 @@ describe( 'Navigation', () => { // For the second nav link block use an existing internal page. // Mock the api response so that it's consistent. await mockSearchResponse( [ - { title: 'Contact Us', slug: 'contact-us' }, + { title: 'Get in Touch', slug: 'get-in-touch' }, ] ); // Add a link to the default Navigation Link block. await updateActiveNavigationLink( { - url: 'Contact Us', - label: 'Get in touch', + url: 'Get in Touch', + label: 'Contact', + type: 'entity', } ); // Expect a Navigation Block with two Navigation Links in the snapshot. expect( await getEditedPostContent() ).toMatchSnapshot(); } ); + + it( 'allows pages to be created from the navigation block and their links added to menu', async () => { + // Mock request for creating pages and the page search response. + // We mock the page search to return no results and we use a very long + // page name because if the search returns existing pages then the + // "Create" suggestion might be below the scroll fold within the + // `LinkControl` search suggestions UI. If this happens then it's not + // possible to wait for the element to appear and the test will + // erroneously fail. + await mockSearchResponse( [] ); + await mockCreatePageResponse( + 'A really long page name that will not exist', + 'my-new-page' + ); + + // Add the navigation block. + await insertBlock( 'Navigation' ); + + // Create an empty nav block. + await page.waitForSelector( '.wp-block-navigation-placeholder' ); + const [ createEmptyButton ] = await page.$x( + '//button[text()="Create empty"]' + ); + await createEmptyButton.click(); + + // Wait for URL input to be focused + await page.waitForSelector( + 'input.block-editor-url-input__input:focus' + ); + + // After adding a new block, search input should be shown immediately. + const isInURLInput = await page.evaluate( + () => + !! document.activeElement.matches( + 'input.block-editor-url-input__input' + ) + ); + expect( isInURLInput ).toBe( true ); + + // Insert name for the new page. + await page.type( + 'input[placeholder="Search or type url"]', + 'A really long page name that will not exist' + ); + + // Wait for URL input to be focused + await page.waitForSelector( + 'input.block-editor-url-input__input:focus' + ); + + // Wait for the create button to appear and click it. + await page.waitForSelector( + '.block-editor-link-control__search-create' + ); + + const createPageButton = await page.$( + '.block-editor-link-control__search-create' + ); + + await createPageButton.click(); + + // wait for the creating confirmation to go away, and we should now be focused on our text input + await page.waitForSelector( ':focus.rich-text' ); + + // Confirm the new link is focused. + const isInLinkRichText = await page.evaluate( + () => + document.activeElement.classList.contains( 'rich-text' ) && + !! document.activeElement.closest( + '.block-editor-block-list__block' + ) && + document.activeElement.innerText === + 'A really long page name that will not exist' + ); + expect( isInLinkRichText ).toBe( true ); + + // Expect a Navigation Block with a link for "A really long page name that will not exist". + expect( await getEditedPostContent() ).toMatchSnapshot(); + } ); } );