From ce220f5377efd6b694d8c5bc0ed3fb18eb8c3409 Mon Sep 17 00:00:00 2001 From: iseulde Date: Fri, 27 Jul 2018 15:05:37 +0200 Subject: [PATCH] Work on autocompleters --- .../button/test/__snapshots__/index.js.snap | 37 +- .../test/__snapshots__/index.js.snap | 23 +- .../heading/test/__snapshots__/index.js.snap | 37 +- .../list/test/__snapshots__/index.js.snap | 41 +- .../test/__snapshots__/index.js.snap | 37 +- .../test/__snapshots__/index.js.snap | 37 +- .../test/__snapshots__/index.js.snap | 39 +- .../quote/test/__snapshots__/index.js.snap | 39 +- .../verse/test/__snapshots__/index.js.snap | 37 +- editor/components/autocompleters/block.js | 12 +- editor/components/autocompleters/user.js | 12 +- editor/components/rich-text/index.js | 68 +- .../blocks/src/api/rich-text-structure.js | 38 +- packages/components/src/autocomplete/index.js | 311 ++---- .../components/src/autocomplete/test/index.js | 883 ------------------ 15 files changed, 370 insertions(+), 1281 deletions(-) delete mode 100644 packages/components/src/autocomplete/test/index.js diff --git a/core-blocks/button/test/__snapshots__/index.js.snap b/core-blocks/button/test/__snapshots__/index.js.snap index 3b49950a6c5eb..08485547c1c10 100644 --- a/core-blocks/button/test/__snapshots__/index.js.snap +++ b/core-blocks/button/test/__snapshots__/index.js.snap @@ -7,20 +7,29 @@ exports[`core/button block edit matches snapshot 1`] = `
- - - Add text… - +
+
+
+
+
+
`; diff --git a/core-blocks/cover-image/test/__snapshots__/index.js.snap b/core-blocks/cover-image/test/__snapshots__/index.js.snap index a298a5a5ee851..40be79ecf3f6d 100644 --- a/core-blocks/cover-image/test/__snapshots__/index.js.snap +++ b/core-blocks/cover-image/test/__snapshots__/index.js.snap @@ -10,13 +10,22 @@ exports[`core/cover-image block edit matches snapshot 1`] = `
-

+
+
+
+
+
+

-

-

- Write heading… -

+
+
+
+

+ Write heading… +

+
+
+
`; diff --git a/core-blocks/list/test/__snapshots__/index.js.snap b/core-blocks/list/test/__snapshots__/index.js.snap index e88fdaff1c127..d17def7ea765e 100644 --- a/core-blocks/list/test/__snapshots__/index.js.snap +++ b/core-blocks/list/test/__snapshots__/index.js.snap @@ -4,21 +4,30 @@ exports[`core/list block edit matches snapshot 1`] = `
-
`; diff --git a/core-blocks/paragraph/test/__snapshots__/index.js.snap b/core-blocks/paragraph/test/__snapshots__/index.js.snap index 625a4eeb456ea..73dfd3da4df06 100644 --- a/core-blocks/paragraph/test/__snapshots__/index.js.snap +++ b/core-blocks/paragraph/test/__snapshots__/index.js.snap @@ -6,20 +6,29 @@ exports[`core/paragraph block edit matches snapshot 1`] = `
-

-

- Add text or type / to add content -

+
+
+
+

+ Add text or type / to add content +

+
+
+
diff --git a/core-blocks/preformatted/test/__snapshots__/index.js.snap b/core-blocks/preformatted/test/__snapshots__/index.js.snap index 18fe9ab574e43..a0748236e6e0d 100644 --- a/core-blocks/preformatted/test/__snapshots__/index.js.snap +++ b/core-blocks/preformatted/test/__snapshots__/index.js.snap @@ -4,19 +4,28 @@ exports[`core/preformatted block edit matches snapshot 1`] = `
-
-  
-    Write preformatted text…
-  
+
+
+
+
+
+
`; diff --git a/core-blocks/pullquote/test/__snapshots__/index.js.snap b/core-blocks/pullquote/test/__snapshots__/index.js.snap index 9a1a5c5307e67..c1e9bd890f51e 100644 --- a/core-blocks/pullquote/test/__snapshots__/index.js.snap +++ b/core-blocks/pullquote/test/__snapshots__/index.js.snap @@ -7,21 +7,30 @@ exports[`core/pullquote block edit matches snapshot 1`] = `
-
-
-

- Write quote… -

