[wp-trac] [WordPress Trac] #62854: Filter custom post types, terms in the admin area
WordPress Trac
noreply at wordpress.org
Wed Mar 11 08:54:07 UTC 2026
#62854: Filter custom post types,terms in the admin area
-----------------------------+------------------------------
Reporter: dhenriet | Owner: (none)
Type: feature request | Status: new
Priority: normal | Milestone: Awaiting Review
Component: Administration | Version:
Severity: normal | Resolution:
Keywords: has-patch | Focuses:
-----------------------------+------------------------------
Comment (by sanket.parmar):
== Proposed patch for discussion ==
----
== Background ==
Custom post types with multiple taxonomies have no native way to filter
posts by those taxonomies in the admin list table. The only built-in
dropdowns are for {{{category}}} and {{{post_format}}} on the default
{{{post}}} type. Site builders and editors working with, say, a
{{{movie}}} CPT with {{{actor}}}, {{{director}}}, {{{genre}}} taxonomies
currently have no filter UI at all.
This patch adds first-class taxonomy filter dropdowns to
{{{WP_Posts_List_Table}}} using the '''Screen Options panel as the control
surface''', directly addressing the core committer's guidance on the
ticket:
"Maybe the best course of action would be to not display these filters
by default and make them actionable via the screen option panel."
----
== What the patch does ==
=== User-facing behaviour ===
1. '''All taxonomy filters are hidden by default.''' The post list table
looks identical to today for every post type.
1. '''Users opt in per-taxonomy via Screen Options.''' A new "Filters"
fieldset appears in the Screen Options panel, listing only the taxonomies
that have at least one term. Users check the ones they want.
1. '''Enabled filters appear immediately''' — no page reload — in a
dedicated second row below the built-in date/category row.
1. '''The preference is persisted per-user per-screen''' via user meta,
so the choice survives page reloads and browser restarts.
1. '''The Filter button''' is present in both rows so the user can apply
date/category filters independently of taxonomy filters.
1. '''Disabling all taxonomy filters''' hides the second row entirely.
=== Layout ===
{{{
[ Bulk actions ▼ ] [ Apply ] [ All dates ▼ ] [ All Categories ▼ ] [
Filter ]
[ All Actors ▼ ] [ All Genres ▼ ] [ Filter ] ← only when ≥1 taxonomy
enabled
}}}
The taxonomy row has {{{clear: left}}} so it reliably starts on its own
line, and {{{display: flex}}} so the Filter button never orphans onto a
third line regardless of how many dropdowns are active.
----
== Files changed ==
=== 1. `src/wp-admin/includes/class-wp-posts-list-table.php` ===
==== `__construct()` ====
Two new hook registrations:
{{{#!php
add_filter( 'screen_settings', [ $this, 'taxonomy_filter_screen_settings'
], 10, 2 );
add_action( 'pre_get_posts', [ $this, 'handle_taxonomy_query_vars' ] );
}}}
'''Why here:''' The constructor is where all other list-table hooks are
registered ({{{manage_*_columns}}}, {{{restrict_manage_posts}}}, etc.).
Registering in the constructor ensures the hooks fire on every edit screen
regardless of how the table is instantiated.
----
==== `get_filterable_taxonomies( $post_type )` ''(new protected method)''
====
Returns all {{{show_ui}}} taxonomies for the post type, excluding
{{{category}}} and {{{post_format}}}.
'''Why exclude `category` and `post_format`:'''
{{{categories_dropdown()}}} and {{{formats_dropdown()}}} already handle
those. Adding them here would produce duplicate dropdowns.
'''Why `show_ui` check:''' Non-UI taxonomies (e.g. internal tagging
systems) should not surface in the admin filter bar; they are invisible
everywhere else in the UI by convention.
----
==== `taxonomies_dropdown( $post_type )` ''(new protected method)'' ====
Renders {{{<div class="taxonomy-filter-container [hidden]">}}} +
{{{<select>}}} for each eligible taxonomy.
Key implementation decisions:
||= Decision =||= Reasoning =||
|| {{{wp_dropdown_categories()}}} with {{{value_field => 'slug'}}} || Term
slugs work directly as {{{WP_Query}}} query vars and are install-
independent (unlike IDs which vary per database). ||
|| All containers always rendered in the DOM || Allows JavaScript to
show/hide them instantly without a round-trip. Only the ''visible'' ones
are shown on load. ||
|| {{{name}}} → {{{data-name}}} swap for hidden selects || A
{{{<select>}}} without a {{{name}}} attribute is never included in form
serialisation, regardless of {{{disabled}}} state. This is a categorical
guarantee — no reliance on JS running or {{{disabled}}} being preserved.
{{{disabled}}} is also added as a secondary guard for accessibility
tooling. ||
|| {{{disable_taxonomy_dropdown}}} filter || Gives plugin/theme authors an
escape hatch to suppress specific dropdowns programmatically. Follows the
same pattern as the existing {{{disable_categories_dropdown}}} and
{{{disable_formats_dropdown}}} filters. ||
|| {{{hide_empty => true}}} in {{{get_terms()}}} || Only shows dropdowns
for taxonomies that actually have terms. A dropdown with only the "All X"
option is useless clutter. ||
----
==== `taxonomy_filter_screen_settings( $settings, $screen )` ''(new public
method)'' ====
Hooked on {{{screen_settings}}}. Appends a {{{<fieldset class="metabox-
prefs taxonomy-filter-prefs">}}} with one checkbox per taxonomy.
Key decisions:
||= Decision =||= Reasoning =||
|| {{{screen_settings}}} filter || This is the established hook for adding
custom content to the Screen Options panel in WP core.
{{{render_screen_options()}}} echoes it verbatim in the panel. ||
|| {{{number => 1}}} in per-checkbox {{{get_terms()}}} || Only fetch one
term to confirm existence; the full list is fetched only in
{{{taxonomies_dropdown()}}} where it is actually iterated. This avoids a
potentially expensive query per taxonomy just for a presence check. ||
|| Term-existence check mirrors {{{taxonomies_dropdown()}}} || A checkbox
with no corresponding DOM container would silently do nothing when
checked. Mirroring the same {{{hide_empty}}} check ensures every visible
checkbox always has a container to show/hide. ||
|| Checked = visible, unchecked = hidden || Consistent with the existing
Columns section behaviour in the same Screen Options panel. ||
----
==== `handle_taxonomy_query_vars( $query )` ''(new public method)'' ====
Hooked on {{{pre_get_posts}}}. Sanitises the query before WordPress
executes it.
'''Case 1 — "All items" (`?actor=0`):''' {{{wp_dropdown_categories()}}}
hard-codes {{{value="0"}}} for the "All items" option. {{{WP_Query}}}
interprets {{{?actor=0}}} as "find the term with slug {{{0}}}", finds
nothing, and returns zero posts. This method unsets any taxonomy query var
with value {{{'0'}}} to restore the "show everything" behaviour.
'''Case 2 — Stale URL for a now-hidden taxonomy:''' A user might bookmark
{{{?actor=keanu-reeves}}}, then later uncheck Actors in Screen Options.
Without this guard, navigating to that URL would still filter by the
hidden taxonomy. This method unsets query vars for any taxonomy not in the
user's visibility list.
Three guards ensure the callback only fires for the correct context:
* {{{is_admin()}}} — prevents interference with front-end main queries
(e.g. a CPT archive page).
* {{{is_main_query()}}} — skips secondary admin queries (widgets, nav
menu lookups, adjacent-post queries).
* {{{$query->get('post_type') !== $this->screen->post_type}}} — skips
unrelated edit screens or REST API admin-context queries for a different
post type.
----
=== 2. `src/wp-admin/includes/screen.php` ===
==== `get_visible_taxonomy_filters( $screen )` ''(new function)'' ====
{{{#!php
function get_visible_taxonomy_filters( $screen ) {
$visible = get_user_option( 'manage' . $screen->id . 'taxonomyfilters'
);
if ( ! is_array( $visible ) ) { $visible = array(); }
return apply_filters( 'visible_taxonomy_filters', $visible, $screen );
}
}}}
'''Pattern:''' Direct parallel of {{{get_hidden_columns()}}} — same file,
same signature shape, same user-option naming convention
({{{manage{$screen->id}*}}}).
'''Why empty array default (all hidden):''' Implements "hidden by default"
at the data layer. A new user or a new screen will always start with no
filters visible.
'''Why `visible_taxonomy_filters` filter:''' Allows site builders to
programmatically force certain filters visible (e.g. in a custom admin
plugin) without touching user meta.
----
=== 3. `src/wp-admin/includes/ajax-actions.php` ===
==== `wp_ajax_taxonomy_filter_visibility()` ''(new function)'' ====
Validates the screen-options nonce, sanitises the taxonomy slugs with
{{{sanitize_key()}}}, and writes the preference to
{{{manage{$page}taxonomyfilters}}} user meta.
'''Pattern:''' Exact mirror of {{{wp_ajax_hidden_columns()}}} — same nonce
check, same {{{page}}} param, same {{{update_user_meta}}} call, placed
directly after it in the file.
----
=== 4. `src/wp-admin/admin-ajax.php` ===
Adds {{{'taxonomy-filter-visibility'}}} to {{{$core_actions_post}}}.
'''Why needed:''' All core AJAX actions must be explicitly whitelisted
here; the handler function alone is not sufficient.
----
=== 5. `src/js/_enqueues/admin/common.js` ===
==== `window.taxonomyFilters` ''(new object)'' ====
Three methods, mirroring {{{window.columns}}}:
||= Method =||= Purpose =||
|| {{{init()}}} || Binds {{{change}}} on {{{.taxonomy-filter-tog}}}
checkboxes. On enable: removes {{{.hidden}}}, swaps {{{data-name}}} →
{{{name}}}, removes {{{disabled}}}, shows the taxonomy row. On disable:
adds {{{.hidden}}}, swaps {{{name}}} → {{{data-name}}}, adds
{{{disabled}}}, hides the row if no filters remain. Calls
{{{saveState()}}}. ||
|| {{{saveState()}}} || POSTs checked slugs to {{{taxonomy-filter-
visibility}}} AJAX action. Updates the screen-reader live region via
{{{wp.a11y.speak()}}}. ||
|| {{{visible()}}} || Returns comma-separated slugs of currently checked
filters. ||
'''Why `name` ↔ `data-name` in JS (mirroring PHP):''' When JS re-enables a
filter, it must restore the {{{name}}} attribute so the select is included
in the form submission. {{{removeAttr('disabled')}}} alone is not enough
if the server never emitted a {{{name}}} attribute for hidden fields.
'''Why no page reload on toggle:''' Immediate visual feedback is critical
for a Screen Options interaction. The existing {{{columns}}} object
demonstrates this is the correct pattern in WP core.
----
=== 6. `src/wp-admin/css/list-tables.css` ===
{{{#!css
.tablenav .taxonomy-filters-row {
clear: left;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 0;
}
.tablenav .taxonomy-filters-row.hidden {
display: none;
}
.tablenav .taxonomy-filters-row .taxonomy-filter-container,
.tablenav .taxonomy-filters-row .taxonomy-filter-container.hidden {
float: none;
}
.tablenav .actions + .taxonomy-filters-row {
margin-top: 6px;
}
}}}
||= Rule =||= Reasoning =||
|| {{{clear: left}}} || Guarantees the taxonomy row always starts on its
own line below row 1, regardless of viewport width. Without this, at very
wide viewports both rows could share a line and the margin would be
invisible. ||
|| {{{display: flex; flex-wrap: wrap; align-items: center}}} || Children
(taxonomy {{{<div>}}} wrappers + Filter button) become flex items that
wrap inline as a single flow. The Filter button always sits immediately
after the last dropdown — never orphaned on a third line, no matter how
many taxonomies are enabled. ||
|| {{{gap: 6px 0}}} || Adds a small vertical gap only between wrapped
lines within the row. No horizontal gap needed since {{{.taxonomy-filter-
container}}} already inherits {{{padding-right}}} from {{{.tablenav
.actions}}}. ||
|| {{{float: none}}} on {{{.taxonomy-filter-container}}} || Cancels the
inherited {{{float: left}}} from {{{.alignleft}}} so the containers
participate correctly in flex layout, rather than being pulled out of
flow. ||
|| {{{.actions + .taxonomy-filters-row { margin-top: 6px }}}} || Adjacent-
sibling selector: the gap only appears when a built-in filter row
({{{div.actions}}}) actually precedes the taxonomy row. Prevents orphaned
top margin in the edge case where months/categories/formats all have
nothing to show and row 1 is omitted. ||
----
== What was intentionally left out ==
* '''Standard {{{post}}} post type:''' {{{category}}} and
{{{post_format}}} already have dedicated dropdowns and are excluded
explicitly. {{{post_tag}}} is a free-text taxonomy rather than a
hierarchical filter — a separate ticket could address it.
* '''Bottom tablenav:''' {{{extra_tablenav()}}} is called for both
{{{top}}} and {{{bottom}}}. Taxonomy filters only render on {{{top}}},
consistent with how months and category dropdowns behave.
* '''Bulk-action row:''' Taxonomy filters are purely filter controls and
never appear in the bulk-action area.
----
== Screenshots for the ticket ==
The following screenshots would best illustrate the proposal for
reviewers:
'''Screen Options panel — default state'''[[BR]]The Screen Options panel
open with the new "Filters" fieldset visible, all checkboxes unchecked.
Shows the opt-in entry point and that nothing is visible out of the box.
[[Image(1-screen-options-panel-default-state.png)]]
----
'''Screen Options panel — filters checked'''[[BR]]Same panel with two or
three checkboxes checked. Capture it while the panel is still open so
reviewers can see both the checked state and the rendered dropdowns
appearing in the background simultaneously.
[[Image(2-screen-options-panel-filters-checked.gif)]]
----
'''List table — two or three filters enabled'''[[BR]]Full-page screenshot
showing row 1 (dates / categories / Filter) and row 2 (e.g. "All Actors ▼
All Genres ▼ Filter") clearly separated with the gap. This is the primary
"after" screenshot.
[[Image(3-list-table-two-or-three-filters-enabled.png)]]
----
'''List table — no filters enabled'''[[BR]]Same page with all taxonomy
checkboxes unchecked. Row 2 is absent. Shows that the default experience
is completely unchanged from core.
[[Image(4-list-table-no-filters-enabled.png)]]
----
'''List table — many filters enabled (wrapping)'''[[BR]]A CPT with 5–6
taxonomies all enabled. Demonstrates that {{{flex-wrap}}} keeps the Filter
button on the same line as the last dropdown even when the row wraps
internally.
[[Image(5-list-table-many-filters-enabled-wrapping.png)]]
----
'''URL / query string — filtering in action'''[[BR]]Browser address bar
showing e.g. {{{?post_type=movie&actor=keanu-reeves&genre=action}}} after
clicking Filter. Confirms that {{{value_field=slug}}} and
{{{handle_taxonomy_query_vars}}} are working correctly (no {{{?actor=0}}}
leaking through).
[[Image(6-url-query-string-filtering-in-action.png)]]
--
Ticket URL: <https://core.trac.wordpress.org/ticket/62854#comment:3>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform
More information about the wp-trac
mailing list