Extending the admin post list
Slot notification-style boxes above and below the WordPress ‘All Posts’ table.
A recent project required extending the WordPress ‘All Posts’ listing page (wp-admin) to display
small amounts of custom content above and below the default posts table.
Here’s what we're aiming for – notice the new notification-style boxes:
As I outlined in an earlier post, WordPress doesn't offer many hooks for customizing the layout of admin index pages (unlike post edit pages, which are highly customizable). So, to get around this, I had to get a bit more technical and use a JavaScript-based solution. Definitely not ideal, but since we can't modify core admin files, that’s pretty much all we can do. You know, sometimes you just have to work with what you've got.
Heads up This piece predates a cleaner option. WordPress does expose server-side hooks
around the list table: manage_posts_extra_tablenav (fired above and below the table, with a
$which argument of 'top' or 'bottom') and restrict_manage_posts
for the filter row. These run in PHP, need no JavaScript, and are the modern way to inject content around the table —
so “that’s pretty much all we can do” no longer holds. The JS approach below still works as a fallback.
add_action('manage_posts_extra_tablenav', function (string $which): void {
if ($which !== 'top' || get_current_screen()?->post_type !== 'post') {
return;
}
echo '<div class="custom-admin-table-content above"><p>Custom content here.</p></div>';
});
(Quick Link: If you just want the code, you can download the plugin zip file here. Some setup is needed, explained below.)
Note: This solution relies on JavaScript. Users with JavaScript disabled won't see the custom content. Therefore, don't depend on this for mission-critical information. Use it to present content that complements the standard admin view.
The JavaScript itself is relatively straightforward. I decided to hook onto two existing elements:
All (2) | Published (2)) – Content will be inserted before this..tablenav.bottom) – Content will be inserted after this.
The code gets inserted directly into the <head> of the admin pages, which is typically the simplest way
to add extra scripts or styles to the admin area.
Here's the essence of the JavaScript:
// Wait for the DOM to be fully loaded
document.addEventListener('DOMContentLoaded', function() {
// Create container divs for our custom content
var aboveContainer = document.createElement("div");
var belowContainer = document.createElement("div");
// Add CSS classes for styling
aboveContainer.classList.add('custom-admin-table-content', 'above');
belowContainer.classList.add('custom-admin-table-content', 'below');
// Find the PHP-generated templates (we'll define these later)
// Using unique IDs is generally safer than querySelector('template[name=...]')
var aboveTemplate = document.getElementById('admin-template-above');
var belowTemplate = document.getElementById('admin-template-below');
// If the templates exist, clone their content into our containers
if (aboveTemplate) {
// Use template.content.cloneNode(true) for proper template handling
aboveContainer.appendChild(aboveTemplate.content.cloneNode(true));
}
if (belowTemplate) {
belowContainer.appendChild(belowTemplate.content.cloneNode(true));
}
// Target elements to insert content relative to
var targetAbove = document.querySelector(".subsubsub"); // The All | Published | Trash links container
var targetBelow = document.querySelector('.tablenav.bottom'); // The bottom table navigation
// Insert the containers into the DOM
if (aboveTemplate && targetAbove && targetAbove.parentNode) {
// Insert the "above" container just before the .subsubsub links
targetAbove.parentNode.insertBefore(aboveContainer, targetAbove);
}
if (belowTemplate && targetBelow && targetBelow.parentNode) {
// Insert the "below" container right after the bottom navigation element
// Use insertAdjacentElement for potentially cleaner insertion
targetBelow.insertAdjacentElement('afterend', belowContainer);
}
});
Because we’re adding some custom containers, some basic styling helps. Naturally, these styles are added to the admin
<head> via the admin_head hook in PHP.
<style>
.custom-admin-table-content {
border: 1px solid #ccd0d4; /* Updated WP admin color */
color: #1d2327; /* Updated WP admin color */
background: #fff;
border-left: 4px solid #7e8993; /* Updated WP admin color */
padding: 1px 12px; /* Standard WP notice padding */
margin-bottom: 15px; /* Spacing below */
box-shadow: 0 1px 1px rgba(0,0,0,.04);
}
/* Add margin-top only if it's the first element after the header */
.wp-header-end + .custom-admin-table-content.above {
margin-top: 15px; /* Spacing above */
}
.custom-admin-table-content.below {
margin-top: 15px; /* Spacing above */
}
.custom-admin-table-content p {
margin: 0.5em 0;
padding: 2px;
font-size: 13px; /* Standard WP notice font size */
}
/* Clearfix if needed, depending on content */
.custom-admin-table-content::after {
content: "";
display: table;
clear: both;
}
</style>
I’ve included .above and .below classes so you can target styles specifically.
To make this robust, the actual content for these boxes is defined in PHP files within your active theme directory. This keeps content manageable and trackable in version control, which is pretty neat.
The plugin code looks for files inside an /admin-above-below directory within your theme. Create this folder
and place files named like {$post_type}-above-all.php and {$post_type}-below-all.php inside it
(e.g., post-above-all.php, page-below-all.php).
Here are a couple of sample files you can use:
This example greets the current user and shows info about their last post.
File: {your_theme_folder}/admin-above-below/post-above-all.php
<?php
// Get the current logged-in user's details
$user = wp_get_current_user();
$name = $user->display_name; // Use display_name
// Determine the current post type (default to 'post')
$post_type = isset($_GET['post_type']) ? sanitize_key($_GET['post_type']) : 'post';
$post_type_object = get_post_type_object($post_type);
$post_type_label = $post_type_object ? $post_type_object->labels->singular_name : 'post';
// Check for the user's latest post of the current type
$args = array(
'author' => $user->ID,
'orderby' => 'post_date',
'post_type' => $post_type,
'post_status' => 'any', // Include drafts, etc.
'order' => 'DESC',
'posts_per_page' => 1 // More efficient than numberposts
);
$user_posts = get_posts($args);
// Show a different message depending on whether they've posted or not
if ($user_posts) :
$last_post = $user_posts[0];
$edit_link = get_edit_post_link($last_post->ID);
$post_title = get_the_title($last_post->ID);
// translators: 1: User name, 2: Edit link, 3: Post title, 4: Time difference
$message = sprintf(
esc_html__('Welcome back, %1$s! Your last %4$s was %2$s, created %3$s ago.', 'your-text-domain'),
esc_html($name),
sprintf('<a href="%s">%s</a>', esc_url($edit_link), esc_html($post_title)),
esc_html(human_time_diff(get_post_time('U', false, $last_post), current_time('timestamp'))),
esc_html(strtolower($post_type_label))
);
else :
$new_post_link = admin_url('post-new.php?post_type=' . $post_type);
// translators: 1: User name, 2: New post link, 3: Post type label
$message = sprintf(
esc_html__('Hi %1$s. Get started by creating a %2$s.', 'your-text-domain'),
esc_html($name),
sprintf('<a href="%s">new %s</a>', esc_url($new_post_link), esc_html(strtolower($post_type_label)))
);
endif;
// Output the message within a paragraph tag
echo '<p>' . wp_kses_post($message) . '</p>'; // Use wp_kses_post for security
?>
This snippet shows how many revisions have been made in the last 7 days for the current post type. Maybe useful? Who knows.
File: {your_theme_folder}/admin-above-below/post-below-all.php
<?php
global $wpdb;
// Determine the current post type (default to 'post')
$post_type = isset($_GET['post_type']) ? sanitize_key($_GET['post_type']) : 'post';
// SQL to count distinct parent posts with revisions in the last 7 days
// This is more efficient than fetching all revision IDs
$sql = $wpdb->prepare(
"SELECT COUNT(DISTINCT P1.ID)
FROM {$wpdb->posts} as P0
LEFT JOIN {$wpdb->posts} as P1 ON P0.post_parent = P1.ID
WHERE P0.post_type = 'revision'
AND P1.post_type = %s
AND P1.post_status != 'trash'
AND P1.post_status != 'auto-draft'
AND P0.post_modified_gmt >= DATE_SUB(UTC_TIMESTAMP(), INTERVAL 7 DAY)",
// Use post_modified_gmt and UTC_TIMESTAMP for timezone consistency
$post_type
);
$count = $wpdb->get_var($sql);
if ($count === null) {
// Handle potential DB error
echo '<p>' . esc_html__('Could not retrieve revision count.', 'your-text-domain') . '</p>';
} else {
// translators: %d is the number of revisions
printf(
'<p>' . esc_html(_n(
'There has been %d revision in the past 7 days for this post type.',
'There have been %d revisions in the past 7 days for this post type.',
$count,
'your-text-domain'
)) . '</p>',
(int)$count // Cast to integer
);
}
?>
Here’s the code wrapped up in a plugin for easy installation. It includes logic to check the current screen and display content only for the relevant post types based on your template filenames.
Installation:
/wp-content/plugins/ directory, or upload the zip via the WordPress admin Plugins > Add New > Upload Plugin.admin-above-below folder in your active theme directory (/wp-content/themes/your-theme-name/).post-above-all.php, post-below-all.php) from the plugin's /templates/ directory into your theme's admin-above-below folder to get started.{$post_type}-above-all.php and {$post_type}-below-all.php (e.g., page-above-all.php).I help content-rich organisations understand their data and pull it back into one place. Tell me what specific problems you're having and I'll be happy to talk solutions with you.
Tell me what specific problems you’re having and I’ll be happy to talk solutions with you.