Wordpress - How to filter users on admin users page by custom meta field?

UPDATE 2018-06-28

While the code below mostly works fine, here is a rewrite of the code for WP >=4.6.0 (using PHP 7):

function add_course_section_filter( $which ) {

    // create sprintf templates for <select> and <option>s
    $st = '<select name="course_section_%s" style="float:none;"><option value="">%s</option>%s</select>';
    $ot = '<option value="%s" %s>Section %s</option>';

    // determine which filter button was clicked, if any and set section
    $button = key( array_filter( $_GET, function($v) { return __( 'Filter' ) === $v; } ) );
    $section = $_GET[ 'course_section_' . $button ] ?? -1;

    // generate <option> and <select> code
    $options = implode( '', array_map( function($i) use ( $ot, $section ) {
        return sprintf( $ot, $i, selected( $i, $section, false ), $i );
    }, range( 1, 3 ) ));
    $select = sprintf( $st, $which, __( 'Course Section...' ), $options );

    // output <select> and submit button
    echo $select;
    submit_button(__( 'Filter' ), null, $which, false);
}
add_action('restrict_manage_users', 'add_course_section_filter');

function filter_users_by_course_section($query)
{
    global $pagenow;
    if (is_admin() && 'users.php' == $pagenow) {
        $button = key( array_filter( $_GET, function($v) { return __( 'Filter' ) === $v; } ) );
        if ($section = $_GET[ 'course_section_' . $button ]) {
            $meta_query = [['key' => 'courses','value' => $section, 'compare' => 'LIKE']];
            $query->set('meta_key', 'courses');
            $query->set('meta_query', $meta_query);
        }
    }
}
add_filter('pre_get_users', 'filter_users_by_course_section');

I incorporated several ideas from @birgire and @cale_b who also offers solutions below that are worth reading. Specifically, I:

  1. Used the $which variable that was added in v4.6.0
  2. Used best practice for i18n by using translatable strings, e.g. __( 'Filter' )
  3. Exchanged loops for the (more fashionable?) array_map(), array_filter(), and range()
  4. Used sprintf() for generating the markup templates
  5. Used the square bracket array notation instead of array()

