Как отфильтровать пользователей на странице администратора по пользовательскому мета-полю?
Проблема
WordPress, похоже, удаляет значение моей переменной запроса до того, как она используется для фильтрации списка пользователей.
Мой код
Эта функция добавляет пользовательскую колонку в таблицу пользователей на /wp-admin/users.php
:
function add_course_section_to_user_meta( $columns ) {
$columns['course_section'] = 'Раздел';
return $columns;
}
add_filter( 'manage_users_columns', 'add_course_section_to_user_meta' );
Эта функция указывает WordPress, как заполнять значения в колонке:
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' );
Это добавляет выпадающий список и кнопку Фильтр
над таблицей пользователей:
function add_course_section_filter() {
echo '<select name="course_section" style="float:none;">';
echo '<option value="">Раздел курса...</option>';
for ( $i = 1; $i <= 3; ++$i ) {
if ( $i == $_GET[ 'course_section' ] ) {
echo '<option value="'.$i.'" selected="selected">Раздел '.$i.'</option>';
} else {
echo '<option value="'.$i.'">Раздел '.$i.'</option>';
}
}
echo '<input id="post-query-submit" type="submit" class="button" value="Фильтр" name="">';
}
add_action( 'restrict_manage_users', 'add_course_section_filter' );
Эта функция изменяет запрос пользователей, добавляя мой 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' );
Дополнительная информация
Выпадающий список создается корректно. Когда я выбираю раздел курса и нажимаю Фильтр
, страница обновляется и course_section
появляется в URL, но без связанного с ним значения. Если я проверяю HTTP-запросы, видно, что он отправляется с правильным значением переменной, но затем происходит 302 Redirect
, который, похоже, удаляет выбранное значение.
Если я отправляю переменную course_section
, вводя ее напрямую в URL, фильтр работает как ожидается.
Мой код примерно основан на этом коде от Dave Court.
Я также попробовал добавить мою переменную запроса в белый список, используя этот код, но безуспешно:
function add_course_section_query_var( $qvars ) {
$qvars[] = 'course_section';
return $qvars;
}
add_filter( 'query_vars', 'add_course_section_query_var' );
Я использую WordPress 4.4. Есть идеи, почему мой фильтр не работает?

ОБНОВЛЕНИЕ 2018-06-28
Хотя приведенный ниже код в основном работает нормально, вот переписанная версия кода для WP >=4.6.0 (с использованием PHP 7):
function add_course_section_filter( $which ) {
// создаем шаблоны sprintf для <select> и <option>
$st = '<select name="course_section_%s" style="float:none;"><option value="">%s</option>%s</select>';
$ot = '<option value="%s" %s>Section %s</option>';
// определяем, какая кнопка фильтра была нажата (если была) и устанавливаем раздел
$button = key( array_filter( $_GET, function($v) { return __( 'Filter' ) === $v; } ) );
$section = $_GET[ 'course_section_' . $button ] ?? -1;
// генерируем код для <option> и <select>
$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 );
// выводим <select> и кнопку отправки
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');
Я включил несколько идей от @birgire и @cale_b, которые также предлагают решения ниже, достойные прочтения. В частности, я:
- Использовал переменную
$which
, добавленную вv4.6.0
- Использовал лучшие практики для интернационализации, применяя переводимые строки, например
__( 'Filter' )
- Заменил циклы на (более модные?)
array_map()
,array_filter()
иrange()
- Использовал
sprintf()
для создания шаблонов разметки - Использовал квадратные скобки для массивов вместо
array()
Наконец, я обнаружил ошибку в своих предыдущих решениях. В тех решениях всегда отдавалось предпочтение ВЕРХНЕМУ <select>
перед НИЖНИМ <select>
. Поэтому если вы выбирали опцию фильтра из верхнего выпадающего списка, а затем выбирали вариант из нижнего списка, фильтр все равно использовал только значение из верхнего списка (если оно не было пустым). Эта новая версия исправляет эту ошибку.
ОБНОВЛЕНИЕ 2018-02-14
Эта проблема была исправлена, начиная с WP 4.6.0, и изменения задокументированы в официальной документации. Приведенное ниже решение по-прежнему работает.
Причина проблемы (WP <4.6.0)
Проблема заключалась в том, что действие restrict_manage_users
вызывается дважды: один раз НАД таблицей пользователей и один раз ПОД ней. Это означает, что создаются два выпадающих списка select
с одинаковыми именами. При нажатии кнопки Filter
значение из второго элемента select
(т.е. того, что ПОД таблицей) переопределяет значение в первом элементе, т.е. том, что НАД таблицей.
Если вы хотите углубиться в исходный код WP, действие restrict_manage_users
вызывается из функции WP_Users_List_Table::extra_tablenav($which)
, которая создает нативный выпадающий список для изменения роли пользователя. Эта функция использует переменную $which
, которая указывает, создается ли список select
над или под формой, и позволяет задать разные атрибуты name
для двух выпадающих списков. К сожалению, переменная $which
не передается в действие restrict_manage_users
, поэтому нам нужно придумать другой способ различить наши собственные элементы.
Один из способов сделать это, как предлагает @Linnea, — добавить JavaScript, который будет перехватывать нажатие кнопки Filter
и синхронизировать значения двух выпадающих списков. Я выбрал решение только на PHP, которое опишу сейчас.
Как это исправить
Вы можете воспользоваться возможностью превратить HTML-входные данные в массивы значений, а затем отфильтровать массив, чтобы избавиться от любых неопределенных значений. Вот код:
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' );
Бонус: Рефакторинг для PHP 7
Поскольку я в восторге от PHP 7, вот более короткая и стильная версия с использованием оператора объединения с null ??
, если вы используете WP на сервере с PHP 7:
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' );
Наслаждайтесь!

Так ваше решение все еще работает после версии 4.6.0? Есть ли более простой способ сделать это с последней версией WordPress? Кажется, я не могу найти никаких руководств, сделанных в этом году

@JeremyMuckel короткий ответ на ваш вопрос — "да". Мое старое решение все еще работает. Я использую его в продакшене регулярно уже несколько месяцев, и большинство моих сайтов обновлены до последней стабильной версии WP (в настоящее время 4.9.6). Тем не менее, я предоставил обновленное решение, которое использует новый патч и также исправляет небольшую ошибку в моем предыдущем решении.

Это было полезно, но ваш код формы в разделах "How to Fix It" и "Bonus: PHP 7 Refactor" пропускает закрывающий тег </select>
. Также я обнаружил, что для его работы необходимо добавить <form method="get">
перед select-меню и </form>
после кнопки фильтра.

Я протестировал ваш код в WordPress 4.4 и WordPress 4.3.1. В версии 4.4 я столкнулся с точно такой же проблемой, как и вы. Однако ваш код корректно работает в версии 4.3.1!
Я считаю, что это баг WordPress. Не знаю, был ли он уже зарегистрирован. Думаю, причина бага может заключаться в том, что кнопка отправки передает query vars дважды. Если посмотреть на query vars, можно увидеть, что course_section указан дважды — один раз с правильным значением, а второй раз пустым.
Редактирование: Это решение на JavaScript
Просто добавьте это в файл functions.php вашей темы и замените NAME_OF_YOUR_INPUT_FIELD на имя вашего поля ввода! Поскольку WordPress автоматически загружает jQuery в админке, вам не нужно подключать дополнительные скрипты. Этот фрагмент кода просто добавляет слушатель изменений к выпадающим спискам и автоматически обновляет другой выпадающий список, чтобы он соответствовал тому же значению. Подробнее здесь.
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
} );
Надеюсь, это поможет!

Спасибо, Линнеа. Да, я заметил то же самое — при нажатии на Filter
отправляется правильное значение, но затем происходит переадресация обратно на страницу, и на этот раз значение пропадает. Полагаю, это какая-то "функция" безопасности, чтобы предотвратить отправку случайных, потенциально вредоносных значений, но я не знаю, как это обойти. Вот досада.

О! Я понял, почему переменная появляется дважды. Потому что выпадающий список есть и НАД таблицей пользователей, и ПОД ней, и у обоих одинаковый атрибут name
. Если использовать выпадающий список ПОД таблицей для фильтрации, всё работает как ожидалось. Поскольку это поле идёт после верхнего, его пустое значение перезаписывает предыдущее. Хм...

В ядре WordPress имена полей ввода снизу помечаются номером экземпляра, например new_role
(вверху) и new_role2
(внизу). Вот два подхода для аналогичного соглашения об именах, а именно course_section1
(вверху) и course_section2
(внизу):
Подход №1
Поскольку переменная $which
(top, bottom) не передается в хук restrict_manage_users
, мы можем обойти это, создав свою собственную версию этого хука:
Создадим хук действия wpse_restrict_manage_users
, который имеет доступ к переменной $which
:
add_action( 'restrict_manage_users', function()
{
static $instance = 0;
do_action( 'wpse_restrict_manage_users', 1 === ++$instance ? 'top' : 'bottom' );
} );
Затем мы можем подключиться к нему следующим образом:
add_action( 'wpse_restrict_manage_users', function( $which )
{
$name = 'top' === $which ? 'course_section1' : 'course_section2';
// ваш код здесь
} );
где теперь у нас есть $name
как course_section1
в верхней части и course_section2
в нижней части.
Подход №2
Подключимся к restrict_manage_users
, чтобы отобразить выпадающие списки с разными именами для каждого экземпляра:
function add_course_section_filter()
{
static $instance= 0;
// Опции выпадающего списка
$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 )
);
}
// Отображаем выпадающий список с разными именами для каждого экземпляра
printf(
'<select name="%s" style="float:none;"><option value="0">%s</option>%s</select>',
'course_section' . ++$instance,
__( 'Course Section...' ),
$options
);
// Кнопка
printf (
'<input id="post-query-submit" type="submit" class="button" value="%s" name="">',
__( 'Filter' )
);
}
add_action( 'restrict_manage_users', 'add_course_section_filter' );
где мы использовали встроенную функцию selected()
и вспомогательную функцию:
/**
* Получить выбранный раздел курса
* @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; // значение по умолчанию
return (int) $course_section;
}
Затем мы также можем использовать это при проверке выбранного раздела курса в обработчике действия pre_get_users
.

Это интересный подход. Я никогда не использовал ключевое слово static
таким образом (только внутри классов). Становится ли $instance
глобальной переменной при таком использовании? Нужно ли беспокоиться о конфликтах имен переменных? Мне также нравится техника создания нового действия, которое "цепляется" к существующему. Спасибо!

Такой подход иногда может быть удобен и используется в ядре, например, для подсчета экземпляров шорткодов (галерея, плейлист, аудио). Область видимости статической переменной здесь не будет конфликтовать с глобальной областью видимости. Значение статической переменной сохраняется между вызовами функции, что не происходит с локальными переменными. Я поискал и нашел этот хороший туториал, где есть больше деталей. @morphatic

Это альтернативное решение на JavaScript, которое может быть полезно для некоторых пользователей. В моем случае я просто полностью удалил второй (нижний) выпадающий список. Я обнаружил, что вообще не использую нижние поля ввода...
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
} );

Решение без JavaScript
Дайте элементу select имя в стиле массива, например:
echo '<select name="course_section[]" style="float:none;">';
Тогда ОБА параметра будут переданы (из верхней и нижней части таблицы) в известном формате массива.
Затем значение можно использовать так в функции pre_get_users
:
function filter_users_by_course_section( $query ) {
global $pagenow;
// если не на странице пользователей в админке, выходим
if ( ! is_admin() || 'users.php' != $pagenow ) {
return;
}
// если раздел не выбран, выходим
if ( empty( $_GET['course_section'] ) ) {
return;
}
// course_section теперь точно установлен, загружаем его
$section = $_GET['course_section'];
// значение является массивом, и один из двух select-боксов
// скорее всего не был установлен, используем array_filter для удаления пустых элементов
$section = array_filter( $section );
// значение всё ещё массив, берём первое значение
$section = reset( $section );
// теперь значение одиночное, например 1
$meta_query = array(
array(
'key' => 'course_section',
'value' => $section
)
);
$query->set( 'meta_key', 'course_section' );
$query->set( 'meta_query', $meta_query );
}

Другое решение
Вы можете вынести фильтр с select в отдельный файл, например user_list_filter.php
И использовать require_once 'user_list_filter.php'
в callback-функции вашего действия
Содержимое файла user_list_filter.php
:
<select name="course_section" style="float:none;">
<option value="">Раздел курса...</option>
<?php for ( $i = 1; $i <= 3; ++$i ) {
if ( $i == $_GET[ 'course_section' ] ) { ?>
<option value="<?=$i?>" selected="selected">Раздел <?=$i?></option>
<?php } else { ?>
<option value="<?=$i?>">Раздел <?=$i?></option>
<?php }
}?>
</select>
<input id="post-query-submit" type="submit" class="button" value="Фильтр" name="">
И в вашей callback-функции:
function add_course_section_filter() {
require_once 'user_list_filter.php';
}

Я взял решение morphatic и сделал его немного более динамичным:
// Добавляет пользовательский фильтр в таблицу пользователей в админке
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]);
}
Использовать можно так:
add_action('load-users.php', 'init_custom_users_table_functions');
function init_custom_users_table_functions() {
// Добавляем фильтр "Раздел курса" в таблицу пользователей
add_custom_users_table_filter(
'course_section',
'Раздел курса...',
array(
'1' => 'Раздел 1',
'2' => 'Раздел 2',
'3' => 'Раздел 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;
}
);
}
Самое приятное - эту функцию можно вызывать несколько раз для добавления нескольких фильтров.