+
+
+
+ +
diff --git a/core-blocks/quote/test/__snapshots__/index.js.snap b/core-blocks/quote/test/__snapshots__/index.js.snap index 276cbd854afa1..813b6d8417cb6 100644 --- a/core-blocks/quote/test/__snapshots__/index.js.snap +++ b/core-blocks/quote/test/__snapshots__/index.js.snap @@ -7,21 +7,30 @@ exports[`core/quote block edit matches snapshot 1`] = `
-
-
-

- Write quote… -

+
+
+
+ +
diff --git a/core-blocks/verse/test/__snapshots__/index.js.snap b/core-blocks/verse/test/__snapshots__/index.js.snap index af3597a7b8e55..dadd75177de95 100644 --- a/core-blocks/verse/test/__snapshots__/index.js.snap +++ b/core-blocks/verse/test/__snapshots__/index.js.snap @@ -4,19 +4,28 @@ exports[`core/verse block edit matches snapshot 1`] = `
-
-  
-    Write…
-  
+
+
+
+
+
+
`; diff --git a/editor/components/autocompleters/block.js b/editor/components/autocompleters/block.js index 963137e4eb40b..b1dc7896a356b 100644 --- a/editor/components/autocompleters/block.js +++ b/editor/components/autocompleters/block.js @@ -59,7 +59,17 @@ export function createBlockCompleter( { return { name: 'blocks', className: 'editor-autocompleters__block', - triggerPrefix: '/', + test( string ) { + if ( string.indexOf( '/' ) !== 0 ) { + return false; + } + + return /^\/\w*$/.test( string ); + }, + getQuery( string ) { + const match = string.match( /^\/(\w*)$/ ); + return match && match[ 1 ]; + }, options() { const selectedBlockName = getSelectedBlockName(); return getInserterItems( getBlockInsertionParentClientId() ).filter( diff --git a/editor/components/autocompleters/user.js b/editor/components/autocompleters/user.js index 19c7527e015df..9a97e445f7ab1 100644 --- a/editor/components/autocompleters/user.js +++ b/editor/components/autocompleters/user.js @@ -11,7 +11,17 @@ import apiFetch from '@wordpress/api-fetch'; export default { name: 'users', className: 'editor-autocompleters__user', - triggerPrefix: '@', + test( string ) { + if ( string.indexOf( '@' ) === -1 ) { + return false; + } + + return /(?:\s|^)@\w*$/.test( string ); + }, + getQuery( string ) { + const match = string.match( /@(\w*)$/ ); + return match && match[ 1 ]; + }, options( search ) { let payload = ''; if ( search ) { diff --git a/editor/components/rich-text/index.js b/editor/components/rich-text/index.js index c5a90b7876e0f..17ac2840dd286 100644 --- a/editor/components/rich-text/index.js +++ b/editor/components/rich-text/index.js @@ -32,6 +32,7 @@ import { withInstanceId, withSafeTimeout, compose } from '@wordpress/compose'; * Internal dependencies */ import './style.scss'; +import Autocomplete from '../autocomplete'; import BlockFormatControls from '../block-format-controls'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; @@ -720,11 +721,12 @@ export class RichText extends Component { keepPlaceholderOnFocus = false, isSelected, formatters, + autocompleters, format, } = this.props; const { selection } = this.state; - + const record = { value, selection }; const ariaProps = pickAriaProps( this.props ); // Generating a key that includes `tagName` ensures that if the tag @@ -736,7 +738,7 @@ export class RichText extends Component { const formatToolbar = ( } - - { isPlaceholderVisible && - - { MultilineTag ? { placeholder } : placeholder } - - } - { isSelected && } + + { ( { isExpanded, listBoxId, activeId } ) => ( + + + { isPlaceholderVisible && + + { MultilineTag ? { placeholder } : placeholder } + + } + { isSelected && } + + ) } +
); } diff --git a/packages/blocks/src/api/rich-text-structure.js b/packages/blocks/src/api/rich-text-structure.js index 38b30d02a6f3b..eced263361d76 100644 --- a/packages/blocks/src/api/rich-text-structure.js +++ b/packages/blocks/src/api/rich-text-structure.js @@ -235,10 +235,10 @@ export function apply( value, current, multiline ) { const sel = window.getSelection(); const range = current.ownerDocument.createRange(); - const isCollapsed = startContainer === endContainer && startOffset === endOffset; + const collapsed = startContainer === endContainer && startOffset === endOffset; if ( - isCollapsed && + collapsed && startOffset === 0 && startContainer.previousSibling && startContainer.previousSibling.nodeType === ELEMENT_NODE && @@ -462,8 +462,21 @@ export function splice( { formats, text, selection, value }, start, deleteCount, return { formats, text }; } -export function getTextContent( { text, value } ) { - return text || value.text; +export function getTextContent( { text, value, selection } ) { + if ( value !== undefined ) { + if ( Array.isArray( value ) ) { + if ( isCollapsed( { selection } ) ) { + const [ index ] = selection.start; + return value[ index ].text; + } + + return; + } + + return value.text; + } + + return text; } export function applyFormat( { formats, text, value, selection }, format, start, end ) { @@ -620,3 +633,20 @@ export function split( { text, formats, selection, value }, start, end ) { }, ]; } + +export function isCollapsed( { selection } ) { + const { start, end } = selection; + + if ( ! start ) { + return; + } + + if ( typeof start === 'number' ) { + return start === end; + } + + const [ startRecord, startOffset ] = start; + const [ endRecord, endOffset ] = end; + + return startRecord === endRecord && startOffset === endOffset; +} diff --git a/packages/components/src/autocomplete/index.js b/packages/components/src/autocomplete/index.js index 17a2d29aeab0c..10e6a2750afd9 100644 --- a/packages/components/src/autocomplete/index.js +++ b/packages/components/src/autocomplete/index.js @@ -2,16 +2,18 @@ * External dependencies */ import classnames from 'classnames'; -import { escapeRegExp, find, filter, map, debounce } from 'lodash'; +import { escapeRegExp, find, map, debounce } from 'lodash'; import 'element-closest'; /** * WordPress dependencies */ -import { Component, renderToString } from '@wordpress/element'; +import { Component } from '@wordpress/element'; import { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } from '@wordpress/keycodes'; import { __, _n, sprintf } from '@wordpress/i18n'; import { withInstanceId, compose } from '@wordpress/compose'; +import { richTextStructure } from '@wordpress/blocks'; +import { getRectangleFromRange } from '@wordpress/dom'; /** * Internal dependencies @@ -104,74 +106,6 @@ import withSpokenMessages from '../higher-order/with-spoken-messages'; * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. */ -/** - * Recursively select the firstChild until hitting a leaf node. - * - * @param {Node} node The node to find the recursive first child. - * - * @return {Node} The first leaf-node >= node in the ordering. - */ -function descendFirst( node ) { - let n = node; - while ( n.firstChild ) { - n = n.firstChild; - } - return n; -} - -/** - * Recursively select the lastChild until hitting a leaf node. - * - * @param {Node} node The node to find the recursive last child. - * - * @return {Node} The first leaf-node <= node in the ordering. - */ -function descendLast( node ) { - let n = node; - while ( n.lastChild ) { - n = n.lastChild; - } - return n; -} - -/** - * Is the node a text node. - * - * @param {?Node} node The node to check. - * - * @return {boolean} True if the node is a text node. - */ -function isTextNode( node ) { - return node !== null && node.nodeType === 3; -} - -/** - * Return the node only if it is a text node, otherwise return null. - * - * @param {?Node} node The node to filter. - * - * @return {?Node} The node or null if it is not a text node. - */ -function onlyTextNode( node ) { - return isTextNode( node ) ? node : null; -} - -/** - * Find the index of the last character in the text that is whitespace. - * - * @param {string} text The text to search. - * - * @return {number} The last index of a white space character in the text or -1. - */ -function lastIndexOfSpace( text ) { - for ( let i = text.length - 1; i >= 0; i-- ) { - if ( /\s/.test( text.charAt( i ) ) ) { - return i; - } - } - return -1; -} - function filterOptions( search, options = [], maxResults = 10 ) { const filtered = []; for ( let i = 0; i < options.length; i++ ) { @@ -219,9 +153,8 @@ export class Autocomplete extends Component { this.select = this.select.bind( this ); this.reset = this.reset.bind( this ); this.resetWhenSuppressed = this.resetWhenSuppressed.bind( this ); - this.search = this.search.bind( this ); this.handleKeyDown = this.handleKeyDown.bind( this ); - this.getWordRect = this.getWordRect.bind( this ); + this.getAnchorRect = this.getAnchorRect.bind( this ); this.debouncedLoadOptions = debounce( this.loadOptions, 250 ); this.state = this.constructor.getInitialState(); @@ -231,31 +164,19 @@ export class Autocomplete extends Component { this.node = node; } - insertCompletion( range, replacement ) { - const container = document.createElement( 'div' ); - container.innerHTML = renderToString( replacement ); - while ( container.firstChild ) { - const child = container.firstChild; - container.removeChild( child ); - range.insertNode( child ); - range.setStartAfter( child ); - } - range.deleteContents(); + insertCompletion( replacement ) { + const { record, onChange } = this.props; - let inputEvent; - if ( undefined !== window.InputEvent ) { - inputEvent = new window.InputEvent( 'input', { bubbles: true, cancelable: false } ); - } else { - // IE11 doesn't provide an InputEvent constructor. - inputEvent = document.createEvent( 'UIEvent' ); - inputEvent.initEvent( 'input', true /* bubbles */, false /* cancelable */ ); - } - range.commonAncestorContainer.closest( '[contenteditable=true]' ).dispatchEvent( inputEvent ); + replacement = replacement.slice( 1 + this.state.query.length ); + + const newRecord = richTextStructure.splice( record, undefined, 0, replacement ); + + onChange( newRecord ); } select( option ) { const { onReplace } = this.props; - const { open, range, query } = this.state; + const { open, query } = this.state; const { getOptionCompletion } = open || {}; if ( option.isDisabled ) { @@ -263,7 +184,7 @@ export class Autocomplete extends Component { } if ( getOptionCompletion ) { - const completion = getOptionCompletion( option.value, range, query ); + const completion = getOptionCompletion( option.value, query ); const { action, value } = ( undefined === completion.action || undefined === completion.value ) ? @@ -273,7 +194,7 @@ export class Autocomplete extends Component { if ( 'replace' === action ) { onReplace( [ value ] ); } else if ( 'insert-at-caret' === action ) { - this.insertCompletion( range, value ); + this.insertCompletion( value ); } } @@ -303,31 +224,6 @@ export class Autocomplete extends Component { this.reset(); } - // this method is separate so it can be overridden in tests - getCursor( container ) { - const selection = window.getSelection(); - if ( selection.isCollapsed ) { - if ( 'production' !== process.env.NODE_ENV ) { - if ( ! container.contains( selection.anchorNode ) ) { - throw new Error( 'Invalid assumption: expected selection to be within the autocomplete container' ); - } - } - return { - node: selection.anchorNode, - offset: selection.anchorOffset, - }; - } - return null; - } - - // this method is separate so it can be overridden in tests - createRange( startNode, startOffset, endNode, endOffset ) { - const range = document.createRange(); - range.setStart( startNode, startOffset ); - range.setEnd( endNode, endOffset ); - return range; - } - announce( filteredOptions ) { const { debouncedSpeak } = this.props; if ( ! debouncedSpeak ) { @@ -391,119 +287,6 @@ export class Autocomplete extends Component { } ); } - findMatch( container, cursor, allCompleters, wasOpen ) { - const allowAnything = () => true; - let endTextNode; - let endIndex; - // search backwards to find the first preceding space or non-text node. - if ( isTextNode( cursor.node ) ) { // TEXT node - endTextNode = cursor.node; - endIndex = cursor.offset; - } else if ( cursor.offset === 0 ) { - endTextNode = onlyTextNode( descendFirst( cursor.node ) ); - endIndex = 0; - } else { - endTextNode = onlyTextNode( descendLast( cursor.node.childNodes[ cursor.offset - 1 ] ) ); - endIndex = endTextNode ? endTextNode.nodeValue.length : 0; - } - if ( endTextNode === null ) { - return null; - } - // store the index of a completer in the object so we can use it to reference the options - let completers = map( allCompleters, ( completer, idx ) => ( { ...completer, idx } ) ); - if ( wasOpen ) { - // put the open completer at the start so it has priority - completers = [ - wasOpen, - ...filter( completers, ( completer ) => completer.idx !== wasOpen.idx ), - ]; - } - // filter the completers to those that could handle this node - completers = filter( completers, - ( { allowNode = allowAnything } ) => allowNode( endTextNode, container ) ); - // exit early if nothing can handle it - if ( completers.length === 0 ) { - return null; - } - let startTextNode = endTextNode; - let text = endTextNode.nodeValue.substring( 0, endIndex ); - let pos = lastIndexOfSpace( text ); - while ( pos === -1 ) { - const prev = onlyTextNode( startTextNode.previousSibling ); - if ( prev === null ) { - break; - } - // filter the completers to those that could handle this node - completers = filter( completers, - ( { allowNode = allowAnything } ) => allowNode( endTextNode, container ) ); - // exit early if nothing can handle it - if ( completers.length === 0 ) { - return null; - } - startTextNode = prev; - text = prev.nodeValue + text; - pos = lastIndexOfSpace( prev.nodeValue ); - } - // exit early if nothing can handle it - if ( text.length <= pos + 1 ) { - return null; - } - // find a completer that matches - const open = find( completers, ( { triggerPrefix = '', allowContext = allowAnything } ) => { - if ( text.substr( pos + 1, triggerPrefix.length ) !== triggerPrefix ) { - return false; - } - const before = this.createRange( container, 0, startTextNode, pos + 1 ); - const after = this.createRange( endTextNode, endIndex, container, container.childNodes.length ); - return allowContext( before, after ); - } ); - // exit if no completers match - if ( ! open ) { - return null; - } - const { triggerPrefix = '' } = open; - const range = this.createRange( startTextNode, pos + 1, endTextNode, endIndex ); - const query = text.substr( pos + 1 + triggerPrefix.length ); - return { open, range, query }; - } - - search( event ) { - const { completers } = this.props; - const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; - const container = event.target; - - // ensure that the cursor location is unambiguous - const cursor = this.getCursor( container ); - if ( ! cursor ) { - return; - } - // look for the trigger prefix and search query just before the cursor location - const match = this.findMatch( container, cursor, completers, wasOpen ); - const { open, query, range } = match || {}; - // asynchronously load the options for the open completer - if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { - if ( open.isDebounced ) { - this.debouncedLoadOptions( open, query ); - } else { - this.loadOptions( open, query ); - } - } - // create a regular expression to filter the options - const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; - // filter the options we already have - const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; - // check if we should still suppress the popover - const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; - // update the state - if ( wasOpen || open ) { - this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query, range } ); - } - // announce the count of filtered options but only if they have loaded - if ( open && this.state[ 'options_' + open.idx ] ) { - this.announce( filteredOptions ); - } - } - handleKeyDown( event ) { const { open, suppress, selectedIndex, filteredOptions } = this.state; if ( ! open ) { @@ -568,13 +351,12 @@ export class Autocomplete extends Component { event.stopPropagation(); } - getWordRect() { - const { range } = this.state; - if ( ! range ) { - return; - } + getAnchorRect() { + const range = window.getSelection().getRangeAt( 0 ); - return range.getBoundingClientRect(); + if ( range ) { + return getRectangleFromRange( range ); + } } toggleKeyEvents( isListening ) { @@ -588,10 +370,53 @@ export class Autocomplete extends Component { } componentDidUpdate( prevProps, prevState ) { - const { open } = this.state; + const { record, completers } = this.props; + const { record: prevRecord } = prevProps; const { open: prevOpen } = prevState; - if ( ( ! open ) !== ( ! prevOpen ) ) { - this.toggleKeyEvents( ! ! open ); + + if ( ( ! this.state.open ) !== ( ! prevOpen ) ) { + this.toggleKeyEvents( ! ! this.state.open ); + } + + if ( richTextStructure.isCollapsed( record ) ) { + const text = richTextStructure.getTextContent( record ); + const prevText = richTextStructure.getTextContent( prevRecord ); + const textToSearch = text.slice( 0, record.selection.start ); + const prevTextToSearch = prevText.slice( 0, prevRecord.selection.start ); + + if ( textToSearch !== prevTextToSearch ) { + const allCompleters = map( completers, ( completer, idx ) => ( { ...completer, idx } ) ); + const open = find( allCompleters, ( settings ) => settings.test( textToSearch ) ); + + if ( ! open ) { + return; + } + + const query = open.getQuery( textToSearch ); + const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; + + if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { + if ( open.isDebounced ) { + this.debouncedLoadOptions( open, query ); + } else { + this.loadOptions( open, query ); + } + } + // create a regular expression to filter the options + const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; + // filter the options we already have + const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; + // check if we should still suppress the popover + const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; + // update the state + if ( wasOpen || open ) { + this.setState( { selectedIndex: 0, filteredOptions, suppress, search, open, query } ); + } + // announce the count of filtered options but only if they have loaded + if ( open && this.state[ 'options_' + open.idx ] ) { + this.announce( filteredOptions ); + } + } } } @@ -608,12 +433,12 @@ export class Autocomplete extends Component { const isExpanded = suppress !== idx && filteredOptions.length > 0; const listBoxId = isExpanded ? `components-autocomplete-listbox-${ instanceId }` : null; const activeId = isExpanded ? `components-autocomplete-item-${ instanceId }-${ selectedKey }` : null; + // Disable reason: Clicking the editor should reset the autocomplete when the menu is suppressed /* eslint-disable jsx-a11y/no-static-element-interactions, jsx-a11y/onclick-has-role, jsx-a11y/click-events-have-key-events */ return (
@@ -624,7 +449,7 @@ export class Autocomplete extends Component { onClose={ this.reset } position="top right" className="components-autocomplete__popover" - getAnchorRect={ this.getWordRect } + getAnchorRect={ this.getAnchorRect } >
- { children } -
- ); - } -} - -function makeAutocompleter( completers, { - AutocompleteComponent = Autocomplete, - mountImplementation = mount, - onReplace = noop, -} = {} ) { - return mountImplementation( - - { ( { isExpanded, listBoxId, activeId } ) => ( - - ) } - - ); -} - -/** - * Create a text node. - * - * @param {string} text Text of text node. - - * @return {Node} A text node. - */ -function tx( text ) { - return document.createTextNode( text ); -} - -/** - * Create a paragraph node with the arguments as children. - - * @return {Node} A paragraph node. - */ -function par( /* arguments */ ) { - const p = document.createElement( 'p' ); - Array.from( arguments ).forEach( ( element ) => p.appendChild( element ) ); - return p; -} - -/** - * Simulate typing into the fake editor by updating the content and simulating - * an input event. It also updates the data-cursor attribute which is used to - * simulate the cursor position in the test mocks. - * - * @param {*} wrapper Enzyme wrapper around react node - * containing a FakeEditor. - * @param {Array.} nodeList Array of dom nodes. - * @param {Array.} cursorPosition Array specifying the child indexes and - * offset of the cursor. - */ -function simulateInput( wrapper, nodeList, cursorPosition ) { - // update the editor content - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.innerHTML = ''; - nodeList.forEach( ( element ) => fakeEditor.appendChild( element ) ); - if ( cursorPosition && cursorPosition.length >= 1 ) { - fakeEditor.setAttribute( 'data-cursor', cursorPosition.join( ',' ) ); - } else { - fakeEditor.removeAttribute( 'data-cursor' ); - } - // simulate input event - wrapper.find( '.fake-editor' ).simulate( 'input', { - target: fakeEditor, - } ); - wrapper.update(); -} - -/** - * Same as simulateInput except configured for use with React.TestUtils - * @param {*} wrapper Wrapper around react node - * containing a FakeEditor. - * @param {Array.} nodeList Array of dom nodes. - * @param {Array.} cursorPosition Array specifying the child indexes and - * offset of the cursor. - */ -function simulateInputForUtils( wrapper, nodeList, cursorPosition ) { - // update the editor content - const fakeEditor = TestUtils.findRenderedDOMComponentWithClass( - wrapper, - 'fake-editor' - ); - fakeEditor.innerHTML = ''; - nodeList.forEach( ( element ) => fakeEditor.appendChild( element ) ); - if ( cursorPosition && cursorPosition.length >= 1 ) { - fakeEditor.setAttribute( 'data-cursor', cursorPosition.join( ',' ) ); - } else { - fakeEditor.removeAttribute( 'data-cursor' ); - } - TestUtils.Simulate.input( - fakeEditor, - { - target: fakeEditor, - } - ); -} - -/** - * Fire a native keydown event on the fake editor in the wrapper. - * - * @param {*} wrapper The wrapper containing the FakeEditor where the event will - * be dispatched. - * @param {*} keyCode The keycode of the key event. - */ -function simulateKeydown( wrapper, keyCode ) { - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - const event = new KeyboardEvent( 'keydown', { keyCode } ); // eslint-disable-line - fakeEditor.dispatchEvent( event ); - wrapper.update(); -} - -/** - * Check that the autocomplete matches the initial state. - * - * @param {*} wrapper The enzyme react wrapper. - */ -function expectInitialState( wrapper ) { - expect( wrapper.state( 'open' ) ).toBeUndefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toBeUndefined(); - expect( wrapper.state( 'search' ) ).toEqual( /./ ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - expect( wrapper.find( '.components-autocomplete__result' ) ).toHaveLength( 0 ); -} - -describe( 'Autocomplete', () => { - const options = [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - }, - { - id: 3, - label: 'Avocado', - keywords: [ 'fruit' ], - }, - ]; - - const basicCompleter = { - options, - getOptionLabel: ( option ) => option.label, - getOptionKeywords: ( option ) => option.keywords, - isOptionDisabled: ( option ) => option.isDisabled, - }; - - const slashCompleter = { - triggerPrefix: '/', - ...basicCompleter, - }; - - let realGetCursor, realCreateRange; - - beforeAll( () => { - realGetCursor = Autocomplete.prototype.getCursor; - - Autocomplete.prototype.getCursor = jest.fn( ( container ) => { - if ( container.hasAttribute( 'data-cursor' ) ) { - // the cursor position is specified by a list of child indexes (relative to the container) and the offset - const path = container.getAttribute( 'data-cursor' ).split( ',' ).map( ( val ) => parseInt( val, 10 ) ); - const offset = path.pop(); - let node = container; - for ( let i = 0; i < path.length; i++ ) { - node = container.childNodes[ path[ i ] ]; - } - return { node, offset }; - } - // by default we say the cursor is at the end of the editor - return { - node: container, - offset: container.childNodes.length, - }; - } ); - - realCreateRange = Autocomplete.prototype.createRange; - - Autocomplete.prototype.createRange = jest.fn( ( startNode, startOffset, endNode, endOffset ) => { - const fakeBounds = { x: 0, y: 0, width: 1, height: 1, top: 0, right: 1, bottom: 1, left: 0 }; - return { - startNode, - startOffset, - endNode, - endOffset, - getClientRects: () => [ fakeBounds ], - getBoundingClientRect: () => fakeBounds, - }; - } ); - } ); - - afterAll( () => { - Autocomplete.prototype.getCursor = realGetCursor; - Autocomplete.prototype.createRange = realCreateRange; - } ); - - describe( 'render()', () => { - it( 'renders children', () => { - const wrapper = makeAutocompleter( [] ); - expect( wrapper.state().open ).toBeUndefined(); - expect( wrapper.childAt( 0 ).hasClass( 'components-autocomplete' ) ).toBe( true ); - expect( wrapper.find( '.fake-editor' ) ).toHaveLength( 1 ); - } ); - - it( 'opens on absent trigger prefix search', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'b' - simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for async popover display - process.nextTick( function() { - wrapper.update(); - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'b' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)b/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'Popover' ).prop( 'focusOnMount' ) ).toBe( false ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 1 ); - done(); - } ); - } ); - - it( 'does not render popover as open if no results', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'zzz' - simulateInput( wrapper, [ tx( 'zzz' ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options to empty - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'query' ) ).toEqual( 'zzz' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)zzz/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 0 ); - done(); - } ); - } ); - - it( 'does not open without trigger prefix', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'b' - simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that the popup is not open - expectInitialState( wrapper ); - done(); - } ); - } ); - - it( 'opens on trigger prefix search', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 3 ); - done(); - } ); - } ); - - it( 'searches by keywords', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing fruit (split over 2 text nodes because these things happen) - simulateInput( wrapper, [ par( tx( 'fru' ), tx( 'it' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and filtered the options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'fruit' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)fruit/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 3 ); - done(); - } ); - } ); - - it( 'closes when search ends (whitespace)', ( done ) => { - const wrapper = makeAutocompleter( [ basicCompleter ] ); - expectInitialState( wrapper ); - // simulate typing 'a' - simulateInput( wrapper, [ tx( 'a' ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // now check that we've opened the popup and all options are displayed - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'a' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)a/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 2 ); - // simulate typing 'p' - simulateInput( wrapper, [ tx( 'ap' ) ] ); - // now check that the popup is still open and we've filtered the options to just the apple - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( 'ap' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)ap/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - expect( wrapper.find( 'button.components-autocomplete__result' ) ).toHaveLength( 1 ); - // simulate typing ' ' - simulateInput( wrapper, [ tx( 'ap ' ) ] ); - // check the popup closes - expectInitialState( wrapper ); - done(); - } ); - } ); - - it( 'renders options provided via array', ( done ) => { - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - it( 'renders options provided via function that returns array', ( done ) => { - const optionsMock = jest.fn( () => options ); - - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options: optionsMock }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - it( 'renders options provided via function that returns promise', ( done ) => { - const optionsMock = jest.fn( () => Promise.resolve( options ) ); - - const wrapper = makeAutocompleter( [ - { ...slashCompleter, options: optionsMock }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); - - const itemWrappers = wrapper.find( 'button.components-autocomplete__result' ); - expect( itemWrappers ).toHaveLength( 3 ); - - const expectedLabelContent = options.map( ( o ) => o.label ); - const actualLabelContent = itemWrappers.map( ( itemWrapper ) => itemWrapper.text() ); - expect( actualLabelContent ).toEqual( expectedLabelContent ); - - done(); - } ); - } ); - - it( 'set the disabled attribute on results', ( done ) => { - const wrapper = makeAutocompleter( [ - { - ...slashCompleter, - options: [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - isDisabled: true, - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - isDisabled: false, - }, - ], - }, - ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - - const firstItem = wrapper.find( 'button.components-autocomplete__result' ).at( 0 ).getDOMNode(); - expect( firstItem.hasAttribute( 'disabled' ) ).toBe( true ); - - const secondItem = wrapper.find( 'button.components-autocomplete__result' ).at( 1 ).getDOMNode(); - expect( secondItem.hasAttribute( 'disabled' ) ).toBe( false ); - - done(); - } ); - } ); - - it( 'navigates options by arrow keys', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press an arrow and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, DOWN ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/', the menu is open so the editor should not get key down events - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, UP ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 2 ); - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'resets selected index on subsequent search', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - simulateKeydown( wrapper, DOWN ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 1 ); - // simulate typing 'f - simulateInput( wrapper, [ par( tx( '/f' ) ) ] ); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - done(); - } ); - } ); - - it( 'closes by escape', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press escape and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ESCAPE ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'suppress' ) ).toBeUndefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // pressing escape should suppress the dialog but it maintains the state - simulateKeydown( wrapper, ESCAPE ); - expect( wrapper.state( 'suppress' ) ).toEqual( 0 ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - expect( wrapper.find( 'Popover' ) ).toHaveLength( 0 ); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'closes by blur', () => { - jest.spyOn( Autocomplete.prototype, 'handleFocusOutside' ); - // required because TestUtils doesn't handle stateless components for some - // reason. Without this, wrapper would end up with the value of null. - class Enhanced extends Component { - render() { - return ; - } - } - - const wrapper = makeAutocompleter( [], { - AutocompleteComponent: Enhanced, - mountImplementation: TestUtils.renderIntoDocument, - } ); - simulateInputForUtils( wrapper, [ par( tx( '/' ) ) ] ); - TestUtils.Simulate.blur( - TestUtils.findRenderedDOMComponentWithClass( - wrapper, - 'fake-editor' - ) - ); - - jest.runAllTimers(); - - expect( Autocomplete.prototype.handleFocusOutside ).toHaveBeenCalled(); - } ); - - it( 'selects by enter', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValue( { - action: 'non-existent-action', - value: 'dummy-value', - } ); - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion } ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press enter and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ENTER ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // pressing enter should reset and call getOptionCompletion - simulateKeydown( wrapper, ENTER ); - expectInitialState( wrapper ); - expect( getOptionCompletion ).toHaveBeenCalled(); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'does not select when option is disabled', ( done ) => { - const getOptionCompletion = jest.fn(); - const testOptions = [ - { - id: 1, - label: 'Bananas', - keywords: [ 'fruit' ], - isDisabled: true, - }, - { - id: 2, - label: 'Apple', - keywords: [ 'fruit' ], - isDisabled: false, - }, - ]; - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion, options: testOptions } ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - // the menu is not open so press enter and see if the editor gets it - expect( editorKeydown ).not.toHaveBeenCalled(); - simulateKeydown( wrapper, ENTER ); - expect( editorKeydown ).toHaveBeenCalledTimes( 1 ); - // clear the call count - editorKeydown.mockClear(); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: testOptions[ 0 ], label: 'Bananas', keywords: [ 'fruit' ], isDisabled: true }, - { key: '0-1', value: testOptions[ 1 ], label: 'Apple', keywords: [ 'fruit' ], isDisabled: false }, - ] ); - // pressing enter should NOT reset and NOT call getOptionCompletion - simulateKeydown( wrapper, ENTER ); - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( getOptionCompletion ).not.toHaveBeenCalled(); - // the editor should not have gotten the event - expect( editorKeydown ).not.toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'doesn\'t otherwise interfere with keydown behavior', ( done ) => { - const wrapper = makeAutocompleter( [ slashCompleter ] ); - // listen to keydown events on the editor to see if it gets them - const editorKeydown = jest.fn(); - const fakeEditor = wrapper.getDOMNode().querySelector( '.fake-editor' ); - fakeEditor.addEventListener( 'keydown', editorKeydown, false ); - expectInitialState( wrapper ); - [ UP, DOWN, ENTER, ESCAPE, SPACE ].forEach( ( keyCode, i ) => { - simulateKeydown( wrapper, keyCode ); - expect( editorKeydown ).toHaveBeenCalledTimes( i + 1 ); - } ); - expect( editorKeydown ).toHaveBeenCalledTimes( 5 ); - done(); - } ); - - it( 'selects by click', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValue( { - action: 'non-existent-action', - value: 'dummy-value', - } ); - const wrapper = makeAutocompleter( [ { ...slashCompleter, getOptionCompletion } ] ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with all options - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); - expect( wrapper.state( 'query' ) ).toEqual( '' ); - expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); - expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: options[ 1 ], label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: options[ 2 ], label: 'Avocado', keywords: [ 'fruit' ] }, - ] ); - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - expectInitialState( wrapper ); - expect( getOptionCompletion ).toHaveBeenCalled(); - done(); - } ); - } ); - - it( 'calls insertCompletion for a completion with action `insert-at-caret`', ( done ) => { - const getOptionCompletion = jest.fn() - .mockReturnValueOnce( { - action: 'insert-at-caret', - value: 'expected-value', - } ); - - const insertCompletion = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { - AutocompleteComponent: class extends Autocomplete { - insertCompletion( ...args ) { - return insertCompletion( ...args ); - } - }, - } - ); - expectInitialState( wrapper ); - - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( insertCompletion ).toHaveBeenCalledTimes( 1 ); - expect( insertCompletion.mock.calls[ 0 ][ 1 ] ).toBe( 'expected-value' ); - done(); - } ); - } ); - - it( 'calls insertCompletion for a completion without an action property', ( done ) => { - const getOptionCompletion = jest.fn().mockReturnValueOnce( 'expected-value' ); - - const insertCompletion = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { - AutocompleteComponent: class extends Autocomplete { - insertCompletion( ...args ) { - return insertCompletion( ...args ); - } - }, - } - ); - expectInitialState( wrapper ); - - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( insertCompletion ).toHaveBeenCalledTimes( 1 ); - expect( insertCompletion.mock.calls[ 0 ][ 1 ] ).toBe( 'expected-value' ); - done(); - } ); - } ); - - it( 'calls onReplace for a completion with action `replace`', ( done ) => { - const getOptionCompletion = jest.fn() - .mockReturnValueOnce( { - action: 'replace', - value: 'expected-value', - } ); - - const onReplace = jest.fn(); - - const wrapper = makeAutocompleter( - [ { ...slashCompleter, getOptionCompletion } ], - { onReplace } ); - expectInitialState( wrapper ); - // simulate typing '/' - simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - - // wait for async popover display - process.nextTick( () => { - wrapper.update(); - // menu should be open with at least one option - expect( wrapper.state( 'open' ) ).toBeDefined(); - expect( wrapper.state( 'filteredOptions' ).length ).toBeGreaterThanOrEqual( 1 ); - - // clicking should reset and select the item - wrapper.find( '.components-autocomplete__result Button' ).at( 0 ).simulate( 'click' ); - wrapper.update(); - - expect( onReplace ).toHaveBeenCalledTimes( 1 ); - expect( onReplace ).toHaveBeenLastCalledWith( [ 'expected-value' ] ); - done(); - } ); - } ); - } ); -} );