12

The Problem

WP appears to remove the value of my query variable before it gets used to filter the list of users.

My Code

This function adds a custom column to my Users table on /wp-admin/users.php:

function add_course_section_to_user_meta( $columns ) {
    $columns['course_section'] = 'Section';
    return $columns;
}
add_filter( 'manage_users_columns', 'add_course_section_to_user_meta' );

This function tells WP how to fill values in the column:

function manage_users_course_section( $val, $col, $uid ) {
    if ( 'course_section' === $col )
        return get_the_author_meta( 'course_section', $uid );
}
add_filter( 'manage_users_custom_column', 'manage_users_course_section' );

This adds a dropdown and Filter button above the Users table:

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

This function alters the user query to add my meta_query:

function filter_users_by_course_section( $query ) {
    global $pagenow;

    if ( is_admin() && 
         'users.php' == $pagenow && 
         isset( $_GET[ 'course_section' ] ) && 
         !empty( $_GET[ 'course_section' ] ) 
       ) {
        $section = $_GET[ 'course_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' );

Other Information

It creates my dropdown correctly. When I select a course section and click Filter the page refreshes and course_section shows up in the URL, but it has no value associated with it. If I check the HTTP requests, it shows that it gets submitted with the correct variable value, but then there's a 302 Redirect that seems to strip out the value I selected.

If I submit the course_section variable by typing it directly into the URL, the filter works as expected.

My code is roughly based on this code from Dave Court.

I also tried whitelisting my query var using this code, but with no luck:

function add_course_section_query_var( $qvars ) {
    $qvars[] = 'course_section';
    return $qvars;
}
add_filter( 'query_vars', 'add_course_section_query_var' );

I'm using WP 4.4. Any ideas why my filter is not working?

1

7 Answers 7

16

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!

4
  • So does your solution still work after 4.6.0? Is there an easier way to do it with the newest version of wordpress? I can't seem to find any guides made like, this year Commented Jun 26, 2018 at 20:05
  • 1
    @JeremyMuckel the short answer to your question is "yes." My old solution still works. I've been using it in production regularly for months now and most of my sites are updated to the latest stable WP version (currently 4.9.6). That being said, I've provided an updated solution that uses the new patch and which also fixes a subtle bug in my previous solution.
    – morphatic
    Commented Jun 28, 2018 at 4:40
  • This was helpful but your form code under "How to Fix It" and "Bonus: PHP 7 Refactor" is missing a </select> I also found to get it to work I had to put <form method="get"> before the select menu and a </form> after the filter button.
    – cogdog
    Commented Oct 21, 2019 at 14:36
  • @cogdog good catch on the missing </select> tags! I added them. Strange that you needed to wrap it in a <form> since this entire page is wrapped in one big form, and this code gets injected into the middle of it. Glad you got it working, though. :)
    – morphatic
    Commented Oct 22, 2019 at 3:23
4

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.

2
  • This is a fascinating approach. I've never used the static key word this way (only within classes). Does $instance become a global variable when you do this? Do you have to worry about variable name collisions? I also like the technique of creating a new action that piggy backs on an existing one. Thanks!
    – morphatic
    Commented Jan 4, 2016 at 20:04
  • This approach can be handy sometimes and is used in the core to e.g. count shortcode (gallery, playlist, audio) instances. The static variable scope here will not mess with the global variable scope. The value of the static variable will be preserved between those function calls, which isn't the case with local variables. I searched and found this nice tutorial that has more details. @morphatic
    – birgire
    Commented Jan 4, 2016 at 21:46
4

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!

3
  • Thanks Linnea. Yes, I found the same thing, that when you click Filter it submits the correct value, but then redirects back to the page again, this time stripping out the value. My guess is that it is some sort of security "feature" to prevent random, potentially malicious, values from being submitted, but I don't know how to work around it. Sigh.
    – morphatic
    Commented Jan 4, 2016 at 5:22
  • OH! I figured out why the var shows up twice. Because there is a dropdown both ABOVE and BELOW the users table and both of them have the same name attribute. If I use the dropdown BELOW the table to do the filtering, it works as expected. Since that field comes after the one above it, it's null value overrides the earlier one. Hmmm....
    – morphatic
    Commented Jan 4, 2016 at 5:38
  • Good find! I was trying to figure out where the duplicate was coming from. I think maybe a little JavaScript could fix this. Have it set the other drop down as the same value before submitting the form. Commented Jan 4, 2016 at 5:54
1

This is a different Javascript solution that may be helpful for some people. In my case I simply removed the 2nd (bottom) select list entirely. I find that I never use the bottom inputs anyway...

add_action( 'in_admin_footer', function() {
    ?>
    <script type="text/javascript">
        jQuery(".tablenav.bottom select[name='course_section']").remove();
        jQuery(".tablenav.bottom input#post-query-submit").remove();
    </script>
    <?php
} );
1

Non-JavaScript Solution

Give the select a name that is "array-style", like so:

echo '<select name="course_section[]" style="float:none;">';

Then BOTH parameters are passed (from the top and the botom of the table), and now in a known array format.

Then, the value can be used like this in the pre_get_users function:

function filter_users_by_course_section( $query ) {
    global $pagenow;

    // if not on users page in admin, get out
    if ( ! is_admin() || 'users.php' != $pagenow ) {
        return;
    } 

    // if no section selected, get out
    if ( empty( $_GET['course_section'] ) ) {
        return;
    }

    // course_section is known to be set now, so load it
    $section = $_GET['course_section'];

    // the value is an array, and one of the two select boxes was likely
    // not set to anything, so use array_filter to eliminate empty elements
    $section = array_filter( $section );

    // the value is still an array, so get the first value
    $section = reset( $section );

    // now the value is a single value, such as 1
    $meta_query = array(
        array(
            'key' => 'course_section',
            'value' => $section
        )
    );

    $query->set( 'meta_key', 'course_section' );
    $query->set( 'meta_query', $meta_query );
}
0

another solution

you can put your filter select box in separate file like user_list_filter.php

and use require_once 'user_list_filter.php' in your action callback function

user_list_filter.php file:

<select name="course_section" style="float:none;">
    <option value="">Course Section...</option>
    <?php for ( $i = 1; $i <= 3; ++$i ) {
        if ( $i == $_GET[ 'course_section' ] ) { ?>
        <option value="<?=$i?>" selected="selected">Section <?=$i?></option>
        <?php } else { ?>
        <option value="<?=$i?>">Section <?=$i?></option>
        <?php }
     }?>
</select>
<input id="post-query-submit" type="submit" class="button" value="Filter" name="">

and in your action callback:

function add_course_section_filter() {
    require_once 'user_list_filter.php';
}
0

I took morphatic's solution and made it a little more dynamic:

// Adds a custom filter option to the users table in the dashboard
function add_custom_users_table_filter($id, $placeholder_text, $filter_options, $query_modifier, $button_text = 'Filter') {
  $html_function_name = "users_table_{$id}_filter";
  $query_function_name = $html_function_name . '_query';

  $GLOBALS[$html_function_name] = function($which) use($id, $button_text, $placeholder_text, $filter_options) {
    $select_template = '<label class="screen-reader-text" for="%s_%s">%s</label>';
    $select_template .= '<select name="%s_%s" id="%s_%s">';
    $select_template .= '<option value="">%s</option>';
    $select_template .= '%s</select>';
    $option_template = '<option value="%s" %s>%s</option>';

    $button = key(array_filter($_GET, function($v) use($button_text) {
      return $button_text === $v;
    }));

    $selection = $_GET[$id . '_' . $button] ?? -1;

    $options = implode('', array_map(function($value, $key) use($option_template, $selection) {
      return sprintf($option_template, $key, selected($key, $selection, false), $value);
    }, array_values($filter_options), array_keys($filter_options)));

    $select = sprintf($select_template, $id, $which, $placeholder_text, $id, $which, $id, $which, $placeholder_text, $options);

    echo '</div><div class="alignleft actions">';
    echo $select;

    submit_button($button_text, null, $which, false);
  };

  $GLOBALS[$query_function_name] = function($query) use($id, $button_text, $query_modifier) {
    global $pagenow;

    if (is_admin() && $pagenow == 'users.php') {
      $button = key(array_filter($_GET, function($v) use($button_text) {
        return $button_text === $v;
      }));

      if (isset($_GET[$id . '_' . $button]) && ($selection = $_GET[$id . '_' . $button])) {
        return $query_modifier($query, $selection);
      }
    }

    return $query;
  };

  add_action('restrict_manage_users', $GLOBALS[$html_function_name]);
  add_filter('pre_get_users', $GLOBALS[$query_function_name]);
}

and then you can use it like this:

add_action('load-users.php', 'init_custom_users_table_functions');
function init_custom_users_table_functions() {
  // Adds the "Course Section" filter to the users table
  add_custom_users_table_filter(
    'course_section',
    'Course Section...',
    array(
      '1' => 'Section 1',
      '2' => 'Section 2',
      '3' => 'Section 3'
    ),
    function($query, $selection) {
      $meta_query = array(
        array(
          'key'   => 'course_section',
          'value' => $selection
        )
      );
  
      if (isset($query->query_vars['meta_query']) && ($og_meta_query = $query->query_vars['meta_query'])) {
        $meta_query = array('relation' => 'AND', $og_meta_query, $meta_query);
      }
  
      $query->set('meta_query', $meta_query);

      return $query;
    }
  );
}

Best of all the function can be called multiple times to add multiple filters.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.