Skip to content
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

Merged
merged 71 commits into from
Apr 4, 2018

Conversation

adamsilverstein
Copy link
Member

@adamsilverstein adamsilverstein commented Jan 11, 2018

Description

Looking into user autocomplete (typing @ and then starting to type a username, see #2896), specifically the code for userAutocompleter: currently, it attempts to load all users with a single fetch via new 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

  • Load a post and type @ and start typing a username.
  • Check the network console, you will see requests being sent with a search string
  • Note the requests are sent at most every 250ms which should feel immediate while preventing over requesting

Screenshots (jpeg or gifs if applicable):

Types of changes

  • debounce search callback is autocompleters/index.js
  • in search callback, in autocompleter popover is open and selected autocompleter exposes a setSearch function, calls this function with the current search string on every (debounced) input event

Checklist:

  • My code is tested.
  • My code follows the WordPress code style.
  • My code follows has proper inline documentation.

@adamsilverstein
Copy link
Member Author

This PR breaks some tests that need fixing if/when we add this. Also, I'm wondering if calling new wp.api.collections.Users over and over is a good idea (is it getting garbage collected?), seems like it would be more efficient to instantiate this object once, then re-use it.

Copy link

@BoardJames BoardJames left a 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.

this.doSearch = ( event ) => {
// Ensure the synthetic event can be handled asynchronously.
event.persist();
this.debouncedSearch( event );

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.

Copy link
Member Author

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.

@@ -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 );

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.

Copy link
Member Author

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'

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).

Copy link
Member Author

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.

// 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 ) {

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 ];

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 );

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.

Copy link
Member Author

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.

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;

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?

Copy link
Member Author

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.

Copy link
Member Author

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?

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 } ) );

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.

Copy link
Member Author

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?

Copy link
Member Author

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?

Copy link
Member Author

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

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).

@@ -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 ] ) : [];

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.

@@ -122,6 +122,10 @@ export function userAutocompleter() {
return textNode.parentElement.closest( 'a' ) === null;
};

const setSearch = ( search, getOptions ) => {

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?

Copy link
Member Author

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

Copy link
Member Author

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

// 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 ) => {

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?

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 ) => {
Copy link
Member

@gziolo gziolo Jan 12, 2018

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 } }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, changed

@adamsilverstein
Copy link
Member Author

@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?

@adamsilverstein
Copy link
Member Author

@aduth This should be ready for a merge.

const getOptions = ( search ) => {
let payload;
if ( search ) {
payload = { data: { search } };
Copy link
Member

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.

Copy link
Member Author

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.

@adamsilverstein
Copy link
Member Author

addressed your latest feedback, can you give this a test please @aduth?

this.props.completers[ index ]
.getOptions( query )
.then( ( options ) => {
const keyedOptions = map( options, ( option, i ) => ( { ...option, key: index + '-' + ( option.value.slug ? option.value.slug : i ) } ) );
Copy link
Member

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?

Copy link
Member Author

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

this.props.completers[ index ]
.getOptions( query )
.then( ( options ) => {
const keyedOptions = map( options, ( option, i ) => ( { ...option, key: index + '-' + ( option.value.slug ? option.value.slug : i ) } ) );
Copy link
Member

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( '-' ),
	};
} );

Copy link
Member

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.

Copy link
Member Author

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.

Copy link
Member Author

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

const getOptions = ( search ) => {
let payload = '';
if ( search ) {
payload = '?search=' + search;
Copy link
Member

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?

Copy link
Member Author

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?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

adjusted

Copy link
Member

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.

Copy link
Member Author

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

@adamsilverstein
Copy link
Member Author

adamsilverstein commented Apr 4, 2018

Any blockers for merging this? I'd like to leverage this for #5921

@adamsilverstein adamsilverstein merged commit e02ea64 into master Apr 4, 2018
@adamsilverstein adamsilverstein deleted the feature/autocomplete-user-searching branch April 4, 2018 15:15
@adamsilverstein adamsilverstein added this to the 2.6 milestone Apr 4, 2018
@adamsilverstein adamsilverstein changed the title User autocomplete should used debounced search request User autocomplete - use debounced search request Apr 4, 2018
@mtias
Copy link
Member

mtias commented Apr 5, 2018

@adamsilverstein thanks for working and finishing this one!

@adamsilverstein
Copy link
Member Author

@mtias happy to help, and especially to see the effort improve Gutenberg.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants