Skip to content
Merged
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
52 changes: 49 additions & 3 deletions inc/namespace.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand Down Expand Up @@ -801,15 +804,58 @@ 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' );
$block_content->set_attribute( 'value', $search_value );
$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 ) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TIL that unslash() takes an array. Nice!

if ( in_array( (string) $key, $exclude, true ) || ! is_scalar( $value ) ) {
continue;
}

$inputs .= sprintf(
'<input type="hidden" name="%s" value="%s" />',
esc_attr( (string) $key ),
esc_attr( sanitize_text_field( (string) $value ) )
);
}

if ( $inputs === '' ) {
return $block_content;
}

return preg_replace( '#</form>#i', $inputs . '</form>', $block_content, 1 );
}

/**
Expand Down
48 changes: 33 additions & 15 deletions src/taxonomy/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>} 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 } );
};

Expand Down Expand Up @@ -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 );
} ),
},
} );
105 changes: 105 additions & 0 deletions tests/e2e/search-action.spec.js
Original file line number Diff line number Diff line change
@@ -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 );
} );
} );
41 changes: 41 additions & 0 deletions tests/mu-plugins/register-test-content.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading