diff --git a/lib/experiments-page.php b/lib/experiments-page.php index 9033e3c2d0c1fb..67b1a4bd18c955 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -187,6 +187,18 @@ function gutenberg_initialize_experiments_settings() { ) ); + add_settings_field( + 'gutenberg-search-query-block', + __( 'Instant Search and Query Block', 'gutenberg' ), + 'gutenberg_display_experiment_field', + 'gutenberg-experiments', + 'gutenberg_experiments_section', + array( + 'label' => __( 'Enable instant search functionality of the Search + Query blocks.', 'gutenberg' ), + 'id' => 'gutenberg-search-query-block', + ) + ); + add_settings_field( 'gutenberg-editor-write-mode', __( 'Editor write mode', 'gutenberg' ), diff --git a/packages/block-library/src/post-template/index.php b/packages/block-library/src/post-template/index.php index 9126355c096a57..0168290dcbdaf4 100644 --- a/packages/block-library/src/post-template/index.php +++ b/packages/block-library/src/post-template/index.php @@ -50,6 +50,19 @@ function render_block_core_post_template( $attributes, $content, $block ) { $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $search_query_direct = ''; + + // Get the search query parameter for the specific query if it exists. + if ( isset( $block->context['queryId'] ) ) { + $search_param = 'instant-search-' . $block->context['queryId']; + if ( ! empty( $_GET[ $search_param ] ) ) { + $search_query_direct = sanitize_text_field( $_GET[ $search_param ] ); + } + } + + // Check if the Instant Search experiment is enabled. + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = isset( $gutenberg_experiments['gutenberg-search-query-block'] ) && $gutenberg_experiments['gutenberg-search-query-block']; // Use global query if needed. $use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ); @@ -69,7 +82,13 @@ function render_block_core_post_template( $attributes, $content, $block ) { } } else { $query_args = build_query_vars_from_query_block( $block, $page ); - $query = new WP_Query( $query_args ); + + // Add search parameter if enhanced pagination is on and search query exists + if ( $enhanced_pagination && $instant_search_enabled && ! empty( $search_query_direct ) ) { + $query_args['s'] = $search_query_direct; + } + + $query = new WP_Query( $query_args ); } if ( ! $query->have_posts() ) { diff --git a/packages/block-library/src/query-no-results/block.json b/packages/block-library/src/query-no-results/block.json index 2f656594afa306..edc268243ecc07 100644 --- a/packages/block-library/src/query-no-results/block.json +++ b/packages/block-library/src/query-no-results/block.json @@ -7,7 +7,7 @@ "description": "Contains the block elements used to render content when no query results are found.", "parent": [ "core/query" ], "textdomain": "default", - "usesContext": [ "queryId", "query" ], + "usesContext": [ "queryId", "query", "enhancedPagination" ], "example": { "innerBlocks": [ { diff --git a/packages/block-library/src/query-no-results/index.php b/packages/block-library/src/query-no-results/index.php index 34d6b321cbd6ba..fcd2655f257707 100644 --- a/packages/block-library/src/query-no-results/index.php +++ b/packages/block-library/src/query-no-results/index.php @@ -23,8 +23,22 @@ function render_block_core_query_no_results( $attributes, $content, $block ) { return ''; } - $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; - $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + $page_key = isset( $block->context['queryId'] ) ? 'query-' . $block->context['queryId'] . '-page' : 'query-page'; + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; + + // Add check for instant search experiment and search query + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = isset( $gutenberg_experiments['gutenberg-search-query-block'] ) && $gutenberg_experiments['gutenberg-search-query-block']; + $search_query_direct = ''; + + // Get the search query parameter for the specific query if it exists + if ( isset( $block->context['queryId'] ) ) { + $search_param = 'instant-search-' . $block->context['queryId']; + if ( ! empty( $_GET[ $search_param ] ) ) { + $search_query_direct = sanitize_text_field( $_GET[ $search_param ] ); + } + } // Override the custom query with the global query if needed. $use_global_query = ( isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] ); @@ -33,7 +47,13 @@ function render_block_core_query_no_results( $attributes, $content, $block ) { $query = $wp_query; } else { $query_args = build_query_vars_from_query_block( $block, $page ); - $query = new WP_Query( $query_args ); + + // Add search parameter if instant search is enabled and search query exists + if ( $enhanced_pagination && $instant_search_enabled && ! empty( $search_query_direct ) ) { + $query_args['s'] = $search_query_direct; + } + + $query = new WP_Query( $query_args ); } if ( $query->post_count > 0 ) { diff --git a/packages/block-library/src/query-pagination-next/index.php b/packages/block-library/src/query-pagination-next/index.php index 3b5be47fbed371..bdc1dec04a9dbd 100644 --- a/packages/block-library/src/query-pagination-next/index.php +++ b/packages/block-library/src/query-pagination-next/index.php @@ -24,6 +24,16 @@ function render_block_core_query_pagination_next( $attributes, $content, $block $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + // Add check for instant search experiment and search query + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = isset( $gutenberg_experiments['gutenberg-search-query-block'] ) && $gutenberg_experiments['gutenberg-search-query-block']; + + $search_query_direct = ''; + if ( isset( $block->context['queryId'] ) ) { + $search_param = 'instant-search-' . $block->context['queryId']; + $search_query_direct = empty( $_GET[ $search_param ] ) ? '' : sanitize_text_field( $_GET[ $search_param ] ); + } + $wrapper_attributes = get_block_wrapper_attributes(); $show_label = isset( $block->context['showLabel'] ) ? (bool) $block->context['showLabel'] : true; $default_label = __( 'Next Page' ); @@ -54,7 +64,17 @@ function render_block_core_query_pagination_next( $attributes, $content, $block $content = get_next_posts_link( $label, $max_page ); remove_filter( 'next_posts_link_attributes', $filter_link_attributes ); } elseif ( ! $max_page || $max_page > $page ) { - $custom_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + // Add check for instant search experiment and search query + if ( $enhanced_pagination && $instant_search_enabled && ! empty( $search_query_direct ) ) { + $args = array_merge( + build_query_vars_from_query_block( $block, $page ), + array( 's' => $search_query_direct ) + ); + $custom_query = new WP_Query( $args ); + } else { + $custom_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + } + $custom_query_max_pages = (int) $custom_query->max_num_pages; if ( $custom_query_max_pages && $custom_query_max_pages !== $page ) { $content = sprintf( diff --git a/packages/block-library/src/query-pagination-numbers/index.php b/packages/block-library/src/query-pagination-numbers/index.php index fddf28660fe4f8..beb204cae6c9c1 100644 --- a/packages/block-library/src/query-pagination-numbers/index.php +++ b/packages/block-library/src/query-pagination-numbers/index.php @@ -24,6 +24,19 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo $page = empty( $_GET[ $page_key ] ) ? 1 : (int) $_GET[ $page_key ]; $max_page = isset( $block->context['query']['pages'] ) ? (int) $block->context['query']['pages'] : 0; + // Add check for instant search experiment and search query + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = isset( $gutenberg_experiments['gutenberg-search-query-block'] ) && $gutenberg_experiments['gutenberg-search-query-block']; + + // Get the search query parameter for the specific query if it exists. + $search_query_direct = ''; + if ( isset( $block->context['queryId'] ) ) { + $search_param = 'instant-search-' . $block->context['queryId']; + if ( ! empty( $_GET[ $search_param ] ) ) { + $search_query_direct = sanitize_text_field( $_GET[ $search_param ] ); + } + } + $wrapper_attributes = get_block_wrapper_attributes(); $content = ''; global $wp_query; @@ -41,7 +54,17 @@ function render_block_core_query_pagination_numbers( $attributes, $content, $blo } $content = paginate_links( $paginate_args ); } else { - $block_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + // Add check for instant search experiment and search query + // If instant search is enabled and we have a search query, run a new query + if ( $enhanced_pagination && $instant_search_enabled && ! empty( $search_query_direct ) ) { + $args = array_merge( + build_query_vars_from_query_block( $block, $page ), + array( 's' => $search_query_direct ) + ); + $block_query = new WP_Query( $args ); + } else { + $block_query = new WP_Query( build_query_vars_from_query_block( $block, $page ) ); + } // `paginate_links` works with the global $wp_query, so we have to // temporarily switch it with our custom query. $prev_wp_query = $wp_query; diff --git a/packages/block-library/src/search/block.json b/packages/block-library/src/search/block.json index c5af5a29d21beb..e7207498125d6e 100644 --- a/packages/block-library/src/search/block.json +++ b/packages/block-library/src/search/block.json @@ -48,6 +48,7 @@ "default": false } }, + "usesContext": [ "enhancedPagination", "query", "queryId" ], "supports": { "align": [ "left", "center", "right" ], "color": { diff --git a/packages/block-library/src/search/edit.js b/packages/block-library/src/search/edit.js index f193c04e2493aa..7165f818c61a49 100644 --- a/packages/block-library/src/search/edit.js +++ b/packages/block-library/src/search/edit.js @@ -67,6 +67,7 @@ export default function SearchEdit( { toggleSelection, isSelected, clientId, + context, } ) { const { label, @@ -82,6 +83,26 @@ export default function SearchEdit( { style, } = attributes; + const isEnhancedPagination = context?.enhancedPagination; + + useEffect( () => { + if ( isEnhancedPagination ) { + // Add the name to the metadata + setAttributes( { metadata: { name: 'Instant Search' } } ); + } else { + // Remove the name from the metadata + const { name, ...metadata } = attributes.metadata || {}; + setAttributes( { metadata } ); + } + + // We disable the exhaustive-deps warning because the effect should not depend + // on the attributes.metadata value. We only want to re-run the effect when the + // isEnhancedPagination value changes. + + // eslint-disable-next-line react-compiler/react-compiler + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ isEnhancedPagination, setAttributes ] ); + const wasJustInsertedIntoNavigationBlock = useSelect( ( select ) => { const { getBlockParentsByBlockName, wasBlockJustInserted } = @@ -385,24 +406,28 @@ export default function SearchEdit( { } } className={ showLabel ? 'is-pressed' : undefined } /> - - { ! hasNoButton && ( - { - setAttributes( { - buttonUseIcon: ! buttonUseIcon, - } ); - } } - className={ - buttonUseIcon ? 'is-pressed' : undefined - } - /> + { ! isEnhancedPagination && ( + <> + + { ! hasNoButton && ( + { + setAttributes( { + buttonUseIcon: ! buttonUseIcon, + } ); + } } + className={ + buttonUseIcon ? 'is-pressed' : undefined + } + /> + ) } + ) } @@ -596,16 +621,22 @@ export default function SearchEdit( { } } showHandle={ isSelected } > - { ( isButtonPositionInside || - isButtonPositionOutside || - hasOnlyButton ) && ( + { isEnhancedPagination ? ( + renderTextField() + ) : ( <> - { renderTextField() } - { renderButton() } + { ( isButtonPositionInside || + isButtonPositionOutside || + hasOnlyButton ) && ( + <> + { renderTextField() } + { renderButton() } + + ) } + + { hasNoButton && renderTextField() } ) } - - { hasNoButton && renderTextField() } ); diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index 87e12f5d33d079..c0eb88a528d5d1 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -16,7 +16,7 @@ * * @return string The search block markup. */ -function render_block_core_search( $attributes ) { +function render_block_core_search( $attributes, $content, $block ) { // Older versions of the Search block defaulted the label and buttonText // attributes to `__( 'Search' )` meaning that many posts contain ``. Support these by defaulting an undefined label and @@ -29,11 +29,16 @@ function render_block_core_search( $attributes ) { ) ); - $input_id = wp_unique_id( 'wp-block-search__input-' ); - $classnames = classnames_for_block_core_search( $attributes ); - $show_label = ! empty( $attributes['showLabel'] ); - $use_icon_button = ! empty( $attributes['buttonUseIcon'] ); - $show_button = ( ! empty( $attributes['buttonPosition'] ) && 'no-button' === $attributes['buttonPosition'] ) ? false : true; + $input_id = wp_unique_id( 'wp-block-search__input-' ); + $classnames = classnames_for_block_core_search( $attributes ); + $show_label = ( ! empty( $attributes['showLabel'] ) ) ? true : false; + $use_icon_button = ( ! empty( $attributes['buttonUseIcon'] ) ) ? true : false; + $show_button = true; + if ( isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination'] ) { + $show_button = false; + } elseif ( ! empty( $attributes['buttonPosition'] ) && 'no-button' === $attributes['buttonPosition'] ) { + $show_button = false; + } $button_position = $show_button ? $attributes['buttonPosition'] : null; $query_params = ( ! empty( $attributes['query'] ) ) ? $attributes['query'] : array(); $button = ''; @@ -48,6 +53,12 @@ function render_block_core_search( $attributes ) { // This variable is a constant and its value is always false at this moment. // It is defined this way because some values depend on it, in case it changes in the future. $open_by_default = false; + // Check if the block is using the enhanced pagination. + $enhanced_pagination = isset( $block->context['enhancedPagination'] ) && $block->context['enhancedPagination']; + + // Check if the block is using the instant search experiment. + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = $gutenberg_experiments && array_key_exists( 'gutenberg-search-query-block', $gutenberg_experiments ); $label_inner_html = empty( $attributes['label'] ) ? __( 'Search' ) : wp_kses_post( $attributes['label'] ); $label = new WP_HTML_Tag_Processor( sprintf( '', $inline_styles['label'], $label_inner_html ) ); @@ -90,6 +101,16 @@ function render_block_core_search( $attributes ) { $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); } + + // Instant search is only available when using the enhanced pagination. + if ( $enhanced_pagination ) { + wp_enqueue_script_module( '@wordpress/block-library/search/view' ); + + if ( $instant_search_enabled ) { + $input->set_attribute( 'data-wp-bind--value', 'context.search' ); + $input->set_attribute( 'data-wp-on-async--input', 'actions.updateSearch' ); + } + } } if ( count( $query_params ) > 0 ) { @@ -163,28 +184,52 @@ function render_block_core_search( $attributes ) { array( 'class' => $classnames ) ); $form_directives = ''; + $form_context = array(); // If it's interactive, add the directives. + if ( $is_expandable_searchfield || ( $enhanced_pagination && $instant_search_enabled ) ) { + $form_directives = 'data-wp-interactive="core/search"'; + } + if ( $is_expandable_searchfield ) { $aria_label_expanded = __( 'Submit Search' ); $aria_label_collapsed = __( 'Expand search field' ); - $form_context = wp_interactivity_data_wp_context( + $form_context = array( + 'isSearchInputInitiallyVisible' => $open_by_default, + 'inputId' => $input_id, + 'ariaLabelExpanded' => $aria_label_expanded, + 'ariaLabelCollapsed' => $aria_label_collapsed, + ); + $form_directives .= + 'data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" + data-wp-on-async--keydown="actions.handleSearchKeydown" + data-wp-on-async--focusout="actions.handleSearchFocusout" + '; + } + + if ( $enhanced_pagination && $instant_search_enabled && isset( $block->context['queryId'] ) ) { + + $search = ''; + + // If the query is defined in the block context, use it + if ( isset( $block->context['query']['search'] ) && '' !== $block->context['query']['search'] ) { + $search = $block->context['query']['search']; + } + + // If the query is defined in the URL, it overrides the block context value if defined + $search = empty( $_GET[ 'instant-search-' . $block->context['queryId'] ] ) ? $search : sanitize_text_field( $_GET[ 'instant-search-' . $block->context['queryId'] ] ); + + $form_context = array_merge( + $form_context, array( - 'isSearchInputVisible' => $open_by_default, - 'inputId' => $input_id, - 'ariaLabelExpanded' => $aria_label_expanded, - 'ariaLabelCollapsed' => $aria_label_collapsed, + 'search' => $search, + 'queryId' => $block->context['queryId'], ) ); - $form_directives = ' - data-wp-interactive="core/search" - ' . $form_context . ' - data-wp-class--wp-block-search__searchfield-hidden="!context.isSearchInputVisible" - data-wp-on-async--keydown="actions.handleSearchKeydown" - data-wp-on-async--focusout="actions.handleSearchFocusout" - '; } + $form_directives .= wp_interactivity_data_wp_context( $form_context ); + return sprintf( '
%4s
', esc_url( home_url( '/' ) ), diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index 0e4c462a2e3213..d280d355322afa 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -3,7 +3,10 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; -const { actions } = store( +/** @type {( () => void ) | null} */ +let supersedePreviousSearch = null; + +const { state, actions } = store( 'core/search', { state: { @@ -29,14 +32,25 @@ const { actions } = store( const { isSearchInputVisible } = getContext(); return isSearchInputVisible ? '0' : '-1'; }, + get isSearchInputVisible() { + const ctx = getContext(); + + // `ctx.isSearchInputVisible` is a client-side-only context value, so + // if it's not set, it means that it's an initial page load, so we need + // to return the value of `ctx.isSearchInputInitiallyVisible`. + if ( typeof ctx.isSearchInputVisible === 'undefined' ) { + return ctx.isSearchInputInitiallyVisible; + } + return ctx.isSearchInputVisible; + }, }, actions: { openSearchInput( event ) { - const ctx = getContext(); - const { ref } = getElement(); - if ( ! ctx.isSearchInputVisible ) { + if ( ! state.isSearchInputVisible ) { event.preventDefault(); + const ctx = getContext(); ctx.isSearchInputVisible = true; + const { ref } = getElement(); ref.parentElement.querySelector( 'input' ).focus(); } }, @@ -66,6 +80,62 @@ const { actions } = store( actions.closeSearchInput(); } }, + *updateSearch( e ) { + const { value } = e.target; + + const ctx = getContext(); + + // Don't navigate if the search didn't really change. + if ( value === ctx.search ) { + return; + } + + ctx.search = value; + + // Debounce the search by 300ms to prevent multiple navigations. + supersedePreviousSearch?.(); + let resolve, reject; + const promise = new Promise( ( res, rej ) => { + resolve = res; + reject = rej; + } ); + const timeout = setTimeout( resolve, 300 ); + supersedePreviousSearch = () => { + clearTimeout( timeout ); + reject(); + }; + try { + yield promise; + } catch { + return; + } + + const url = new URL( window.location.href ); + + if ( value ) { + // Set the instant-search parameter using the query ID and search value + const queryId = ctx.queryId; + url.searchParams.set( + `instant-search-${ queryId }`, + value + ); + + // Make sure we reset the pagination. + url.searchParams.set( `query-${ queryId }-page`, '1' ); + } else { + // Reset specific search for non-inherited queries + url.searchParams.delete( + `instant-search-${ ctx.queryId }` + ); + url.searchParams.delete( `query-${ ctx.queryId }-page` ); + } + + const { actions: routerActions } = yield import( + '@wordpress/interactivity-router' + ); + + routerActions.navigate( url.href ); + }, }, }, { lock: true } diff --git a/test/e2e/specs/interactivity/instant-search.spec.ts b/test/e2e/specs/interactivity/instant-search.spec.ts new file mode 100644 index 00000000000000..19362ad27d41b6 --- /dev/null +++ b/test/e2e/specs/interactivity/instant-search.spec.ts @@ -0,0 +1,547 @@ +/** + * Internal dependencies + */ +import { test, expect } from './fixtures'; +/** + * External dependencies + */ +import type { Page } from '@playwright/test'; + +/** + * Go to the next page of the query. + * @param page - The page object. + * @param pageNumber - The page number to navigate to. + * @param testId - The test ID of the query. + * @param queryId - The query ID. + */ +async function goToNextPage( + page: Page, + pageNumber: number, + testId: string, + queryId: number +) { + await page + .getByTestId( testId ) + .getByRole( 'link', { name: 'Next Page' } ) + .click(); + + // Wait for the response + return page.waitForResponse( ( response ) => + response.url().includes( `query-${ queryId }-page=${ pageNumber }` ) + ); +} + +test.describe( 'Instant Search', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activateTheme( 'emptytheme' ); + await requestUtils.setGutenbergExperiments( [ + 'gutenberg-search-query-block', + ] ); + await requestUtils.deleteAllPosts(); + + // Create test posts + // Make sure to create them last-to-first to avoid flakiness + await requestUtils.createPost( { + title: 'Unique Post', + content: 'This post has unique content.', + status: 'publish', + date_gmt: new Date( + new Date().getTime() - 1000 * 60 * 60 * 24 * 5 + ).toISOString(), + } ); + await requestUtils.createPost( { + title: 'Fourth Test Post', + content: 'This is the fourth test post content.', + status: 'publish', + date_gmt: new Date( + new Date().getTime() - 1000 * 60 * 60 * 24 * 4 + ).toISOString(), + } ); + await requestUtils.createPost( { + title: 'Third Test Post', + content: 'This is the third test post content.', + status: 'publish', + date_gmt: new Date( + new Date().getTime() - 1000 * 60 * 60 * 24 * 3 + ).toISOString(), + } ); + await requestUtils.createPost( { + title: 'Second Test Post', + content: 'This is the second test post content.', + status: 'publish', + date_gmt: new Date( + new Date().getTime() - 1000 * 60 * 60 * 24 * 2 + ).toISOString(), + } ); + await requestUtils.createPost( { + title: 'First Test Post', + content: 'This is the first test post content.', + status: 'publish', + date_gmt: new Date( + new Date().getTime() - 1000 * 60 * 60 * 24 * 1 + ).toISOString(), + } ); + + // Set the Blog pages show at most 2 posts + await requestUtils.updateSiteSettings( { + posts_per_page: 2, + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + await requestUtils.activateTheme( 'twentytwentyone' ); + } ); + + test.describe( 'Custom Query', () => { + let pageId: number; + + const queryId = 123; + + test.beforeAll( async ( { requestUtils } ) => { + // Create page with custom query + const { id } = await requestUtils.createPage( { + status: 'publish', + date_gmt: new Date().toISOString(), + title: 'Custom Query', + content: ` + +
+ + + + + + + + + + + + +

No results found.

+ + +
+`, + } ); + + pageId = id; + } ); + + test.beforeEach( async ( { page } ) => { + await page.goto( `/?p=${ pageId }` ); + } ); + + test( 'should update search results without page reload', async ( { + page, + } ) => { + // Check that the first post is shown initially + await expect( + page.getByText( 'First Test Post', { exact: true } ) + ).toBeVisible(); + + // Type in search input and verify results update + await page.locator( 'input[type="search"]' ).fill( 'Unique' ); + await page.waitForResponse( ( response ) => + response.url().includes( `instant-search-${ queryId }=Unique` ) + ); + + // Verify only the unique post is shown + await expect( + page.getByText( 'Unique Post', { exact: true } ) + ).toBeVisible(); + + // Check that there is only one post + const posts = page + .getByTestId( 'custom-query' ) + .getByRole( 'heading', { level: 3 } ); + await expect( posts ).toHaveCount( 1 ); + + // Verify that the other posts are hidden + await expect( + page.getByText( 'First Test Post', { exact: true } ) + ).toBeHidden(); + } ); + + test( 'should update URL with search parameter', async ( { page } ) => { + // Test global query search parameter + await page.locator( 'input[type="search"]' ).fill( 'Test' ); + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ queryId }=Test` ) + ); + + // Clear search and verify parameter is removed + await page.locator( 'input[type="search"]' ).fill( '' ); + await expect( page ).not.toHaveURL( + new RegExp( `instant-search-${ queryId }=` ) + ); + } ); + + test( 'should handle search debouncing', async ( { page } ) => { + let responseCount = 0; + + // Monitor the number of requests + page.on( 'response', ( res ) => { + if ( res.url().includes( `instant-search-${ queryId }=` ) ) { + responseCount++; + } + } ); + + // Type quickly and wait for the response + let responsePromise = page.waitForResponse( ( response ) => { + return ( + response + .url() + .includes( `instant-search-${ queryId }=Test` ) && + response.status() === 200 + ); + } ); + await page + .locator( 'input[type="search"]' ) + .pressSequentially( 'Test', { delay: 100 } ); + await responsePromise; + + // Check that only one request was made + expect( responseCount ).toBe( 1 ); + + // Verify URL is updated after debounce + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ queryId }=Test` ) + ); + + responsePromise = page.waitForResponse( ( response ) => { + return response + .url() + .includes( `instant-search-${ queryId }=Test1234` ); + } ); + // Type again with a large delay and verify that a request is made + // for each character + await page + .locator( 'input[type="search"]' ) + .pressSequentially( '1234', { delay: 500 } ); + await responsePromise; + + // Check that five requests were made (Test, Test1, Test12, Test123, Test1234) + expect( responseCount ).toBe( 5 ); + } ); + + test( 'should reset pagination when searching', async ( { page } ) => { + // Navigate to second page + await page.click( 'a.wp-block-query-pagination-next' ); + + await expect( page ).toHaveURL( + new RegExp( `query-${ queryId }-page=2` ) + ); + + // Search and verify we're back to first page + await page.locator( 'input[type="search"]' ).fill( 'Test' ); + await expect( page ).not.toHaveURL( + new RegExp( `query-${ queryId }-page=2` ) + ); + + // The url should now contain `?paged=1` because we're on the first page + // We cannot remove the `paged` param completely because the pathname + // might contain the `/page/2` suffix so we need to set `paged` to `1` to + // override it. + await expect( page ).toHaveURL( + new RegExp( `query-${ queryId }-page=1` ) + ); + } ); + + test( 'should show no-results block when search has no matches', async ( { + page, + } ) => { + await page + .locator( 'input[type="search"]' ) + .fill( 'NonexistentContent' ); + await page.waitForResponse( ( response ) => + response + .url() + .includes( + `instant-search-${ queryId }=NonexistentContent` + ) + ); + + // Verify no-results block is shown + await expect( page.getByText( 'No results found.' ) ).toBeVisible(); + } ); + + test( 'should update pagination numbers based on search results', async ( { + page, + } ) => { + // Initially should show pagination numbers for 3 pages + await expect( + page.locator( '.wp-block-query-pagination-numbers' ) + ).toBeVisible(); + await expect( + page.getByRole( 'link', { name: '2' } ) + ).toBeVisible(); + await expect( + page.getByRole( 'link', { name: '3' } ) + ).toBeVisible(); + + // Search for unique post + await page.locator( 'input[type="search"]' ).fill( 'Unique' ); + await page.waitForResponse( ( response ) => + response.url().includes( `instant-search-${ queryId }=Unique` ) + ); + + // Pagination numbers should not be visible with single result + await expect( + page.locator( '.wp-block-query-pagination-numbers' ) + ).toBeHidden(); + } ); + + test( 'should handle pre-defined search from query attributes', async ( { + requestUtils, + page, + } ) => { + // Create page with custom query that includes a search parameter + const { id } = await requestUtils.createPage( { + status: 'publish', + title: 'Query with Search', + content: ` + +
+ + + + + + + + + + + + +

