diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 68113276ec1c06..3bf7a53f1c2de7 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -117,3 +117,37 @@ function gutenberg_register_block_style( $block_name, $style_properties ) { return $result; } + +/** + * Adds the search query to the context if the instant search gutenberg experiment is enabled. + * + * @param array $context The block context. + * @return array The block context. + */ +function gutenberg_block_core_query_add_url_filtering( $context ) { + + // Make sure it only runs for blocks with a queryId + if ( empty( $context['queryId'] ) ) { + return $context; + } + + // Check if the instant search gutenberg experiment is enabled + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = $gutenberg_experiments && array_key_exists( 'gutenberg-search-query-block', $gutenberg_experiments ); + if ( ! $instant_search_enabled ) { + return $context; + } + + // Get the search key from the URL + $search_key = 'instant-search-' . $context['queryId']; + if ( ! isset( $_GET[ $search_key ] ) ) { + return $context; + } + + // Add the search query to the context, it will be picked up by all the blocks that + // use the `query` context like `post-template` or `query-pagination`. + $context['query']['search'] = sanitize_text_field( $_GET[ $search_key ] ); + + return $context; +} +add_filter( 'render_block_context', 'gutenberg_block_core_query_add_url_filtering', 10, 2 ); 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/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..9da028f338fab3 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,19 @@ 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 } ); + } + }, [ isEnhancedPagination, setAttributes ] ); + const wasJustInsertedIntoNavigationBlock = useSelect( ( select ) => { const { getBlockParentsByBlockName, wasBlockJustInserted } = @@ -385,24 +399,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 +614,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 b74daf548a8025..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( '%2$s', $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` ) + ); + } ); + } ); +} );
No results found.