Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
48 changes: 44 additions & 4 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );

Expand Down Expand Up @@ -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'] ?? [] ) );

Expand All @@ -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 ) ) {
Expand Down Expand Up @@ -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.
*
Expand Down
4 changes: 4 additions & 0 deletions src/taxonomy/block.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@
"items": { "type": "string" },
"default": []
},
"filterByPostType": {
"type": "boolean",
"default": false
},
"maxVisibleTerms": {
"type": "number",
"default": 0
Expand Down
15 changes: 15 additions & 0 deletions src/taxonomy/edit.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export default function Edit( { attributes, setAttributes } ) {
layoutDirection,
includeTerms,
excludeTerms,
filterByPostType,
maxVisibleTerms,
showAllLabel,
} = attributes;
Expand Down Expand Up @@ -189,6 +190,20 @@ export default function Edit( { attributes, setAttributes } ) {
title={ __( 'Terms', 'query-filter' ) }
initialOpen={ false }
>
<ToggleControl
label={ __(
'Only show terms used by queried post types',
'query-filter'
) }
checked={ filterByPostType }
onChange={ ( value ) =>
setAttributes( { filterByPostType: value } )
}
help={ __(
'Hide terms that are only assigned to content types outside this Query Loop.',
'query-filter'
) }
/>
<FormTokenField
label={ __(
'Include only these terms',
Expand Down
50 changes: 49 additions & 1 deletion src/taxonomy/render.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,55 @@
$base_url = str_replace( '/page/' . get_query_var( 'paged' ), '', remove_query_arg( [ $query_var, $page_var ] ) );
}

$terms = \HM\Query_Loop_Filter\get_filter_terms( $attributes );
$post_types = [];

if ( ! empty( $attributes['filterByPostType'] ) ) {
global $wp_query;

$query_context = (array) ( $block->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;
Expand Down
7 changes: 5 additions & 2 deletions tests/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
43 changes: 43 additions & 0 deletions tests/e2e/taxonomy-filter.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions tests/mu-plugins/register-test-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
39 changes: 33 additions & 6 deletions tests/seed.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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' => '',
Expand Down Expand Up @@ -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,
'<!-- wp:query-filter/taxonomy {"taxonomy":"qf_topic","filterByPostType":true} /-->',
'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,
'<!-- wp:query-filter/taxonomy {"taxonomy":"qf_topic"} /-->',
'qf_doc'
),
] );

update_option( 'query_filter_e2e_seeded', 1 );

echo "Seeded query filter e2e fixtures.\n";