No results found.

+ + +
+`, + } ); + + // Navigate to the page + await page.goto( `/?p=${ id }` ); + + // Verify the search input has the initial value + await expect( page.locator( 'input[type="search"]' ) ).toHaveValue( + 'Unique' + ); + + // Verify only the unique post is shown + await expect( + page.getByText( 'Unique Post', { exact: true } ) + ).toBeVisible(); + const posts = page + .getByTestId( 'query-with-search' ) + .getByRole( 'heading', { level: 3 } ); + await expect( posts ).toHaveCount( 1 ); + + // Verify URL does not contain the instant-search parameter + await expect( page ).not.toHaveURL( + new RegExp( `instant-search-${ queryId }=` ) + ); + + // Type new search term and verify normal instant search behavior + await page.locator( 'input[type="search"]' ).fill( 'Test' ); + await page.waitForResponse( ( response ) => + response.url().includes( `instant-search-${ queryId }=Test` ) + ); + + // Verify URL now contains the instant-search parameter + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ queryId }=Test` ) + ); + + // Verify search results update + await expect( + page.getByText( 'First Test Post', { exact: true } ) + ).toBeVisible(); + } ); + } ); + + test.describe( 'Multiple Queries', () => { + let pageId: number; + + const firstQueryId = 1234; + const secondQueryId = 5678; + + test.beforeAll( async ( { requestUtils } ) => { + // Edit the Home template to include two custom queries + const { id } = await requestUtils.createPage( { + status: 'publish', + title: 'Home', + content: ` + +
+ +

