diff --git a/CLAUDE.md b/CLAUDE.md index 94fda98..34c8dbc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,7 +102,9 @@ The plugin provides a PHP API for registering custom query presets that can be s 'related_articles', // Unique identifier 'Related Articles', // Human-readable label function( $query_vars, $context ) { - // $context includes: post_id, is_rest, block (perPage, page) + // $context includes: post_id, is_rest, block_instance, block (perPage, page) + // $context['block'] is pagination metadata, NOT the block — the block is + // $context['block_instance'], e.g. $context['block_instance']?->context['termId'] ?? 0 // Modify and return $query_vars return $query_vars; } @@ -113,9 +115,13 @@ The plugin provides a PHP API for registering custom query presets that can be s 1. Presets are registered via PHP callbacks that receive query args and context 2. The preset selector appears in the block editor when presets are registered 3. REST API hooks are automatically added for all public post types via `rest_{$post_type}_collection_params` and `rest_{$post_type}_query` -4. Frontend queries are modified via `query_loop_block_query_vars` filter +4. Frontend queries are modified via `query_loop_block_query_vars` filter, which puts the `WP_Block` into the context as `block_instance` 5. The selected preset is stored in `query.hmPreset` block attribute +`block_instance` is null on REST. The editor preview queries the collection endpoint and never renders a block, so there is no instance to carry. See `modify_rest_query_for_preset()`. Both paths set the key explicitly, so the context has one shape. + +`post_id` comes from `$block->context['postId']` where the block has it, falling back to `get_the_ID()`. The block context is what core resolved for that block; `get_the_ID()` is global loop state, and the two can diverge. + ## Key Files - `hm-query-loop.php` - Main plugin file with all PHP hooks and query modification logic @@ -124,6 +130,7 @@ The plugin provides a PHP API for registering custom query presets that can be s - `docs/query-caching.md` - Why exclusions are applied in PHP, and what is left to do - `src/index.js` - Block filters for adding inspector controls and editor preview behavior - `tests/php/deferred-exclusions-test.php` - Unit tests for the exclusion planner +- `tests/php/query-presets-test.php` - Unit tests for the preset callback contract - `tests/e2e/fixtures.js` - Playwright test fixtures for WordPress admin - `tests/e2e/posts-per-page.spec.js` - E2E tests for posts per page functionality - `tests/e2e/query-presets.spec.js` - E2E tests for query presets diff --git a/README.md b/README.md index be173c4..6c876f2 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,12 @@ add_action( 'init', function() { 'Related Articles', // Label shown in dropdown function( $query_vars, $context ) { // $context includes: - // - post_id: Current post ID (useful for related content) + // - post_id: The post the block is rendering for ($block->context['postId'], + // falling back to get_the_ID()) // - is_rest: Boolean, true when called from REST API (editor) - // - block: Array with perPage and page values + // - block_instance: The WP_Block being rendered, or null on REST + // - block: Array with perPage and page values. Despite the name this is + // pagination metadata, not the block — that is block_instance. $related_ids = get_post_meta( $context['post_id'], 'related_posts', true ); @@ -164,6 +167,47 @@ add_action( 'init', function() { }); ``` +### Reading block context + +`$context['block_instance']` is the `WP_Block` being rendered, so a preset can read +anything core puts in its context — `postId`, or the `termId` and `taxonomy` a Term +Template provides to the blocks inside it. This example is illustrative — the +plugin ships no presets of its own: + +```php +\HM\QueryLoop\QueryPresets\register_query_preset( + 'posts_in_this_term', + 'Posts in this term', + function( $query_vars, $context ) { + $term_id = $context['block_instance']?->context['termId'] ?? 0; + + if ( ! $term_id ) { + return $query_vars; + } + + $query_vars['tax_query'] = [ + [ + 'taxonomy' => $context['block_instance']->context['taxonomy'] ?? 'category', + 'terms' => [ $term_id ], + ], + ]; + + return $query_vars; + } +); +``` + +It is named `block_instance` because `$context['block']` was there first and is not +the block: it holds `perPage` and `page`. Renaming that would break presets already +reading it, so both keys stay. + +`block_instance` is null on REST requests. The editor preview fetches posts from the +collection endpoint rather than rendering the block, so there is no block instance to +carry. A preset that depends on block context behaves differently in the editor +preview from the front end. Check `block_instance` before using it, and return +`$query_vars` unchanged when it is null. The key is always present, so `?->` reads it +safely and no `isset()` check is needed. + **Available Functions:** - `\HM\QueryLoop\QueryPresets\register_query_preset( $name, $label, $callback )` - Register a preset diff --git a/inc/query-presets.php b/inc/query-presets.php index fa28f0c..f4d51b7 100644 --- a/inc/query-presets.php +++ b/inc/query-presets.php @@ -10,6 +10,8 @@ namespace HM\QueryLoop\QueryPresets; +use WP_Block; + /** * Registered query presets. * @@ -24,7 +26,11 @@ * @param string $label Human-readable label for the preset (e.g., 'Related Articles'). * @param callable $callback Function that receives query args and block context, returns modified query args. * Signature: function(array $query_vars, array $context): array - * Context includes: 'post_id' (current post), 'block' (block attributes), 'is_rest' (bool). + * Context includes: 'post_id' (the post the block is rendering for), + * 'is_rest' (bool), 'block_instance' (the WP_Block, or null), and 'block' — + * which despite the name is pagination metadata ('perPage', 'page'). + * 'block_instance' is null on REST requests, because the editor preview + * queries the collection endpoint directly: no block is being rendered. * @return bool True on success, false if preset already exists. */ function register_query_preset( string $name, string $label, callable $callback ): bool { @@ -94,7 +100,8 @@ function get_query_preset( string $name ): ?array { * * @param string $name Preset identifier. * @param array $query_vars Current query arguments. - * @param array $context Additional context (post_id, block, is_rest). + * @param array $context Additional context (post_id, is_rest, block_instance, block). Note + * that 'block' is pagination metadata; 'block_instance' is the WP_Block. * @return array Modified query arguments. */ function apply_query_preset( string $name, array $query_vars, array $context = [] ): array { @@ -182,9 +189,13 @@ function modify_rest_query_for_preset( array $args, \WP_REST_Request $request ): } $context = [ - 'post_id' => $request->get_param( 'post_id' ) ?? get_the_ID() ?? 0, - 'is_rest' => true, - 'block' => [ + 'post_id' => $request->get_param( 'post_id' ) ?? get_the_ID() ?? 0, + 'is_rest' => true, + // Set explicitly, so the context has one shape and presets can read it with + // `?->` and no isset() check. REST renders no block: the editor preview + // queries the collection endpoint. + 'block_instance' => null, + 'block' => [ 'perPage' => $request->get_param( 'per_page' ), ], ]; @@ -195,14 +206,14 @@ function modify_rest_query_for_preset( array $args, \WP_REST_Request $request ): /** * Filter query vars for the Query Loop block on the frontend. * - * @param array $query_vars Existing query variables. - * @param \WP_Block $block Block instance. - * @param int $page Current page number. + * @param array $query_vars Existing query variables. + * @param WP_Block $block Block instance. + * @param int $page Current page number. * @return array Modified query variables. */ -function filter_query_loop_block_query_vars( array $query_vars, \WP_Block $block, int $page ): array { - $context = $block->context ?? []; - $query_attr = $context['query'] ?? []; +function filter_query_loop_block_query_vars( array $query_vars, WP_Block $block, int $page ): array { + $block_context = $block->context ?? []; + $query_attr = $block_context['query'] ?? []; $preset_name = $query_attr['hmPreset'] ?? ''; if ( empty( $preset_name ) ) { @@ -210,9 +221,15 @@ function filter_query_loop_block_query_vars( array $query_vars, \WP_Block $block } $context = [ - 'post_id' => get_the_ID() ?? 0, - 'is_rest' => false, - 'block' => [ + // Prefer what core resolved for this block over global loop state: the two + // agree on a singular template, and diverge where the global post is not + // what the block is rendering for. + 'post_id' => $block_context['postId'] ?? get_the_ID() ?? 0, + 'is_rest' => false, + // Presets need the block itself to reach core's context — termId and taxonomy + // inside a Term Template, postId, and so on. 'block' below is not that. + 'block_instance' => $block, + 'block' => [ 'perPage' => $query_vars['posts_per_page'] ?? get_option( 'posts_per_page', 10 ), 'page' => $page, ], diff --git a/package.json b/package.json index dfec269..7355ce2 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint:css:fix": "wp-scripts lint-style --fix", "format": "wp-scripts format", "wp-env": "wp-env", - "test:php": "php tests/php/deferred-exclusions-test.php", + "test:php": "php tests/php/deferred-exclusions-test.php && php tests/php/query-presets-test.php", "test:e2e": "wp-scripts test-playwright", "test:e2e:debug": "wp-scripts test-playwright --debug", "test:e2e:watch": "wp-scripts test-playwright --watch" diff --git a/tests/php/query-presets-test.php b/tests/php/query-presets-test.php new file mode 100644 index 0000000..c1c75cf --- /dev/null +++ b/tests/php/query-presets-test.php @@ -0,0 +1,208 @@ +name = $name; + $this->context = $context; + } + } + + class WP_REST_Request { + private $params = []; + + public function __construct( $params = [] ) { + $this->params = $params; + } + + public function get_param( $key ) { + return $this->params[ $key ] ?? null; + } + } + + function add_filter( ...$args ) {} + + function add_action( ...$args ) {} + + function get_option( $name, $default = false ) { + return $default; + } + + function get_the_ID() { + return $GLOBALS['current_post_id']; + } + + function _doing_it_wrong( ...$args ) {} + + function __( $text, $domain = null ) { + return $text; + } + + function esc_html__( $text, $domain = null ) { + return $text; + } + + function esc_attr( $text ) { + return $text; + } +} + +namespace HM\QueryLoop\Tests\Presets { + + use WP_Block; + use WP_REST_Request; + + use function HM\QueryLoop\QueryPresets\apply_query_preset; + use function HM\QueryLoop\QueryPresets\filter_query_loop_block_query_vars; + use function HM\QueryLoop\QueryPresets\modify_rest_query_for_preset; + use function HM\QueryLoop\QueryPresets\register_query_preset; + + require_once dirname( __DIR__, 2 ) . '/inc/query-presets.php'; + + $failures = 0; + $checks = 0; + + /** + * Assert that two values match. + * + * @param string $label What is being checked. + * @param mixed $actual The value produced. + * @param mixed $expected The value wanted. + * @return void + */ + function check( string $label, $actual, $expected ): void { + global $failures, $checks; + + $checks++; + + if ( $actual === $expected ) { + printf( "ok %s\n", $label ); + return; + } + + $failures++; + printf( + "FAIL %s\n expected: %s\n actual: %s\n", + $label, + var_export( $expected, true ), + var_export( $actual, true ) + ); + } + + /** + * The context each preset invocation was handed, keyed by preset name. + * + * @var array + */ + $seen_context = []; + + // Records the whole context, so the tests can inspect any key of it. + register_query_preset( + 'records_context', + 'Records Context', + function ( array $query_vars, array $context ): array { + $GLOBALS['seen_context']['records_context'] = $context; + return $query_vars; + } + ); + + // Reads block context the way a preset inside a Term Template would. + register_query_preset( + 'reads_term', + 'Reads Term', + function ( array $query_vars, array $context ): array { + $query_vars['term_id'] = $context['block_instance']?->context['termId'] ?? 0; + return $query_vars; + } + ); + + $block = new WP_Block( + 'core/post-template', + [ + 'query' => [ 'hmPreset' => 'records_context' ], + 'termId' => 77, + 'taxonomy' => 'category', + ] + ); + + // The filter holds the block already; it must put it in the context it builds. + filter_query_loop_block_query_vars( [ 'posts_per_page' => 5 ], $block, 2 ); + $frontend = $seen_context['records_context']; + + check( 'frontend: context carries the block instance', $frontend['block_instance'], $block ); + check( 'frontend: is_rest false', $frontend['is_rest'], false ); + + // post_id follows the block, not the global loop. $block carries no postId, so + // this one falls back; the next block supplies one that disagrees with the global. + $GLOBALS['current_post_id'] = 12; + filter_query_loop_block_query_vars( [ 'posts_per_page' => 5 ], $block, 1 ); + check( 'post_id: falls back to the global post', $seen_context['records_context']['post_id'], 12 ); + + $other_post_block = new WP_Block( + 'core/post-template', + [ + 'query' => [ 'hmPreset' => 'records_context' ], + 'postId' => 34, + ] + ); + filter_query_loop_block_query_vars( [ 'posts_per_page' => 5 ], $other_post_block, 1 ); + check( 'post_id: block context wins over the global post', $seen_context['records_context']['post_id'], 34 ); + $GLOBALS['current_post_id'] = 0; + + // 'block' is misnamed but load-bearing: renaming it would break existing presets. + check( 'frontend: block stays pagination metadata', $frontend['block'], [ 'perPage' => 5, 'page' => 2 ] ); + + // REST renders no block, and says so with the key present rather than absent. + modify_rest_query_for_preset( [], new WP_REST_Request( [ 'hmPreset' => 'records_context' ] ) ); + $rest = $seen_context['records_context']; + + check( 'REST: block_instance key exists', array_key_exists( 'block_instance', $rest ), true ); + check( 'REST: block_instance is null', $rest['block_instance'], null ); + + // Reaching core's block context is the whole point of carrying the instance. + $term_block = new WP_Block( + 'core/post-template', + [ + 'query' => [ 'hmPreset' => 'reads_term' ], + 'termId' => 77, + ] + ); + $term_vars = filter_query_loop_block_query_vars( [ 'posts_per_page' => 5 ], $term_block, 1 ); + check( 'preset reads termId through block_instance', $term_vars['term_id'], 77 ); + + // Applied directly, the context passes through untouched. + apply_query_preset( 'records_context', [], [ 'post_id' => 5, 'block_instance' => null ] ); + check( 'apply_query_preset: context passed through', $seen_context['records_context'], [ 'post_id' => 5, 'block_instance' => null ] ); + + // An unregistered preset is still a no-op. + check( 'unknown preset: query vars untouched', apply_query_preset( 'nope', [ 'orderby' => 'date' ], [] ), [ 'orderby' => 'date' ] ); + + printf( "\n%d checks, %d failures\n", $checks, $failures ); + + exit( $failures > 0 ? 1 : 0 ); +}