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
3 changes: 3 additions & 0 deletions .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
"https://downloads.wordpress.org/plugin/advanced-query-loop.zip"
],
"themes": [ "WordPress/twentytwentyfour", "WordPress/twentytwentyfive" ],
"mappings": {
"wp-content/mu-plugins": "./tests/mu-plugins"
},
"port": 8888,
"testsPort": 8889,
"config": {
Expand Down
26 changes: 26 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,35 @@ The plugin handles two different query scenarios:
### Editor Preview Synchronization
In src/index.js, a `useEffect` hook syncs the `hmQueryLoop.perPage` attribute to `query.perPage` to reflect the override in the editor preview. The `withPostTemplateStyles` filter adds inline CSS to hide excess posts in the editor beyond the `perPage` limit.

### Query Presets System

The plugin provides a PHP API for registering custom query presets that can be selected in the block editor:

**Registration API** (`inc/query-presets.php`):
```php
// Register a custom query preset
\HM\QueryLoop\QueryPresets\register_query_preset(
'related_articles', // Unique identifier
'Related Articles', // Human-readable label
function( $query_vars, $context ) {
// $context includes: post_id, is_rest, block (perPage, page)
// Modify and return $query_vars
return $query_vars;
}
);
```

**How it works**:
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
5. The selected preset is stored in `query.hmPreset` block attribute

## Key Files

- `hm-query-loop.php` - Main plugin file with all PHP hooks and query modification logic
- `inc/query-presets.php` - Query presets registration API and hooks
- `src/index.js` - Block filters for adding inspector controls and editor preview behavior
- `tests/e2e/posts-per-page.spec.js` - E2E tests for posts per page functionality
- `tests/e2e/fixtures.js` - Playwright test fixtures for WordPress admin
Expand Down
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ Enable this option to automatically exclude posts that have been displayed by pr

**Important:** The exclusion applies to all query loops rendered before the current one, regardless of whether they were visible (e.g., hidden due to pagination settings).

### 4. Query Presets

Register custom query configurations in PHP that can be selected from a dropdown in the block editor. This allows developers to create reusable, dynamic queries (like "Related Articles" or "Trending Posts") that content editors can easily apply to any Query Loop block.

**Key Benefits:**
- Define complex query logic in PHP while keeping the editor interface simple
- Queries work in both the editor preview and on the frontend
- Automatically hooks into all public post types via the REST API

## Installation

1. Upload the plugin to your `/wp-content/plugins/` directory
Expand Down Expand Up @@ -67,6 +76,7 @@ See [tests/e2e/README.md](tests/e2e/README.md) for more details on the test setu
1. Add a Query Loop block to your page or template
2. In the block settings sidebar, find the "HM Query Loop Settings" panel
3. Configure the options as needed:
- **Query Preset**: Select a predefined query configuration (only visible when presets are registered)
- **Posts per page (Override)**: Only visible when inheriting query - enter a number to override posts per page, or leave empty to use default
- **Hide on paginated pages**: Toggle to hide this block on page 2+
- **Exclude already displayed posts**: Toggle to avoid showing duplicate posts
Expand Down Expand Up @@ -119,6 +129,43 @@ Note: Since `query_loop_block_query_vars` doesn't fire for inherited queries, we
- Tracks post IDs from Query Loop blocks (both approaches) and main query
- Builds a global list for subsequent query loops to exclude

### Query Presets:

Register custom query presets that appear in the block editor and modify queries on both frontend and REST API requests.

```php
// In your theme's functions.php or a plugin
add_action( 'init', function() {
\HM\QueryLoop\QueryPresets\register_query_preset(
'related_articles', // Unique identifier
'Related Articles', // Label shown in dropdown
function( $query_vars, $context ) {
// $context includes:
// - post_id: Current post ID (useful for related content)
// - is_rest: Boolean, true when called from REST API (editor)
// - block: Array with perPage and page values

$related_ids = get_post_meta( $context['post_id'], 'related_posts', true );

if ( ! empty( $related_ids ) ) {
$query_vars['post__in'] = $related_ids;
$query_vars['orderby'] = 'post__in';
}

return $query_vars;
}
);
});
```

**Available Functions:**

- `\HM\QueryLoop\QueryPresets\register_query_preset( $name, $label, $callback )` - Register a preset
- `\HM\QueryLoop\QueryPresets\unregister_query_preset( $name )` - Remove a preset
- `\HM\QueryLoop\QueryPresets\get_registered_presets()` - Get all registered presets
- `\HM\QueryLoop\QueryPresets\get_query_preset( $name )` - Get a specific preset
- `\HM\QueryLoop\QueryPresets\apply_query_preset( $name, $query_vars, $context )` - Manually apply a preset

## Example Use Case

Create a custom archive layout with different query loops:
Expand Down
6 changes: 6 additions & 0 deletions hm-query-loop.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
define( 'HM_QUERY_LOOP_PATH', plugin_dir_path( __FILE__ ) );
define( 'HM_QUERY_LOOP_URL', plugin_dir_url( __FILE__ ) );

// Load query presets functionality.
require_once HM_QUERY_LOOP_PATH . 'inc/query-presets.php';

/**
* Initialize the plugin.
*/
Expand All @@ -43,6 +46,9 @@ function init() {

// Add contexts to query and post-template block.
add_filter( 'block_type_metadata', __NAMESPACE__ . '\\filter_block_metadata' );

// Initialize query presets functionality.
QueryPresets\init();
}

add_action( 'init', __NAMESPACE__ . '\\init', 9 );
Expand Down
248 changes: 248 additions & 0 deletions inc/query-presets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
<?php
/**
* Query Presets functionality for the Query Loop block.
*
* Provides a PHP API for registering custom query configurations that can be
* selected in the block editor and applied on both frontend and REST API requests.
*
* @package HM\QueryLoop\QueryPresets
*/

namespace HM\QueryLoop\QueryPresets;

/**
* Registered query presets.
*
* @var array<string, array{label: string, callback: callable}>
*/
$registered_presets = [];

/**
* Register a query preset.
*
* @param string $name Unique identifier for the preset (e.g., 'related_articles').
* @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).
* @return bool True on success, false if preset already exists.
*/
function register_query_preset( string $name, string $label, callable $callback ): bool {
global $registered_presets;

if ( isset( $registered_presets[ $name ] ) ) {
_doing_it_wrong(
__FUNCTION__,
sprintf(
/* translators: %s: preset name */
esc_html__( 'Query preset "%s" is already registered.', 'hm-query-loop' ),
$name
),
'1.0.0'
);
return false;
}

$registered_presets[ $name ] = [
'label' => $label,
'callback' => $callback,
];

return true;
}

/**
* Unregister a query preset.
*
* @param string $name Preset identifier to unregister.
* @return bool True if preset was unregistered, false if it didn't exist.
*/
function unregister_query_preset( string $name ): bool {
global $registered_presets;

if ( ! isset( $registered_presets[ $name ] ) ) {
return false;
}

unset( $registered_presets[ $name ] );
return true;
}

/**
* Get all registered query presets.
*
* @return array<string, array{label: string, callback: callable}> Registered presets.
*/
function get_registered_presets(): array {
global $registered_presets;
return $registered_presets ?? [];
}

/**
* Get a specific query preset by name.
*
* @param string $name Preset identifier.
* @return array{label: string, callback: callable}|null Preset data or null if not found.
*/
function get_query_preset( string $name ): ?array {
global $registered_presets;
return $registered_presets[ $name ] ?? null;
}

/**
* Apply a query preset to query arguments.
*
* @param string $name Preset identifier.
* @param array $query_vars Current query arguments.
* @param array $context Additional context (post_id, block, is_rest).
* @return array Modified query arguments.
*/
function apply_query_preset( string $name, array $query_vars, array $context = [] ): array {
$preset = get_query_preset( $name );

if ( ! $preset || ! is_callable( $preset['callback'] ) ) {
return $query_vars;
}

return call_user_func( $preset['callback'], $query_vars, $context );
}

/**
* Initialize query presets functionality.
*
* @return void
*/
function init(): void {
// Hook into REST API initialization to add filters for all post types.
add_action( 'rest_api_init', __NAMESPACE__ . '\\register_rest_hooks' );

// Frontend query filtering for the Query Loop block.
add_filter( 'query_loop_block_query_vars', __NAMESPACE__ . '\\filter_query_loop_block_query_vars', 15, 3 );

// Pass registered presets to JavaScript.
add_action( 'enqueue_block_editor_assets', __NAMESPACE__ . '\\enqueue_presets_data' );
}

/**
* Register REST API hooks for all public post types.
*
* This dynamically adds filters to all REST post controllers, avoiding the need
* to manually add hooks for each post type.
*
* @return void
*/
function register_rest_hooks(): void {
$post_types = get_post_types( [ 'show_in_rest' => true ], 'objects' );

foreach ( $post_types as $post_type ) {
// Allow the hmPreset parameter on collection endpoints.
add_filter(
"rest_{$post_type->name}_collection_params",
__NAMESPACE__ . '\\add_preset_collection_param'
);

// Modify queries based on the preset parameter.
add_filter(
"rest_{$post_type->name}_query",
__NAMESPACE__ . '\\modify_rest_query_for_preset',
10,
2
);
}
}

/**
* Add the hmPreset parameter to REST collection endpoints.
*
* @param array $params Existing collection parameters.
* @return array Modified parameters.
*/
function add_preset_collection_param( array $params ): array {
$params['hmPreset'] = [
'description' => __( 'HM Query Loop preset identifier.', 'hm-query-loop' ),
'type' => 'string',
'sanitize_callback' => 'sanitize_key',
];

return $params;
}

/**
* Modify REST queries based on the preset parameter.
*
* @param array $args Query arguments.
* @param \WP_REST_Request $request REST request object.
* @return array Modified query arguments.
*/
function modify_rest_query_for_preset( array $args, \WP_REST_Request $request ): array {
$preset_name = $request->get_param( 'hmPreset' );

if ( empty( $preset_name ) ) {
return $args;
}

$context = [
'post_id' => $request->get_param( 'post_id' ) ?? get_the_ID() ?? 0,
'is_rest' => true,
'block' => [
'perPage' => $request->get_param( 'per_page' ),
],
];

return apply_query_preset( $preset_name, $args, $context );
}

/**
* 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.
* @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'] ?? [];
$preset_name = $query_attr['hmPreset'] ?? '';

if ( empty( $preset_name ) ) {
return $query_vars;
}

$context = [
'post_id' => get_the_ID() ?? 0,
'is_rest' => false,
'block' => [
'perPage' => $query_vars['posts_per_page'] ?? get_option( 'posts_per_page', 10 ),
'page' => $page,
],
];

return apply_query_preset( $preset_name, $query_vars, $context );
}

/**
* Enqueue presets data for the block editor.
*
* @return void
*/
function enqueue_presets_data(): void {
$presets = get_registered_presets();

// Format presets for JavaScript (only send name and label, not callbacks).
$presets_for_js = [];
foreach ( $presets as $name => $preset ) {
$presets_for_js[] = [
'name' => $name,
'label' => $preset['label'],
];
}

wp_localize_script(
'hm-query-loop-editor',
'hmQueryLoopPresets',
[
'presets' => $presets_for_js,
]
);
}
Loading
Loading