diff --git a/inc/namespace.php b/inc/namespace.php index 55f8917..e09f4b4 100644 --- a/inc/namespace.php +++ b/inc/namespace.php @@ -36,7 +36,10 @@ function bootstrap() : void { add_action( 'init', __NAMESPACE__ . '\\register_blocks' ); // Search. - add_filter( 'render_block_core/search', __NAMESPACE__ . '\\render_block_search', 10, 3 ); + // Late, so a search block inside a Query Loop posts back to the loop even when + // another plugin rewrites search form actions at the default priority (as + // Polylang does, pointing every search form at the language's home page). + add_filter( 'render_block_core/search', __NAMESPACE__ . '\\render_block_search', 20, 3 ); // Query. add_filter( 'render_block_core/query', __NAMESPACE__ . '\\render_block_query', 10, 3 ); @@ -801,7 +804,10 @@ function render_block_search( string $block_content, array $block, \WP_Block $in $block_content->set_attribute( 'data-wp-interactive', 'query-filter' ); $block_content->set_attribute( 'data-wp-on--submit', 'actions.search' ); // Scope search to block context so multiple searchable query loops may coexist. - $block_content->set_attribute( 'data-wp-context', wp_json_encode( [ 'searchValue' => $search_value ] ) ); + $block_content->set_attribute( 'data-wp-context', wp_json_encode( [ + 'searchValue' => $search_value, + 'pageParam' => empty( $instance->context['query']['inherit'] ) ? $page_var : null, + ] ) ); $block_content->next_tag( [ 'tag_name' => 'input', 'class_name' => 'wp-block-search__input' ] ); $block_content->set_attribute( 'name', $query_var ); $block_content->set_attribute( 'inputmode', 'search' ); @@ -809,7 +815,47 @@ function render_block_search( string $block_content, array $block, \WP_Block $in $block_content->set_attribute( 'data-wp-bind--value', 'context.searchValue' ); $block_content->set_attribute( 'data-wp-on--input', 'actions.search' ); - return (string) $block_content; + $exclude = [ $query_var, $page_var ]; + if ( ! empty( $instance->context['query']['inherit'] ) ) { + // Core pages an inherited loop with `paged` (`?paged=N` on plain permalinks), not `page`. + $exclude[] = 'paged'; + } + + return add_filter_hidden_inputs( (string) $block_content, $exclude ); +} + +/** + * Add hidden inputs for the current query params so they still work in non-JS search submissions. + * + * A GET form replaces the query string of its action with its own fields, so the + * filters already applied would otherwise be lost when the search is submitted. + * + * @param string $block_content Search block markup. + * @param string[] $exclude Parameters the form sets or resets itself. + * @return string Search block markup with a hidden input per carried parameter. + */ +function add_filter_hidden_inputs( string $block_content, array $exclude ) : string { + $inputs = ''; + + // Only echoed back, escaped, as the current URL already is in the form action. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + foreach ( wp_unslash( $_GET ) as $key => $value ) { + if ( in_array( (string) $key, $exclude, true ) || ! is_scalar( $value ) ) { + continue; + } + + $inputs .= sprintf( + '', + esc_attr( (string) $key ), + esc_attr( sanitize_text_field( (string) $value ) ) + ); + } + + if ( $inputs === '' ) { + return $block_content; + } + + return preg_replace( '##i', $inputs . '', $block_content, 1 ); } /** diff --git a/src/taxonomy/view.js b/src/taxonomy/view.js index ddd5940..7a976b8 100644 --- a/src/taxonomy/view.js +++ b/src/taxonomy/view.js @@ -17,14 +17,38 @@ import { */ const NAVIGATION_TIMEOUT = 30000; -const updateURL = async ( action, value, name ) => { - const url = new URL( action ); +/** + * Build the URL for a search from the page being viewed. + * + * The form's action is not used: other plugins filter search form actions + * (Polylang points them at the language's home page), and the URL may have + * moved on through client-side navigation since the form was rendered. + * + * @param {string} name Search parameter. + * @param {string} value Search term. + * @param {string|null} pageParam The query's page parameter, or null when it + * inherits the main query and pages by path. + * @return {Promise} Resolves once navigated. + */ +const updateURL = async ( name, value, pageParam ) => { + const { actions } = await import( '@wordpress/interactivity-router' ); + const { state: routerState } = store( 'core/router' ); + const url = new URL( routerState?.url || window.location.href ); + + // A search is a new set of results, so it starts on the first page. + if ( pageParam ) { + url.searchParams.delete( pageParam ); + } else { + url.searchParams.delete( 'paged' ); + url.pathname = url.pathname.replace( /\/page\/\d+\/?$/, '/' ); + } + if ( value || name === 's' ) { url.searchParams.set( name, value ); } else { url.searchParams.delete( name ); } - const { actions } = await import( '@wordpress/interactivity-router' ); + await actions.navigate( url.toString(), { timeout: NAVIGATION_TIMEOUT } ); }; @@ -52,24 +76,18 @@ store( 'query-filter', { // Scope search to block context so multiple searchable query loops may coexist. const context = getContext(); const { ref } = getElement(); - let action, name, value; - if ( ref.tagName === 'FORM' ) { - const input = ref.querySelector( 'input[type="search"]' ); - action = ref.action; - name = input.name; - value = input.value; - } else { - action = ref.closest( 'form' ).action; - name = ref.name; - value = ref.value; - } + const input = + ref.tagName === 'FORM' + ? ref.querySelector( 'input[type="search"]' ) + : ref; + const { name, value } = input; // Don't navigate if the search didn't really change. if ( value === context.searchValue ) return; context.searchValue = value; - yield updateURL( action, value, name ); + yield updateURL( name, value, context.pageParam ); } ), }, } ); diff --git a/tests/e2e/search-action.spec.js b/tests/e2e/search-action.spec.js new file mode 100644 index 0000000..a6f2352 --- /dev/null +++ b/tests/e2e/search-action.spec.js @@ -0,0 +1,105 @@ +const { test, expect, POSTS } = require( './fixtures' ); + +test.describe( 'Search action ownership', () => { + test( 'a search block whose action another plugin rewrote still searches the loop', async ( { + page, + loop, + } ) => { + // The test mu-plugin rewrites search actions to the home page at + // priority 10 when `qf_hijack` is set, as Polylang does. + await page.goto( '/post-type-filter/?qf_hijack=1' ); + + const form = page.locator( 'form.wp-block-search' ); + await expect( form ).toHaveAttribute( 'action', /post-type-filter/ ); + + await page.locator( '.wp-block-search__input' ).fill( 'Alpha' ); + + await page.waitForURL( /\/post-type-filter\/.*query-3-s=Alpha/ ); + await loop.expectTitles( POSTS.alpha ); + } ); + + test( 'the URL is built from the page, not the rendered action', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-filter/' ); + + const form = page.locator( 'form.wp-block-search' ); + await form.evaluate( ( el ) => + el.setAttribute( 'action', 'https://example.org/' ) + ); + + await page.locator( '.wp-block-search__input' ).fill( 'Beta' ); + + await page.waitForURL( /\/post-type-filter\/.*query-3-s=Beta/ ); + await loop.expectTitles( POSTS.beta ); + } ); + + test( 'the filters applied are carried as hidden inputs, without the page', async ( { + page, + } ) => { + await page.goto( + '/search-pagination/?query-5-page=2&query-5-post_type=post&query-5-s=x' + ); + + const form = page.locator( 'form.wp-block-search' ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-post_type"]' ) + ).toHaveValue( 'post' ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-page"]' ) + ).toHaveCount( 0 ); + await expect( + form.locator( 'input[type="hidden"][name="query-5-s"]' ) + ).toHaveCount( 0 ); + } ); + + test( 'an inherited loop does not carry its page as a hidden input', async ( { + page, + } ) => { + // An inherited loop pages with `paged`, which is how its links read on plain + // permalinks. One post per page puts page 2 in range. + await page.goto( '/?s=&paged=2&qf_per_page=1' ); + + const form = page.locator( '.wp-block-query form.wp-block-search' ); + await expect( + form.locator( 'input[type="hidden"][name="qf_per_page"]' ) + ).toHaveValue( '1' ); + await expect( + form.locator( 'input[type="hidden"][name="paged"]' ) + ).toHaveCount( 0 ); + } ); +} ); + +test.describe( 'Search without JavaScript', () => { + test.use( { javaScriptEnabled: false } ); + + test( 'submitting keeps the filters already applied', async ( { + page, + loop, + } ) => { + await page.goto( '/post-type-filter/?query-3-post_type=qf_doc' ); + await loop.expectTitles( POSTS.docs ); + + const input = page.locator( '.wp-block-search__input' ); + await input.fill( 'One' ); + await input.press( 'Enter' ); + + await page.waitForURL( /query-3-s=One/ ); + expect( page.url() ).toContain( 'query-3-post_type=qf_doc' ); + await loop.expectTitles( [ 'Doc One' ] ); + } ); + + test( 'a search from a later page of an inherited loop starts on page 1', async ( { + page, + } ) => { + await page.goto( '/?s=&paged=2&qf_per_page=1' ); + + const input = page.locator( '.wp-block-query .wp-block-search__input' ); + await input.fill( 'Alpha' ); + await input.press( 'Enter' ); + + await page.waitForURL( /[?&]s=Alpha/ ); + expect( new URL( page.url() ).searchParams.has( 'paged' ) ).toBe( false ); + } ); +} ); diff --git a/tests/mu-plugins/register-test-content.php b/tests/mu-plugins/register-test-content.php index 0e26049..04304d5 100644 --- a/tests/mu-plugins/register-test-content.php +++ b/tests/mu-plugins/register-test-content.php @@ -14,6 +14,47 @@ add_action( 'init', __NAMESPACE__ . '\\register_test_content' ); add_filter( 'get_block_templates', __NAMESPACE__ . '\\replace_search_template', 10, 2 ); add_filter( 'get_block_templates', __NAMESPACE__ . '\\replace_archive_template', 10, 2 ); +// Added once plugins have loaded, so it runs after this plugin's filter would at +// the same priority, as Polylang's does. +add_action( 'plugins_loaded', function () { + add_filter( 'render_block_core/search', __NAMESPACE__ . '\\hijack_search_action', 10 ); +} ); + +/** + * Point every search form at the home page, as Polylang does, when asked to. + * + * Registered at the default priority, as Polylang's own filter is, so the tests + * can prove the plugin still owns the action of a search inside a Query Loop. + * + * @param string $block_content Search block markup. + * @return string Search block markup. + */ +function hijack_search_action( string $block_content ) : string { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( empty( $_GET['qf_hijack'] ) ) { + return $block_content; + } + + return preg_replace( '#action="[^"]*"#', 'action="' . esc_url( home_url( '/' ) ) . '"', $block_content ); +} + +add_action( 'pre_get_posts', __NAMESPACE__ . '\\set_main_query_page_size' ); + +/** + * Shrink the main query's page size when asked to, so a few fixtures span several pages. + * + * @param \WP_Query $query Query being prepared. + * @return void + */ +function set_main_query_page_size( \WP_Query $query ) : void { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! $query->is_main_query() || empty( $_GET['qf_per_page'] ) ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $query->set( 'posts_per_page', absint( $_GET['qf_per_page'] ) ); +} /** * Register the test post types and taxonomies.