Lastly, I discovered a bug in my earlier solutions. Those solutions always favor the TOP <select> over the BOTTOM <select>. So if you selected a filter option from the top dropdown, and then subsequently select one from the bottom dropdown, the filter will still only use whatever value was up top (if it's not blank). This new version corrects that bug.

UPDATE 2018-02-14

This issue has been patched since WP 4.6.0 and the changes are documented in the official docs. The solution below still works, though.

What Caused the Problem (WP <4.6.0)

The problem was that the restrict_manage_users action gets called twice: once ABOVE the Users table, and once BELOW it. This means that TWO select dropdowns get created with the same name. When the Filter button is clicked, whatever value is in the second select element (i.e. the one BELOW the table) overrides the value in the first one, i.e. the one ABOVE the table.

In case you want to dive into the WP source, the restrict_manage_users action is triggered from within WP_Users_List_Table::extra_tablenav($which), which is the function that creates the native dropdown to change a user's role. That function has the help of the $which variable that tells it whether it is creating the select above or below the form, and allows it to give the two dropdowns different name attributes. Unfortunately, the $which variable doesn't get passed to the restrict_manage_users action, so we have to come up with another way to differentiate our own custom elements.

One way to do this, as @Linnea suggests, would be to add some JavaScript to catch the Filter click and sync up the values of the two dropdowns. I chose a PHP-only solution that I'll describe now.

How to Fix It

You can take advantage of the ability to turn HTML inputs into arrays of values, and then filter the array to get rid of any undefined values. Here's the code:

    function add_course_section_filter() {
        if ( isset( $_GET[ 'course_section' ]) ) {
            $section = $_GET[ 'course_section' ];
            $section = !empty( $section[ 0 ] ) ? $section[ 0 ] : $section[ 1 ];
        } else {
            $section = -1;
        }
        echo ' <select name="course_section[]" style="float:none;"><option value="">Course Section...</option>';
        for ( $i = 1; $i <= 3; ++$i ) {
            $selected = $i == $section ? ' selected="selected"' : '';
            echo '<option value="' . $i . '"' . $selected . '>Section ' . $i . '</option>';
        }
        echo '</select>';
        echo '<input type="submit" class="button" value="Filter">';
    }
    add_action( 'restrict_manage_users', 'add_course_section_filter' );

    function filter_users_by_course_section( $query ) {
        global $pagenow;

        if ( is_admin() && 
             'users.php' == $pagenow && 
             isset( $_GET[ 'course_section' ] ) && 
             is_array( $_GET[ 'course_section' ] )
            ) {
            $section = $_GET[ 'course_section' ];
            $section = !empty( $section[ 0 ] ) ? $section[ 0 ] : $section[ 1 ];
            $meta_query = array(
                array(
                    'key' => 'course_section',
                    'value' => $section
                )
            );
            $query->set( 'meta_key', 'course_section' );
            $query->set( 'meta_query', $meta_query );
        }
    }
    add_filter( 'pre_get_users', 'filter_users_by_course_section' );

Bonus: PHP 7 Refactor

Since I'm excited about PHP 7, in case you're running WP on a PHP 7 server, here's a shorter, sexier version using the null coalescing operator ??:

function add_course_section_filter() {
    $section = $_GET[ 'course_section' ][ 0 ] ?? $_GET[ 'course_section' ][ 1 ] ?? -1;
    echo ' <select name="course_section[]" style="float:none;"><option value="">Course Section...</option>';
    for ( $i = 1; $i <= 3; ++$i ) {
        $selected = $i == $section ? ' selected="selected"' : '';
        echo '<option value="' . $i . '"' . $selected . '>Section ' . $i . '</option>';
    }
    echo '</select>';
    echo '<input type="submit" class="button" value="Filter">';
}
add_action( 'restrict_manage_users', 'add_course_section_filter' );

function filter_users_by_course_section( $query ) {
    global $pagenow;

    if ( is_admin() && 'users.php' == $pagenow) {
        $section = $_GET[ 'course_section' ][ 0 ] ?? $_GET[ 'course_section' ][ 1 ] ?? null;
        if ( null !== $section ) {
            $meta_query = array(
                array(
                    'key' => 'course_section',
                    'value' => $section
                )
            );
            $query->set( 'meta_key', 'course_section' );
            $query->set( 'meta_query', $meta_query );
        }
    }
}
add_filter( 'pre_get_users', 'filter_users_by_course_section' );

Enjoy!


I tested your code in both Wordpress 4.4 and in Wordpress 4.3.1. With version 4.4, I encounter exactly the same issue as you. However, your code works correctly in version 4.3.1!

I think this is a Wordpress bug. I don't know if it's been reported yet. I think the reason behind the bug might be that the submit button is sending the query vars twice. If you look at the query vars, you will see that course_section is listed twice, once with the correct value and once empty.

Edit: This is the JavaScript Solution

Simply add this to your theme’s functions.php file and change the NAME_OF_YOUR_INPUT_FIELD to the name of your input field! Since WordPress automatically loads jQuery on the admin side, you do not have to enqueue any scripts. This snippet of code simply adds a change listener to the dropdown inputs and then automatically updates the other dropdown to match the same value. More explanation here.

add_action( 'in_admin_footer', function() {
?>
<script type="text/javascript">
    var el = jQuery("[name='NAME_OF_YOUR_INPUT_FIELD']");
    el.change(function() {
        el.val(jQuery(this).val());
    });
</script>
<?php
} );

Hope this helps!


In the core, the bottom input names are marked with the instance number, e.g. new_role (top) and new_role2 (bottom). Here are two approaches for a similar naming convention, namely course_section1 (top) and course_section2 (bottom):

Approach #1

Since the $which variable (top,bottom) doesn't get passed to the restrict_manage_users hook, we could get around that by creating our own version of that hook:

Let's create the action hook wpse_restrict_manage_users that has access to a $which variable:

add_action( 'restrict_manage_users', function() 
{
    static $instance = 0;   
    do_action( 'wpse_restrict_manage_users', 1 === ++$instance ? 'top' : 'bottom'  );

} );

Then we can hook it with:

add_action( 'wpse_restrict_manage_users', function( $which )
{
    $name = 'top' === $which ? 'course_section1' : 'course_section2';

    // your stuff here
} );

where we now have $name as course_section1 at the top and course_section2 at the bottom.

Approach #2

Let's hook into restrict_manage_users, to display dropdowns, with a different name for each instance:

function add_course_section_filter() 
{
    static $instance= 0;    

    // Dropdown options         
    $options = '';
    foreach( range( 1, 3 ) as $rng )
    {
        $options = sprintf( 
            '<option value="%1$d" %2$s>Section %1$d</option>',
            $rng,
            selected( $rng, get_selected_course_section(), 0 )
        );
    }

    // Display dropdown with a different name for each instance
    printf( 
        '<select name="%s" style="float:none;"><option value="0">%s</option>%s</select>', 
        'course_section' . ++$instance,
        __( 'Course Section...' ),
        $options 
    );


    // Button
    printf (
        '<input id="post-query-submit" type="submit" class="button" value="%s" name="">',
        __( 'Filter' )
    );
}
add_action( 'restrict_manage_users', 'add_course_section_filter' );

where we used the core function selected() and the helper function:

/**
 * Get the selected course section 
 * @return int $course_section
 */
function get_selected_course_section()
{
    foreach( range( 1, 2) as $rng )
        $course_section = ! empty( $_GET[ 'course_section' . $rng ] )
            ? $_GET[ 'course_section' . $rng ]
            : -1; // default

    return (int) $course_section;
}

Then we could also use this when we check for the selected course section in the pre_get_users action callback.