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:
- Used the
$which
variable that was added inv4.6.0
- Used best practice for i18n by using translatable strings, e.g.
__( 'Filter' )
- Exchanged loops for the (more fashionable?)
array_map()
,array_filter()
, andrange()
- Used
sprintf()
for generating the markup templates - 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.