[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