User autocomplete - use debounced search request#4406
User autocomplete - use debounced search request#4406adamsilverstein merged 71 commits intomasterfrom
Conversation
|
This PR breaks some tests that need fixing if/when we add this. Also, I'm wondering if calling |
BoardJames
left a comment
There was a problem hiding this comment.
A good idea but needs some changes.
components/autocomplete/index.js
Outdated
| this.doSearch = ( event ) => { | ||
| // Ensure the synthetic event can be handled asynchronously. | ||
| event.persist(); | ||
| this.debouncedSearch( event ); |
There was a problem hiding this comment.
search only uses event.target so you should be able to avoid using persist if you extract it.
There was a problem hiding this comment.
aha! I struggled to figure this out, I thought because of React's synthetic events I had to persist when debouncing... So if I extract search hereI won't need the event later. I'll change that.
components/autocomplete/index.js
Outdated
| this.getWordRect = this.getWordRect.bind( this ); | ||
| this.debouncedSearch = debounce( ( e ) => { | ||
| this.search( e ); | ||
| }, 250 ); |
There was a problem hiding this comment.
I wonder if all these debounce's are really needed, the search code is already pretty efficient and tends to exit early when it can.
There was a problem hiding this comment.
yes, when typing a search term with an asynchronous source, debouncing is appropriate. before adding the debounce I was getting a search fired for every character I typed. When I quickly type 'cat' I would expect only one search for 'cat' not three searches for 'c', 'ca', and 'cat'
There was a problem hiding this comment.
Yes, but in that case the debounce should be in user search code in autocompleters, not in the general code in autocomplete. The general code may still be used to search things that have a fixed list (ie the forward-slash block completer).
There was a problem hiding this comment.
Yea, good point. I'll move it there.
components/autocomplete/index.js
Outdated
| // update the state | ||
| if ( wasOpen || open ) { | ||
| if ( open ) { | ||
| if ( this.props.completers[ open.idx ].setSearch ) { |
There was a problem hiding this comment.
To make this clearer to read in the multiple places it is used I would extract this.props.completers[ open.idx ] into a constant, ie:
const completer = this.props.completers[ open.idx ];
components/autocomplete/index.js
Outdated
| if ( this.props.completers[ open.idx ].setSearch ) { | ||
| this.props.completers[ open.idx ].setSearch( query, this.props.completers[ open.idx ].getOptions ).then( ( options ) => { | ||
| const keyedOptions = map( options, ( option, i ) => ( { ...option, key: open.idx + '-' + i } ) ); | ||
| filteredOptions = filterOptions( this.state.search, keyedOptions ); |
There was a problem hiding this comment.
As this code is asynchronous I would not reuse the filteredOptions variable (call it asyncFilteredOptions or something) because the code that comes after this async block on the page will not get access to the updated value.
There was a problem hiding this comment.
Right, good point, I'll rework this part.
components/autocomplete/index.js
Outdated
| this.props.completers[ open.idx ].setSearch( query, this.props.completers[ open.idx ].getOptions ).then( ( options ) => { | ||
| const keyedOptions = map( options, ( option, i ) => ( { ...option, key: open.idx + '-' + i } ) ); | ||
| filteredOptions = filterOptions( this.state.search, keyedOptions ); | ||
| const selectedIndex = filteredOptions.length === this.state.filteredOptions.length ? this.state.selectedIndex : 0; |
There was a problem hiding this comment.
I know my code probably made a similar bad assumption but just because the lengths are the same the item at that position may not be the same - something that will probably be more noticeable with the new search code. It might be desirable to search the filtered options?
There was a problem hiding this comment.
yea, good point, the slug should be unique. i'll work on this some more.
There was a problem hiding this comment.
I removed my copy of this code block, the length check seems to work, I guess in some cases it might show the incorrect selection?
components/autocomplete/index.js
Outdated
| if ( open ) { | ||
| if ( this.props.completers[ open.idx ].setSearch ) { | ||
| this.props.completers[ open.idx ].setSearch( query, this.props.completers[ open.idx ].getOptions ).then( ( options ) => { | ||
| const keyedOptions = map( options, ( option, i ) => ( { ...option, key: open.idx + '-' + i } ) ); |
There was a problem hiding this comment.
The original intent of adding the key here was that each unique option would have a unique key that was not effected by the filtering - this effects the ARIA the most because it used the key to determine if the selection had changed and needed to be re-announced. Unfortunately the dynamic server-side search breaks this assumption and I am not sure how you will fix it.
There was a problem hiding this comment.
Hmm, not sure i understand. the issue is the tool will not re announce the selection, or that it will over announce when the selection hasn't changed?
There was a problem hiding this comment.
could we use the item slug here which should be unique?
There was a problem hiding this comment.
I reworked the keying to use slugs if available, seems to work but needs testing
There was a problem hiding this comment.
Ok, make sure it works in the general case (ie for the slash completer and the proposed hash-tag completer).
components/autocomplete/index.js
Outdated
| 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 ] ) : []; | ||
| let filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; |
There was a problem hiding this comment.
If you rename the asynchronously calculated output further down this doesn't need to be a let.
blocks/autocompleters/index.js
Outdated
| return textNode.parentElement.closest( 'a' ) === null; | ||
| }; | ||
|
|
||
| const setSearch = ( search, getOptions ) => { |
There was a problem hiding this comment.
Why is getOptions a parameter? Why not just pass the search?
There was a problem hiding this comment.
I am calling getOptions in the inner function, I couldn't seem to call it without passing, I'll double check this
There was a problem hiding this comment.
removed setSearch entirely so this no longer applies
components/autocomplete/index.js
Outdated
| if ( wasOpen || open ) { | ||
| if ( open ) { | ||
| if ( this.props.completers[ open.idx ].setSearch ) { | ||
| this.props.completers[ open.idx ].setSearch( query, this.props.completers[ open.idx ].getOptions ).then( ( options ) => { |
There was a problem hiding this comment.
As before, why are you passing getOptions?
blocks/autocompleters/index.js
Outdated
| const getOptions = () => { | ||
| return ( new wp.api.collections.Users() ).fetch().then( ( users ) => { | ||
| const getOptions = ( search = '' ) => { | ||
| return ( new wp.api.collections.Users() ).fetch( { data: { search: search } } ).then( ( users ) => { |
There was a problem hiding this comment.
You can use ES6 shorthand for search field:
{ data: { search } }There was a problem hiding this comment.
Good point, changed
|
@EphoxJames thanks for all the feedback. I gave this a little tree shaking and simplified the changes. Can you please give it another review? One think I'd like to figure out is how to make this extensible. In particular, for the original use case that brought about this PR, we want the autocomplete to insert the user slug instead of the user name. Would the correct approach be to make the entire component filterable with the HOC? |
# Conflicts: # blocks/autocompleters/index.js
|
@aduth This should be ready for a merge. |
blocks/autocompleters/index.js
Outdated
| const getOptions = ( search ) => { | ||
| let payload; | ||
| if ( search ) { | ||
| payload = { data: { search } }; |
There was a problem hiding this comment.
This is never used? Cause of your lint error.
There was a problem hiding this comment.
Ah, that was lost in a merge, fixed and verified its working again.
|
addressed your latest feedback, can you give this a test please @aduth? |
components/autocomplete/index.js
Outdated
| this.props.completers[ index ] | ||
| .getOptions( query ) | ||
| .then( ( options ) => { | ||
| const keyedOptions = map( options, ( option, i ) => ( { ...option, key: index + '-' + ( option.value.slug ? option.value.slug : i ) } ) ); |
There was a problem hiding this comment.
What is slug here? Are we assuming a particularly-shaped REST API entity where the autocomplete options are otherwise generic?
There was a problem hiding this comment.
Hmmm, I'm not sure we need this maybe a remnant of a previous approach, I'll revert this change
components/autocomplete/index.js
Outdated
| this.props.completers[ index ] | ||
| .getOptions( query ) | ||
| .then( ( options ) => { | ||
| const keyedOptions = map( options, ( option, i ) => ( { ...option, key: index + '-' + ( option.value.slug ? option.value.slug : i ) } ) ); |
There was a problem hiding this comment.
This line is excessively long. Implicit object arrow returns are hard to distinguish from arrow function body. Dense single lines ought be undesirable.
With some breathing room, and clear labeling of what we're constructing via variable assignment of the suffix.
const keyedOptions = map( options, ( option, i ) => {
const keySuffix = option.value.slug || i;
return {
...option,
key: [ index, keySuffix ].join( '-' ),
};
} );There was a problem hiding this comment.
Aside: Existed previously, but as a reader, I'm not sure what the difference between index and i are here at a glance. Maybe we ought to name each autocompleter instead of using its index as an identifier.
There was a problem hiding this comment.
I renamed this variable autocompleterIndex to make it clearer.
There was a problem hiding this comment.
also, unfurled that long line as you suggested to make it easier to read
blocks/autocompleters/index.js
Outdated
| const getOptions = ( search ) => { | ||
| let payload = ''; | ||
| if ( search ) { | ||
| payload = '?search=' + search; |
There was a problem hiding this comment.
I assume you mean just the search part of the payload, for security? otherwise I wouldn’t think its needed, as we are searching usernames?
There was a problem hiding this comment.
Not necessarily for security. If someone entered @foo&bar, I assume it would (have previously) caused issue for the payload request.
There was a problem hiding this comment.
ah, right '&' could cause unexpected results, good catch
|
Any blockers for merging this? I'd like to leverage this for #5921 |
|
@adamsilverstein thanks for working and finishing this one! |
|
@mtias happy to help, and especially to see the effort improve Gutenberg. |
Description
Looking into user autocomplete (typing
@and then starting to type a username, see #2896), specifically the code foruserAutocompleter: currently, it attempts to load all users with a single fetch vianew wp.api.collections.Users() ).fetch().then( ( users ) => {.... While this works for testing with a few users, it is not scalable. By default the REST API users endpoint returns 10 users and the default maximum for REST requests is 100 records. Some sites might have hundreds or even thousands of users, so the filtering applied as you type won't work.This should instead use a debounced search. Every time the user types and pauses briefly, fire off a request to the REST API with the typed search string, this will work:
new wp.api.collections.Users() ).fetch( { data: { search: searchString } } ).then( ( users ) => {....Addresses #2793 (comment)
How Has This Been Tested/How to test
@and start typing a username.Screenshots (jpeg or gifs if applicable):
Types of changes
setSearchfunction, calls this function with the current search string on every (debounced) input eventChecklist: