El widget de actividad del escritorio de WordPress es uno de esos elementos que la mayoría de gente da por sentado. Te muestra las cinco últimas entradas publicadas, las cinco próximas programadas y los cinco últimos comentarios, y ahí se queda.
Para una web grande con flujo editorial, una tienda con productos que se agotan o un sitio con tipos de contenido personalizados, esa información por defecto se queda corta enseguida.
La buena noticia es que se puede personalizar mucho más de lo que parece, y se me ocurren hasta cuatro formas de hacerlo, de la menos invasiva a la más completa, y cada una sirve para un caso distinto. Vamos a verlas todas con código que puedes copiar y pegar.
Antes de empezar: lo que el widget muestra por defecto
Si abres el escritorio de WordPress de cualquier instalación ahí lo tienes, es esa caja que pone «Actividad» y muestra tres bloques bien diferenciados:
- Próximas publicaciones, con las cinco siguientes entradas programadas, ordenadas por fecha de salida.
- Publicaciones recientes, con las cinco últimas entradas que ya están en la web.
- Comentarios recientes, con los últimos cinco recibidos, da igual su estado (aprobados, pendientes, spam).
En una web personal con poco movimiento sirve para hacerse una idea rápida, pero en cuanto tienes un flujo editorial activo varios autores escribiendo o una tienda online, esa información se queda corta, o larga, o no del todo útil, según tu necesidad.
Igual quieres ver solo lo que va a salir esta semana, solo los comentarios pendientes de moderar, los productos que se han quedado sin stock o las entradas que están pendientes de revisión. Cada caso pide algo distinto.
Para personalizarlo te planteo cuatro formas de abordarlo como te decía, de más sencilla a más completa.
La primera solo cambia las entradas que aparecen y se monta con tres líneas, la segunda reescribe el contenido del widget original sin tocar la caja, la tercera quita el widget y mete uno tuyo de cero, y la última un formulario para que el propio usuario lo configure desde el escritorio.
Al final del artículo también verás plugins que hacen lo mismo sin código, por si no te apetece pelearte con el editor de código.
Método 1: Cambiar las entradas que muestra el widget
Si lo único que quieres es ajustar las entradas que aparecen (cambiar el número, ocultar las publicadas o las programadas, incluir productos o páginas, o que cada autor solo vea las suyas), este es el camino más corto.
No reemplazas nada, no tocas el HTML, solo le dices a WordPress qué entradas quieres ver.
Lo hacemos con un filtro que que tiene WordPress desde la versión 4.2 (dashboard_recent_posts_query_args). Pega cualquiera de los siguientes snippets en un mu-plugin o en el functions.php del tema hijo y ya está.
Como detalle a tener en cuenta, el filtro se ejecuta dos veces, una para las entradas programadas (post_status => 'future') y otra para las publicadas (post_status => 'publish'), así que mirando el estado puedes tratar cada bloque por separado.
Cambiar el número de entradas mostradas
Por defecto son cinco de cada tipo. Si quieres diez:
/* Mostrar 10 entradas en el widget de actividad del escritorio en vez de 5 */
add_filter( 'dashboard_recent_posts_query_args', 'ayudawp_dashboard_activity_posts_per_page' );
function ayudawp_dashboard_activity_posts_per_page( $query_args ) {
$query_args['posts_per_page'] = 10;
return $query_args;
}
El filtro afecta tanto a publicadas como a programadas. Si quieres números distintos para cada bloque, comprueba el estado dentro de la función y aplica el valor que toque.
Ocultar las entradas publicadas y dejar solo las programadas
En sitios con flujo editorial activo, lo que interesa es ver lo que está por publicar, no lo que ya salió. Poniendo posts_per_page a cero en el bloque de publicadas, esa sección desaparece sin tocar el resto:
/* Mostrar solo entradas programadas en el widget de actividad */
add_filter( 'dashboard_recent_posts_query_args', 'ayudawp_hide_published_in_activity' );
function ayudawp_hide_published_in_activity( $query_args ) {
if ( 'publish' === $query_args['post_status'] ) {
$query_args['post__in'] = array( 0 );
}
return $query_args;
}
Funciona también al revés: cambia 'publish' por 'future' y desaparecen las programadas. La función wp_dashboard_recent_posts() devuelve sin pintar nada si la consulta no tiene resultados.
Incluir tipos de contenido personalizados
Por defecto el widget solo muestra entradas estándar (post). Si tienes páginas, productos de WooCommerce o un CPT propio, puedes incluirlos así:
/* Mostrar también productos y páginas en widget de actividad del escritorio */
add_filter( 'dashboard_recent_posts_query_args', 'ayudawp_include_cpts_in_activity' );
function ayudawp_include_cpts_in_activity( $query_args ) {
$query_args['post_type'] = array( 'post', 'page', 'product' );
return $query_args;
}
Si lo quieres a lo bestia y mostrar cualquier tipo de contenido, pasa 'any' en lugar del array.
Filtrar por autor
En un sitio con varios redactores, igual quieres que cada editor solo vea sus propias entradas en el widget. Los administradores ven todo, los demás solo lo suyo:
/* Mostrar solo entradas propias (salvo admins) en widget de actividad del escritorio */
add_filter( 'dashboard_recent_posts_query_args', 'ayudawp_activity_filter_by_author' );
function ayudawp_activity_filter_by_author( $query_args ) {
if ( ! current_user_can( 'manage_options' ) ) {
$query_args['author'] = get_current_user_id();
}
return $query_args;
}
Y los comentarios, ¿qué?
El filtro dashboard_recent_posts_query_args solo toca entradas. Para los comentarios no hay un filtro equivalente, de modo que si lo único que quieres es ocultar la sección de comentarios entera, hay un apaño rápido con CSS:
/* Ocultar comentarios en widget de actividad del escritorio */
add_action( 'admin_enqueue_scripts', 'ayudawp_hide_dashboard_comments_css' );
function ayudawp_hide_dashboard_comments_css( $hook ) {
if ( 'index.php' !== $hook ) {
return;
}
$css = '#latest-comments { display: none; }';
wp_add_inline_style( 'dashboard', $css );
}
Para cambiar qué comentarios se muestran (solo pendientes, solo aprobados) o reorganizar las secciones, hace falta saltar al método 2 y reemplazar el callback del widget.
Es la limitación natural de este enfoque, que el filtro te da control sobre la consulta de entradas, pero no sobre el resto del widget.
Detalles técnicos: La función wp_dashboard_site_activity() llama a wp_dashboard_recent_posts() dos veces pasándole un array de argumentos. Antes de ejecutar WP_Query con esos argumentos, aplica el filtro dashboard_recent_posts_query_args, que te permite modificarlos. Es un patrón muy típico de WordPress y la forma idiomática de tocar consultas internas sin reemplazar funciones del core.
Método 2: Reescribir el contenido del widget original
Si necesitas controlar también los comentarios, cambiar el aspecto del widget o reorganizar las secciones, el método 1 se queda corto.
La siguiente opción menos invasiva es reemplazar el callback del widget original (la función PHP que dibuja el contenido). Mantienes el meta box (con su ID, su posición y los ajustes de visibilidad que tenga cada usuario) pero reescribes lo que se pinta dentro.
Plantilla base
Este es el esqueleto. Muestra entradas programadas y comentarios pendientes de moderación, dos secciones que suelen ser las más útiles en un flujo de trabajo real:
/**
* Sustituir el contenido del widget de actividad del escritorio con contenido propio
*/
add_action( 'wp_dashboard_setup', 'ayudawp_replace_activity_callback', 999 );
function ayudawp_replace_activity_callback() {
global $wp_meta_boxes;
if ( isset( $wp_meta_boxes['dashboard']['normal']['core']['dashboard_activity'] ) ) {
$wp_meta_boxes['dashboard']['normal']['core']['dashboard_activity']['callback'] = 'ayudawp_render_activity_widget';
}
}
/* Mostrar un widget de actividad personalizada: solo entradas programadas y comentarios pendientes */
function ayudawp_render_activity_widget() {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
// Entradas programadas
$scheduled = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'future',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'ASC',
'no_found_rows' => true,
) );
if ( $scheduled->have_posts() ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Próximas publicaciones', 'ayudawp' ) . '</h3>';
echo '<ul>';
while ( $scheduled->have_posts() ) {
$scheduled->the_post();
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( get_the_date( 'd/m/Y H:i' ) )
);
}
echo '</ul>';
echo '</div>';
wp_reset_postdata();
}
// Solo comentarios pendientes (con acciones AJAX nativas: aprobar, responder, editar, spam, papelera)
$pending = get_comments( array(
'status' => 'hold',
'number' => 5,
) );
if ( ! empty( $pending ) ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Comentarios pendientes de moderación', 'ayudawp' ) . '</h3>';
echo '<ul id="the-comment-list" data-wp-lists="list:comment">';
foreach ( $pending as $comment ) {
_wp_dashboard_recent_comments_row( $comment );
}
echo '</ul>';
echo '</div>';
}
}
Variación: cambiar qué comentarios se muestran
El bloque de comentarios usa get_comments() con un argumento status. Cambiándolo cambias el filtro. Los valores aceptados son:
'hold': solo comentarios pendientes de moderación (lo que tiene la plantilla base).'approve': solo comentarios aprobados.'spam': comentarios marcados como spam.'trash': comentarios en la papelera.'all': todos, sin filtrar por estado.
También puedes filtrar comentarios por entrada concreta, por usuario, por rango de fechas o por tipo (incluyendo o excluyendo pingbacks y trackbacks). La documentación de WP_Comment_Query recoge todas las opciones.
Variación: quitar la sección de comentarios entera
Si no quieres comentarios en el widget, borra el bloque // Pending comments only entero del callback. Tan sencillo como eso. El widget mostrará solo las entradas programadas.
Variación: añadir las entradas publicadas recientemente
La plantilla base solo muestra programadas, pero si quieres recuperar también las publicadas (al estilo del widget original), añade este bloque antes o después del de programadas:
// Entradas publicadas recientes
$recent = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'publish',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
'no_found_rows' => true,
) );
if ( $recent->have_posts() ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Publicado recientemente', 'ayudawp' ) . '</h3>';
echo '<ul>';
while ( $recent->have_posts() ) {
$recent->the_post();
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( get_the_date( 'd/m/Y H:i' ) )
);
}
echo '</ul>';
echo '</div>';
wp_reset_postdata();
}
Variación: añadir borradores en revisión
Otro bloque útil para flujos editoriales son los borradores con estado pending (pendientes de revisión por un editor):
// Entradas pendientes de revisión
$pending_posts = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'pending',
'posts_per_page' => 5,
'orderby' => 'modified',
'order' => 'DESC',
'no_found_rows' => true,
) );
if ( $pending_posts->have_posts() ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Pendientes de revisión', 'ayudawp' ) . '</h3>';
echo '<ul>';
while ( $pending_posts->have_posts() ) {
$pending_posts->the_post();
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( get_the_author() )
);
}
echo '</ul>';
echo '</div>';
wp_reset_postdata();
}
Variación: productos WooCommerce sin existencias
Para una tienda WooCommerce, ver productos agotados de un vistazo te ahorra dolores de cabeza, así que igual es una utilidad candidata para aprovechar el widget de actividad.
Mucha gente sigue tirando de WP_Query con meta_query sobre _stock_status, pero esa ya no es la forma recomendada por WooCommerce. La función wc_get_products() es más rápida porque usa la tabla wc_product_meta_lookup y es compatible con HPOS:
// Productos sin existencias (WooCommerce)
if ( function_exists( 'wc_get_products' ) ) {
$products = wc_get_products( array(
'stock_status' => 'outofstock',
'status' => 'publish',
'limit' => 5,
'orderby' => 'date',
'order' => 'DESC',
) );
if ( ! empty( $products ) ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Productos sin stock', 'ayudawp' ) . '</h3>';
echo '<ul>';
foreach ( $products as $product ) {
printf(
'<li><a href="%1$s">%2$s</a></li>',
esc_url( get_edit_post_link( $product->get_id() ) ),
esc_html( $product->get_name() )
);
}
echo '</ul>';
echo '</div>';
}
}
Detalles técnicos: El array global $wp_meta_boxes guarda todas las meta cajas registradas en el escritorio. Cada widget tiene su ID, su contexto (normal o side) y su callback. Reasignando la propiedad callback, le decimos a WordPress que cuando vaya a pintar ese widget concreto ejecute nuestra función en lugar de la del core. El meta box sigue ahí, los ajustes del usuario también, pero el contenido lo controlamos nosotros. La prioridad 999 en wp_dashboard_setup nos asegura que corremos después de que el core haya registrado el widget original.
Método 3: Quitar el widget original y crear uno propio
Cuando lo que quieres es darle al escritorio un aire totalmente distinto (un «Panel editorial» con tu flujo, un «Panel de la tienda» para WooCommerce, o un «Resumen del cliente» en una web a medida), olvídate del widget de actividad y monta uno tuyo.
Aquí lo eliminas del escritorio y registras otro nuevo con su propio título, contenido e identidad. Es un widget completamente nuevo, no una reescritura del de actividad.
La pega frente al método anterior es que los usuarios pierden los ajustes que tuvieran (posición, colapsado, etc.) porque cambia el ID del meta box. La ventaja es que el resultado es mucho más limpio y reutilizable.
Las funciones que entran en juego son remove_meta_box() para quitar el original y wp_add_dashboard_widget() para registrar el nuevo.
Ejemplo 1: Panel editorial completo
Esta pequeña-gran virguería muestra un panel editorial, mucho mejor que el habitual widget de actividad ¿no?…
/* Widget de actividad personalizado para panel editorial */
add_action( 'wp_dashboard_setup', 'ayudawp_register_editorial_widget' );
function ayudawp_register_editorial_widget() {
// Quitamos el widget de actividad original
remove_meta_box( 'dashboard_activity', 'dashboard', 'normal' );
// Registramos el propio
wp_add_dashboard_widget(
'ayudawp_editorial_widget',
__( 'Panel editorial', 'ayudawp' ),
'ayudawp_editorial_widget_render'
);
}
/* Estilos del panel editorial */
add_action( 'admin_enqueue_scripts', 'ayudawp_editorial_widget_styles' );
function ayudawp_editorial_widget_styles( $hook ) {
if ( 'index.php' !== $hook ) {
return;
}
$css = '
.ayudawp-editorial-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 15px; }
.ayudawp-editorial-stats > div { flex: 1; min-width: 90px; background: #f6f7f7; padding: 10px; border-left: 4px solid #2271b1; }
.ayudawp-editorial-stats a { text-decoration: none; color: inherit; display: block; }
.ayudawp-editorial-stats strong { font-size: 22px; display: block; line-height: 1.2; }
.ayudawp-editorial-stats .label { font-size: 12px; color: #50575e; }
';
wp_add_inline_style( 'dashboard', $css );
}
/* Renderizado del widget */
function ayudawp_editorial_widget_render() {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
// Cabecera con métricas en una sola consulta cacheada por el core
$counts = wp_count_posts( 'post' );
$stats = array(
'draft' => array( __( 'Borradores', 'ayudawp' ), (int) $counts->draft ),
'pending' => array( __( 'En revisión', 'ayudawp' ), (int) $counts->pending ),
'future' => array( __( 'Programadas', 'ayudawp' ), (int) $counts->future ),
'publish' => array( __( 'Publicadas', 'ayudawp' ), (int) $counts->publish ),
);
echo '<div class="ayudawp-editorial-stats">';
foreach ( $stats as $status => $data ) {
printf(
'<div><a href="%1$s"><strong>%2$d</strong><span class="label">%3$s</span></a></div>',
esc_url( admin_url( 'edit.php?post_status=' . $status . '&post_type=post' ) ),
$data[1],
esc_html( $data[0] )
);
}
echo '</div>';
// Próximas publicaciones programadas
$scheduled = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'future',
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'ASC',
'no_found_rows' => true,
) );
if ( $scheduled->have_posts() ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Próximas publicaciones', 'ayudawp' ) . '</h3>';
echo '<ul>';
while ( $scheduled->have_posts() ) {
$scheduled->the_post();
printf(
'<li><a href="%1$s">%2$s</a> — %3$s (%4$s)</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( get_the_date( 'd/m/Y H:i' ) ),
esc_html( get_the_author() )
);
}
echo '</ul>';
echo '</div>';
wp_reset_postdata();
}
// Entradas pendientes de revisión
$pending_review = new WP_Query( array(
'post_type' => 'post',
'post_status' => 'pending',
'posts_per_page' => 5,
'orderby' => 'modified',
'order' => 'DESC',
'no_found_rows' => true,
) );
if ( $pending_review->have_posts() ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Pendientes de revisión', 'ayudawp' ) . '</h3>';
echo '<ul>';
while ( $pending_review->have_posts() ) {
$pending_review->the_post();
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( get_the_author() )
);
}
echo '</ul>';
echo '</div>';
wp_reset_postdata();
}
// Comentarios pendientes de moderación con acciones AJAX nativas
$pending_comments = get_comments( array(
'status' => 'hold',
'number' => 5,
) );
if ( ! empty( $pending_comments ) ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Comentarios pendientes', 'ayudawp' ) . '</h3>';
echo '<ul id="the-comment-list" data-wp-lists="list:comment">';
foreach ( $pending_comments as $comment ) {
_wp_dashboard_recent_comments_row( $comment );
}
echo '</ul>';
echo '</div>';
}
}
Ejemplo 2: Panel de tienda WooCommerce
Para una tienda, lo más útil es ver de un vistazo productos sin stock y pedidos pendientes:
/* Widget de actividad personalizado para panel de tienda WooCommerce */
add_action( 'wp_dashboard_setup', 'ayudawp_register_shop_widget' );
function ayudawp_register_shop_widget() {
// Solo si WooCommerce está activo
if ( ! function_exists( 'wc_get_products' ) ) {
return;
}
// Quitamos el widget de actividad original
remove_meta_box( 'dashboard_activity', 'dashboard', 'normal' );
// Registramos el propio
wp_add_dashboard_widget(
'ayudawp_shop_widget',
__( 'Panel de la tienda', 'ayudawp' ),
'ayudawp_shop_dashboard_widget_render'
);
}
/* Estilos del panel de tienda */
add_action( 'admin_enqueue_scripts', 'ayudawp_shop_widget_styles' );
function ayudawp_shop_widget_styles( $hook ) {
if ( 'index.php' !== $hook ) {
return;
}
$css = '
.ayudawp-shop-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 15px; }
.ayudawp-shop-stats > div { flex: 1; min-width: 90px; background: #f6f7f7; padding: 10px; border-left: 4px solid #674399; }
.ayudawp-shop-stats strong { font-size: 22px; display: block; line-height: 1.2; }
.ayudawp-shop-stats .label { font-size: 12px; color: #50575e; }
';
wp_add_inline_style( 'dashboard', $css );
}
/* Renderizado del widget */
function ayudawp_shop_dashboard_widget_render() {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return;
}
// Métricas de pedidos por estado
$stats = array(
__( 'Pendientes', 'ayudawp' ) => wc_orders_count( 'pending' ),
__( 'En proceso', 'ayudawp' ) => wc_orders_count( 'processing' ),
__( 'En espera', 'ayudawp' ) => wc_orders_count( 'on-hold' ),
__( 'Completados', 'ayudawp' ) => wc_orders_count( 'completed' ),
);
echo '<div class="ayudawp-shop-stats">';
foreach ( $stats as $label => $count ) {
printf(
'<div><strong>%1$d</strong><span class="label">%2$s</span></div>',
(int) $count,
esc_html( $label )
);
}
echo '</div>';
// Productos sin stock
$out_of_stock = wc_get_products( array(
'stock_status' => 'outofstock',
'status' => 'publish',
'limit' => 5,
) );
if ( ! empty( $out_of_stock ) ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Productos sin stock', 'ayudawp' ) . '</h3>';
echo '<ul>';
foreach ( $out_of_stock as $product ) {
printf(
'<li><a href="%1$s">%2$s</a></li>',
esc_url( get_edit_post_link( $product->get_id() ) ),
esc_html( $product->get_name() )
);
}
echo '</ul>';
echo '</div>';
}
// Pedidos por atender (pendientes y en espera)
$orders = wc_get_orders( array(
'status' => array( 'pending', 'on-hold' ),
'limit' => 5,
'orderby' => 'date',
'order' => 'DESC',
) );
if ( ! empty( $orders ) ) {
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Pedidos por atender', 'ayudawp' ) . '</h3>';
echo '<ul>';
foreach ( $orders as $order ) {
printf(
'<li><a href="%1$s">#%2$s — %3$s</a> — %4$s</li>',
esc_url( $order->get_edit_order_url() ),
esc_html( $order->get_order_number() ),
esc_html( $order->get_formatted_billing_full_name() ),
wp_kses_post( wc_price( $order->get_total() ) )
);
}
echo '</ul>';
echo '</div>';
}
}
Para registrarlo, igual que antes: cambia la llamada a wp_add_dashboard_widget() con el nuevo callback. Y restringe la visibilidad a usuarios con la capacidad manage_woocommerce.
Ejemplo 3: Panel de eventos próximos
Si gestionas reservas, eventos o un tipo de contenido con una fecha guardada, esta versión lista los próximos ordenados por la fecha del evento, no por la fecha de publicación:
/* Widget de actividad personalizado para panel de eventos */
function ayudawp_events_widget_render() {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
$upcoming = new WP_Query( array(
'post_type' => 'evento',
'post_status' => 'publish',
'posts_per_page' => 5,
'meta_key' => 'fecha_del_evento',
'orderby' => 'meta_value',
'order' => 'ASC',
'meta_query' => array(
array(
'key' => 'fecha_del_evento',
'value' => current_time( 'Y-m-d' ),
'compare' => '>=',
'type' => 'DATE',
),
),
'no_found_rows' => true,
) );
if ( ! $upcoming->have_posts() ) {
echo '<p>' . esc_html__( 'No hay eventos próximos.', 'ayudawp' ) . '</p>';
return;
}
echo '<ul>';
while ( $upcoming->have_posts() ) {
$upcoming->the_post();
$event_date = get_post_meta( get_the_ID(), 'fecha_del_evento', true );
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
esc_html( $event_date )
);
}
echo '</ul>';
wp_reset_postdata();
}
Si el sitio tiene muchos eventos, mete los resultados en un transient de unos minutos para no machacar la base de datos cada vez que alguien entra al escritorio.
Detalles técnicos: remove_meta_box() elimina la entrada del array $wp_meta_boxes, así que el widget original deja de existir para WordPress. wp_add_dashboard_widget() registra uno nuevo con su propio ID, título y callback. Como es un meta box nuevo, los ajustes del usuario (posición, colapsado, oculto) no se heredan del anterior. Es un widget partido de cero.
Método 4: Widget con opciones configurables desde el escritorio
Cuando montas algo para un cliente o quieres aplicar la misma personalización en varios sitios, lo suyo es que el propio usuario pueda elegir cuántas entradas mostrar, si quiere ver comentarios o no, o si solo quiere ver lo suyo.
En lugar de dejar cada cosa fijada en el código, le añades un pequeño formulario de configuración que aparece al pulsar en el enlace «Configurar» del widget, ahí cada uno decide.
WordPress lo admite de fábrica, al registrar el widget con wp_add_dashboard_widget(), pasas un cuarto parámetro (el llamado control callback) que se encarga tanto de pintar el formulario como de guardar los valores cuando el usuario lo envía.
El nonce y la verificación de permisos los gestiona el propio core, así que tú solo te ocupas de leer la opción guardada y aplicarla cuando se pinta el widget.
Widget configurable con opciones configurables
Esta es una versión más completa que el típico «número de entradas». Permite configurar cuántas entradas programadas mostrar, si incluir comentarios pendientes, si filtrar por autor (cada editor solo ve lo suyo) y mucho más:
/**
* Widget de actividad configurable mediante panel de ajustes propio
*/
class AyudaWP_Configurable_Activity_Widget {
const OPTION_NAME = 'ayudawp_activity_widget_settings';
const WIDGET_ID = 'ayudawp_configurable_activity';
public function __construct() {
add_action( 'wp_dashboard_setup', array( $this, 'register_widget' ) );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_styles' ) );
}
public function register_widget() {
wp_add_dashboard_widget(
self::WIDGET_ID,
__( 'Actividad editorial configurable', 'ayudawp' ),
array( $this, 'render_widget' ),
array( $this, 'render_form' )
);
}
public function enqueue_styles( $hook ) {
if ( 'index.php' !== $hook ) {
return;
}
$css = '
.ayudawp-editorial-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 0 0 15px; }
.ayudawp-editorial-stats > div { flex: 1; min-width: 90px; background: #f6f7f7; padding: 10px; border-left: 4px solid #2271b1; }
.ayudawp-editorial-stats a { text-decoration: none; color: inherit; display: block; }
.ayudawp-editorial-stats strong { font-size: 22px; display: block; line-height: 1.2; }
.ayudawp-editorial-stats .label { font-size: 12px; color: #50575e; }
';
wp_add_inline_style( 'dashboard', $css );
}
public function render_widget() {
if ( ! current_user_can( 'edit_posts' ) ) {
return;
}
$settings = $this->get_settings();
$limit = (int) $settings['limit'];
$author = ! empty( $settings['only_mine'] ) ? get_current_user_id() : 0;
if ( ! empty( $settings['show_stats'] ) ) {
$this->render_stats();
}
if ( ! empty( $settings['show_scheduled'] ) ) {
$this->render_posts_block(
'future',
'ASC',
'date',
__( 'Próximas publicaciones', 'ayudawp' ),
__( 'No hay entradas programadas.', 'ayudawp' ),
$limit,
$author,
'date'
);
}
if ( ! empty( $settings['show_pending_review'] ) ) {
$this->render_posts_block(
'pending',
'DESC',
'modified',
__( 'Pendientes de revisión', 'ayudawp' ),
__( 'Sin entradas pendientes de revisión.', 'ayudawp' ),
$limit,
$author,
'author'
);
}
if ( ! empty( $settings['show_recent'] ) ) {
$this->render_posts_block(
'publish',
'DESC',
'date',
__( 'Publicado recientemente', 'ayudawp' ),
__( 'Sin publicaciones recientes.', 'ayudawp' ),
$limit,
$author,
'date'
);
}
if ( ! empty( $settings['show_comments'] ) ) {
$this->render_comments_block( $limit );
}
}
private function render_stats() {
$counts = wp_count_posts( 'post' );
$stats = array(
'draft' => array( __( 'Borradores', 'ayudawp' ), (int) $counts->draft ),
'pending' => array( __( 'En revisión', 'ayudawp' ), (int) $counts->pending ),
'future' => array( __( 'Programadas', 'ayudawp' ), (int) $counts->future ),
'publish' => array( __( 'Publicadas', 'ayudawp' ), (int) $counts->publish ),
);
echo '<div class="ayudawp-editorial-stats">';
foreach ( $stats as $status => $data ) {
printf(
'<div><a href="%1$s"><strong>%2$d</strong><span class="label">%3$s</span></a></div>',
esc_url( admin_url( 'edit.php?post_status=' . $status . '&post_type=post' ) ),
$data[1],
esc_html( $data[0] )
);
}
echo '</div>';
}
private function render_posts_block( $status, $order, $orderby, $title, $empty_msg, $limit, $author, $meta ) {
$query_args = array(
'post_type' => 'post',
'post_status' => $status,
'posts_per_page' => $limit,
'orderby' => $orderby,
'order' => $order,
'no_found_rows' => true,
);
if ( $author ) {
$query_args['author'] = $author;
}
$query = new WP_Query( $query_args );
echo '<div class="activity-block">';
echo '<h3>' . esc_html( $title ) . '</h3>';
if ( $query->have_posts() ) {
echo '<ul>';
while ( $query->have_posts() ) {
$query->the_post();
$extra = ( 'date' === $meta )
? esc_html( get_the_date( 'd/m/Y H:i' ) )
: esc_html( get_the_author() );
printf(
'<li><a href="%1$s">%2$s</a> — %3$s</li>',
esc_url( get_edit_post_link() ),
esc_html( get_the_title() ),
$extra
);
}
echo '</ul>';
wp_reset_postdata();
} else {
echo '<p>' . esc_html( $empty_msg ) . '</p>';
}
echo '</div>';
}
private function render_comments_block( $limit ) {
$pending = get_comments( array(
'status' => 'hold',
'number' => $limit,
) );
echo '<div class="activity-block">';
echo '<h3>' . esc_html__( 'Comentarios pendientes', 'ayudawp' ) . '</h3>';
if ( ! empty( $pending ) ) {
// Estructura nativa para que el JS del core enganche las acciones AJAX
echo '<ul id="the-comment-list" data-wp-lists="list:comment">';
foreach ( $pending as $comment ) {
_wp_dashboard_recent_comments_row( $comment );
}
echo '</ul>';
} else {
echo '<p>' . esc_html__( 'Sin comentarios pendientes.', 'ayudawp' ) . '</p>';
}
echo '</div>';
}
public function render_form() {
// Procesar envío del formulario. El nonce ya lo verifica WP
if ( 'POST' === $_SERVER['REQUEST_METHOD']
&& isset( $_POST['widget_id'] )
&& self::WIDGET_ID === $_POST['widget_id']
&& current_user_can( 'edit_dashboard' ) ) {
$limit = isset( $_POST['ayudawp_limit'] )
? absint( wp_unslash( $_POST['ayudawp_limit'] ) )
: 5;
update_option( self::OPTION_NAME, array(
'limit' => max( 1, min( 20, $limit ) ),
'show_stats' => ! empty( $_POST['ayudawp_show_stats'] ),
'show_scheduled' => ! empty( $_POST['ayudawp_show_scheduled'] ),
'show_pending_review' => ! empty( $_POST['ayudawp_show_pending_review'] ),
'show_recent' => ! empty( $_POST['ayudawp_show_recent'] ),
'show_comments' => ! empty( $_POST['ayudawp_show_comments'] ),
'only_mine' => ! empty( $_POST['ayudawp_only_mine'] ),
) );
}
$settings = $this->get_settings();
?>
<p>
<label for="ayudawp_limit">
<?php esc_html_e( 'Cantidad de elementos por sección:', 'ayudawp' ); ?>
</label>
<input class="small-text"
id="ayudawp_limit"
name="ayudawp_limit"
type="number"
min="1"
max="20"
value="<?php echo esc_attr( $settings['limit'] ); ?>">
</p>
<p><strong><?php esc_html_e( 'Secciones a mostrar:', 'ayudawp' ); ?></strong></p>
<p>
<label>
<input type="checkbox" name="ayudawp_show_stats" value="1" <?php checked( ! empty( $settings['show_stats'] ) ); ?>>
<?php esc_html_e( 'Resumen de cifras (borradores, en revisión, programadas, publicadas)', 'ayudawp' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="ayudawp_show_scheduled" value="1" <?php checked( ! empty( $settings['show_scheduled'] ) ); ?>>
<?php esc_html_e( 'Próximas publicaciones programadas', 'ayudawp' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="ayudawp_show_pending_review" value="1" <?php checked( ! empty( $settings['show_pending_review'] ) ); ?>>
<?php esc_html_e( 'Entradas pendientes de revisión', 'ayudawp' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="ayudawp_show_recent" value="1" <?php checked( ! empty( $settings['show_recent'] ) ); ?>>
<?php esc_html_e( 'Publicaciones recientes', 'ayudawp' ); ?>
</label>
</p>
<p>
<label>
<input type="checkbox" name="ayudawp_show_comments" value="1" <?php checked( ! empty( $settings['show_comments'] ) ); ?>>
<?php esc_html_e( 'Comentarios pendientes de moderación', 'ayudawp' ); ?>
</label>
</p>
<p><strong><?php esc_html_e( 'Filtros:', 'ayudawp' ); ?></strong></p>
<p>
<label>
<input type="checkbox" name="ayudawp_only_mine" value="1" <?php checked( ! empty( $settings['only_mine'] ) ); ?>>
<?php esc_html_e( 'Mostrar solo mis entradas (no las de otros autores)', 'ayudawp' ); ?>
</label>
</p>
<?php
}
private function get_settings() {
$defaults = array(
'limit' => 5,
'show_stats' => true,
'show_scheduled' => true,
'show_pending_review' => true,
'show_recent' => false,
'show_comments' => true,
'only_mine' => false,
);
$saved = get_option( self::OPTION_NAME, array() );
return wp_parse_args( $saved, $defaults );
}
}
new AyudaWP_Configurable_Activity_Widget();
Con esto, al pasar el ratón por el título del widget aparece el enlace «Configurar», y al hacer clic se despliega el formulario con las opciones disponibles, ejemplo de lo que hemos estado viendo, pero ya a golpe de clic.
Al guardar, los valores se persisten en la opción ayudawp_activity_widget_settings y se aplican al renderizado del widget.
Si quieres añadir más controles solo tienes que añadir más campos al formulario, ampliar los valores por defecto en get_settings() y leerlos en render_widget(), la estructura ya está montada.
Detalles técnicos: wp_add_dashboard_widget() acepta hasta cuatro parámetros principales: ID, título, callback de renderizado y callback de configuración. Si pasas el cuarto, WordPress añade automáticamente el enlace «Configurar» en el título del widget. Cuando el usuario guarda el formulario, WordPress se encarga del nonce (lo verifica con check_admin_referer( 'edit-dashboard-widget_' . $widget_id, 'dashboard-widget-nonce' )) antes de llamar al control callback. Por eso dentro del callback solo necesitamos comprobar capacidad y procesar los datos. El formulario se envía a la misma página del escritorio y se procesa en el mismo callback que lo pinta.
Mostrar el widget solo a ciertos perfiles de usuario
En muchos casos no quieres que un autor o colaborador vea actividad del resto del sitio, o que un editor vea productos de la tienda. Combinando remove_meta_box() con current_user_can() filtras con una línea:
/* Mostrar widget de actividad solo a ciertos usuarios */
add_action( 'wp_dashboard_setup', 'ayudawp_hide_activity_for_non_editors', 100 );
function ayudawp_hide_activity_for_non_editors() {
if ( ! current_user_can( 'edit_others_posts' ) ) {
remove_meta_box( 'dashboard_activity', 'dashboard', 'normal' );
}
}
El widget desaparece para todo el mundo salvo editores y administradores. Cambia la capacidad por la que necesites según el caso: manage_woocommerce, manage_options, publish_posts, etc.
Plugins alternativos si prefieres no tocar código
Si no quieres meter mano al código, hay tres plugins que cubren bien el caso:
- Ultimate Dashboard, de David Vongries. Desactiva los widgets nativos de un clic y permite crear los tuyos con texto, HTML o iconos. La versión pro añade restricciones por rol y limpieza de widgets de otros plugins.
- Admin Menu Editor, de Janis Elsts. Aunque está pensado para el menú lateral, también permite reordenar y dar permisos por rol al panel de control. Más de 400.000 instalaciones activas y mantenimiento constante.
- Dashboard Directory Size, de Pete Nelson. Añade un widget que muestra el tamaño en disco de las carpetas de WordPress y de la base de datos. Útil para sitios donde controlar el crecimiento del almacenamiento importa.
Si lo que te interesa es ver actividad de bots de IA en el panel (un caso cada vez más habitual con ChatGPT, Claude o Perplexity rastreando tu sitio), echa un vistazo a VigIA, que añade su propio widget de analítica de crawlers de IA al escritorio.
Dónde poner cada bloque de código
Cualquiera de estos snippets puede ir en tres sitios:
- El
functions.phpde un tema hijo. La opción más rápida pero también la más frágil, porque si cambias de tema pierdes los cambios. - Un mu-plugin en
wp-content/mu-plugins/. Es la forma que prefiero para personalizaciones del escritorio porque se mantienen activas pase lo que pase con temas y plugins. Si todavía no usas mu-plugins, tengo una serie de artículos sobre ellos que te puede ayudar. - Un plugin propio. La opción más profesional si vas a empaquetar varias personalizaciones del admin y compartirlas entre sitios.
Si combinas varios métodos, ojo con el orden. El filtro dashboard_recent_posts_query_args (método 1) no tiene sentido si reemplazas el widget entero (métodos 2, 3 o 4), porque ya no se ejecuta la función que lo dispara. Elige uno según lo lejos que necesites llegar y ya está.
Mi consejo, así en general, es que empieces por el método 1, que es el más limpio, y si te quedas corto pasa al 2.
Solo monta el 4 si necesitas que el propio usuario configure el widget desde el panel, que cuanto menos toques el comportamiento del core menos cosas se romperán cuando WordPress se actualice.
Para todo lo demás, aquí me tienes.
¿Te gustó este artículo? ¡Ni te imaginas lo que te estás perdiendo en YouTube!














