From f61073aa74b0791d17773022f119317fd2893d07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:21:36 +0200 Subject: [PATCH 01/17] Slice-and-dice LinkControl to make parts of it reusable --- .../src/components/link-control/index.js | 502 +++--------------- .../link-control/search-create-button.js | 18 +- .../components/link-control/search-input.js | 139 +++-- .../components/link-control/search-item.js | 9 +- .../src/components/link-control/style.scss | 2 + .../block-library/src/navigation-link/edit.js | 24 +- 6 files changed, 159 insertions(+), 535 deletions(-) diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index bdf9112ef0153..38c5492d2546a 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -1,82 +1,26 @@ /** * External dependencies */ -import classnames from 'classnames'; -import { noop, startsWith } from 'lodash'; +import { noop } from 'lodash'; /** * WordPress dependencies */ -import { - Button, - ExternalLink, - Spinner, - VisuallyHidden, - createSlotFill, -} from '@wordpress/components'; -import { __, sprintf } from '@wordpress/i18n'; -import { - useRef, - useCallback, - useState, - Fragment, - useEffect, - createElement, -} from '@wordpress/element'; -import { - safeDecodeURI, - filterURLForDisplay, - isURL, - prependHTTP, - getProtocol, -} from '@wordpress/url'; -import { useInstanceId } from '@wordpress/compose'; -import { useSelect } from '@wordpress/data'; +import { Button, Spinner, Notice } from '@wordpress/components'; +import { keyboardReturn } from '@wordpress/icons'; +import { __ } from '@wordpress/i18n'; +import { useRef, useState, useEffect } from '@wordpress/element'; import { focus } from '@wordpress/dom'; /** * Internal dependencies */ import LinkControlSettingsDrawer from './settings-drawer'; -import LinkControlSearchItem from './search-item'; import LinkControlSearchInput from './search-input'; -import LinkControlSearchCreate from './search-create-button'; +import LinkOverview from './link-overview'; +import useCreatePage from './use-create-page'; +import { ViewerFill } from './viewer-slot'; -const { Slot: ViewerSlot, Fill: ViewerFill } = createSlotFill( - 'BlockEditorLinkControlViewer' -); - -// 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. * @@ -145,7 +89,7 @@ const makeCancelable = ( promise ) => { * @property {boolean=} noDirectEntry Whether to disable direct entries or not. * @property {boolean=} showSuggestions Whether to present suggestions when typing the URL. * @property {boolean=} showInitialSuggestions Whether to present initial suggestions immediately. - * @property {WPLinkControlCreateSuggestionProp=} createSuggestion Handler to manage creation of link value from suggestion. + * @property {boolean=} withCreateSuggestion Whether to allow creation of link value from suggestion. */ /** @@ -164,35 +108,21 @@ function LinkControl( { showSuggestions = true, showInitialSuggestions, forceIsEditingLink, - createSuggestion, + withCreateSuggestion = false, + inputValue: propInputValue = '', } ) { - const cancelableOnCreate = useRef(); - const cancelableCreateSuggestion = useRef(); - const wrapperNode = useRef(); - const instanceId = useInstanceId( LinkControl ); - const [ inputValue, setInputValue ] = useState( + const [ internalInputValue, setInternalInputValue ] = useState( ( value && value.url ) || '' ); + const currentInputValue = propInputValue || internalInputValue; const [ isEditingLink, setIsEditingLink ] = useState( forceIsEditingLink !== undefined ? 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 { - fetchSearchSuggestions: getSettings() - .__experimentalFetchLinkSuggestions, - }; - }, [] ); - const displayURL = - ( value && filterURLForDisplay( safeDecodeURI( value.url ) ) ) || ''; - useEffect( () => { if ( forceIsEditingLink !== undefined && @@ -228,108 +158,6 @@ 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 - * - * @param {string} val Current value returned by the search. - */ - const onInputChange = ( val = '' ) => { - setInputValue( val ); - }; - - const handleDirectEntry = noDirectEntry - ? () => Promise.resolve( [] ) - : ( val ) => { - let type = 'URL'; - - const protocol = getProtocol( val ) || ''; - - if ( protocol.includes( 'mailto' ) ) { - type = 'mailto'; - } - - if ( protocol.includes( 'tel' ) ) { - type = 'tel'; - } - - if ( startsWith( val, '#' ) ) { - type = 'internal'; - } - - return Promise.resolve( [ - { - id: val, - title: val, - url: type === 'URL' ? prependHTTP( val ) : val, - type, - }, - ] ); - }; - - const handleEntitySearch = async ( val, args ) => { - let results = await Promise.all( [ - fetchSearchSuggestions( val, { - ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), - } ), - handleDirectEntry( val ), - ] ); - - const couldBeURL = ! val.includes( ' ' ); - - // 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. - results = - couldBeURL && ! args.isInitialSuggestions - ? results[ 0 ].concat( results[ 1 ] ) - : results[ 0 ]; - - // If displaying initial suggestions just return plain results. - if ( args.isInitialSuggestions ) { - return results; - } - - // 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, - } ); - }; - /** * Cancels editing state and marks that focus may need to be restored after * the next render, if focus was within the wrapper when editing finished. @@ -342,205 +170,12 @@ 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; - } + const { createPage, isCreatingPage, errorMessage } = useCreatePage(); - // Effects - const getSearchHandler = useCallback( - ( val, args ) => { - if ( ! showSuggestions ) { - return Promise.resolve( [] ); - } - - 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 = {} ) => { + const handleSelectSuggestion = ( updatedValue ) => { setIsEditingLink( false ); - const __value = { ..._value }; - // Some direct entries don't have types or IDs, and we still need to clear the previous ones. - delete __value.type; - delete __value.id; - onChange( { ...__value, ...suggestion } ); - }; - - // Render Components - const renderSearchResults = ( { - suggestionsListProps, - buildSuggestionItemProps, - suggestions, - selectedSuggestion, - isLoading, - isInitialSuggestions, - } ) => { - const resultsListClasses = classnames( - 'block-editor-link-control__search-results', - { - 'is-loading': isLoading, - } - ); - - 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 searchResultsLabelId = `block-editor-link-control-search-results-label-${ instanceId }`; - const labelText = isInitialSuggestions - ? __( 'Recently updated' ) - : sprintf( - /* translators: %s: search term. */ - __( '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 } - - ); - - return ( -
- { searchResultsLabel } -
- { 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 } - /> - ); - } ) } -
-
- ); + onChange( updatedValue ); + stopEditing(); }; return ( @@ -549,74 +184,57 @@ function LinkControl( { ref={ wrapperNode } className="block-editor-link-control" > - { isResolvingLink && ( + { isCreatingPage && (
{ __( 'Creating' ) }…
) } - { ( isEditingLink || ! value ) && ! isResolvingLink && ( - { - if ( CREATE_TYPE === suggestion.type ) { - await handleOnCreate( inputValue ); - } else if ( - ! noDirectEntry || - Object.keys( suggestion ).length > 1 - ) { - handleSelectSuggestion( suggestion, value ); - stopEditing(); - } - } } - renderSuggestions={ - showSuggestions ? renderSearchResults : null - } - fetchSuggestions={ getSearchHandler } - showInitialSuggestions={ showInitialSuggestions } - errorMessage={ errorMessage } - /> - ) } - - { value && ! isEditingLink && ! isResolvingLink && ( - -
- - - { ( value && value.title ) || displayURL } - - { value && value.title && ( - - { displayURL } - - ) } - - - - +
+
+
-
+ { errorMessage && ( + + { errorMessage } + + ) } + ) } + + { value && ! isEditingLink && ! isCreatingPage && ( + setIsEditingLink( true ) } + /> + ) } + - { createInterpolateElement( - sprintf( - /* translators: %s: search term. */ - __( 'New page: %s' ), - searchTerm - ), - { mark: } - ) } + { searchTerm } + + + { __( 'Create a new page' ) } 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 305d2b00f5148..df8ef4e7d1161 100644 --- a/packages/block-editor/src/components/link-control/search-input.js +++ b/packages/block-editor/src/components/link-control/search-input.js @@ -1,27 +1,47 @@ +/** + * External dependencies + */ +import { noop, omit } from 'lodash'; + /** * WordPress dependencies */ +import { useInstanceId } from '@wordpress/compose'; import { useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; -import { Button, Notice } from '@wordpress/components'; -import { keyboardReturn } from '@wordpress/icons'; /** * Internal dependencies */ import { URLInput } from '../'; +import LinkControlSearchResults from './search-results'; +import { CREATE_TYPE } from './constants'; +import useSearchHandler from './use-search-handler'; +const noopSearchHandler = Promise.resolve( [] ); const LinkControlSearchInput = ( { - placeholder, value, - onChange, - onSelect, - renderSuggestions, - fetchSuggestions, - showInitialSuggestions, - errorMessage, + children, + currentLink = {}, + className = null, + placeholder = null, + withCreateSuggestion = false, + onCreateSuggestion = noop, + onChange = noop, + onSelect = noop, + showSuggestions = true, + renderSuggestions = ( props ) => , + fetchSuggestions = null, + allowDirectEntry = true, + showInitialSuggestions = false, } ) => { - const [ selectedSuggestion, setSelectedSuggestion ] = useState(); + const genericSearchHandler = useSearchHandler( allowDirectEntry ); + const searchHandler = showSuggestions + ? fetchSuggestions || genericSearchHandler + : noopSearchHandler; + + const instanceId = useInstanceId( LinkControlSearchInput ); + const [ focusedSuggestion, setFocusedSuggestion ] = useState(); /** * Handles the user moving between different suggestions. Does not handle @@ -30,54 +50,71 @@ const LinkControlSearchInput = ( { * @param {string} selection the url of the selected suggestion. * @param {Object} suggestion the suggestion object. */ - const selectItemHandler = ( selection, suggestion ) => { + const onInputChange = ( selection, suggestion ) => { onChange( selection ); - setSelectedSuggestion( suggestion ); + setFocusedSuggestion( suggestion ); }; - function selectSuggestionOrCurrentInputValue( event ) { - // Avoid default forms behavior, since it's being handled custom here. + const onFormSubmit = ( event ) => { event.preventDefault(); + onSuggestionSelected( focusedSuggestion || { url: value } ); + }; - // Interpret the selected value as either the selected suggestion, if - // exists, or otherwise the current input value as entered. - onSelect( selectedSuggestion || { url: value } ); - } + const handleRenderSuggestions = ( props ) => + renderSuggestions( { + ...props, + instanceId, + withCreateSuggestion, + currentInputValue: value, + handleSuggestionClick: ( suggestion ) => { + if ( props.handleSuggestionClick ) { + props.handleSuggestionClick( suggestion ); + } + onSuggestionSelected( suggestion ); + }, + } ); - return ( - -
- -
-
-
+ const onSuggestionSelected = async ( selectedSuggestion ) => { + let suggestion = selectedSuggestion; + if ( CREATE_TYPE === selectedSuggestion.type ) { + // Create a new page and call onSelect with the output from the onCreateSuggestion callback + try { + suggestion = await onCreateSuggestion( + selectedSuggestion.title + ); + if ( suggestion?.url ) { + onChange( suggestion.url ); + } + } catch ( e ) {} + } + + if ( + allowDirectEntry || + ( suggestion && Object.keys( suggestion ).length >= 1 ) + ) { + onSelect( + // Some direct entries don't have types or IDs, and we still need to clear the previous ones. + { ...omit( currentLink, 'id', 'url' ), ...suggestion }, + suggestion + ); + } + }; - { errorMessage && ( - - { errorMessage } - - ) } + return ( + + + { children } ); }; diff --git a/packages/block-editor/src/components/link-control/search-item.js b/packages/block-editor/src/components/link-control/search-item.js index a7ef58e36d491..a8e0e26ce3fc0 100644 --- a/packages/block-editor/src/components/link-control/search-item.js +++ b/packages/block-editor/src/components/link-control/search-item.js @@ -9,7 +9,6 @@ import classnames from 'classnames'; import { safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; import { Button, TextHighlight } from '@wordpress/components'; -import { Icon, globe } from '@wordpress/icons'; export const LinkControlSearchItem = ( { itemProps, @@ -29,12 +28,6 @@ export const LinkControlSearchItem = ( { 'is-entity': ! isURL, } ) } > - { isURL && ( - - ) } { suggestion.type && ( diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 338c93ee5d6c9..751493cb96056 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -203,6 +203,7 @@ $block-editor-link-control-number-of-actions: 1; } } + .block-editor-link-control__loading { margin: $grid-unit-20; // when only loading control is shown it requires it's own spacing. display: flex; @@ -279,6 +280,7 @@ $block-editor-link-control-number-of-actions: 1; } } + .block-editor-link-control__search-item-action { margin-left: auto; // push to far right hand side flex-shrink: 0; diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 3d737c9b6661e..a46cce1590c2f 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -52,7 +52,6 @@ function NavigationLinkEdit( { backgroundColor, rgbTextColor, rgbBackgroundColor, - saveEntityRecord, selectedBlockHasDescendants, userCanCreatePages = false, insertBlocksAfter, @@ -118,21 +117,6 @@ 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 ( @@ -232,11 +216,7 @@ function NavigationLinkEdit( { className="wp-block-navigation-link__inline-link-input" value={ link } showInitialSuggestions={ true } - createSuggestion={ - userCanCreatePages - ? handleCreatePage - : undefined - } + withCreateSuggestion={ userCanCreatePages } onChange={ ( { title: newTitle = '', url: newURL = '', @@ -383,9 +363,7 @@ export default compose( [ }; } ), withDispatch( ( dispatch, ownProps, registry ) => { - const { saveEntityRecord } = dispatch( 'core' ); return { - saveEntityRecord, insertLinkBlock() { const { clientId } = ownProps; From 3f91e9c6bb9efff3f948f215bea0361136e525fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:24:01 +0200 Subject: [PATCH 02/17] Remove out of scope changes to search create button --- .../link-control/search-create-button.js | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/block-editor/src/components/link-control/search-create-button.js b/packages/block-editor/src/components/link-control/search-create-button.js index 751b7a17e8c17..5f9cb9406eed9 100644 --- a/packages/block-editor/src/components/link-control/search-create-button.js +++ b/packages/block-editor/src/components/link-control/search-create-button.js @@ -6,8 +6,9 @@ import classnames from 'classnames'; /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { Button, Icon } from '@wordpress/components'; +import { createInterpolateElement } from '@wordpress/element'; export const LinkControlSearchCreate = ( { searchTerm, @@ -32,16 +33,19 @@ export const LinkControlSearchCreate = ( { > - { searchTerm } - - - { __( 'Create a new page' ) } + { createInterpolateElement( + sprintf( + /* translators: %s: search term. */ + __( 'New page: %s' ), + searchTerm + ), + { mark: } + ) } From 3903165fcfbf8bd7273f16412fea2b2d596da917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:29:54 +0200 Subject: [PATCH 03/17] Remove out of scope changes --- .../src/components/link-control/search-item.js | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/link-control/search-item.js b/packages/block-editor/src/components/link-control/search-item.js index a8e0e26ce3fc0..a7ef58e36d491 100644 --- a/packages/block-editor/src/components/link-control/search-item.js +++ b/packages/block-editor/src/components/link-control/search-item.js @@ -9,6 +9,7 @@ import classnames from 'classnames'; import { safeDecodeURI, filterURLForDisplay } from '@wordpress/url'; import { __ } from '@wordpress/i18n'; import { Button, TextHighlight } from '@wordpress/components'; +import { Icon, globe } from '@wordpress/icons'; export const LinkControlSearchItem = ( { itemProps, @@ -28,6 +29,12 @@ export const LinkControlSearchItem = ( { 'is-entity': ! isURL, } ) } > + { isURL && ( + + ) } { suggestion.type && ( From 73bf49542406f57ec8475b2b6dfd67f19de873b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:30:39 +0200 Subject: [PATCH 04/17] Add missing files --- .../src/components/link-control/constants.js | 4 + .../components/link-control/is-url-like.js | 24 +++ .../components/link-control/link-overview.js | 54 +++++++ .../components/link-control/search-results.js | 138 ++++++++++++++++++ .../src/components/link-control/style.scss | 2 - .../link-control/use-create-page.js | 105 +++++++++++++ .../link-control/use-search-handler.js | 115 +++++++++++++++ .../components/link-control/viewer-slot.js | 11 ++ 8 files changed, 451 insertions(+), 2 deletions(-) create mode 100644 packages/block-editor/src/components/link-control/constants.js create mode 100644 packages/block-editor/src/components/link-control/is-url-like.js create mode 100644 packages/block-editor/src/components/link-control/link-overview.js create mode 100644 packages/block-editor/src/components/link-control/search-results.js create mode 100644 packages/block-editor/src/components/link-control/use-create-page.js create mode 100644 packages/block-editor/src/components/link-control/use-search-handler.js create mode 100644 packages/block-editor/src/components/link-control/viewer-slot.js diff --git a/packages/block-editor/src/components/link-control/constants.js b/packages/block-editor/src/components/link-control/constants.js new file mode 100644 index 0000000000000..6c4b7dd42c473 --- /dev/null +++ b/packages/block-editor/src/components/link-control/constants.js @@ -0,0 +1,4 @@ +// 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. +export const CREATE_TYPE = '__CREATE__'; diff --git a/packages/block-editor/src/components/link-control/is-url-like.js b/packages/block-editor/src/components/link-control/is-url-like.js new file mode 100644 index 0000000000000..e1d2b563cfaec --- /dev/null +++ b/packages/block-editor/src/components/link-control/is-url-like.js @@ -0,0 +1,24 @@ +/** + * External dependencies + */ +import { startsWith } from 'lodash'; + +/** + * WordPress dependencies + */ +import { isURL } from '@wordpress/url'; + +/** + * 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. + */ +export default function isURLLike( val ) { + const isInternal = startsWith( val, '#' ); + return isURL( val ) || ( val && val.includes( 'www.' ) ) || isInternal; +} diff --git a/packages/block-editor/src/components/link-control/link-overview.js b/packages/block-editor/src/components/link-control/link-overview.js new file mode 100644 index 0000000000000..95234e2fed547 --- /dev/null +++ b/packages/block-editor/src/components/link-control/link-overview.js @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Button, ExternalLink } from '@wordpress/components'; +import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { ViewerSlot } from './viewer-slot'; + +export default function LinkOverview( { value, onEditClick } ) { + const displayURL = + ( value && filterURLForDisplay( safeDecodeURI( value.url ) ) ) || ''; + + return ( +
+ + + { ( value && value.title ) || displayURL } + + { value && value.title && ( + + { displayURL } + + ) } + + + + +
+ ); +} diff --git a/packages/block-editor/src/components/link-control/search-results.js b/packages/block-editor/src/components/link-control/search-results.js new file mode 100644 index 0000000000000..113c180b63ae6 --- /dev/null +++ b/packages/block-editor/src/components/link-control/search-results.js @@ -0,0 +1,138 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { VisuallyHidden } from '@wordpress/components'; + +/** + * External dependencies + */ +import classnames from 'classnames'; +import { createElement, Fragment } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import LinkControlSearchCreate from './search-create-button'; +import LinkControlSearchItem from './search-item'; +import { CREATE_TYPE } from './constants'; + +export default function LinkControlSearchResults( { + // From LinkControl: + instanceId, + withCreateSuggestion, + currentInputValue, + + // From URLInput: + handleSuggestionClick, + suggestionsListProps, + buildSuggestionItemProps, + suggestions, + selectedSuggestion, + isLoading, + isInitialSuggestions, +} ) { + const resultsListClasses = classnames( + 'block-editor-link-control__search-results', + { + 'is-loading': isLoading, + } + ); + + const directLinkEntryTypes = [ 'url', 'mailto', 'tel', 'internal' ]; + const isSingleDirectEntryResult = + suggestions.length === 1 && + directLinkEntryTypes.includes( suggestions[ 0 ].type.toLowerCase() ); + const shouldShowCreateSuggestion = + withCreateSuggestion && + ! 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 searchResultsLabelId = `block-editor-link-control-search-results-label-${ instanceId }`; + const labelText = isInitialSuggestions + ? __( 'Recently updated' ) + : sprintf( + /* translators: %s: search term. */ + __( 'Search results for "%s"' ), + currentInputValue + ); + + // 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 } + + ); + + return ( +
+ { searchResultsLabel } +
+ { suggestions.map( ( suggestion, index ) => { + if ( + shouldShowCreateSuggestion && + CREATE_TYPE === suggestion.type + ) { + return ( + + handleSuggestionClick( suggestion ) + } + // 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 ( + { + handleSuggestionClick( suggestion ); + } } + isSelected={ index === selectedSuggestion } + isURL={ directLinkEntryTypes.includes( + suggestion.type.toLowerCase() + ) } + searchTerm={ currentInputValue } + /> + ); + } ) } +
+
+ ); +} diff --git a/packages/block-editor/src/components/link-control/style.scss b/packages/block-editor/src/components/link-control/style.scss index 751493cb96056..338c93ee5d6c9 100644 --- a/packages/block-editor/src/components/link-control/style.scss +++ b/packages/block-editor/src/components/link-control/style.scss @@ -203,7 +203,6 @@ $block-editor-link-control-number-of-actions: 1; } } - .block-editor-link-control__loading { margin: $grid-unit-20; // when only loading control is shown it requires it's own spacing. display: flex; @@ -280,7 +279,6 @@ $block-editor-link-control-number-of-actions: 1; } } - .block-editor-link-control__search-item-action { margin-left: auto; // push to far right hand side flex-shrink: 0; diff --git a/packages/block-editor/src/components/link-control/use-create-page.js b/packages/block-editor/src/components/link-control/use-create-page.js new file mode 100644 index 0000000000000..911272063b6e4 --- /dev/null +++ b/packages/block-editor/src/components/link-control/use-create-page.js @@ -0,0 +1,105 @@ +/** + * WordPress dependencies + */ +import { __ } from '@wordpress/i18n'; +import { useDispatch } from '@wordpress/data'; +import { useEffect, useState, useRef } from '@wordpress/element'; + +export default function useCreatePage() { + const { saveEntityRecord } = useDispatch( 'core' ); + + 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, + }; + } + + const cancelableCreateSuggestion = useRef(); + const [ isCreatingPage, setIsCreatingPage ] = useState( false ); + const [ errorMessage, setErrorMessage ] = useState( null ); + + const createPage = async function ( suggestionTitle ) { + setIsCreatingPage( 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( handleCreatePage( suggestionTitle ) ) + ); + + return await cancelableCreateSuggestion.current.promise; + } 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.' + ) + ); + throw error; + } finally { + setIsCreatingPage( false ); + } + }; + + /** + * Handles cancelling any pending Promises that have been made cancelable. + */ + useEffect( () => { + return () => { + // componentDidUnmount + if ( cancelableCreateSuggestion.current ) { + cancelableCreateSuggestion.current.cancel(); + } + }; + }, [] ); + + return { + createPage, + isCreatingPage, + errorMessage, + }; +} + +/** + * 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; + }, + }; +}; diff --git a/packages/block-editor/src/components/link-control/use-search-handler.js b/packages/block-editor/src/components/link-control/use-search-handler.js new file mode 100644 index 0000000000000..09389623eae02 --- /dev/null +++ b/packages/block-editor/src/components/link-control/use-search-handler.js @@ -0,0 +1,115 @@ +/** + * WordPress dependencies + */ +import { getProtocol, prependHTTP } from '@wordpress/url'; +import { useCallback } from '@wordpress/element'; +import { useSelect } from '@wordpress/data'; + +/** + * External dependencies + */ +import { startsWith } from 'lodash'; + +/** + * Internal dependencies + */ +import isURLLike from './is-url-like'; +import { CREATE_TYPE } from './constants'; + +export const handleDirectEntry = ( val ) => { + let type = 'URL'; + + const protocol = getProtocol( val ) || ''; + + if ( protocol.includes( 'mailto' ) ) { + type = 'mailto'; + } + + if ( protocol.includes( 'tel' ) ) { + type = 'tel'; + } + + if ( startsWith( val, '#' ) ) { + type = 'internal'; + } + + return Promise.resolve( [ + { + id: val, + title: val, + url: type === 'URL' ? prependHTTP( val ) : val, + type, + }, + ] ); +}; + +export const handleEntitySearch = async ( + val, + args, + fetchSearchSuggestions +) => { + let results = await Promise.all( [ + handleDirectEntry( val ), + fetchSearchSuggestions( val, { + ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), + } ), + ] ); + + const couldBeURL = ! val.includes( ' ' ); + + // 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. + results = + couldBeURL && ! args.isInitialSuggestions + ? results[ 0 ].concat( results[ 1 ] ) + : results[ 0 ]; + + // If displaying initial suggestions just return plain results. + if ( args.isInitialSuggestions ) { + return results; + } + + // 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, + } ); +}; + +export default function useSearchHandler( enableDirectEntry = true ) { + const { fetchSearchSuggestions } = useSelect( ( select ) => { + const { getSettings } = select( 'core/block-editor' ); + return { + fetchSearchSuggestions: getSettings() + .__experimentalFetchLinkSuggestions, + }; + }, [] ); + return useCallback( + ( val, args ) => { + return isURLLike( val ) + ? handleDirectEntry( val, args ) + : handleEntitySearch( val, args, fetchSearchSuggestions ); + }, + [ enableDirectEntry, fetchSearchSuggestions ] + ); +} diff --git a/packages/block-editor/src/components/link-control/viewer-slot.js b/packages/block-editor/src/components/link-control/viewer-slot.js new file mode 100644 index 0000000000000..482eaab2d0db2 --- /dev/null +++ b/packages/block-editor/src/components/link-control/viewer-slot.js @@ -0,0 +1,11 @@ +/** + * WordPress dependencies + */ +import { createSlotFill } from '@wordpress/components'; + +const { Slot: ViewerSlot, Fill: ViewerFill } = createSlotFill( + 'BlockEditorLinkControlViewer' +); + +export { ViewerSlot, ViewerFill }; +export default ViewerSlot; From 6602b1d8235b448e72cc6f8bed439692e471907b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:42:59 +0200 Subject: [PATCH 05/17] Simplify handleSelectSuggestion --- packages/block-editor/src/components/link-control/index.js | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index 38c5492d2546a..af30e352610fb 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -173,7 +173,6 @@ function LinkControl( { const { createPage, isCreatingPage, errorMessage } = useCreatePage(); const handleSelectSuggestion = ( updatedValue ) => { - setIsEditingLink( false ); onChange( updatedValue ); stopEditing(); }; From 91788cf69f5992ecae0b9ec0bcd2de4a01ba8776 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 15:59:13 +0200 Subject: [PATCH 06/17] Update LinkControlSearchResults props description --- .../block-editor/src/components/link-control/search-results.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/block-editor/src/components/link-control/search-results.js b/packages/block-editor/src/components/link-control/search-results.js index 113c180b63ae6..db5db4920340e 100644 --- a/packages/block-editor/src/components/link-control/search-results.js +++ b/packages/block-editor/src/components/link-control/search-results.js @@ -18,12 +18,9 @@ import LinkControlSearchItem from './search-item'; import { CREATE_TYPE } from './constants'; export default function LinkControlSearchResults( { - // From LinkControl: instanceId, withCreateSuggestion, currentInputValue, - - // From URLInput: handleSuggestionClick, suggestionsListProps, buildSuggestionItemProps, From 6234d875fb1b9ffbabae5bb533d80baa13ed7f6f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 16:27:03 +0200 Subject: [PATCH 07/17] Update snapshots --- .../components/link-control/test/__snapshots__/index.js.snap | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 87cd5c30741fd..ba12d07037b9d 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`] = `""`; From 637acb7ef90c83a168cb610d68e10ef3715d4ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 16:38:57 +0200 Subject: [PATCH 08/17] Adjust directEntry handler usage to be consistent with the master branch --- .../link-control/use-search-handler.js | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/packages/block-editor/src/components/link-control/use-search-handler.js b/packages/block-editor/src/components/link-control/use-search-handler.js index 09389623eae02..e431eb8c72e91 100644 --- a/packages/block-editor/src/components/link-control/use-search-handler.js +++ b/packages/block-editor/src/components/link-control/use-search-handler.js @@ -16,6 +16,8 @@ import { startsWith } from 'lodash'; import isURLLike from './is-url-like'; import { CREATE_TYPE } from './constants'; +export const handleNoop = () => Promise.resolve( [] ); + export const handleDirectEntry = ( val ) => { let type = 'URL'; @@ -46,10 +48,11 @@ export const handleDirectEntry = ( val ) => { export const handleEntitySearch = async ( val, args, - fetchSearchSuggestions + fetchSearchSuggestions, + directEntryHandler ) => { let results = await Promise.all( [ - handleDirectEntry( val ), + directEntryHandler( val ), fetchSearchSuggestions( val, { ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), } ), @@ -96,7 +99,7 @@ export const handleEntitySearch = async ( } ); }; -export default function useSearchHandler( enableDirectEntry = true ) { +export default function useSearchHandler( allowDirectEntry ) { const { fetchSearchSuggestions } = useSelect( ( select ) => { const { getSettings } = select( 'core/block-editor' ); return { @@ -104,12 +107,22 @@ export default function useSearchHandler( enableDirectEntry = true ) { .__experimentalFetchLinkSuggestions, }; }, [] ); + + const directEntryHandler = allowDirectEntry + ? handleDirectEntry + : handleNoop; + return useCallback( ( val, args ) => { return isURLLike( val ) - ? handleDirectEntry( val, args ) - : handleEntitySearch( val, args, fetchSearchSuggestions ); + ? directEntryHandler( val, args ) + : handleEntitySearch( + val, + args, + fetchSearchSuggestions, + directEntryHandler + ); }, - [ enableDirectEntry, fetchSearchSuggestions ] + [ directEntryHandler, fetchSearchSuggestions ] ); } From d875ed4d580859460ced9999a5a2ce88c87ffb9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 16:39:09 +0200 Subject: [PATCH 09/17] Add mock --- .../block-editor/src/components/link-control/test/index.js | 4 ++++ 1 file changed, 4 insertions(+) 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 976866af42435..f415fa1f7b04d 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -21,6 +21,10 @@ jest.mock( '@wordpress/data/src/components/use-select', () => () => ( { fetchSearchSuggestions: mockFetchSearchSuggestions, } ) ); +jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { + useDispatch: () => ( { saveEntityRecords: jest.fn() } ), +} ) ); + /** * Wait for next tick of event loop. This is required * because the `fetchSearchSuggestions` Promise will From 5570f29fe47131c3dda1eda2e53a498becd60e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 16:44:24 +0200 Subject: [PATCH 10/17] Restore the intended order of results in useSearchHandler --- .../src/components/link-control/use-search-handler.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/block-editor/src/components/link-control/use-search-handler.js b/packages/block-editor/src/components/link-control/use-search-handler.js index e431eb8c72e91..6a4af9ecd5b21 100644 --- a/packages/block-editor/src/components/link-control/use-search-handler.js +++ b/packages/block-editor/src/components/link-control/use-search-handler.js @@ -52,10 +52,10 @@ export const handleEntitySearch = async ( directEntryHandler ) => { let results = await Promise.all( [ - directEntryHandler( val ), fetchSearchSuggestions( val, { ...( args.isInitialSuggestions ? { perPage: 3 } : {} ), } ), + directEntryHandler( val ), ] ); const couldBeURL = ! val.includes( ' ' ); From 7644b9559f06b8211215aa736537bb4319c9bb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 17:49:03 +0200 Subject: [PATCH 11/17] Use state in Creating Entities test --- .../src/components/link-control/test/index.js | 43 ++++++++++++++----- 1 file changed, 32 insertions(+), 11 deletions(-) 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 f415fa1f7b04d..9afdc3ea54a44 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -25,6 +25,9 @@ jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { useDispatch: () => ( { saveEntityRecords: jest.fn() } ), } ) ); +import mockUseCreatePage from '../use-create-page'; +jest.mock( '../use-create-page' ); + /** * Wait for next tick of event loop. This is required * because the `fetchSearchSuggestions` Promise will @@ -44,6 +47,11 @@ beforeEach( () => { container = document.createElement( 'div' ); document.body.appendChild( container ); mockFetchSearchSuggestions.mockImplementation( fetchFauxEntitySuggestions ); + mockUseCreatePage.mockImplementation( () => ( { + createPage: () => {}, + isCreatingPage: false, + errorMessage: null, + } ) ); } ); afterEach( () => { @@ -673,16 +681,29 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { let resolver; let resolvedEntity; - const createSuggestion = ( title ) => - new Promise( ( resolve ) => { - resolver = resolve; - resolvedEntity = { - title, - id: 123, - url: '/?p=123', - type: 'page', - }; - } ); + mockUseCreatePage.mockImplementation( () => { + const [ isCreatingPage, setIsCreatingPage ] = useState( false ); + + return { + createPage: ( title ) => { + return new Promise( ( resolve ) => { + setIsCreatingPage( true ); + resolver = ( arg ) => { + resolve( arg ); + setIsCreatingPage( false ); + }; + resolvedEntity = { + title, + id: 123, + url: '/?p=123', + type: 'page', + }; + } ); + }, + isCreatingPage, + errorMessage: '', + }; + } ); const LinkControlConsumer = () => { const [ link, setLink ] = useState( null ); @@ -693,7 +714,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { onChange={ ( suggestion ) => { setLink( suggestion ); } } - createSuggestion={ createSuggestion } + withCreateSuggestion /> ); }; From 73edc5c3a3f34c3a5e52b1778d38f6a51bcd8856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 17:52:32 +0200 Subject: [PATCH 12/17] Fix two other createSuggestion tests --- .../src/components/link-control/test/index.js | 90 +++---------------- 1 file changed, 13 insertions(+), 77 deletions(-) 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 9afdc3ea54a44..02afc048213da 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -796,78 +796,21 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { } ); - 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'; + mockUseCreatePage.mockImplementation( () => ( { + createPage: ( title ) => + Promise.resolve( { + title, + id: 123, + url: '/?p=123', + type: 'page', + } ), + isCreatingPage: false, + errorMessage: '', + } ) ); + const LinkControlConsumer = () => { const [ link, setLink ] = useState( null ); @@ -877,14 +820,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { onChange={ ( suggestion ) => { setLink( suggestion ); } } - createSuggestion={ ( title ) => - Promise.resolve( { - title, - id: 123, - url: '/?p=123', - type: 'page', - } ) - } + withCreateSuggestion /> ); }; From 4e23dab1385e00f5cab34c7a942bff1060d8d3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 10 Jul 2020 18:09:31 +0200 Subject: [PATCH 13/17] Fix all unit tests --- .../src/components/link-control/index.js | 11 +- .../src/components/link-control/test/index.js | 140 ++++++++++++------ .../link-control/use-create-page.js | 6 +- 3 files changed, 106 insertions(+), 51 deletions(-) diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index af30e352610fb..fd84e8fcb02bf 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -108,9 +108,14 @@ function LinkControl( { showSuggestions = true, showInitialSuggestions, forceIsEditingLink, - withCreateSuggestion = false, + createSuggestion, + withCreateSuggestion, inputValue: propInputValue = '', } ) { + if ( withCreateSuggestion === undefined && createSuggestion ) { + withCreateSuggestion = true; + } + const wrapperNode = useRef(); const [ internalInputValue, setInternalInputValue ] = useState( ( value && value.url ) || '' @@ -170,7 +175,9 @@ function LinkControl( { setIsEditingLink( false ); } - const { createPage, isCreatingPage, errorMessage } = useCreatePage(); + const { createPage, isCreatingPage, errorMessage } = useCreatePage( + createSuggestion + ); const handleSelectSuggestion = ( updatedValue ) => { onChange( updatedValue ); 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 02afc048213da..c54177ed97798 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -25,9 +25,6 @@ jest.mock( '@wordpress/data/src/components/use-dispatch', () => ( { useDispatch: () => ( { saveEntityRecords: jest.fn() } ), } ) ); -import mockUseCreatePage from '../use-create-page'; -jest.mock( '../use-create-page' ); - /** * Wait for next tick of event loop. This is required * because the `fetchSearchSuggestions` Promise will @@ -47,11 +44,6 @@ beforeEach( () => { container = document.createElement( 'div' ); document.body.appendChild( container ); mockFetchSearchSuggestions.mockImplementation( fetchFauxEntitySuggestions ); - mockUseCreatePage.mockImplementation( () => ( { - createPage: () => {}, - isCreatingPage: false, - errorMessage: null, - } ) ); } ); afterEach( () => { @@ -681,29 +673,18 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { let resolver; let resolvedEntity; - mockUseCreatePage.mockImplementation( () => { - const [ isCreatingPage, setIsCreatingPage ] = useState( false ); - - return { - createPage: ( title ) => { - return new Promise( ( resolve ) => { - setIsCreatingPage( true ); - resolver = ( arg ) => { - resolve( arg ); - setIsCreatingPage( false ); - }; - resolvedEntity = { - title, - id: 123, - url: '/?p=123', - type: 'page', - }; - } ); - }, - isCreatingPage, - errorMessage: '', - }; - } ); + const createSuggestion = ( title ) => + new Promise( ( resolve ) => { + resolver = ( arg ) => { + resolve( arg ); + }; + resolvedEntity = { + title, + id: 123, + url: '/?p=123', + type: 'page', + }; + } ); const LinkControlConsumer = () => { const [ link, setLink ] = useState( null ); @@ -714,7 +695,7 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { onChange={ ( suggestion ) => { setLink( suggestion ); } } - withCreateSuggestion + createSuggestion={ createSuggestion } /> ); }; @@ -742,6 +723,9 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { '[role="listbox"] [role="option"]' ); + // console.log( 'searchResultElements', container.innerHTML ); + // console.log( 'searchResultElements', searchResultElements ); + const createButton = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'New page' ) @@ -796,21 +780,78 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { } ); + 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'; - mockUseCreatePage.mockImplementation( () => ( { - createPage: ( title ) => - Promise.resolve( { - title, - id: 123, - url: '/?p=123', - type: 'page', - } ), - isCreatingPage: false, - errorMessage: '', - } ) ); - const LinkControlConsumer = () => { const [ link, setLink ] = useState( null ); @@ -820,7 +861,14 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { onChange={ ( suggestion ) => { setLink( suggestion ); } } - withCreateSuggestion + createSuggestion={ ( title ) => + Promise.resolve( { + title, + id: 123, + url: '/?p=123', + type: 'page', + } ) + } /> ); }; @@ -1073,8 +1121,6 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { result.innerHTML.includes( 'New page' ) ) ); - - expect( createButton ).not.toBeFalsy(); // shouldn't exist! } ); } ); } ); diff --git a/packages/block-editor/src/components/link-control/use-create-page.js b/packages/block-editor/src/components/link-control/use-create-page.js index 911272063b6e4..d4c17b6c6ee44 100644 --- a/packages/block-editor/src/components/link-control/use-create-page.js +++ b/packages/block-editor/src/components/link-control/use-create-page.js @@ -5,10 +5,10 @@ import { __ } from '@wordpress/i18n'; import { useDispatch } from '@wordpress/data'; import { useEffect, useState, useRef } from '@wordpress/element'; -export default function useCreatePage() { +export default function useCreatePage( customCreatePage = null ) { const { saveEntityRecord } = useDispatch( 'core' ); - async function handleCreatePage( pageTitle ) { + async function genericCreatePage( pageTitle ) { const type = 'page'; const page = await saveEntityRecord( 'postType', type, { title: pageTitle, @@ -23,6 +23,8 @@ export default function useCreatePage() { }; } + const handleCreatePage = customCreatePage || genericCreatePage; + const cancelableCreateSuggestion = useRef(); const [ isCreatingPage, setIsCreatingPage ] = useState( false ); const [ errorMessage, setErrorMessage ] = useState( null ); From 2ab08ed9c6f278582897c8cf9237c1d782c647d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Jul 2020 12:44:23 +0200 Subject: [PATCH 14/17] Remove the dependency on saveEntityRecord --- .../src/components/link-control/search-input.js | 3 ++- .../src/components/link-control/use-create-page.js | 11 +++-------- packages/block-library/src/navigation-link/edit.js | 8 +++++++- 3 files changed, 12 insertions(+), 10 deletions(-) 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 df8ef4e7d1161..9613c72c2900a 100644 --- a/packages/block-editor/src/components/link-control/search-input.js +++ b/packages/block-editor/src/components/link-control/search-input.js @@ -83,9 +83,10 @@ const LinkControlSearchInput = ( { selectedSuggestion.title ); if ( suggestion?.url ) { - onChange( suggestion.url ); + onSelect( suggestion ); } } catch ( e ) {} + return; } if ( diff --git a/packages/block-editor/src/components/link-control/use-create-page.js b/packages/block-editor/src/components/link-control/use-create-page.js index d4c17b6c6ee44..23e3fd23822ef 100644 --- a/packages/block-editor/src/components/link-control/use-create-page.js +++ b/packages/block-editor/src/components/link-control/use-create-page.js @@ -2,15 +2,12 @@ * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { useDispatch } from '@wordpress/data'; import { useEffect, useState, useRef } from '@wordpress/element'; -export default function useCreatePage( customCreatePage = null ) { - const { saveEntityRecord } = useDispatch( 'core' ); - - async function genericCreatePage( pageTitle ) { +export default function useCreatePage( requestNewPage ) { + async function handleCreatePage( pageTitle ) { const type = 'page'; - const page = await saveEntityRecord( 'postType', type, { + const page = await requestNewPage( type, { title: pageTitle, status: 'publish', } ); @@ -23,8 +20,6 @@ export default function useCreatePage( customCreatePage = null ) { }; } - const handleCreatePage = customCreatePage || genericCreatePage; - const cancelableCreateSuggestion = useRef(); const [ isCreatingPage, setIsCreatingPage ] = useState( false ); const [ errorMessage, setErrorMessage ] = useState( null ); diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index a46cce1590c2f..3036ac97660bd 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -9,7 +9,7 @@ import { escape, get, head, find } from 'lodash'; */ import { compose } from '@wordpress/compose'; import { createBlock } from '@wordpress/blocks'; -import { withDispatch, withSelect } from '@wordpress/data'; +import { useDispatch, withDispatch, withSelect } from '@wordpress/data'; import { KeyboardShortcuts, PanelBody, @@ -63,6 +63,7 @@ function NavigationLinkEdit( { url, opensInNewTab, }; + const { saveEntityRecord } = useDispatch( 'core' ); const [ isLinkOpen, setIsLinkOpen ] = useState( false ); const itemLabelPlaceholder = __( 'Add link…' ); const ref = useRef(); @@ -117,6 +118,10 @@ function NavigationLinkEdit( { selection.addRange( range ); } + function handleCreatePage( type, data ) { + return saveEntityRecord( 'postType', type, data ); + } + return ( @@ -217,6 +222,7 @@ function NavigationLinkEdit( { value={ link } showInitialSuggestions={ true } withCreateSuggestion={ userCanCreatePages } + createSuggestion={ handleCreatePage } onChange={ ( { title: newTitle = '', url: newURL = '', From 5d4ea03ed48b0d4a957aa92ce6b3b032449f4950 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Tue, 14 Jul 2020 13:16:50 +0200 Subject: [PATCH 15/17] Fix unit tests --- .../components/link-control/use-create-page.js | 17 +---------------- .../block-library/src/navigation-link/edit.js | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/packages/block-editor/src/components/link-control/use-create-page.js b/packages/block-editor/src/components/link-control/use-create-page.js index 23e3fd23822ef..42e243a2625fd 100644 --- a/packages/block-editor/src/components/link-control/use-create-page.js +++ b/packages/block-editor/src/components/link-control/use-create-page.js @@ -4,22 +4,7 @@ import { __ } from '@wordpress/i18n'; import { useEffect, useState, useRef } from '@wordpress/element'; -export default function useCreatePage( requestNewPage ) { - async function handleCreatePage( pageTitle ) { - const type = 'page'; - const page = await requestNewPage( type, { - title: pageTitle, - status: 'publish', - } ); - - return { - id: page.id, - type, - title: page.title.rendered, - url: page.link, - }; - } - +export default function useCreatePage( handleCreatePage ) { const cancelableCreateSuggestion = useRef(); const [ isCreatingPage, setIsCreatingPage ] = useState( false ); const [ errorMessage, setErrorMessage ] = useState( null ); diff --git a/packages/block-library/src/navigation-link/edit.js b/packages/block-library/src/navigation-link/edit.js index 3036ac97660bd..6e86c2ca81c72 100644 --- a/packages/block-library/src/navigation-link/edit.js +++ b/packages/block-library/src/navigation-link/edit.js @@ -118,8 +118,19 @@ function NavigationLinkEdit( { selection.addRange( range ); } - function handleCreatePage( type, data ) { - return saveEntityRecord( 'postType', type, data ); + 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 ( From 5b030bc5fbd182353841296058dc2d167a7f3bbf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Thu, 16 Jul 2020 17:47:14 +0200 Subject: [PATCH 16/17] Rename LinkOverview to LinkPreview --- packages/block-editor/src/components/link-control/index.js | 4 ++-- .../link-control/{link-overview.js => link-preview.js} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename packages/block-editor/src/components/link-control/{link-overview.js => link-preview.js} (95%) diff --git a/packages/block-editor/src/components/link-control/index.js b/packages/block-editor/src/components/link-control/index.js index fd84e8fcb02bf..a77b3f49ade87 100644 --- a/packages/block-editor/src/components/link-control/index.js +++ b/packages/block-editor/src/components/link-control/index.js @@ -17,7 +17,7 @@ import { focus } from '@wordpress/dom'; */ import LinkControlSettingsDrawer from './settings-drawer'; import LinkControlSearchInput from './search-input'; -import LinkOverview from './link-overview'; +import LinkPreview from './link-preview'; import useCreatePage from './use-create-page'; import { ViewerFill } from './viewer-slot'; @@ -235,7 +235,7 @@ function LinkControl( { ) } { value && ! isEditingLink && ! isCreatingPage && ( - setIsEditingLink( true ) } /> diff --git a/packages/block-editor/src/components/link-control/link-overview.js b/packages/block-editor/src/components/link-control/link-preview.js similarity index 95% rename from packages/block-editor/src/components/link-control/link-overview.js rename to packages/block-editor/src/components/link-control/link-preview.js index 95234e2fed547..673ba1a7a7449 100644 --- a/packages/block-editor/src/components/link-control/link-overview.js +++ b/packages/block-editor/src/components/link-control/link-preview.js @@ -15,7 +15,7 @@ import { filterURLForDisplay, safeDecodeURI } from '@wordpress/url'; */ import { ViewerSlot } from './viewer-slot'; -export default function LinkOverview( { value, onEditClick } ) { +export default function LinkPreview( { value, onEditClick } ) { const displayURL = ( value && filterURLForDisplay( safeDecodeURI( value.url ) ) ) || ''; From 9566dad00374974e84712cec9f7e0c9aa84632ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Fri, 17 Jul 2020 11:15:27 +0200 Subject: [PATCH 17/17] Remove console.log --- .../block-editor/src/components/link-control/test/index.js | 3 --- 1 file changed, 3 deletions(-) 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 c54177ed97798..3732b658d72a1 100644 --- a/packages/block-editor/src/components/link-control/test/index.js +++ b/packages/block-editor/src/components/link-control/test/index.js @@ -723,9 +723,6 @@ describe( 'Creating Entities (eg: Posts, Pages)', () => { '[role="listbox"] [role="option"]' ); - // console.log( 'searchResultElements', container.innerHTML ); - // console.log( 'searchResultElements', searchResultElements ); - const createButton = first( Array.from( searchResultElements ).filter( ( result ) => result.innerHTML.includes( 'New page' )