First Query

+ + + + + + + + + + + + + +

No results found.

+ + +
+ + + +
+ +

Second Query

+ + + + + + + + + + + + + +

No results found.

+ + +
+`, + } ); + + pageId = id; + } ); + + test.beforeEach( async ( { page } ) => { + await page.goto( `/?p=${ pageId }` ); + } ); + + test( 'should handle searches independently', async ( { page } ) => { + // Get search inputs + const firstQuerySearch = page.getByLabel( '1st-instant-search' ); + const secondQuerySearch = page.getByLabel( '2nd-instant-search' ); + + // Search in first query + await firstQuerySearch.fill( 'Unique' ); + await page.waitForResponse( ( response ) => + response + .url() + .includes( `instant-search-${ firstQueryId }=Unique` ) + ); + + // Verify first query ONLY shows the unique post + await expect( + page + .getByTestId( 'first-query' ) + .getByText( 'Unique Post', { exact: true } ) + ).toBeVisible(); + + // Verify that the second query shows exactly 2 posts: First Test Post and Second Test Post + const secondQuery = page.getByTestId( 'second-query' ); + const posts = secondQuery.getByRole( 'heading', { level: 3 } ); + await expect( posts ).toHaveCount( 2 ); + await expect( posts ).toContainText( [ + 'First Test Post', + 'Second Test Post', + ] ); + + // Search in second query + await secondQuerySearch.fill( 'Third' ); + await page.waitForResponse( ( response ) => + response + .url() + .includes( `instant-search-${ secondQueryId }=Third` ) + ); + + // Verify URL contains both search parameters + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ firstQueryId }=Unique` ) + ); + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ secondQueryId }=Third` ) + ); + + // Verify that the first query has only one post which is the "Unique" post + const firstQueryPosts = page + .getByTestId( 'first-query' ) + .getByRole( 'heading', { level: 3 } ); + await expect( firstQueryPosts ).toHaveCount( 1 ); + await expect( firstQueryPosts ).toContainText( 'Unique Post' ); + + // Verify that the second query has only one post which is the "Third Test Post" + const secondQueryPosts = page + .getByTestId( 'second-query' ) + .getByRole( 'heading', { level: 3 } ); + await expect( secondQueryPosts ).toHaveCount( 1 ); + await expect( secondQueryPosts ).toContainText( 'Third Test Post' ); + + // Clear first query search + await firstQuerySearch.fill( '' ); + await expect( page ).not.toHaveURL( + new RegExp( `instant-search-${ firstQueryId }=` ) + ); + await expect( page ).toHaveURL( + new RegExp( `instant-search-${ secondQueryId }=Third` ) + ); + + // Clear second query search + await secondQuerySearch.fill( '' ); + await expect( page ).not.toHaveURL( + new RegExp( `instant-search-${ secondQueryId }=` ) + ); + } ); + + test( 'should handle pagination independently', async ( { page } ) => { + const firstQuerySearch = page.getByLabel( '1st-instant-search' ); + const secondQuerySearch = page.getByLabel( '2nd-instant-search' ); + + // Navigate to second page in first query + await goToNextPage( page, 2, 'first-query', firstQueryId ); + + // Navigate to second page in second query + await goToNextPage( page, 2, 'second-query', secondQueryId ); + + // Navigate to third page in second query + await goToNextPage( page, 3, 'second-query', secondQueryId ); + + // Verify URL contains both pagination parameters + await expect( page ).toHaveURL( + new RegExp( `query-${ firstQueryId }-page=2` ) + ); + await expect( page ).toHaveURL( + new RegExp( `query-${ secondQueryId }-page=3` ) + ); + + // Search in first query and verify only its pagination resets + await firstQuerySearch.fill( 'Test' ); + await expect( page ).toHaveURL( + new RegExp( `query-${ firstQueryId }-page=1` ) + ); + await expect( page ).toHaveURL( + new RegExp( `query-${ secondQueryId }-page=3` ) + ); + + // Search in second query and verify only its pagination resets + await secondQuerySearch.fill( 'Test' ); + await expect( page ).toHaveURL( + new RegExp( `query-${ firstQueryId }-page=1` ) + ); + await expect( page ).toHaveURL( + new RegExp( `query-${ secondQueryId }-page=1` ) + ); + } ); + } ); +} );