-
Notifications
You must be signed in to change notification settings - Fork 4.3k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
User autocomplete - use debounced search request #4406
Conversation
This PR breaks some tests that need fixing if/when we add this. Also, I'm wondering if calling |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -131,6 +131,14 @@ export class Autocomplete extends Component { | |||
this.search = this.search.bind( this ); | |||
this.handleKeyDown = this.handleKeyDown.bind( this ); | |||
this.getWordRect = this.getWordRect.bind( this ); | |||
this.debouncedSearch = debounce( ( e ) => { | |||
this.search( e ); | |||
}, 250 ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yea, good point. I'll move it there.
components/autocomplete/index.js
Outdated
// check if we should still suppress the popover | ||
const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; | ||
// update the state | ||
if ( wasOpen || open ) { | ||
if ( open ) { | ||
if ( this.props.completers[ open.idx ].setSearch ) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
yea, good point, the slug should be unique. i'll work on this some more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
could we use the item slug here which should be unique?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I reworked the keying to use slugs if available, seems to work but needs testing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
@@ -330,11 +338,25 @@ export class Autocomplete extends Component { | |||
// create a regular expression to filter the options | |||
const search = open ? new RegExp( '(?:\\b|\\s|^)' + escapeRegExp( query ), 'i' ) : /./; | |||
// filter the options we already have | |||
const filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; | |||
let filteredOptions = open ? filterOptions( search, this.state[ 'options_' + open.idx ] ) : []; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you rename the asynchronously calculated output further down this doesn't need to be a let
.
blocks/autocompleters/index.js
Outdated
@@ -122,6 +122,10 @@ export function userAutocompleter() { | |||
return textNode.parentElement.closest( 'a' ) === null; | |||
}; | |||
|
|||
const setSearch = ( search, getOptions ) => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is getOptions
a parameter? Why not just pass the search?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
removed setSearch entirely so this no longer applies
components/autocomplete/index.js
Outdated
// check if we should still suppress the popover | ||
const suppress = ( open && wasSuppress === open.idx ) ? wasSuppress : undefined; | ||
// update the state | ||
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You can use ES6 shorthand for search
field:
{ data: { search } }
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is never used? Cause of your lint error.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I renamed this variable autocompleterIndex
to make it clearer.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does this need to be encoded?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
adjusted
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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
setSearch
function, calls this function with the current search string on every (debounced) input eventChecklist: