¿Cómo filtrar usuarios en la página de administración por campo meta personalizado?

4 ene 2016, 05:07:23
Vistas: 27.4K
Votos: 12

El Problema

WordPress parece eliminar el valor de mi variable de consulta antes de que se use para filtrar la lista de usuarios.

Mi Código

Esta función agrega una columna personalizada a mi tabla de Usuarios en /wp-admin/users.php:

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

Esta función le indica a WordPress cómo llenar los valores en la columna:

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' );

Esto agrega un menú desplegable y un botón Filtrar encima de la tabla de Usuarios:

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

Esta función modifica la consulta de usuarios para agregar mi 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' );

Información Adicional

Crea mi menú desplegable correctamente. Cuando selecciono una sección del curso y hago clic en Filtrar, la página se actualiza y course_section aparece en la URL, pero no tiene ningún valor asociado. Si reviso las solicitudes HTTP, muestra que se envía con el valor de variable correcto, pero luego hay una Redirección 302 que parece eliminar el valor que seleccioné.

Si envío la variable course_section escribiéndola directamente en la URL, el filtro funciona como se espera.

Mi código está basado aproximadamente en este código de Dave Court.

También intenté incluir en la lista blanca mi variable de consulta usando este código, pero sin éxito:

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

Estoy usando WP 4.4. ¿Alguna idea de por qué mi filtro no está funcionando?

1
Comentarios

Para tu información, agregué un ticket en el sitio WP Trac que evitaría que los desarrolladores tengan que pasar por todos los obstáculos descritos a continuación.

morphatic morphatic
4 ene 2016 22:47:14
Todas las respuestas a la pregunta 7
4
16

ACTUALIZACIÓN 2018-06-28

Si bien el código a continuación funciona en su mayoría, aquí hay una reescritura del código para WP >=4.6.0 (usando PHP 7):

function add_course_section_filter( $which ) {

    // crear plantillas sprintf para <select> y <option>s
    $st = '<select name="course_section_%s" style="float:none;"><option value="">%s</option>%s</select>';
    $ot = '<option value="%s" %s>Sección %s</option>';

    // determinar qué botón de filtro se hizo clic, si alguno, y establecer la sección
    $button = key( array_filter( $_GET, function($v) { return __( 'Filtrar' ) === $v; } ) );
    $section = $_GET[ 'course_section_' . $button ] ?? -1;

    // generar código <option> y <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, __( 'Sección del Curso...' ), $options );

    // mostrar <select> y botón de envío
    echo $select;
    submit_button(__( 'Filtrar' ), 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 __( 'Filtrar' ) === $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');

Incorporé varias ideas de @birgire y @cale_b que también ofrecen soluciones a continuación que vale la pena leer. Específicamente:

  1. Usé la variable $which que se agregó en v4.6.0
  2. Usé las mejores prácticas para i18n usando cadenas traducibles, por ejemplo __( 'Filtrar' )
  3. Cambié bucles por (¿lo más moderno?) array_map(), array_filter() y range()
  4. Usé sprintf() para generar las plantillas de marcado
  5. Usé la notación de matriz con corchetes en lugar de array()

Por último, descubrí un error en mis soluciones anteriores. Esas soluciones siempre favorecen el <select> SUPERIOR sobre el <select> INFERIOR. Entonces, si seleccionas una opción de filtro del menú desplegable superior y luego seleccionas una del menú desplegable inferior, el filtro seguirá usando solo el valor que estaba arriba (si no está en blanco). Esta nueva versión corrige ese error.

ACTUALIZACIÓN 2018-02-14

Este problema ha sido parcheado desde WP 4.6.0 y los cambios están documentados en la documentación oficial. La solución a continuación todavía funciona, sin embargo.

Qué causó el problema (WP <4.6.0)

El problema fue que la acción restrict_manage_users se llama dos veces: una vez ARRIBA de la tabla de Usuarios, y una vez DEBAJO. Esto significa que se crean DOS menús desplegables select con el mismo nombre. Cuando se hace clic en el botón Filtrar, el valor que esté en el segundo elemento select (es decir, el que está DEBAJO de la tabla) anula el valor del primero, es decir, el que está ARRIBA de la tabla.

En caso de que quieras sumergirte en el código fuente de WP, la acción restrict_manage_users se activa desde WP_Users_List_Table::extra_tablenav($which), que es la función que crea el menú desplegable nativo para cambiar el rol de un usuario. Esa función tiene la ayuda de la variable $which que le indica si está creando el select arriba o debajo del formulario, y le permite dar a los dos menús desplegables diferentes atributos name. Desafortunadamente, la variable $which no se pasa a la acción restrict_manage_users, por lo que tenemos que encontrar otra manera de diferenciar nuestros propios elementos personalizados.

Una forma de hacer esto, como sugiere @Linnea, sería agregar algo de JavaScript para capturar el clic en Filtrar y sincronizar los valores de los dos menús desplegables. Elegí una solución solo con PHP que describiré ahora.

Cómo solucionarlo

Puedes aprovechar la capacidad de convertir inputs HTML en arrays de valores, y luego filtrar el array para eliminar cualquier valor no definido. Aquí está el código:

    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="">Sección del Curso...</option>';
        for ( $i = 1; $i <= 3; ++$i ) {
            $selected = $i == $section ? ' selected="selected"' : '';
            echo '<option value="' . $i . '"' . $selected . '>Sección ' . $i . '</option>';
        }
        echo '</select>';
        echo '<input type="submit" class="button" value="Filtrar">';
    }
    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: Refactorización para PHP 7

Como estoy emocionado con PHP 7, en caso de que estés ejecutando WP en un servidor con PHP 7, aquí hay una versión más corta y elegante usando el operador de fusión de null ??:

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="">Sección del Curso...</option>';
    for ( $i = 1; $i <= 3; ++$i ) {
        $selected = $i == $section ? ' selected="selected"' : '';
        echo '<option value="' . $i . '"' . $selected . '>Sección ' . $i . '</option>';
    }
    echo '</select>';
    echo '<input type="submit" class="button" value="Filtrar">';
}
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' );

¡Disfrútalo!

4 ene 2016 08:32:14
Comentarios

¿Tu solución sigue funcionando después de la versión 4.6.0? ¿Hay una forma más fácil de hacerlo con la versión más reciente de WordPress? No puedo encontrar guías hechas este año

Jeremy Muckel Jeremy Muckel
26 jun 2018 23:05:15

@JeremyMuckel la respuesta corta a tu pregunta es "sí". Mi antigua solución sigue funcionando. La he estado usando en producción regularmente durante meses y la mayoría de mis sitios están actualizados a la última versión estable de WP (actualmente 4.9.6). Dicho esto, he proporcionado una solución actualizada que usa el nuevo parche y que también corrige un error sutil en mi solución anterior.

morphatic morphatic
28 jun 2018 07:40:52

Esto fue útil pero tu código de formulario en "Cómo solucionarlo" y "Bonus: Refactorización para PHP 7" le falta un </select>. También descubrí que para que funcione tenía que poner <form method="get"> antes del menú select y un </form> después del botón de filtro.

cogdog cogdog
21 oct 2019 17:36:20

@cogdog buena observación sobre las etiquetas </select> faltantes! Las he añadido. Es extraño que hayas necesitado envolverlo en un <form> ya que toda esta página está envuelta en un gran formulario, y este código se inyecta en medio de él. Pero me alegro de que hayas conseguido que funcione. :)

morphatic morphatic
22 oct 2019 06:23:17
3

Probé tu código tanto en WordPress 4.4 como en WordPress 4.3.1. Con la versión 4.4, encuentro exactamente el mismo problema que tú. Sin embargo, ¡tu código funciona correctamente en la versión 4.3.1!

Creo que esto es un error de WordPress. No sé si ya ha sido reportado. Pienso que la razón detrás del error podría ser que el botón de envío está enviando las variables de consulta dos veces. Si miras las variables de consulta, verás que course_section aparece listado dos veces, una con el valor correcto y otra vacío.

Edición: Esta es la Solución con JavaScript

¡Simplemente agrega esto al archivo functions.php de tu tema y cambia NAME_OF_YOUR_INPUT_FIELD por el nombre de tu campo de entrada! Como WordPress carga automáticamente jQuery en el área de administración, no necesitas encolar ningún script. Este fragmento de código simplemente añade un listener de cambio a los menús desplegables y luego actualiza automáticamente el otro menú para que coincida con el mismo valor. Más explicación aquí.

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
} );

¡Espero que esto ayude!

4 ene 2016 07:18:01
Comentarios

Gracias Linnea. Sí, encontré lo mismo, que cuando haces clic en Filtrar envía el valor correcto, pero luego redirige de vuelta a la página, esta vez eliminando el valor. Supongo que es algún tipo de "función" de seguridad para evitar que se envíen valores aleatorios potencialmente maliciosos, pero no sé cómo solucionarlo. Suspiro.

morphatic morphatic
4 ene 2016 07:22:20

¡OH! Descubrí por qué la variable aparece dos veces. Porque hay un menú desplegable tanto ARRIBA como ABAJO de la tabla de usuarios y ambos tienen el mismo atributo name. Si uso el menú desplegable DEBAJO de la tabla para hacer el filtrado, funciona como se esperaba. Como ese campo viene después del que está arriba, su valor nulo sobrescribe el anterior. Hmmm...

morphatic morphatic
4 ene 2016 07:38:04

¡Buen hallazgo! Estaba intentando averiguar de dónde venía el duplicado. Creo que tal vez un poco de JavaScript podría solucionar esto. Hacer que establezca el otro menú desplegable con el mismo valor antes de enviar el formulario.

Linnea Huxford Linnea Huxford
4 ene 2016 07:54:56
2

En el núcleo, los nombres de las entradas inferiores están marcados con el número de instancia, por ejemplo new_role (superior) y new_role2 (inferior). Aquí hay dos enfoques para una convención de nombres similar, es decir, course_section1 (superior) y course_section2 (inferior):

Enfoque #1

Dado que la variable $which (top, bottom) no se pasa al gancho restrict_manage_users, podríamos solucionarlo creando nuestra propia versión de ese gancho:

Creemos el gancho de acción wpse_restrict_manage_users que tenga acceso a una variable $which:

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

} );

Luego podemos engancharlo con:

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

    // tu código aquí
} );

donde ahora tenemos $name como course_section1 en la parte superior y course_section2 en la parte inferior.

Enfoque #2

Enganchemos a restrict_manage_users, para mostrar menús desplegables, con un nombre diferente para cada instancia:

function add_course_section_filter() 
{
    static $instance= 0;    

    // Opciones del menú desplegable         
    $options = '';
    foreach( range( 1, 3 ) as $rng )
    {
        $options = sprintf( 
            '<option value="%1$d" %2$s>Sección %1$d</option>',
            $rng,
            selected( $rng, get_selected_course_section(), 0 )
        );
    }

    // Mostrar menú desplegable con un nombre diferente para cada instancia
    printf( 
        '<select name="%s" style="float:none;"><option value="0">%s</option>%s</select>', 
        'course_section' . ++$instance,
        __( 'Sección del Curso...' ),
        $options 
    );


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

donde utilizamos la función del núcleo selected() y la función auxiliar:

/**
 * Obtener la sección del curso seleccionada
 * @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; // valor por defecto

    return (int) $course_section;
}

Luego también podríamos usar esto cuando verificamos la sección del curso seleccionada en la devolución de llamada de la acción pre_get_users.

4 ene 2016 12:41:29
Comentarios

Este es un enfoque fascinante. Nunca había usado la palabra clave static de esta manera (solo dentro de clases). ¿$instance se convierte en una variable global cuando haces esto? ¿Debes preocuparte por colisiones de nombres de variables? También me gusta la técnica de crear una nueva acción que se aprovecha de una existente. ¡Gracias!

morphatic morphatic
4 ene 2016 22:04:31

Este enfoque puede ser útil a veces y se usa en el núcleo para, por ejemplo, contar instancias de shortcodes (galería, lista de reproducción, audio). El alcance de la variable estática aquí no interferirá con el alcance de variables globales. El valor de la variable estática se preservará entre esas llamadas de función, lo cual no ocurre con variables locales. Busqué y encontré este buen tutorial que tiene más detalles. @morphatic

birgire birgire
4 ene 2016 23:46:22
0

Esta es una solución diferente en Javascript que puede ser útil para algunas personas. En mi caso simplemente eliminé por completo la segunda lista de selección inferior (la de abajo). Me di cuenta que nunca utilizo los campos inferiores de todos modos...

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 feb 2016 23:19:33
0

Solución sin JavaScript

Asigna al select un nombre con formato de "array", así:

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

De esta manera, AMBOS parámetros son pasados (tanto desde la parte superior como inferior de la tabla), y ahora en un formato de array conocido.

Luego, el valor puede ser utilizado así en la función pre_get_users:

function filter_users_by_course_section( $query ) {
    global $pagenow;

    // si no está en la página de usuarios en el admin, salir
    if ( ! is_admin() || 'users.php' != $pagenow ) {
        return;
    } 

    // si no se seleccionó ninguna sección, salir
    if ( empty( $_GET['course_section'] ) ) {
        return;
    }

    // course_section ahora sabemos que está definido, así que lo cargamos
    $section = $_GET['course_section'];

    // el valor es un array, y uno de los dos select probablemente
    // no tenía ningún valor seleccionado, así que usamos array_filter para eliminar elementos vacíos
    $section = array_filter( $section );

    // el valor sigue siendo un array, así que tomamos el primer valor
    $section = reset( $section );

    // ahora el valor es un único valor, como por ejemplo 1
    $meta_query = array(
        array(
            'key' => 'course_section',
            'value' => $section
        )
    );

    $query->set( 'meta_key', 'course_section' );
    $query->set( 'meta_query', $meta_query );
}
5 jul 2017 23:37:50
0

Otra solución

Puedes colocar tu cuadro de selección de filtro en un archivo separado como user_list_filter.php

y usar require_once 'user_list_filter.php' en tu función de callback de acción

Archivo user_list_filter.php:

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

y en tu callback de acción:

function add_course_section_filter() {
    require_once 'user_list_filter.php';
}
13 feb 2018 09:13:54
0

Tomé la solución de morphatic y la hice un poco más dinámica:

// Añade una opción de filtro personalizado a la tabla de usuarios en el panel
function add_custom_users_table_filter($id, $placeholder_text, $filter_options, $query_modifier, $button_text = 'Filtrar') {
  $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]);
}

y luego puedes usarla así:

add_action('load-users.php', 'init_custom_users_table_functions');
function init_custom_users_table_functions() {
  // Añade el filtro "Sección del Curso" a la tabla de usuarios
  add_custom_users_table_filter(
    'course_section',
    'Sección del Curso...',
    array(
      '1' => 'Sección 1',
      '2' => 'Sección 2',
      '3' => 'Sección 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;
    }
  );
}

Lo mejor de todo es que la función puede llamarse múltiples veces para añadir múltiples filtros.

7 ago 2023 17:53:30