From 9e78e634aadbf3886f6edf456946be6c66f5aa8c Mon Sep 17 00:00:00 2001 From: Florian Kutschera Date: Fri, 4 Sep 2026 09:30:58 +0200 Subject: [PATCH] Add post type-aware taxonomy terms --- README.md | 2 +- inc/namespace.php | 48 +++++++++++++++++++-- src/taxonomy/block.json | 4 ++ src/taxonomy/edit.js | 15 +++++++ src/taxonomy/render.php | 50 +++++++++++++++++++++- tests/e2e/README.md | 7 ++- tests/e2e/taxonomy-filter.spec.js | 43 +++++++++++++++++++ tests/mu-plugins/register-test-content.php | 9 ++++ tests/seed.php | 39 ++++++++++++++--- 9 files changed, 203 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 94714cb..54fcfd7 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Easy to use and lightweight, built using the WordPress Interactivity API. * Add a query block. This can anyhere that the query block is supported e.g. page, template, or pattern. * Add one of the filter blocks and configure as required: - * Taxonomy filter. Select which taxonomy to to use, customise the label (and whether it's shown), and customise the text used when none is selected. + * Taxonomy filter. Select which taxonomy to use, customise the label (and whether it's shown), customise the text used when none is selected, and optionally limit the available terms to those assigned to the Query Loop's post types. * Post type filter. Customise the label (and whether it's shown), as well as the text used when no filter is applied. * Search block. No extra options. diff --git a/inc/namespace.php b/inc/namespace.php index c451dc4..290ac0a 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -19,6 +19,7 @@ function bootstrap() : void { // General hooks. add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\filter_query_loop_block_query_vars', 10, 3 ); add_action( 'pre_get_posts', __NAMESPACE__ . '\\pre_get_posts_transpose_query_vars' ); + add_filter( 'terms_clauses', __NAMESPACE__ . '\\filter_terms_clauses_by_post_types', 10, 3 ); add_filter( 'block_type_metadata', __NAMESPACE__ . '\\filter_block_type_metadata', 10 ); add_action( 'init', __NAMESPACE__ . '\\register_blocks' ); @@ -184,17 +185,21 @@ function pre_get_posts_transpose_query_vars( WP_Query $query ) : void { * * Two modes, selected by whether `includeTerms` is populated: * - * - Curated: render exactly the listed terms, in the order given. Empty terms - * are kept, because naming a term explicitly is unambiguous intent. + * - Curated: render the listed terms, in the order given. * - Derived: render every term with posts, minus `excludeTerms`. * + * When post types are provided, both modes are limited to terms assigned to a + * published post of one of those types. Core's `hide_empty` option only uses a + * taxonomy's global counts and cannot distinguish between post types. + * * Terms are addressed by slug rather than ID so that curated lists stay * readable and reviewable in pattern markup. * - * @param array $attributes Taxonomy filter block attributes. + * @param array $attributes Taxonomy filter block attributes. + * @param string[] $post_types Post types represented by the query loop. * @return \WP_Term[] Terms to render, in display order. */ -function get_filter_terms( array $attributes ) : array { +function get_filter_terms( array $attributes, array $post_types = [] ) : array { $include = array_filter( (array) ( $attributes['includeTerms'] ?? [] ) ); $exclude = array_filter( (array) ( $attributes['excludeTerms'] ?? [] ) ); @@ -208,6 +213,7 @@ function get_filter_terms( array $attributes ) : array { 'hide_empty' => empty( $include ), 'slug' => $include_slugs, 'number' => 100, + 'query_filter_post_types' => $post_types, ] ); if ( is_wp_error( $terms ) || empty( $terms ) ) { @@ -235,6 +241,40 @@ function get_filter_terms( array $attributes ) : array { return $terms; } +/** + * Limit a term query to relationships with published posts of selected types. + * + * This is opt-in through the private `query_filter_post_types` argument used by + * get_filter_terms(). Keeping the constraint in the term query avoids loading + * every matching post ID into PHP merely to pass them back through object_ids. + * + * @param array $clauses SQL clauses for the terms query. + * @param string[] $taxonomies Taxonomies requested by the query. + * @param array $args Term query arguments. + * @return array Filtered SQL clauses. + */ +function filter_terms_clauses_by_post_types( array $clauses, array $taxonomies, array $args ) : array { + $post_types = array_values( array_filter( array_unique( (array) ( $args['query_filter_post_types'] ?? [] ) ) ) ); + + if ( empty( $post_types ) ) { + return $clauses; + } + + global $wpdb; + + $placeholders = implode( ', ', array_fill( 0, count( $post_types ), '%s' ) ); + $clauses['join'] .= " INNER JOIN {$wpdb->term_relationships} AS query_filter_relationships ON query_filter_relationships.term_taxonomy_id = tt.term_taxonomy_id"; + $clauses['join'] .= " INNER JOIN {$wpdb->posts} AS query_filter_posts ON query_filter_posts.ID = query_filter_relationships.object_id"; + $clauses['where'] .= $wpdb->prepare( + // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- The placeholder list is generated internally and every value is prepared below. + " AND query_filter_posts.post_type IN ({$placeholders}) AND query_filter_posts.post_status = %s", + array_merge( $post_types, [ 'publish' ] ) + ); + $clauses['distinct'] = 'DISTINCT'; + + return $clauses; +} + /** * Filters the settings determined from the block type metadata. * diff --git a/src/taxonomy/block.json b/src/taxonomy/block.json index 3e9876f..9052b0f 100644 --- a/src/taxonomy/block.json +++ b/src/taxonomy/block.json @@ -73,6 +73,10 @@ "items": { "type": "string" }, "default": [] }, + "filterByPostType": { + "type": "boolean", + "default": false + }, "maxVisibleTerms": { "type": "number", "default": 0 diff --git a/src/taxonomy/edit.js b/src/taxonomy/edit.js index e931a0f..5bf26f1 100644 --- a/src/taxonomy/edit.js +++ b/src/taxonomy/edit.js @@ -21,6 +21,7 @@ export default function Edit( { attributes, setAttributes } ) { layoutDirection, includeTerms, excludeTerms, + filterByPostType, maxVisibleTerms, showAllLabel, } = attributes; @@ -189,6 +190,20 @@ export default function Edit( { attributes, setAttributes } ) { title={ __( 'Terms', 'query-filter' ) } initialOpen={ false } > + + setAttributes( { filterByPostType: value } ) + } + help={ __( + 'Hide terms that are only assigned to content types outside this Query Loop.', + 'query-filter' + ) } + /> context['query'] ?? [] ); + $inherit = ! empty( $query_context['inherit'] ); + + if ( $inherit ) { + $inherited_post_types = $wp_query->get( 'query-filter-post_type' ); + $post_types = $inherited_post_types === 'any' + ? get_post_types( [ 'public' => true, 'exclude_from_search' => false ] ) + : (array) $inherited_post_types; + } else { + $post_types = array_map( 'trim', explode( ',', $query_context['postType'] ?? 'post' ) ); + } + + // Support the Advanced Query Loop block's multiple-post-type option. + if ( ! $inherit && isset( $query_context['multiple_posts'] ) && is_array( $query_context['multiple_posts'] ) ) { + $post_types = array_merge( $post_types, $query_context['multiple_posts'] ); + } + + // A post type filter changes the effective query without changing the block + // context, so prefer its current URL value when one is present. + $post_type_query_var = ! empty( $query_context['inherit'] ) + ? 'query-post_type' + : sprintf( 'query-%d-post_type', $block->context['queryId'] ?? 0 ); + + if ( isset( $_GET[ $post_type_query_var ] ) && is_scalar( $_GET[ $post_type_query_var ] ) ) { + $post_types = wp_parse_list( sanitize_text_field( wp_unslash( $_GET[ $post_type_query_var ] ) ) ); + } + + if ( in_array( 'any', $post_types, true ) ) { + $post_types = get_post_types( [ + 'public' => true, + 'exclude_from_search' => false, + ] ); + } + + $post_types = array_values( array_filter( array_unique( $post_types ), 'is_post_type_viewable' ) ); + + // Do not fall back to global taxonomy counts when the requested post types + // were all unknown or private. + if ( empty( $post_types ) ) { + return; + } +} + +$terms = \HM\Query_Loop_Filter\get_filter_terms( $attributes, $post_types ); if ( empty( $terms ) ) { return; diff --git a/tests/e2e/README.md b/tests/e2e/README.md index 36ccf5b..7053408 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -51,12 +51,15 @@ Seeding is idempotent, guarded by the `query_filter_e2e_seeded` option. | `/post-type-filter/` | 3 | Post type filter and core search block | | `/no-inherit-context/` | 4 | Both filters, in a query loop whose `query` context omits `inherit` | | `/search-pagination/` | 5 | Core search block and pagination, two posts to a page | +| `/post-type-taxonomy-filter/` | 7 | Taxonomy filter scoped to terms assigned to the loop's `qf_doc` post type | +| `/global-taxonomy-filter/` | 8 | Taxonomy filter using the default global term counts | Posts: `Alpha One` and `Alpha Two` in the `alpha` category, `Beta One` in `beta`, and `Unfiled Post` in neither — so an active filter is always distinguishable from no filter. `Doc One` and `Doc Two` are in the public -`qf_doc` post type. `Secret One` is published in the private `qf_secret` post -type and must never appear on the front end. +`qf_doc` post type. The shared `qf_topic` taxonomy has a `Posts Only` term on a +post and a `Docs Only` term on a document. `Secret One` is published in the +private `qf_secret` post type and must never appear on the front end. ## Configuration diff --git a/tests/e2e/taxonomy-filter.spec.js b/tests/e2e/taxonomy-filter.spec.js index b185967..be01eb7 100644 --- a/tests/e2e/taxonomy-filter.spec.js +++ b/tests/e2e/taxonomy-filter.spec.js @@ -19,6 +19,49 @@ test.describe( 'Taxonomy filter', () => { await loop.expectTitles( POSTS.all ); } ); + test( 'only lists terms assigned to the query loop post type', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-taxonomy-filter/' ); + + await expect( loop.taxonomySelect().locator( 'option' ) ).toHaveText( [ + 'All', + 'Docs Only', + ] ); + await loop.expectTitles( POSTS.docs ); + + await loop.taxonomySelect().selectOption( { label: 'Docs Only' } ); + await page.waitForURL( /query-7-qf_topic=docs-only/ ); + await loop.expectTitles( [ 'Doc One' ] ); + } ); + + test( 'follows the post type selected in the URL', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-taxonomy-filter/?query-7-post_type=post' ); + + await expect( loop.taxonomySelect().locator( 'option' ) ).toHaveText( [ + 'All', + 'Posts Only', + ] ); + await loop.expectTitles( POSTS.all ); + } ); + + test( 'keeps global taxonomy terms when post type filtering is disabled', async ( { + page, + loop, + } ) => { + await page.goto( '/global-taxonomy-filter/' ); + + await expect( loop.taxonomySelect().locator( 'option' ) ).toHaveText( [ + 'All', + 'Docs Only', + 'Posts Only', + ] ); + } ); + test( 'selecting a term filters the loop and updates the URL', async ( { page, loop, diff --git a/tests/mu-plugins/register-test-content.php b/tests/mu-plugins/register-test-content.php index dee9572..13f7cee 100644 --- a/tests/mu-plugins/register-test-content.php +++ b/tests/mu-plugins/register-test-content.php @@ -40,6 +40,15 @@ function register_test_content() : void { 'supports' => [ 'title', 'editor' ], ] ); + // Shared taxonomy used to prove that term choices respect a loop's post type. + register_taxonomy( 'qf_topic', [ 'post', 'qf_doc' ], [ + 'label' => 'Topics', + 'public' => true, + 'publicly_queryable' => true, + 'show_in_rest' => true, + 'hierarchical' => false, + ] ); + // Private taxonomy, for the same reason. register_taxonomy( 'qf_hidden', [ 'post' ], [ 'label' => 'Hidden', diff --git a/tests/seed.php b/tests/seed.php index 7d88d7d..40e5479 100644 --- a/tests/seed.php +++ b/tests/seed.php @@ -74,9 +74,15 @@ function seed_post( string $title, string $type = 'post', array $extra = [] ) : wp_insert_term( 'Classified', 'qf_hidden', [ 'slug' => 'classified' ] ); wp_set_object_terms( $alpha_one, [ 'classified' ], 'qf_hidden' ); -// Second public post type. -seed_post( 'Doc One', 'qf_doc' ); +// Second public post type, sharing a taxonomy with regular posts. Each term is +// used by only one post type so the taxonomy filter can prove it respects the +// query loop's selected type. +$doc_one = seed_post( 'Doc One', 'qf_doc' ); seed_post( 'Doc Two', 'qf_doc' ); +wp_insert_term( 'Posts Only', 'qf_topic', [ 'slug' => 'posts-only' ] ); +wp_insert_term( 'Docs Only', 'qf_topic', [ 'slug' => 'docs-only' ] ); +wp_set_object_terms( $alpha_one, [ 'posts-only' ], 'qf_topic' ); +wp_set_object_terms( $doc_one, [ 'docs-only' ], 'qf_topic' ); // Published, but in a post type that is not publicly queryable. If this title // ever appears on the front end, something has gone wrong. @@ -85,18 +91,19 @@ function seed_post( string $title, string $type = 'post', array $extra = [] ) : /** * Build the block markup for a query loop carrying filter blocks. * - * @param int $query_id Query ID for the loop. - * @param string $filters Serialized filter block markup to place in the loop. + * @param int $query_id Query ID for the loop. + * @param string $filters Serialized filter block markup to place in the loop. + * @param string $post_type Post type queried by the loop. * @return string Block markup. */ -function query_loop_markup( int $query_id, string $filters ) : string { +function query_loop_markup( int $query_id, string $filters, string $post_type = 'post' ) : string { $query = wp_json_encode( [ 'queryId' => $query_id, 'query' => [ 'perPage' => 10, 'pages' => 0, 'offset' => 0, - 'postType' => 'post', + 'postType' => $post_type, 'order' => 'asc', 'orderBy' => 'title', 'author' => '', @@ -185,6 +192,26 @@ function query_loop_markup( int $query_id, string $filters ) : string { HTML, ] ); +// Page 6: taxonomy terms must be scoped to the custom post type queried here. +seed_post( 'Post Type Taxonomy Filter', 'page', [ + 'post_name' => 'post-type-taxonomy-filter', + 'post_content' => query_loop_markup( + 7, + '', + 'qf_doc' + ), +] ); + +// Page 7: the option defaults off so existing blocks keep global term counts. +seed_post( 'Global Taxonomy Filter', 'page', [ + 'post_name' => 'global-taxonomy-filter', + 'post_content' => query_loop_markup( + 8, + '', + 'qf_doc' + ), +] ); + update_option( 'query_filter_e2e_seeded', 1 ); echo "Seeded query filter e2e fixtures.\n";