From 9e51c6e66cce995e1aa3f98bc1ccb0d7b1e33caf Mon Sep 17 00:00:00 2001 From: sanketio Date: Mon, 6 Apr 2026 12:50:26 +0530 Subject: [PATCH 1/2] Users: Add autocomplete support to wp_dropdown_users() --- src/js/_enqueues/lib/user-suggest.js | 48 +- src/wp-admin/includes/ajax-actions.php | 172 ++++-- src/wp-admin/users.php | 7 +- src/wp-includes/user.php | 99 +++- .../tests/ajax/wpAjaxAutocompleteUser.php | 499 ++++++++++++++++++ tests/phpunit/tests/user/wpDropdownUsers.php | 250 +++++++++ 6 files changed, 1014 insertions(+), 61 deletions(-) create mode 100644 tests/phpunit/tests/ajax/wpAjaxAutocompleteUser.php diff --git a/src/js/_enqueues/lib/user-suggest.js b/src/js/_enqueues/lib/user-suggest.js index f05b7ffa8892f..1a204a013bfc8 100644 --- a/src/js/_enqueues/lib/user-suggest.js +++ b/src/js/_enqueues/lib/user-suggest.js @@ -1,10 +1,9 @@ /** - * Suggests users in a multisite environment. + * Suggests users in site admin and multisite environments. * - * For input fields where the admin can select a user based on email or - * username, this script shows an autocompletion menu for these inputs. Should - * only be used in a multisite environment. Only users in the currently active - * site are shown. + * For input fields where the admin can select a user by searching their name, + * login, or email, this script shows an autocompletion menu. On multisite, + * only users in the currently active site are shown for 'search' type requests. * * @since 3.4.0 * @output wp-admin/js/user-suggest.js @@ -36,19 +35,29 @@ * - data-autocomplete-type (add, search) * The action that is going to be performed: search for existing users * or add a new one. Default: add - * - data-autocomplete-field (user_login, user_email) + * - data-autocomplete-field (user_login, user_email, user_id) * The field that is returned as the value for the suggestion. + * When set to 'user_id', the input is expected to have an adjacent + * '.wp-suggest-user-helper' hidden input that stores the numeric ID + * while the visible input shows the display label. * Default: user_login + * - data-autocomplete-label + * A template string with {{tokens}} to build each result's display label. + * Supported tokens: {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}. + * Default: empty (server returns display_name). * - * @see wp-admin/includes/admin-actions.php:wp_ajax_autocomplete_user() + * @see wp-admin/includes/ajax-actions.php:wp_ajax_autocomplete_user() */ - $( '.wp-suggest-user' ).each( function(){ - var $this = $( this ), - autocompleteType = ( typeof $this.data( 'autocompleteType' ) !== 'undefined' ) ? $this.data( 'autocompleteType' ) : 'add', - autocompleteField = ( typeof $this.data( 'autocompleteField' ) !== 'undefined' ) ? $this.data( 'autocompleteField' ) : 'user_login'; + $( '.wp-suggest-user' ).each( function() { + var $this = $( this ), + autocompleteType = ( typeof $this.data( 'autocompleteType' ) !== 'undefined' ) ? $this.data( 'autocompleteType' ) : 'add', + autocompleteField = ( typeof $this.data( 'autocompleteField' ) !== 'undefined' ) ? $this.data( 'autocompleteField' ) : 'user_login', + autocompleteLabel = ( typeof $this.data( 'autocompleteLabel' ) !== 'undefined' ) ? $this.data( 'autocompleteLabel' ) : '', + // True when using user_id field with a sibling helper input. + hasHelper = ( 'user_id' === autocompleteField && $this.next( '.wp-suggest-user-helper' ).length > 0 ); $this.autocomplete({ - source: ajaxurl + '?action=autocomplete-user&autocomplete_type=' + autocompleteType + '&autocomplete_field=' + autocompleteField + id, + source: ajaxurl + '?action=autocomplete-user&autocomplete_type=' + autocompleteType + '&autocomplete_field=' + autocompleteField + '&autocomplete_label=' + encodeURIComponent( autocompleteLabel ) + id, delay: 500, minLength: 2, position: position, @@ -57,6 +66,21 @@ }, close: function() { $( this ).removeClass( 'open' ); + }, + focus: function( e, ui ) { + if ( hasHelper ) { + // Show the display label while navigating, not the raw ID value. + $( this ).val( ui.item.label ); + return false; + } + }, + select: function( e, ui ) { + if ( hasHelper ) { + // Store the user ID in the hidden helper; show the label in the text input. + $( this ).next( '.wp-suggest-user-helper' ).val( ui.item.value ); + $( this ).val( ui.item.label ); + return false; + } } }); }); diff --git a/src/wp-admin/includes/ajax-actions.php b/src/wp-admin/includes/ajax-actions.php index 2af08fba70af9..608f90bc1559f 100644 --- a/src/wp-admin/includes/ajax-actions.php +++ b/src/wp-admin/includes/ajax-actions.php @@ -284,80 +284,170 @@ function wp_ajax_oembed_cache() { /** * Handles user autocomplete via AJAX. * + * Works on both single-site and multisite installs. On multisite, the 'add' + * type searches the full network and is restricted to network admins; the + * 'search' type is restricted to users already on the current site. + * On single-site, only the 'search' type is supported and requires + * the 'list_users' capability. + * * @since 3.4.0 + * @since 7.1.0 Added single-site support, the `user_id` autocomplete field, + * label template tokens, and the `autocomplete_user_results` filter. */ function wp_ajax_autocomplete_user() { - if ( ! is_multisite() || ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) { - wp_die( -1 ); - } + /* + * Validate the minimum search term length before anything else. + * The same minimum is enforced in JS via the minLength option. + */ + $term = isset( $_REQUEST['term'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['term'] ) ) : ''; - /** This filter is documented in wp-admin/user-new.php */ - if ( ! current_user_can( 'manage_network_users' ) && ! apply_filters( 'autocomplete_users_for_site_admins', false ) ) { + /** + * Filters the minimum search term length for user autocomplete. + * + * The same minimum should be mirrored in the JavaScript minLength option. + * + * @since 7.1.0 + * + * @param int $length Minimum number of characters required. Default 2. + * @param string $context Context for the autocomplete search. Currently 'users'. + */ + if ( strlen( $term ) < apply_filters( 'autocomplete_term_length', 2, 'users' ) ) { wp_die( -1 ); } - $return = array(); - /* * Check the type of request. - * Current allowed values are `add` and `search`. + * Allowed values are `add` (multisite only) and `search`. */ - if ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] ) { - $type = $_REQUEST['autocomplete_type']; - } else { - $type = 'add'; - } + $type = ( isset( $_REQUEST['autocomplete_type'] ) && 'search' === $_REQUEST['autocomplete_type'] ) + ? 'search' + : 'add'; /* - * Check the desired field for value. - * Current allowed values are `user_email` and `user_login`. + * Determine the user field to return as the suggestion value. + * Allowed values are `user_email`, `user_login`, and `user_id`. */ - if ( isset( $_REQUEST['autocomplete_field'] ) && 'user_email' === $_REQUEST['autocomplete_field'] ) { - $field = $_REQUEST['autocomplete_field']; + $requested_field = isset( $_REQUEST['autocomplete_field'] ) ? $_REQUEST['autocomplete_field'] : ''; + if ( 'user_email' === $requested_field ) { + $field = 'user_email'; + } elseif ( 'user_id' === $requested_field ) { + $field = 'ID'; } else { $field = 'user_login'; } - // Exclude current users of this blog. - if ( isset( $_REQUEST['site_id'] ) ) { - $id = absint( $_REQUEST['site_id'] ); + /* + * Resolve the label template. Supported tokens: + * {{user_login}}, {{user_email}}, {{display_name}}, {{user_id}}. + */ + $label_template = ( isset( $_REQUEST['autocomplete_label'] ) && '' !== $_REQUEST['autocomplete_label'] ) + ? sanitize_text_field( wp_unslash( $_REQUEST['autocomplete_label'] ) ) + : '{{display_name}}'; + + $blog_id = false; + $include_blog_users = array(); + $exclude_blog_users = array(); + $search_columns = array( 'user_login', 'user_nicename', 'display_name' ); + + if ( is_multisite() && 'add' === $type ) { + /* + * Adding a user to a site requires network-level permissions and should + * not run on very large networks where the search would be slow. + */ + if ( ! current_user_can( 'promote_users' ) || wp_is_large_network( 'users' ) ) { + wp_die( -1 ); + } + + /** This filter is documented in wp-admin/user-new.php */ + if ( ! current_user_can( 'manage_network_users' ) && ! apply_filters( 'autocomplete_users_for_site_admins', false ) ) { + wp_die( -1 ); + } + + // Search the full network; exclude users already on the target site. + $site_id = isset( $_REQUEST['site_id'] ) ? absint( $_REQUEST['site_id'] ) : get_current_blog_id(); + $exclude_blog_users = get_users( + array( + 'blog_id' => $site_id, + 'fields' => 'ID', + ) + ); + + // Super-admins may search by email as well. + $search_columns[] = 'user_email'; } else { - $id = get_current_blog_id(); - } + /* + * Single-site search, or multisite 'search' type. + * Requires the ability to list users on this site. + */ + if ( ! current_user_can( 'list_users' ) ) { + wp_die( -1 ); + } - $include_blog_users = ( 'search' === $type ? get_users( - array( - 'blog_id' => $id, - 'fields' => 'ID', - ) - ) : array() ); + $blog_id = get_current_blog_id(); - $exclude_blog_users = ( 'add' === $type ? get_users( - array( - 'blog_id' => $id, - 'fields' => 'ID', - ) - ) : array() ); + if ( is_multisite() ) { + // Restrict results to users already on this site. + $include_blog_users = get_users( + array( + 'blog_id' => $blog_id, + 'fields' => 'ID', + ) + ); + } + + // Include email in search columns only for users who can edit others. + if ( current_user_can( 'edit_users' ) ) { + $search_columns[] = 'user_email'; + } + } + + // Email tokens in the label should only be visible to users who can edit others. + if ( ! current_user_can( 'edit_users' ) ) { + $label_template = str_replace( '{{user_email}}', '', $label_template ); + } $users = get_users( array( - 'blog_id' => false, - 'search' => '*' . $_REQUEST['term'] . '*', + 'blog_id' => $blog_id, + 'search' => '*' . $term . '*', 'include' => $include_blog_users, 'exclude' => $exclude_blog_users, - 'search_columns' => array( 'user_login', 'user_nicename', 'user_email' ), + 'search_columns' => $search_columns, + 'number' => 20, ) ); + $return = array(); + foreach ( $users as $user ) { + // Replace supported tokens in the label template. + $label = $label_template; + foreach ( array( 'user_login', 'user_email', 'display_name' ) as $token ) { + $label = str_replace( '{{' . $token . '}}', $user->$token, $label ); + } + $label = str_replace( '{{user_id}}', $user->ID, $label ); + + if ( '' === trim( $label ) ) { + $label = '(' . $user->user_login . ')'; + } + $return[] = array( - /* translators: 1: User login, 2: User email address. */ - 'label' => sprintf( _x( '%1$s (%2$s)', 'user autocomplete result' ), $user->user_login, $user->user_email ), - 'value' => $user->$field, + 'label' => esc_html( $label ), + 'value' => ( 'ID' === $field ) ? $user->ID : $user->$field, ); } - wp_die( wp_json_encode( $return ) ); + /** + * Filters the user autocomplete results array. + * + * @since 7.1.0 + * + * @param array $return Array of result objects with 'label' and 'value' keys. + * @param string $term The sanitized search term. + */ + $return = apply_filters( 'autocomplete_user_results', $return, $term ); + + wp_send_json( $return ); } /** diff --git a/src/wp-admin/users.php b/src/wp-admin/users.php index 650f81027592c..aa64df1339a70 100644 --- a/src/wp-admin/users.php +++ b/src/wp-admin/users.php @@ -218,7 +218,12 @@ wp_delete_user( $id ); break; case 'reassign': - wp_delete_user( $id, $_REQUEST['reassign_user'] ); + $reassign_id = isset( $_REQUEST['reassign_user'] ) ? absint( $_REQUEST['reassign_user'] ) : 0; + if ( $reassign_id > 0 && $reassign_id !== $id ) { + wp_delete_user( $id, $reassign_id ); + } else { + wp_delete_user( $id ); + } break; } diff --git a/src/wp-includes/user.php b/src/wp-includes/user.php index 9c635f63d288a..d677331cbae0f 100644 --- a/src/wp-includes/user.php +++ b/src/wp-includes/user.php @@ -1652,6 +1652,7 @@ function setup_userdata( $for_user_id = 0 ) { * @since 4.7.0 Added the 'role', 'role__in', and 'role__not_in' parameters. * @since 5.9.0 Added the 'capability', 'capability__in', and 'capability__not_in' parameters. * Deprecated the 'who' parameter. + * @since 7.1.0 Added the 'autocomplete' parameter. * * @param array|string $args { * Optional. Array or string of arguments to generate a drop-down of users. @@ -1713,6 +1714,11 @@ function setup_userdata( $for_user_id = 0 ) { * of these capabilities will not be included in results. * Does NOT work for capabilities not in the database or filtered * via {@see 'map_meta_cap'}. Default empty array. + * @type bool $autocomplete Whether to replace the drop-down with a jQuery UI autocomplete + * text input backed by an AJAX user search. In the admin, this + * is automatically enabled when wp_is_large_user_count() is true + * and no 'include' list or 'show_option_all' is set. + * Default false. * } * @return string HTML dropdown list of users. */ @@ -1742,6 +1748,7 @@ function wp_dropdown_users( $args = '' ) { 'capability' => '', 'capability__in' => array(), 'capability__not_in' => array(), + 'autocomplete' => false, ); $defaults['selected'] = is_author() ? get_query_var( 'author' ) : 0; @@ -1781,9 +1788,37 @@ function wp_dropdown_users( $args = '' ) { $show_option_none = $parsed_args['show_option_none']; $option_none_value = $parsed_args['option_none_value']; + // Determine whether to use autocomplete mode before running the full user query. + // Auto-enable in the admin when the site has a large user count, unless the + // result set is already bounded by an explicit 'include' list or the caller + // requires a 'show_option_all' placeholder (which has no autocomplete equivalent). + $use_autocomplete = (bool) $parsed_args['autocomplete']; + if ( ! $use_autocomplete && ! $show_option_all && empty( $query_args['include'] ) && is_admin() ) { + $use_autocomplete = wp_is_large_user_count(); + } + + /** + * Filters whether to replace the user drop-down with a jQuery UI autocomplete input. + * + * In the admin, this automatically enables when wp_is_large_user_count() returns + * true and no bounded 'include' list or 'show_option_all' is present. Callers + * may also pass `'autocomplete' => true` to force autocomplete mode explicitly. + * + * @since 7.1.0 + * + * @param bool $use_autocomplete Whether to use autocomplete. + * @param array $parsed_args The parsed arguments passed to wp_dropdown_users(). + */ + $use_autocomplete = (bool) apply_filters( 'wp_dropdown_users_autocomplete', $use_autocomplete, $parsed_args ); + /** * Filters the query arguments for the list of users in the dropdown. * + * Always fires so that existing hooks continue to work regardless of whether + * autocomplete mode is active. In autocomplete mode the result of get_users() + * is not used to render options, but plugins may still rely on this filter + * to observe or modify the intended query arguments. + * * @since 4.4.0 * * @param array $query_args The query arguments for get_users(). @@ -1791,16 +1826,66 @@ function wp_dropdown_users( $args = '' ) { */ $query_args = apply_filters( 'wp_dropdown_users_args', $query_args, $parsed_args ); - $users = get_users( $query_args ); + if ( $use_autocomplete ) { + // Users are fetched on demand via the AJAX handler; skip the bulk query. + $users = array(); + } else { + $users = get_users( $query_args ); + } $output = ''; - if ( ! empty( $users ) && ( empty( $parsed_args['hide_if_only_one_author'] ) || count( $users ) > 1 ) ) { - $name = esc_attr( $parsed_args['name'] ); - if ( $parsed_args['multi'] && ! $parsed_args['id'] ) { - $id = ''; - } else { - $id = $parsed_args['id'] ? " id='" . esc_attr( $parsed_args['id'] ) . "'" : " id='$name'"; + $name = esc_attr( $parsed_args['name'] ); + if ( $parsed_args['multi'] && ! $parsed_args['id'] ) { + $id = ''; + } else { + $id = $parsed_args['id'] ? " id='" . esc_attr( $parsed_args['id'] ) . "'" : " id='$name'"; + } + + if ( $use_autocomplete ) { + wp_enqueue_script( 'user-suggest' ); + + // Resolve the display value for the currently selected user. + $display = ''; + if ( (int) $parsed_args['selected'] > 0 ) { + $selected_user = get_userdata( $parsed_args['selected'] ); + if ( $selected_user ) { + if ( 'display_name_with_login' === $show ) { + /* translators: 1: User's display name, 2: User login. */ + $display = sprintf( _x( '%1$s (%2$s)', 'user dropdown' ), $selected_user->display_name, $selected_user->user_login ); + } elseif ( ! empty( $selected_user->$show ) ) { + $display = $selected_user->$show; + } else { + $display = '(' . $selected_user->user_login . ')'; + } + } } + + $input_class = $parsed_args['class'] ? esc_attr( $parsed_args['class'] ) . ' ' : ''; + + /* + * Map the 'show' arg to an autocomplete label template so that the + * suggestion items match what would have appeared as