Как отфильтровать пользователей на странице администратора по пользовательскому мета-полю?

4 янв. 2016 г., 05:07:23
Просмотры: 27.4K
Голосов: 12

Проблема

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. Есть идеи, почему мой фильтр не работает?

1
Комментарии

К вашему сведению, я создал тикет на сайте WP Trac, который избавит разработчиков от необходимости выполнять все описанные ниже действия.

morphatic morphatic
4 янв. 2016 г. 22:47:14
Все ответы на вопрос 7
4
16

ОБНОВЛЕНИЕ 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, которые также предлагают решения ниже, достойные прочтения. В частности, я:

  1. Использовал переменную $which, добавленную в v4.6.0
  2. Использовал лучшие практики для интернационализации, применяя переводимые строки, например __( 'Filter' )
  3. Заменил циклы на (более модные?) array_map(), array_filter() и range()
  4. Использовал sprintf() для создания шаблонов разметки
  5. Использовал квадратные скобки для массивов вместо 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 янв. 2016 г. 08:32:14
Комментарии

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

Jeremy Muckel Jeremy Muckel
26 июн. 2018 г. 23:05:15

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

morphatic morphatic
28 июн. 2018 г. 07:40:52

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

cogdog cogdog
21 окт. 2019 г. 17:36:20

@cogdog хорошее замечание насчёт пропущенных тегов </select>! Я их добавил. Странно, что тебе пришлось оборачивать это в <form>, ведь вся эта страница уже обёрнута в одну большую форму, а этот код вставляется в середину. Но рад, что у тебя заработало. :)

morphatic morphatic
22 окт. 2019 г. 06:23:17
3

Я протестировал ваш код в 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
} );

Надеюсь, это поможет!

4 янв. 2016 г. 07:18:01
Комментарии

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

morphatic morphatic
4 янв. 2016 г. 07:22:20

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

morphatic morphatic
4 янв. 2016 г. 07:38:04

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

Linnea Huxford Linnea Huxford
4 янв. 2016 г. 07:54:56
2

В ядре 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.

4 янв. 2016 г. 12:41:29
Комментарии

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

morphatic morphatic
4 янв. 2016 г. 22:04:31

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

birgire birgire
4 янв. 2016 г. 23:46:22
0

Это альтернативное решение на 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
} );
25 февр. 2016 г. 23:19:33
0

Решение без 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 );
}
5 июл. 2017 г. 23:37:50
0

Другое решение

Вы можете вынести фильтр с 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';
}
13 февр. 2018 г. 09:13:54
0

Я взял решение 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;
    }
  );
}

Самое приятное - эту функцию можно вызывать несколько раз для добавления нескольких фильтров.

7 авг. 2023 г. 17:53:30