diff --git a/.wp-env.json b/.wp-env.json index 476a42f..50d82a1 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -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": { diff --git a/CLAUDE.md b/CLAUDE.md index 3ac9845..cf8806f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/README.md b/README.md index 16000d8..71b9c19 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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: diff --git a/hm-query-loop.php b/hm-query-loop.php index cf8bbfe..5a06baa 100644 --- a/hm-query-loop.php +++ b/hm-query-loop.php @@ -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. */ @@ -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 ); diff --git a/inc/query-presets.php b/inc/query-presets.php new file mode 100644 index 0000000..c211bf0 --- /dev/null +++ b/inc/query-presets.php @@ -0,0 +1,248 @@ + + */ +$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 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, + ] + ); +} diff --git a/src/index.js b/src/index.js index ed97006..3c8fffa 100644 --- a/src/index.js +++ b/src/index.js @@ -5,7 +5,12 @@ import { addFilter } from '@wordpress/hooks'; import { createHigherOrderComponent } from '@wordpress/compose'; import { useSelect } from '@wordpress/data'; import { InspectorControls } from '@wordpress/block-editor'; -import { PanelBody, ToggleControl, TextControl } from '@wordpress/components'; +import { + PanelBody, + ToggleControl, + TextControl, + SelectControl, +} from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { createContext, useContext } from '@wordpress/element'; @@ -103,6 +108,19 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { const isInheritQuery = query.inherit || false; const maxPerPage = window.hmQueryLoopSettings?.postsPerPage || 10; + // Get available presets from PHP. + const availablePresets = window.hmQueryLoopPresets?.presets || []; + const selectedPreset = query.hmPreset || ''; + + // Build preset options for the dropdown. + const presetOptions = [ + { label: __( '— None —', 'hm-query-loop' ), value: '' }, + ...availablePresets.map( ( preset ) => ( { + label: preset.label, + value: preset.name, + } ) ), + ]; + return ( <> @@ -114,6 +132,25 @@ const withInspectorControls = createHigherOrderComponent( ( BlockEdit ) => { ) } initialOpen={ false } > + { availablePresets.length > 0 && ( + + setAttributes( { + query: { + ...query, + hmPreset: value || undefined, + }, + } ) + } + /> + ) } { isInheritQuery && ( { + test( 'should show query preset dropdown when presets are registered', async ( { + page, + blockEditor, + } ) => { + // Open the index template + await blockEditor.visitSiteEditor( 'index', 'twentytwentyfive' ); + + // Select the query loop block + await blockEditor.selectBlock.byName( 'core/query' ); + + // Open the settings sidebar + await blockEditor.openSettingsSidebar(); + + // Expand Extra Query Loop Settings + await blockEditor.queryBlock.openSettingsPanel(); + + // Check for the Query Preset dropdown + const presetLabel = page.locator( 'label:has-text("Query Preset")' ); + await expect( presetLabel ).toBeVisible( { timeout: 5000 } ); + + // Check that the dropdown contains our test presets + const presetSelect = page.locator( + '.components-select-control__input:near(label:has-text("Query Preset"))' + ); + await expect( presetSelect ).toBeVisible( { timeout: 5000 } ); + + // Open the dropdown and verify options + const selectElement = page + .locator( 'label:has-text("Query Preset")' ) + .locator( '..' ) + .locator( 'select' ); + + const options = await selectElement + .locator( 'option' ) + .allTextContents(); + console.log( 'Available preset options:', options ); + + // Verify our test presets are present + expect( options ).toContain( '— None —' ); + expect( options ).toContain( 'Alphabetical by Title' ); + expect( options ).toContain( 'Alphabetical by Title (Z-A)' ); + } ); + + test( 'should change post order when selecting alphabetical preset in editor', async ( { + page, + blockEditor, + } ) => { + // Open the index template + await blockEditor.visitSiteEditor( 'index', 'twentytwentyfive' ); + + // Get the canvas + const canvas = blockEditor.canvas; + + // Wait for post template block + await expect( canvas.locator( '.wp-block-post-template' ) ).toBeVisible( + { + timeout: 10000, + } + ); + + // Get the initial post titles order + const getPostTitles = async () => { + return await canvas + .locator( '.wp-block-post-template .wp-block-post-title' ) + .allTextContents(); + }; + + const initialTitles = await getPostTitles(); + console.log( 'Initial post titles:', initialTitles ); + + // Select the query loop block + await blockEditor.selectBlock.byName( 'core/query' ); + + // Open settings sidebar + await blockEditor.openSettingsSidebar(); + + // Expand Extra Query Loop Settings + await blockEditor.queryBlock.openSettingsPanel(); + + // Select the "Alphabetical by Title" preset + const selectElement = page + .locator( 'label:has-text("Query Preset")' ) + .locator( '..' ) + .locator( 'select' ); + + await selectElement.selectOption( 'alphabetical_title' ); + + // Wait for the query to update + await page.waitForTimeout( 2000 ); + + // Get the new post titles order + const newTitles = await getPostTitles(); + console.log( 'Post titles after preset:', newTitles ); + + // Verify the posts are now sorted alphabetically (A-Z) + const sortedTitles = [ ...newTitles ].sort( ( a, b ) => + a.localeCompare( b ) + ); + expect( newTitles ).toEqual( sortedTitles ); + } ); + + test( 'should change post order when selecting Z-A preset in editor', async ( { + page, + blockEditor, + } ) => { + // Open the index template + await blockEditor.visitSiteEditor( 'index', 'twentytwentyfive' ); + + // Get the canvas + const canvas = blockEditor.canvas; + + // Wait for post template block + await expect( canvas.locator( '.wp-block-post-template' ) ).toBeVisible( + { + timeout: 10000, + } + ); + + // Get post titles helper + const getPostTitles = async () => { + return await canvas + .locator( '.wp-block-post-template .wp-block-post-title' ) + .allTextContents(); + }; + + // Select the query loop block + await blockEditor.selectBlock.byName( 'core/query' ); + + // Open settings sidebar + await blockEditor.openSettingsSidebar(); + + // Expand Extra Query Loop Settings + await blockEditor.queryBlock.openSettingsPanel(); + + // Select the "Alphabetical by Title (Z-A)" preset + const selectElement = page + .locator( 'label:has-text("Query Preset")' ) + .locator( '..' ) + .locator( 'select' ); + + await selectElement.selectOption( 'alphabetical_title_desc' ); + + // Wait for the query to update + await page.waitForTimeout( 2000 ); + + // Get the new post titles order + const newTitles = await getPostTitles(); + console.log( 'Post titles after Z-A preset:', newTitles ); + + // Verify the posts are now sorted alphabetically in reverse (Z-A) + const sortedTitlesDesc = [ ...newTitles ].sort( ( a, b ) => + b.localeCompare( a ) + ); + expect( newTitles ).toEqual( sortedTitlesDesc ); + } ); + + test( 'should apply query preset on frontend', async ( { + page, + admin, + blockEditor, + } ) => { + // First, create a page with a Query Loop block that uses a preset + await admin.createNewPost( { + postType: 'page', + title: 'Preset Test Page', + } ); + + // Insert a Query Loop block + await page.click( 'button[aria-label="Toggle block inserter"]' ); + await page.fill( 'input[placeholder="Search"]', 'Query Loop' ); + await page.click( 'button.editor-block-list-item-query' ); + await page.waitForTimeout( 1000 ); + + // Choose the standard query loop pattern + const chooseButton = page + .locator( 'button:has-text("Choose")' ) + .first(); + if ( + await chooseButton + .isVisible( { timeout: 2000 } ) + .catch( () => false ) + ) { + await chooseButton.click(); + await page.waitForTimeout( 500 ); + } + + // Select the query loop block + await blockEditor.selectBlock.byName( 'core/query' ); + + // Open settings sidebar + await blockEditor.openSettingsSidebar(); + + // Expand Extra Query Loop Settings + await blockEditor.queryBlock.openSettingsPanel(); + + // Select the alphabetical preset + const selectElement = page + .locator( 'label:has-text("Query Preset")' ) + .locator( '..' ) + .locator( 'select' ); + + // Check if the preset dropdown is visible (presets are registered) + const isPresetVisible = await selectElement + .isVisible( { timeout: 3000 } ) + .catch( () => false ); + + if ( isPresetVisible ) { + await selectElement.selectOption( 'alphabetical_title' ); + await page.waitForTimeout( 1000 ); + + // Publish and visit the page + await blockEditor.publishAndVisit(); + + // Get the post titles on the frontend + const frontendTitles = await page + .locator( '.wp-block-post-template .wp-block-post-title' ) + .allTextContents(); + console.log( 'Frontend post titles:', frontendTitles ); + + // Verify they are sorted alphabetically + if ( frontendTitles.length > 1 ) { + const sortedTitles = [ ...frontendTitles ].sort( ( a, b ) => + a.localeCompare( b ) + ); + expect( frontendTitles ).toEqual( sortedTitles ); + } + } else { + console.log( + 'Preset dropdown not visible - test mu-plugin may not be loaded' + ); + } + } ); +} ); diff --git a/tests/mu-plugins/test-query-presets.php b/tests/mu-plugins/test-query-presets.php new file mode 100644 index 0000000..be9fb71 --- /dev/null +++ b/tests/mu-plugins/test-query-presets.php @@ -0,0 +1,42 @@ +