From c48f351ff58c535e9eafd532ec78e0dcd76e6a06 Mon Sep 17 00:00:00 2001 From: Brandon Payton Date: Fri, 19 Jan 2018 17:18:03 -0700 Subject: [PATCH] Support overriding autocomplete with filters --- blocks/autocomplete/README.md | 6 + blocks/autocomplete/index.js | 109 +++++++ blocks/autocomplete/test/index.js | 122 +++++++ blocks/autocompleters/README.md | 4 + blocks/autocompleters/block.js | 51 +++ blocks/autocompleters/index.js | 143 +------- blocks/autocompleters/test/block.js | 91 ++++++ blocks/autocompleters/test/index.js | 79 ----- blocks/autocompleters/user.js | 34 ++ blocks/hooks/default-autocompleters.js | 31 ++ blocks/hooks/index.js | 1 + blocks/hooks/test/default-autocompleters.js | 43 +++ blocks/index.js | 1 + .../button/test/__snapshots__/index.js.snap | 36 ++- .../heading/test/__snapshots__/index.js.snap | 36 ++- .../list/test/__snapshots__/index.js.snap | 38 ++- blocks/library/paragraph/index.js | 86 +++-- .../test/__snapshots__/index.js.snap | 44 +-- .../test/__snapshots__/index.js.snap | 36 ++- .../test/__snapshots__/index.js.snap | 38 ++- .../quote/test/__snapshots__/index.js.snap | 38 ++- .../table/test/__snapshots__/index.js.snap | 58 ++-- .../test/__snapshots__/index.js.snap | 72 +++-- .../verse/test/__snapshots__/index.js.snap | 36 ++- blocks/rich-text/README.md | 4 + blocks/rich-text/index.js | 56 ++-- components/autocomplete/README.md | 121 +++++++ components/autocomplete/completer-compat.js | 56 ++++ components/autocomplete/index.js | 176 ++++++++-- components/autocomplete/test/index.js | 306 +++++++++++++++--- docs/extensibility.md | 6 +- docs/extensibility/autocompletion.md | 85 +++++ 32 files changed, 1514 insertions(+), 529 deletions(-) create mode 100644 blocks/autocomplete/README.md create mode 100644 blocks/autocomplete/index.js create mode 100644 blocks/autocomplete/test/index.js create mode 100644 blocks/autocompleters/README.md create mode 100644 blocks/autocompleters/block.js create mode 100644 blocks/autocompleters/test/block.js delete mode 100644 blocks/autocompleters/test/index.js create mode 100644 blocks/autocompleters/user.js create mode 100644 blocks/hooks/default-autocompleters.js create mode 100644 blocks/hooks/test/default-autocompleters.js create mode 100644 components/autocomplete/README.md create mode 100644 components/autocomplete/completer-compat.js create mode 100644 docs/extensibility/autocompletion.md diff --git a/blocks/autocomplete/README.md b/blocks/autocomplete/README.md new file mode 100644 index 0000000000000..bf3b564d3cb8e --- /dev/null +++ b/blocks/autocomplete/README.md @@ -0,0 +1,6 @@ +Autocomplete +============ + +This is an Autocomplete component for use in block UI. It is based on `Autocomplete` from `@wordpress/components` and takes the same props. In addition, it passes its autocompleters through a `blocks.Autocomplete.completers` filter to give developers an opportunity to override or extend them. + +The autocompleter interface is documented with the original `Autocomplete` component in `@wordpress/components`. diff --git a/blocks/autocomplete/index.js b/blocks/autocomplete/index.js new file mode 100644 index 0000000000000..df366d82106c2 --- /dev/null +++ b/blocks/autocomplete/index.js @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import { clone } from 'lodash'; + +/** + * WordPress dependencies + */ +import { applyFilters, hasFilter } from '@wordpress/hooks'; +import { Component } from '@wordpress/element'; +import { Autocomplete as OriginalAutocomplete } from '@wordpress/components'; + +/* + * Use one array instance for fallback rather than inline array literals + * because the latter may cause rerender due to failed prop equality checks. + */ +const completersFallback = []; + +/** + * Wrap the default Autocomplete component with one that + * supports a filter hook for customizing its list of autocompleters. + * + * Since there may be many Autocomplete instances at one time, this component + * applies the filter on demand, when the component is first focused after + * receiving a new list of completers. + * + * This function is exported for unit test. + * + * @param {Function} Autocomplete Original component. + * @return {Function} Wrapped component + */ +export function withFilteredAutocompleters( Autocomplete ) { + return class FilteredAutocomplete extends Component { + constructor() { + super(); + + this.state = { completers: completersFallback }; + + this.saveParentRef = this.saveParentRef.bind( this ); + this.onFocus = this.onFocus.bind( this ); + } + + componentDidUpdate() { + const hasFocus = this.parentNode.contains( document.activeElement ); + + /* + * It's possible for props to be updated when the component has focus, + * so here, we ensure new completers are immediately applied while we + * have the focus. + * + * NOTE: This may trigger another render but only when the component has focus. + */ + if ( hasFocus && this.hasStaleCompleters() ) { + this.updateCompletersState(); + } + } + + onFocus() { + if ( this.hasStaleCompleters() ) { + this.updateCompletersState(); + } + } + + hasStaleCompleters() { + return ( + ! ( 'lastFilteredCompletersProp' in this.state ) || + this.state.lastFilteredCompletersProp !== this.props.completers + ); + } + + updateCompletersState() { + let { completers: nextCompleters } = this.props; + const lastFilteredCompletersProp = nextCompleters; + + if ( hasFilter( 'blocks.Autocomplete.completers' ) ) { + nextCompleters = applyFilters( + 'blocks.Autocomplete.completers', + // Provide copies so filters may directly modify them. + nextCompleters && nextCompleters.map( clone ) + ); + } + + this.setState( { + lastFilteredCompletersProp, + completers: nextCompleters || completersFallback, + } ); + } + + saveParentRef( parentNode ) { + this.parentNode = parentNode; + } + + render() { + const { completers } = this.state; + const autocompleteProps = { + ...this.props, + completers, + }; + + return ( +
+ +
+ ); + } + }; +} + +export default withFilteredAutocompleters( OriginalAutocomplete ); diff --git a/blocks/autocomplete/test/index.js b/blocks/autocomplete/test/index.js new file mode 100644 index 0000000000000..493a99d5ebcc7 --- /dev/null +++ b/blocks/autocomplete/test/index.js @@ -0,0 +1,122 @@ +/** + * External dependencies + */ +import { mount, shallow } from 'enzyme'; + +/** + * WordPress dependencies + */ +import { addFilter, removeFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { withFilteredAutocompleters } from '..'; + +function TestComponent() { + // Use a naturally focusable element because we will test with focus. + return ; +} +const FilteredComponent = withFilteredAutocompleters( TestComponent ); + +describe( 'Autocomplete', () => { + let wrapper = null; + + afterEach( () => { + removeFilter( 'blocks.Autocomplete.completers', 'test/autocompleters-hook' ); + + if ( wrapper ) { + wrapper.unmount(); + wrapper = null; + } + } ); + + it( 'filters supplied completers when next focused', () => { + const completersFilter = jest.fn(); + addFilter( + 'blocks.Autocomplete.completers', + 'test/autocompleters-hook', + completersFilter + ); + + const expectedCompleters = [ {}, {}, {} ]; + wrapper = mount( ); + + expect( completersFilter ).not.toHaveBeenCalled(); + wrapper.find( 'input' ).simulate( 'focus' ); + expect( completersFilter ).toHaveBeenCalledWith( expectedCompleters ); + } ); + + it( 'filters completers supplied when already focused', () => { + wrapper = mount( ); + + wrapper.find( 'input' ).getDOMNode().focus(); + expect( wrapper.getDOMNode().contains( document.activeElement ) ).toBeTruthy(); + + const completersFilter = jest.fn(); + addFilter( + 'blocks.Autocomplete.completers', + 'test/autocompleters-hook', + completersFilter + ); + + const expectedCompleters = [ {}, {}, {} ]; + + expect( completersFilter ).not.toHaveBeenCalled(); + wrapper.setProps( { completers: expectedCompleters } ); + expect( completersFilter ).toHaveBeenCalledWith( expectedCompleters ); + } ); + + it( 'provides copies of completers to filter', () => { + const completersFilter = jest.fn(); + addFilter( + 'blocks.Autocomplete.completers', + 'test/autocompleters-hook', + completersFilter + ); + + const specifiedCompleters = [ {}, {}, {} ]; + wrapper = mount( ); + + expect( completersFilter ).not.toHaveBeenCalled(); + wrapper.find( 'input' ).simulate( 'focus' ); + expect( completersFilter ).toHaveBeenCalledTimes( 1 ); + + const [ actualCompleters ] = completersFilter.mock.calls[ 0 ]; + expect( actualCompleters ).not.toBe( specifiedCompleters ); + expect( actualCompleters ).toEqual( specifiedCompleters ); + } ); + + it( 'supplies filtered completers to inner component', () => { + const expectedFilteredCompleters = [ {}, {} ]; + const completersFilter = jest.fn( () => expectedFilteredCompleters ); + addFilter( + 'blocks.Autocomplete.completers', + 'test/autocompleters-hook', + completersFilter + ); + + wrapper = mount( ); + + wrapper.find( 'input' ).simulate( 'focus' ); + + const filteredComponentWrapper = wrapper.childAt( 0 ); + const innerComponentWrapper = filteredComponentWrapper.childAt( 0 ); + expect( innerComponentWrapper.name() ).toBe( 'TestComponent' ); + expect( innerComponentWrapper.prop( 'completers' ) ).toEqual( expectedFilteredCompleters ); + } ); + + it( 'passes props to inner component', () => { + const expectedProps = { + expected1: 1, + expected2: 'two', + expected3: '🌳', + }; + + wrapper = shallow( ); + + const innerComponentWrapper = wrapper.childAt( 0 ); + expect( innerComponentWrapper.name() ).toBe( 'TestComponent' ); + expect( innerComponentWrapper.props() ).toMatchObject( expectedProps ); + } ); +} ); diff --git a/blocks/autocompleters/README.md b/blocks/autocompleters/README.md new file mode 100644 index 0000000000000..a802c02f75dba --- /dev/null +++ b/blocks/autocompleters/README.md @@ -0,0 +1,4 @@ +Autocompleters +============== + +The Autocompleter interface is documented [here](../../components/autocomplete/README.md) with the `Autocomplete` component in `@wordpress/components`. diff --git a/blocks/autocompleters/block.js b/blocks/autocompleters/block.js new file mode 100644 index 0000000000000..67bbd72f93057 --- /dev/null +++ b/blocks/autocompleters/block.js @@ -0,0 +1,51 @@ +/** + * External dependencies + */ +import { sortBy, once } from 'lodash'; + +/** + * Internal dependencies + */ +import './style.scss'; +import { createBlock, getBlockTypes } from '../api'; +import BlockIcon from '../block-icon'; + +/** + * A blocks repeater for replacing the current block with a selected block type. + * + * @type {Completer} + */ +export default { + name: 'blocks', + className: 'blocks-autocompleters__block', + triggerPrefix: '/', + options: once( function options() { + return Promise.resolve( + // Prioritize common category in block type options + sortBy( + getBlockTypes(), + ( { category } ) => 'common' !== category + ) + ); + } ), + getOptionKeywords( blockSettings ) { + const { title, keywords = [] } = blockSettings; + return [ ...keywords, title ]; + }, + getOptionLabel( blockSettings ) { + const { icon, title } = blockSettings; + return [ + , + title, + ]; + }, + allowContext( before, after ) { + return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); + }, + getOptionCompletion( blockData ) { + return { + action: 'replace', + value: createBlock( blockData.name ), + }; + }, +}; diff --git a/blocks/autocompleters/index.js b/blocks/autocompleters/index.js index 9810766c05ace..e367e83c199f0 100644 --- a/blocks/autocompleters/index.js +++ b/blocks/autocompleters/index.js @@ -1,146 +1,7 @@ -/** - * External dependencies - */ -import { sortBy } from 'lodash'; - /** * Internal dependencies */ import './style.scss'; -import { createBlock, getBlockTypes } from '../api'; -import BlockIcon from '../block-icon'; - -/** - * @typedef {Object} CompleterOption - * @property {Array.} label list of react components to render. - * @property {Array.} keywords list of key words to search. - * @property {*} value the value that will be passed to onSelect. - */ - -/** - * @callback FnGetOptions - * - * @returns {Promise.>} A promise that resolves to the list of completer options. - */ - -/** - * @callback FnAllowNode - * @param {Node} textNode check if the completer can handle this text node. - * - * @returns {boolean} true if the completer can handle this text node. - */ - -/** - * @callback FnAllowContext - * @param {Range} before the range before the auto complete trigger and query. - * @param {Range} after the range after the autocomplete trigger and query. - * - * @returns {boolean} true if the completer can handle these ranges. - */ - -/** - * @callback FnOnSelect - * @param {*} value the value of the completer option. - * @param {Range} range the nodes included in the autocomplete trigger and query. - * @param {String} query the text value of the autocomplete query. - * - * @returns {?Component} optional html to replace the range. - */ - -/** - * @typedef {Object} Completer - * @property {?String} className A class to apply to the popup menu. - * @property {String} triggerPrefix the prefix that will display the menu. - * @property {FnGetOptions} getOptions get the block options in a resolved promise. - * @property {?FnAllowNode} allowNode filter the allowed text nodes in the autocomplete. - * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. - * @property {FnOnSelect} onSelect - */ - -/** - * Returns an "completer" definition for selecting from available blocks to replace the current one. - * The definition can be understood by the Autocomplete component. - * - * @param {Function} onReplace Callback to replace the current block. - * - * @return {Completer} Completer object used by the Autocomplete component. - */ -export function blockAutocompleter( { onReplace } ) { - // Prioritize common category in block type options - const options = sortBy( - getBlockTypes(), - ( { category } ) => 'common' !== category - ).map( ( blockType ) => { - const { name, title, icon, keywords = [] } = blockType; - return { - value: name, - label: [ - , - title, - ], - keywords: [ ...keywords, title ], - }; - } ); - - const getOptions = () => Promise.resolve( options ); - - const allowContext = ( before, after ) => { - return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); - }; - - const onSelect = ( blockName ) => { - onReplace( createBlock( blockName ) ); - }; - - return { - className: 'blocks-autocompleters__block', - triggerPrefix: '/', - getOptions, - allowContext, - onSelect, - }; -} -/** - * Returns a "completer" definition for inserting a user mention. - * The definition can be understood by the Autocomplete component. - * - * @return {Completer} Completer object used by the Autocomplete component. - */ -export function userAutocompleter() { - const getOptions = ( search ) => { - let payload = ''; - if ( search ) { - payload = '?search=' + encodeURIComponent( search ); - } - return wp.apiRequest( { path: '/wp/v2/users' + payload } ).then( ( users ) => { - return users.map( ( user ) => { - return { - value: user, - label: [ - , - { user.name }, - { user.slug }, - ], - keywords: [ user.slug, user.name ], - }; - } ); - } ); - }; - - const allowNode = () => { - return true; - }; - - const onSelect = ( user ) => { - return ( '@' + user.slug ); - }; - return { - className: 'blocks-autocompleters__user', - triggerPrefix: '@', - getOptions, - allowNode, - onSelect, - isDebounced: true, - }; -} +export { default as blockAutocompleter } from './block'; +export { default as userAutocompleter } from './user'; diff --git a/blocks/autocompleters/test/block.js b/blocks/autocompleters/test/block.js new file mode 100644 index 0000000000000..de395591ffba1 --- /dev/null +++ b/blocks/autocompleters/test/block.js @@ -0,0 +1,91 @@ +/** + * External dependencies + */ +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import { registerBlockType, unregisterBlockType, getBlockTypes } from '../../api'; +import { blockAutocompleter } from '../'; + +describe( 'block', () => { + const blockTypes = { + 'core/foo': { + save: noop, + category: 'common', + title: 'foo', + keywords: [ 'foo-keyword-1', 'foo-keyword-2' ], + }, + 'core/bar': { + save: noop, + category: 'layout', + title: 'bar', + // Intentionally empty keyword list + keywords: [], + }, + 'core/baz': { + save: noop, + category: 'common', + title: 'baz', + // Intentionally omitted keyword list + }, + }; + + beforeEach( () => { + Object.entries( blockTypes ).forEach( + ( [ name, settings ] ) => registerBlockType( name, settings ) + ); + } ); + + afterEach( () => { + getBlockTypes().forEach( ( block ) => { + unregisterBlockType( block.name ); + } ); + } ); + + it( 'should prioritize common blocks in options', () => { + return blockAutocompleter.options().then( ( options ) => { + expect( options ).toMatchObject( [ + blockTypes[ 'core/foo' ], + blockTypes[ 'core/baz' ], + blockTypes[ 'core/bar' ], + ] ); + } ); + } ); + + it( 'should render a block option label composed of @wordpress/element Elements and/or strings', () => { + expect.hasAssertions(); + + // Only verify that a populated label is returned. + // It is likely to be fragile to assert that the contents are renderable by @wordpress/element. + const isAllowedLabelType = label => Array.isArray( label ) || ( typeof label === 'string' ); + + getBlockTypes().forEach( blockType => { + const label = blockAutocompleter.getOptionLabel( blockType ); + expect( isAllowedLabelType( label ) ).toBeTruthy(); + } ); + } ); + + it( 'should derive option keywords from block keywords and block title', () => { + const optionKeywords = getBlockTypes().reduce( + ( map, blockType ) => map.set( + blockType.name, + blockAutocompleter.getOptionKeywords( blockType ) + ), + new Map() + ); + + expect( optionKeywords.get( 'core/foo' ) ).toEqual( [ + 'foo-keyword-1', + 'foo-keyword-2', + blockTypes[ 'core/foo' ].title, + ] ); + expect( optionKeywords.get( 'core/bar' ) ).toEqual( [ + blockTypes[ 'core/bar' ].title, + ] ); + expect( optionKeywords.get( 'core/baz' ) ).toEqual( [ + blockTypes[ 'core/baz' ].title, + ] ); + } ); +} ); diff --git a/blocks/autocompleters/test/index.js b/blocks/autocompleters/test/index.js deleted file mode 100644 index a3ba966544dbd..0000000000000 --- a/blocks/autocompleters/test/index.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * External dependencies - */ -import { noop } from 'lodash'; - -/** - * Internal dependencies - */ -import BlockIcon from '../../block-icon'; -import { registerBlockType, unregisterBlockType, getBlockTypes } from '../../api'; -import { blockAutocompleter } from '../'; - -describe( 'blockAutocompleter', () => { - beforeEach( () => { - registerBlockType( 'core/foo', { - save: noop, - category: 'common', - title: 'foo', - keywords: [ 'keyword' ], - } ); - - registerBlockType( 'core/bar', { - save: noop, - category: 'layout', - title: 'bar', - } ); - - registerBlockType( 'core/baz', { - save: noop, - category: 'common', - title: 'baz', - } ); - } ); - - afterEach( () => { - getBlockTypes().forEach( ( block ) => { - unregisterBlockType( block.name ); - } ); - } ); - - it( 'should prioritize common blocks in options', () => { - return blockAutocompleter( {} ).getOptions().then( ( options ) => { - // Exclude React element from label for assertion, since we can't - // easily test equality. - options = options.map( ( option ) => { - expect( option.label[ 0 ].type ).toBe( BlockIcon ); - - return { - ...option, - label: option.label.slice( 1 ), - }; - } ); - - expect( options ).toEqual( [ - { - keywords: [ 'keyword', 'foo' ], - label: [ - 'foo', - ], - value: 'core/foo', - }, - { - keywords: [ 'baz' ], - label: [ - 'baz', - ], - value: 'core/baz', - }, - { - keywords: [ 'bar' ], - label: [ - 'bar', - ], - value: 'core/bar', - }, - ] ); - } ); - } ); -} ); diff --git a/blocks/autocompleters/user.js b/blocks/autocompleters/user.js new file mode 100644 index 0000000000000..0532472e82b51 --- /dev/null +++ b/blocks/autocompleters/user.js @@ -0,0 +1,34 @@ +/** +* A user mentions completer. +* +* @type {Completer} +*/ +export default { + name: 'users', + className: 'blocks-autocompleters__user', + triggerPrefix: '@', + options( search ) { + let payload = ''; + if ( search ) { + payload = '?search=' + encodeURIComponent( search ); + } + return wp.apiRequest( { path: '/wp/v2/users' + payload } ); + }, + isDebounced: true, + getOptionKeywords( user ) { + return [ user.slug, user.name ]; + }, + getOptionLabel( user ) { + return [ + , + { user.name }, + { user.slug }, + ]; + }, + allowNode() { + return true; + }, + getOptionCompletion( user ) { + return `@${ user.slug }`; + }, +}; diff --git a/blocks/hooks/default-autocompleters.js b/blocks/hooks/default-autocompleters.js new file mode 100644 index 0000000000000..00f4535903dae --- /dev/null +++ b/blocks/hooks/default-autocompleters.js @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import { clone } from 'lodash'; + +/** + * WordPress dependencies + */ +import { addFilter } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { userAutocompleter } from '../autocompleters'; + +// Exported for unit test. +export const defaultAutocompleters = [ userAutocompleter ]; + +function setDefaultCompleters( completers ) { + if ( ! completers ) { + // Provide copies so filters may directly modify them. + completers = defaultAutocompleters.map( clone ); + } + return completers; +} + +addFilter( + 'blocks.Autocomplete.completers', + 'blocks/autocompleters/set-default-completers', + setDefaultCompleters +); diff --git a/blocks/hooks/index.js b/blocks/hooks/index.js index c29b423bc1893..e464747bf6e95 100644 --- a/blocks/hooks/index.js +++ b/blocks/hooks/index.js @@ -4,5 +4,6 @@ import './align'; import './anchor'; import './custom-class-name'; +import './default-autocompleters'; import './generated-class-name'; import './layout'; diff --git a/blocks/hooks/test/default-autocompleters.js b/blocks/hooks/test/default-autocompleters.js new file mode 100644 index 0000000000000..38f102f4e617a --- /dev/null +++ b/blocks/hooks/test/default-autocompleters.js @@ -0,0 +1,43 @@ +/** + * WordPress dependencies + */ +import { applyFilters } from '@wordpress/hooks'; + +/** + * Internal dependencies + */ +import { defaultAutocompleters } from '../default-autocompleters'; + +describe( 'default-autocompleters', () => { + it( 'provides default completers if none are provided', () => { + const result = applyFilters( 'blocks.Autocomplete.completers', null ); + /* + * Assert structural equality because defaults are provided as a + * list of cloned completers (and not referentially equal). + */ + expect( result ).toEqual( defaultAutocompleters ); + } ); + + it( 'does not provide default completers for empty completer list', () => { + const emptyList = []; + const result = applyFilters( 'blocks.Autocomplete.completers', emptyList ); + // Assert referential equality because the list should be unchanged. + expect( result ).toBe( emptyList ); + } ); + + it( 'does not provide default completers for a populated completer list', () => { + const populatedList = [ {}, {} ]; + const result = applyFilters( 'blocks.Autocomplete.completers', populatedList ); + // Assert referential equality because the list should be unchanged. + expect( result ).toBe( populatedList ); + } ); + + it( 'provides copies of defaults so they may be directly modified', () => { + const result = applyFilters( 'blocks.Autocomplete.completers', null ); + result.forEach( ( completer, i ) => { + const defaultCompleter = defaultAutocompleters[ i ]; + expect( completer ).not.toBe( defaultCompleter ); + expect( completer ).toEqual( defaultCompleter ); + } ); + } ); +} ); diff --git a/blocks/index.js b/blocks/index.js index c157fc834c928..ecd584e6bb9ed 100644 --- a/blocks/index.js +++ b/blocks/index.js @@ -15,6 +15,7 @@ import './hooks'; export * from './api'; export { registerCoreBlocks } from './library'; export { default as AlignmentToolbar } from './alignment-toolbar'; +export { default as Autocomplete } from './autocomplete'; export { default as BlockAlignmentToolbar } from './block-alignment-toolbar'; export { default as BlockControls } from './block-controls'; export { default as BlockEdit } from './block-edit'; diff --git a/blocks/library/button/test/__snapshots__/index.js.snap b/blocks/library/button/test/__snapshots__/index.js.snap index 966d980570fa8..05d5185451e85 100644 --- a/blocks/library/button/test/__snapshots__/index.js.snap +++ b/blocks/library/button/test/__snapshots__/index.js.snap @@ -7,19 +7,29 @@ exports[`core/button block edit matches snapshot 1`] = `
- - - Add text… - +
+
+
+
+
+
`; diff --git a/blocks/library/heading/test/__snapshots__/index.js.snap b/blocks/library/heading/test/__snapshots__/index.js.snap index f5c41c3fcf0c5..3da981c56f1bd 100644 --- a/blocks/library/heading/test/__snapshots__/index.js.snap +++ b/blocks/library/heading/test/__snapshots__/index.js.snap @@ -4,18 +4,28 @@ exports[`core/heading block edit matches snapshot 1`] = `
-

-

- Write heading… -

+
+
+
+

+ Write heading… +

+
+
+
`; diff --git a/blocks/library/list/test/__snapshots__/index.js.snap b/blocks/library/list/test/__snapshots__/index.js.snap index 9db54c020d2e4..0dfeabf5c9554 100644 --- a/blocks/library/list/test/__snapshots__/index.js.snap +++ b/blocks/library/list/test/__snapshots__/index.js.snap @@ -4,19 +4,29 @@ exports[`core/list block edit matches snapshot 1`] = `
-
    -
      -
    • - Write list… -
    • -
    +
    +
    +
    +
    +
    +
`; diff --git a/blocks/library/paragraph/index.js b/blocks/library/paragraph/index.js index 381584c17a91f..1bd3bc9e1bb03 100644 --- a/blocks/library/paragraph/index.js +++ b/blocks/library/paragraph/index.js @@ -10,7 +10,6 @@ import { findKey, isFinite, map, omit } from 'lodash'; import { __ } from '@wordpress/i18n'; import { concatChildren, Component, RawHTML } from '@wordpress/element'; import { - Autocomplete, PanelBody, PanelColor, RangeControl, @@ -26,7 +25,8 @@ import { import './editor.scss'; import './style.scss'; import { createBlock } from '../../api'; -import { blockAutocompleter, userAutocompleter } from '../../autocompleters'; +import { blockAutocompleter } from '../../autocompleters'; +import { defaultAutocompleters } from '../../hooks/default-autocompleters'; import AlignmentToolbar from '../../alignment-toolbar'; import BlockAlignmentToolbar from '../../block-alignment-toolbar'; import BlockControls from '../../block-controls'; @@ -56,6 +56,8 @@ const FONT_SIZES = { larger: 48, }; +const autocompleters = [ blockAutocompleter, ...defaultAutocompleters ]; + class ParagraphBlock extends Component { constructor() { super( ...arguments ); @@ -225,51 +227,41 @@ class ParagraphBlock extends Component { ),
- - { ( { isExpanded, listBoxId, activeId } ) => ( - { - setAttributes( { - content: nextContent, - } ); - } } - onSplit={ insertBlocksAfter ? - ( before, after, ...blocks ) => { - setAttributes( { content: before } ); - insertBlocksAfter( [ - ...blocks, - createBlock( 'core/paragraph', { content: after } ), - ] ); - } : - undefined - } - onMerge={ mergeBlocks } - onReplace={ this.onReplace } - onRemove={ () => onReplace( [] ) } - placeholder={ placeholder || __( 'Add text or type / to add content' ) } - aria-autocomplete="list" - aria-expanded={ isExpanded } - aria-owns={ listBoxId } - aria-activedescendant={ activeId } - isSelected={ isSelected } - /> - ) } - + { + setAttributes( { + content: nextContent, + } ); + } } + onSplit={ insertBlocksAfter ? + ( before, after, ...blocks ) => { + setAttributes( { content: before } ); + insertBlocksAfter( [ + ...blocks, + createBlock( 'core/paragraph', { content: after } ), + ] ); + } : + undefined + } + onMerge={ mergeBlocks } + onReplace={ this.onReplace } + onRemove={ () => onReplace( [] ) } + placeholder={ placeholder || __( 'Add text or type / to add content' ) } + isSelected={ isSelected } + autocompleters={ autocompleters } + />
, ]; } diff --git a/blocks/library/paragraph/test/__snapshots__/index.js.snap b/blocks/library/paragraph/test/__snapshots__/index.js.snap index f636c3ab42bd2..02e3dcefd7f66 100644 --- a/blocks/library/paragraph/test/__snapshots__/index.js.snap +++ b/blocks/library/paragraph/test/__snapshots__/index.js.snap @@ -2,28 +2,30 @@ 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/blocks/library/preformatted/test/__snapshots__/index.js.snap b/blocks/library/preformatted/test/__snapshots__/index.js.snap index 507c5a8d5839a..89317044d5b73 100644 --- a/blocks/library/preformatted/test/__snapshots__/index.js.snap +++ b/blocks/library/preformatted/test/__snapshots__/index.js.snap @@ -4,18 +4,28 @@ exports[`core/preformatted block edit matches snapshot 1`] = `
-
-  
-    Write preformatted text…
-  
+
+
+
+
+
+
`; diff --git a/blocks/library/pullquote/test/__snapshots__/index.js.snap b/blocks/library/pullquote/test/__snapshots__/index.js.snap index 0ba1410b92475..f8bdb27de8f50 100644 --- a/blocks/library/pullquote/test/__snapshots__/index.js.snap +++ b/blocks/library/pullquote/test/__snapshots__/index.js.snap @@ -7,20 +7,30 @@ exports[`core/pullquote block edit matches snapshot 1`] = `
-
-
-

- Write quote… -

+
+
+
+ +
diff --git a/blocks/library/quote/test/__snapshots__/index.js.snap b/blocks/library/quote/test/__snapshots__/index.js.snap index de3118a5e0cde..010e122e054ab 100644 --- a/blocks/library/quote/test/__snapshots__/index.js.snap +++ b/blocks/library/quote/test/__snapshots__/index.js.snap @@ -7,20 +7,30 @@ exports[`core/quote block edit matches snapshot 1`] = `
-
-
-

- Write quote… -

+
+
+
+ +
diff --git a/blocks/library/table/test/__snapshots__/index.js.snap b/blocks/library/table/test/__snapshots__/index.js.snap index 671959aa94f7e..77337c6c8b1fb 100644 --- a/blocks/library/table/test/__snapshots__/index.js.snap +++ b/blocks/library/table/test/__snapshots__/index.js.snap @@ -4,29 +4,39 @@ exports[`core/embed block edit matches snapshot 1`] = `
- - - - - - - - - - - -
-
-
-
-
-
-
-
-
+
+
+
+ + + + + + + + + + + + +
+
+
`; diff --git a/blocks/library/text-columns/test/__snapshots__/index.js.snap b/blocks/library/text-columns/test/__snapshots__/index.js.snap index a098343a1056d..c1572062af8c0 100644 --- a/blocks/library/text-columns/test/__snapshots__/index.js.snap +++ b/blocks/library/text-columns/test/__snapshots__/index.js.snap @@ -10,19 +10,29 @@ exports[`core/text-columns block edit matches snapshot 1`] = `
-

-

- New Column -

+
+
+
+

+ New Column +

+
+
+
-

-

- New Column -

+
+
+
+

+ New Column +

+
+
+
diff --git a/blocks/library/verse/test/__snapshots__/index.js.snap b/blocks/library/verse/test/__snapshots__/index.js.snap index c3f2b26dac7d7..7e514a4fb36fd 100644 --- a/blocks/library/verse/test/__snapshots__/index.js.snap +++ b/blocks/library/verse/test/__snapshots__/index.js.snap @@ -4,18 +4,28 @@ exports[`core/verse block edit matches snapshot 1`] = `
-
-  
-    Write…
-  
+
+
+
+
+
+
`; diff --git a/blocks/rich-text/README.md b/blocks/rich-text/README.md index c98aa659f266f..a6bc71a973caa 100644 --- a/blocks/rich-text/README.md +++ b/blocks/rich-text/README.md @@ -59,6 +59,10 @@ a traditional `input` field, usually when the user exits the field. *Optional.* By default, the placeholder will hide as soon as the editable field receives focus. With this setting it can be be kept while the field is focussed and empty. +### `autocompleters: Array` + +*Optional.* A list of autocompleters to use instead of the default. + ## Example {% codetabs %} diff --git a/blocks/rich-text/index.js b/blocks/rich-text/index.js index 514f56e2bee1e..6c84e30871d7f 100644 --- a/blocks/rich-text/index.js +++ b/blocks/rich-text/index.js @@ -20,7 +20,7 @@ import 'element-closest'; /** * WordPress dependencies */ -import { createElement, Component, renderToString } from '@wordpress/element'; +import { createElement, Component, renderToString, Fragment } from '@wordpress/element'; import { keycodes, createBlobURL, isHorizontalEdge, getRectangleFromRange } from '@wordpress/utils'; import { withSafeTimeout, Slot, Fill } from '@wordpress/components'; @@ -29,6 +29,7 @@ import { withSafeTimeout, Slot, Fill } from '@wordpress/components'; */ import './style.scss'; import { rawHandler } from '../api'; +import Autocomplete from '../autocomplete'; import FormatToolbar from './format-toolbar'; import TinyMCE from './tinymce'; import { pickAriaProps } from './aria'; @@ -754,6 +755,7 @@ export class RichText extends Component { keepPlaceholderOnFocus = false, isSelected = false, formatters, + autocompleters, } = this.props; const ariaProps = { ...pickAriaProps( this.props ), 'aria-multiline': !! MultilineTag }; @@ -788,27 +790,37 @@ export class RichText extends Component { { formatToolbar }
} - - { isPlaceholderVisible && - - { MultilineTag ? { placeholder } : placeholder } - - } - { isSelected && } + + { ( { isExpanded, listBoxId, activeId } ) => ( + + + { isPlaceholderVisible && + + { MultilineTag ? { placeholder } : placeholder } + + } + { isSelected && } + + ) } +
); } diff --git a/components/autocomplete/README.md b/components/autocomplete/README.md new file mode 100644 index 0000000000000..a3a3f8a979b9f --- /dev/null +++ b/components/autocomplete/README.md @@ -0,0 +1,121 @@ +Autocomplete +============ + +This component is used to provide autocompletion support for a child input component. + +## Autocompleters + +Autocompleters enable us to offer users options for completing text input. For example, Gutenberg includes a user autocompleter that provides a list of user names and completes a selection with a user mention like `@mary`. + +Each completer declares: + +* Its name. +* The text prefix that should trigger the display of completion options. +* Raw option data. +* How to render an option's label. +* An option's keywords, words that will be used to match an option with user input. +* What the completion of an option looks like, including whether it should be inserted in the text or used to replace the current block. + +In addition, a completer may optionally declare: + +* A class name to be applied to the completion menu. +* Whether it should apply to a specified text node. +* Whether the completer applies in a given context, defined via a Range before and a Range after the autocompletion trigger and query. + +### The Completer Interface + +#### name + +The name of the completer. Useful for identifying a specific completer to be overridden via extensibility hooks. + +- Type: `String` +- Required: Yes + +#### options + +The raw options for completion. May be an array, a function that returns an array, or a function that returns a promise for an array. + +Options may be of any type or shape. The completer declares how those options are rendered and what their completions should be when selected. + +- Type: `Array|Function` +- Required: Yes + +#### triggerPrefix + +The string prefix that should trigger the completer. For example, Gutenberg's block completer is triggered when the '/' character is entered. + +- Type: `String` +- Required: Yes + +#### getOptionLabel + +A function that returns the label for a given option. A label may be a string or a mixed array of strings, elements, and components. + +- Type: `Function` +- Required: Yes + +#### getOptionKeywords + +A function that returns the keywords for the specified option. + +- Type: `Function` +- Required: Yes + +#### getOptionCompletion + +A function that takes an option and responds with how the option should be completed. By default, the result is a value to be inserted in the text. However, a completer may explicitly declare how a completion should be treated by returning an object with `action` and `value` properties. The `action` declares what should be done with the `value`. + +There are currently two supported actions: + +* "insert-at-caret" - Insert the `value` into the text (the default completion action). +* "replace" - Replace the current block with the block specified in the `value` property. + +#### allowNode + +A function that takes a text node and returns a boolean indicating whether the completer should be considered for that node. + +- Type: `Function` +- Required: No + +#### allowContext + +A function that takes a Range before and a Range after the autocomplete trigger and query text and returns a boolean indicating whether the completer should be considered for that context. + +- Type: `Function` +- Required: No + +#### className + +A class name to apply to the autocompletion popup menu. + +- Type: `String` +- Required: No + +### Examples + +The following is a contrived completer for fresh fruit. + +```jsx +const fruitCompleter = { + name: 'fruit', + // The prefix that triggers this completer + triggerPrefix: '~', + // The option data + options: [ + { visual: '🍎', name: 'Apple' }, + { visual: '🍊', name: 'Orange' }, + { visual: '🍇', name: 'Grapes' }, + ], + // Returns a label for an option like "🍊 Orange" + getOptionLabel: option => [ + { option.visual }, + option.name + ], + // Declares that options should be matched by their name + getOptionKeywords: option => [ option.name ], + // Declares completions should be inserted as abbreviations + getOptionCompletion: option => ( + { option.visual } + ), +}; +``` diff --git a/components/autocomplete/completer-compat.js b/components/autocomplete/completer-compat.js new file mode 100644 index 0000000000000..37bfc4454c2cd --- /dev/null +++ b/components/autocomplete/completer-compat.js @@ -0,0 +1,56 @@ +/** + * This mod + */ + +/** + * WordPress dependencies. + */ +import { deprecated } from '@wordpress/utils'; + +const generateCompleterName = ( () => { + let count = 0; + return () => `backcompat-completer-${ count++ }`; +} )(); + +export function isDeprecatedCompleter( completer ) { + return 'onSelect' in completer; +} + +export function toCompatibleCompleter( deprecatedCompleter ) { + deprecated( 'Original autocompleter interface', { + version: '2.8', + alternative: 'Latest autocompleter interface', + plugin: 'Gutenberg', + link: 'https://github.com/WordPress/gutenberg/blob/master/components/autocomplete/README.md', + } ); + + const optionalProperties = [ 'className', 'allowNode', 'allowContext' ] + .filter( key => key in deprecatedCompleter ) + .map( key => deprecatedCompleter[ key ] ); + + return { + name: generateCompleterName(), + triggerPrefix: deprecatedCompleter.triggerPrefix, + + options() { + return deprecatedCompleter.getOptions(); + }, + + getOptionLabel( option ) { + return option.label; + }, + + getOptionKeywords( option ) { + return option.keywords; + }, + + getOptionCompletion() { + return { + action: 'backcompat', + value: deprecatedCompleter.onSelect.bind( deprecatedCompleter ), + }; + }, + + ...optionalProperties, + }; +} diff --git a/components/autocomplete/index.js b/components/autocomplete/index.js index 8b79f219aee3b..bdbb371b892b5 100644 --- a/components/autocomplete/index.js +++ b/components/autocomplete/index.js @@ -15,6 +15,7 @@ import { __, _n, sprintf } from '@wordpress/i18n'; * Internal dependencies */ import './style.scss'; +import { isDeprecatedCompleter, toCompatibleCompleter } from './completer-compat'; import withFocusOutside from '../higher-order/with-focus-outside'; import Button from '../button'; import Popover from '../popover'; @@ -23,6 +24,81 @@ import withSpokenMessages from '../higher-order/with-spoken-messages'; const { ENTER, ESCAPE, UP, DOWN, LEFT, RIGHT, SPACE } = keycodes; +/** + * A raw completer option. + * @typedef {*} CompleterOption + */ + +/** + * @callback FnGetOptions + * + * @returns {(CompleterOption[]|Promise.)} The completer options or a promise for them. + */ + +/** + * @callback FnGetOptionKeywords + * @param {CompleterOption} option a completer option. + * + * @returns {string[]} list of key words to search. + */ + +/** + * @callback FnGetOptionLabel + * @param {CompleterOption} option a completer option. + * + * @returns {(string|Array.<(string|Component)>)} list of react components to render. + */ + +/** + * @callback FnAllowNode + * @param {Node} textNode check if the completer can handle this text node. + * + * @returns {boolean} true if the completer can handle this text node. + */ + +/** + * @callback FnAllowContext + * @param {Range} before the range before the auto complete trigger and query. + * @param {Range} after the range after the autocomplete trigger and query. + * + * @returns {boolean} true if the completer can handle these ranges. + */ + +/** + * @typedef {Object} OptionCompletion + * @property {('insert-at-caret', 'replace')} action the intended placement of the completion. + * @property {OptionCompletionValue} value the completion value. + */ + +/** + * A completion value. + * @typedef {(String|WPElement|Object)} OptionCompletionValue + */ + +/** + * @callback FnGetOptionCompletion + * @param {CompleterOption} value the value of the completer option. + * @param {Range} range the nodes included in the autocomplete trigger and query. + * @param {String} query the text value of the autocomplete query. + * + * @returns {(OptionCompletion|OptionCompletionValue)} the completion for the given option. If an + * OptionCompletionValue is returned, the + * completion action defaults to `insert-at-caret`. + */ + +/** + * @typedef {Object} Completer + * @property {String} name a way to identify a completer, useful for selective overriding. + * @property {?String} className A class to apply to the popup menu. + * @property {String} triggerPrefix the prefix that will display the menu. + * @property {(CompleterOption[]|FnGetOptions)} options the completer options or a function to get them. + * @property {?FnGetOptionKeywords} getOptionKeywords get the keywords for a given option. + * @property {FnGetOptionLabel} getOptionLabel get the label for a given option. + * @property {?FnAllowNode} allowNode filter the allowed text nodes in the autocomplete. + * @property {?FnAllowContext} allowContext filter the context under which the autocomplete activates. + * @property {FnGetOptionCompletion} getOptionCompletion get the completion associated with a given option. + */ + /** * Recursively select the firstChild until hitting a leaf node. * @@ -131,6 +207,36 @@ export class Autocomplete extends Component { }; } + /* + * NOTE: This is necessary for backwards compatibility with the + * previous completer interface. Once we no longer support the + * old interface, we should be able to use the `completers` prop + * directly. + */ + static getDerivedStateFromProps( nextProps, prevState ) { + const { completers: nextCompleters } = nextProps; + const { lastAppliedCompleters } = prevState; + + if ( nextCompleters !== lastAppliedCompleters ) { + let completers = nextCompleters; + + if ( completers.some( isDeprecatedCompleter ) ) { + completers = completers.map( completer => { + return isDeprecatedCompleter( completer ) ? + toCompatibleCompleter( completer ) : + completer; + } ); + } + + return { + completers, + lastAppliedCompleters: nextCompleters, + }; + } + + return null; + } + constructor() { super( ...arguments ); @@ -150,7 +256,7 @@ export class Autocomplete extends Component { this.node = node; } - replace( range, replacement ) { + insertCompletion( range, replacement ) { const container = document.createElement( 'div' ); container.innerHTML = renderToString( replacement ); while ( container.firstChild ) { @@ -163,15 +269,32 @@ export class Autocomplete extends Component { } select( option ) { + const { onReplace } = this.props; const { open, range, query } = this.state; - const { onSelect } = open || {}; + const { getOptionCompletion } = open || {}; this.reset(); - if ( onSelect ) { - const replacement = onSelect( option.value, range, query ); - if ( replacement !== undefined ) { - this.replace( range, replacement ); + if ( getOptionCompletion ) { + const completion = getOptionCompletion( option.value, range, query ); + + const { action, value } = + ( undefined === completion.action || undefined === completion.value ) ? + { action: 'insert-at-caret', value: completion } : + completion; + + if ( 'replace' === action ) { + onReplace( [ value ] ); + } else if ( 'insert-at-caret' === action ) { + this.insertCompletion( range, value ); + } else if ( 'backcompat' === action ) { + // NOTE: This block should be removed once we no longer support the old completer interface. + const onSelect = value; + const deprecatedOptionObject = option.value; + const selectionResult = onSelect( deprecatedOptionObject.value, range, query ); + if ( selectionResult !== undefined ) { + this.insertCompletion( range, selectionResult ); + } } } } @@ -235,21 +358,30 @@ export class Autocomplete extends Component { /** * Load options for an autocompleter. * - * @param {number} autocompleterIndex The autocompleter index. - * @param {string} query The query, if any. + * @param {Completer} completer The autocompleter. + * @param {string} query The query, if any. */ - loadOptions( autocompleterIndex, query ) { - this.props.completers[ autocompleterIndex ].getOptions( query ).then( ( options ) => { - const keyedOptions = map( options, ( option, i ) => { - return { - ...option, - key: autocompleterIndex + '-' + i, - }; - } ); + loadOptions( completer, query ) { + const { options } = completer; + + /* + * We support both synchronous and asynchronous retrieval of completer options + * but internally treat all as async so we maintain a single, consistent code path. + */ + Promise.resolve( + typeof options === 'function' ? options( query ) : options + ).then( optionsData => { + const keyedOptions = optionsData.map( ( optionData, optionIndex ) => ( { + key: `${ completer.idx }-${ optionIndex }`, + value: optionData, + label: completer.getOptionLabel( optionData ), + keywords: completer.getOptionKeywords ? completer.getOptionKeywords( optionData ) : [], + } ) ); + const filteredOptions = filterOptions( this.state.search, keyedOptions ); const selectedIndex = filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; this.setState( { - [ 'options_' + autocompleterIndex ]: keyedOptions, + [ 'options_' + completer.idx ]: keyedOptions, filteredOptions, selectedIndex, } ); @@ -334,9 +466,9 @@ export class Autocomplete extends Component { } search( event ) { - const { open: wasOpen, suppress: wasSuppress, query: wasQuery } = this.state; - const { completers } = this.props; + const { completers, 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 ) { @@ -347,10 +479,10 @@ export class Autocomplete extends Component { const { open, query, range } = match || {}; // asynchronously load the options for the open completer if ( open && ( ! wasOpen || open.idx !== wasOpen.idx || query !== wasQuery ) ) { - if ( this.props.completers[ open.idx ].isDebounced ) { - this.debouncedLoadOptions( open.idx, query ); + if ( open.isDebounced ) { + this.debouncedLoadOptions( open, query ); } else { - this.loadOptions( open.idx, query ); + this.loadOptions( open, query ); } } // create a regular expression to filter the options diff --git a/components/autocomplete/test/index.js b/components/autocomplete/test/index.js index 6abba94aeb5b1..e64299aeabbcd 100644 --- a/components/autocomplete/test/index.js +++ b/components/autocomplete/test/index.js @@ -3,6 +3,7 @@ */ import { mount } from 'enzyme'; import { Component } from '../../../element'; +import { noop } from 'lodash'; /** * WordPress dependencies @@ -37,18 +38,24 @@ class FakeEditor extends Component { } } -function makeAutocompleter( completers, AutocompleteComponent = Autocomplete ) { +function makeAutocompleter( completers, { + AutocompleteComponent = Autocomplete, + onReplace = noop, +} = {} ) { return mount( - { - ( { isExpanded, listBoxId, activeId } ) => ( + + { ( { isExpanded, listBoxId, activeId } ) => ( - ) - } + ) } + ); } @@ -134,29 +141,31 @@ function expectInitialState( wrapper ) { describe( 'Autocomplete', () => { const options = [ { - value: 1, + id: 1, label: 'Bananas', keywords: [ 'fruit' ], }, { - value: 2, + id: 2, label: 'Apple', keywords: [ 'fruit' ], }, { - value: 3, + id: 3, label: 'Avocado', keywords: [ 'fruit' ], }, ]; const basicCompleter = { - getOptions: () => Promise.resolve( options ), + options, + getOptionLabel: option => option.label, + getOptionKeywords: option => option.keywords, }; const slashCompleter = { triggerPrefix: '/', - getOptions: () => Promise.resolve( options ), + ...basicCompleter, }; let realGetCursor, realCreateRange; @@ -215,7 +224,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing 'b' simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( function() { wrapper.update(); expect( wrapper.state( 'open' ) ).toBeDefined(); @@ -223,7 +232,7 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( 'b' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)b/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, + { key: '0-0', value: options[ 0 ], label: 'Bananas', keywords: [ 'fruit' ] }, ] ); expect( wrapper.find( 'Popover' ) ).toHaveLength( 1 ); expect( wrapper.find( 'Popover' ).prop( 'focusOnMount' ) ).toBe( false ); @@ -237,7 +246,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing 'zzz' simulateInput( wrapper, [ tx( 'zzz' ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // now check that we've opened the popup and filtered the options to empty @@ -256,7 +265,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing 'b' simulateInput( wrapper, [ par( tx( 'b' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // now check that the popup is not open @@ -270,7 +279,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing '/' simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // now check that we've opened the popup and filtered the options @@ -279,9 +288,9 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( '' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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 ); @@ -294,7 +303,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing fruit (split over 2 text nodes because these things happen) simulateInput( wrapper, [ par( tx( 'fru' ), tx( 'it' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // now check that we've opened the popup and filtered the options @@ -303,9 +312,9 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( 'fruit' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)fruit/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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 ); @@ -318,7 +327,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing 'a' simulateInput( wrapper, [ tx( 'a' ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // now check that we've opened the popup and all options are displayed @@ -327,8 +336,8 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( 'a' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)a/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', 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( 2 ); @@ -340,7 +349,7 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( 'ap' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)ap/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, + { 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 ); @@ -352,6 +361,80 @@ describe( 'Autocomplete', () => { } ); } ); + 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( 'navigates options by arrow keys', ( done ) => { const wrapper = makeAutocompleter( [ slashCompleter ] ); // listen to keydown events on the editor to see if it gets them @@ -367,7 +450,7 @@ describe( 'Autocomplete', () => { editorKeydown.mockClear(); // simulate typing '/', the menu is open so the editor should not get key down events simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); @@ -395,7 +478,7 @@ describe( 'Autocomplete', () => { expectInitialState( wrapper ); // simulate typing '/' simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); expect( wrapper.state( 'selectedIndex' ) ).toBe( 0 ); @@ -423,7 +506,7 @@ describe( 'Autocomplete', () => { editorKeydown.mockClear(); // simulate typing '/' simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // menu should be open with all options @@ -433,17 +516,17 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( '' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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 @@ -455,7 +538,9 @@ describe( 'Autocomplete', () => { it( 'closes by blur', () => { jest.spyOn( Autocomplete.prototype, 'handleFocusOutside' ); - const wrapper = makeAutocompleter( [], EnhancedAutocomplete ); + const wrapper = makeAutocompleter( [], { + AutocompleteComponent: EnhancedAutocomplete, + } ); simulateInput( wrapper, [ par( tx( '/' ) ) ] ); wrapper.find( '.fake-editor' ).simulate( 'blur' ); @@ -465,8 +550,11 @@ describe( 'Autocomplete', () => { } ); it( 'selects by enter', ( done ) => { - const onSelect = jest.fn(); - const wrapper = makeAutocompleter( [ { ...slashCompleter, onSelect } ] ); + 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' ); @@ -480,7 +568,7 @@ describe( 'Autocomplete', () => { editorKeydown.mockClear(); // simulate typing '/' simulateInput( wrapper, [ par( tx( '/' ) ) ] ); - // wait for getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // menu should be open with all options @@ -489,14 +577,14 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( '' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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 onSelect + // pressing enter should reset and call getOptionCompletion simulateKeydown( wrapper, ENTER ); expectInitialState( wrapper ); - expect( onSelect ).toHaveBeenCalled(); + expect( getOptionCompletion ).toHaveBeenCalled(); // the editor should not have gotten the event expect( editorKeydown ).not.toHaveBeenCalled(); done(); @@ -518,13 +606,16 @@ describe( 'Autocomplete', () => { done(); } ); - it( 'selects by click on result', ( done ) => { - const onSelect = jest.fn(); - const wrapper = makeAutocompleter( [ { ...slashCompleter, onSelect } ] ); + 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 getOptions promise + // wait for async popover display process.nextTick( () => { wrapper.update(); // menu should be open with all options @@ -533,15 +624,126 @@ describe( 'Autocomplete', () => { expect( wrapper.state( 'query' ) ).toEqual( '' ); expect( wrapper.state( 'search' ) ).toEqual( /(?:\b|\s|^)/i ); expect( wrapper.state( 'filteredOptions' ) ).toEqual( [ - { key: '0-0', value: 1, label: 'Bananas', keywords: [ 'fruit' ] }, - { key: '0-1', value: 2, label: 'Apple', keywords: [ 'fruit' ] }, - { key: '0-2', value: 3, label: 'Avocado', keywords: [ 'fruit' ] }, + { 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( onSelect ).toHaveBeenCalled(); + 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(); } ); } ); diff --git a/docs/extensibility.md b/docs/extensibility.md index 4b3ca31f3aac4..2ba9a51c29db8 100644 --- a/docs/extensibility.md +++ b/docs/extensibility.md @@ -49,4 +49,8 @@ Discover how [Meta Box](./extensibility/meta-box) support works in Gutenberg. By default, blocks provide their styles to enable basic support for blocks in themes without any change. Themes can add/override these styles, or rely on defaults. -There are some advanced block features which require opt-in support in the theme. See [theme support](./extensibility/theme-support) +There are some advanced block features which require opt-in support in the theme. See [theme support](./extensibility/theme-support). + +## Autocompletion + +Autocompleters within blocks may be extended and overridden. See [autocompletion](./extensibility/autocompletion). diff --git a/docs/extensibility/autocompletion.md b/docs/extensibility/autocompletion.md new file mode 100644 index 0000000000000..d087fd7d41c4d --- /dev/null +++ b/docs/extensibility/autocompletion.md @@ -0,0 +1,85 @@ +Autocompletion +============== + +Gutenberg provides a `blocks.Autocomplete.completers` filter for extending and overriding the list of autocompleters used by blocks. + +The `Autocomplete` component found in `@wordpress/blocks` applies this filter. The `@wordpress/components` package provides the foundational `Autocomplete` component that does not apply such a filter, but blocks should generally use the component provided by `@wordpress/blocks`. + +### Example + +Here is an example of using the `blocks.Autocomplete.completers` filter to add an acronym completer. You can find full documentation for the autocompleter interface with the `Autocomplete` component in the `@wordpress/components` package. + +{% codetabs %} +{% ES5 %} +```js +// Our completer +var acronymCompleter = { + name: 'acronyms', + triggerPrefix: '::', + options: [ + { letters: 'FYI', expansion: 'For Your Information' }, + { letters: 'AFAIK', expansion: 'As Far As I Know' }, + { letters: 'IIRC', expansion: 'If I Recall Correctly' }, + ], + getOptionKeywords: function( abbr ) { + var expansionWords = abbr.expansion.split( /\s+/ ); + return [ abbr.letters ].concat( expansionWords ); + }, + getOptionLabel: function( acronym ) { + return acronym.letters; + }, + getOptionCompletion: function( abbr ) { + return wp.element.createElement( + 'abbr', + { title: abbr.expansion }, + abbr.letters + ); + }, +}; + +// Our filter function +function appendAcronymCompleter( completers ) { + return completers.concat( acronymCompleter ); +} + +// Adding the filter +wp.hooks.addFilter( + 'blocks.Autocomplete.completers', + 'my-plugin/autocompleters/acronyms', + appendAcronymCompleter +); +``` +{% ESNext %} +```jsx +// Our completer +const acronymCompleter = { + name: 'acronyms', + triggerPrefix: '::', + options: [ + { letters: 'FYI', expansion: 'For Your Information' }, + { letters: 'AFAIK', expansion: 'As Far As I Know' }, + { letters: 'IIRC', expansion: 'If I Recall Correctly' }, + ], + getOptionKeywords( { letters, expansion } ) { + const expansionWords = expansion.split( /\s+/ ); + return [ letters, ...expansionWords ]; + }, + getOptionLabel: acronym => acronym.letters, + getOptionCompletion: ( { letters, expansion } ) => ( + { letters }, + ), +}; + +// Our filter function +function appendAcronymCompleter( completers ) { + return [ ...completers, acronymCompleter ]; +} + +// Adding the filter +wp.hooks.addFilter( + 'blocks.Autocomplete.completers', + 'my-plugin/autocompleters/acronym', + appendAcronymCompleter +); +``` +{% end %}