-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Support overriding autocomplete with filters
- Loading branch information
1 parent
578b66a
commit c48f351
Showing
32 changed files
with
1,514 additions
and
529 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<div onFocus={ this.onFocus } ref={ this.saveParentRef }> | ||
<Autocomplete onFocus={ this.onFocus } { ...autocompleteProps } /> | ||
</div> | ||
); | ||
} | ||
}; | ||
} | ||
|
||
export default withFilteredAutocompleters( OriginalAutocomplete ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 <input />; | ||
} | ||
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( <FilteredComponent completers={ expectedCompleters } /> ); | ||
|
||
expect( completersFilter ).not.toHaveBeenCalled(); | ||
wrapper.find( 'input' ).simulate( 'focus' ); | ||
expect( completersFilter ).toHaveBeenCalledWith( expectedCompleters ); | ||
} ); | ||
|
||
it( 'filters completers supplied when already focused', () => { | ||
wrapper = mount( <FilteredComponent completers={ [] } /> ); | ||
|
||
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( <FilteredComponent completers={ specifiedCompleters } /> ); | ||
|
||
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( <FilteredComponent /> ); | ||
|
||
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( <FilteredComponent { ...expectedProps } /> ); | ||
|
||
const innerComponentWrapper = wrapper.childAt( 0 ); | ||
expect( innerComponentWrapper.name() ).toBe( 'TestComponent' ); | ||
expect( innerComponentWrapper.props() ).toMatchObject( expectedProps ); | ||
} ); | ||
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
Autocompleters | ||
============== | ||
|
||
The Autocompleter interface is documented [here](../../components/autocomplete/README.md) with the `Autocomplete` component in `@wordpress/components`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 [ | ||
<BlockIcon key="icon" icon={ icon } />, | ||
title, | ||
]; | ||
}, | ||
allowContext( before, after ) { | ||
return ! ( /\S/.test( before.toString() ) || /\S/.test( after.toString() ) ); | ||
}, | ||
getOptionCompletion( blockData ) { | ||
return { | ||
action: 'replace', | ||
value: createBlock( blockData.name ), | ||
}; | ||
}, | ||
}; |
Oops, something went wrong.