diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index b4bf0d409b1596..6990f1dab9c2c6 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -118,6 +118,39 @@ function gutenberg_register_block_style( $block_name, $style_properties ) { return $result; } +/** + * Passes the search query param to Query Loop blocks, if the instant search experiment is enabled. + * + * @param array $query The query variables. + * @param WP_Block $block Block instance. + * @return array Modified query variables. + */ +function gutenberg_block_core_query_add_url_filtering( $query, $block ) { + // 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 $query; + } + + // Make sure block has a queryId + if ( empty( $block->context['queryId'] ) ) { + return $query; + } + + // Get the search key from the URL + $search_key = 'instant-search-' . $block->context['queryId']; + if ( ! isset( $_GET[ $search_key ] ) ) { + return $query; + } + + // Add the search parameter to the query + $query['s'] = sanitize_text_field( $_GET[ $search_key ] ); + + return $query; +} +add_filter( 'query_loop_block_query_vars', 'gutenberg_block_core_query_add_url_filtering', 10, 2 ); + /** * Additional data to expose to the view script module in the Form block. */ diff --git a/lib/experimental/editor-settings.php b/lib/experimental/editor-settings.php index 5b36c32b3c8296..c2f3c40289b4df 100644 --- a/lib/experimental/editor-settings.php +++ b/lib/experimental/editor-settings.php @@ -28,6 +28,9 @@ function gutenberg_enable_experiments() { if ( gutenberg_is_experiment_enabled( 'gutenberg-full-page-client-side-navigation' ) ) { wp_add_inline_script( 'wp-block-library', 'window.__experimentalFullPageClientSideNavigation = true', 'before' ); } + if ( $gutenberg_experiments && array_key_exists( 'gutenberg-search-query-block', $gutenberg_experiments ) ) { + wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableSearchQueryBlock = true', 'before' ); + } if ( $gutenberg_experiments && array_key_exists( 'gutenberg-block-comment', $gutenberg_experiments ) ) { wp_add_inline_script( 'wp-block-editor', 'window.__experimentalEnableBlockComment = true', 'before' ); } diff --git a/lib/experiments-page.php b/lib/experiments-page.php index c308b7cb5d4039..34dc8dd14bb01c 100644 --- a/lib/experiments-page.php +++ b/lib/experiments-page.php @@ -199,6 +199,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', + ) + ); + register_setting( 'gutenberg-experiments', 'gutenberg-experiments' diff --git a/packages/block-editor/src/components/block-title/use-block-display-title.js b/packages/block-editor/src/components/block-title/use-block-display-title.js index a51b336554a2a7..137c4ec16039d8 100644 --- a/packages/block-editor/src/components/block-title/use-block-display-title.js +++ b/packages/block-editor/src/components/block-title/use-block-display-title.js @@ -51,7 +51,12 @@ export default function useBlockDisplayTitle( { } const attributes = getBlockAttributes( clientId ); - const label = getBlockLabel( blockType, attributes, context ); + const label = getBlockLabel( + blockType, + attributes, + context, + clientId + ); // If the label is defined we prioritize it over a possible block variation title match. if ( label !== blockType.title ) { return label; 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..88feae5a81a747 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,13 @@ export default function SearchEdit( { style, } = attributes; + // Check if the block is inside a Query block with enhanced pagination enabled + // and if the `__experimentalEnableSearchQueryBlock` flag is enabled. + const hasInstantSearch = !! ( + context?.enhancedPagination && + window?.__experimentalEnableSearchQueryBlock + ); + const wasJustInsertedIntoNavigationBlock = useSelect( ( select ) => { const { getBlockParentsByBlockName, wasBlockJustInserted } = @@ -385,24 +393,28 @@ export default function SearchEdit( { } } className={ showLabel ? 'is-pressed' : undefined } /> - - { ! hasNoButton && ( - { - setAttributes( { - buttonUseIcon: ! buttonUseIcon, - } ); - } } - className={ - buttonUseIcon ? 'is-pressed' : undefined - } - /> + { ! hasInstantSearch && ( + <> + + { ! hasNoButton && ( + { + setAttributes( { + buttonUseIcon: ! buttonUseIcon, + } ); + } } + className={ + buttonUseIcon ? 'is-pressed' : undefined + } + /> + ) } + > ) } @@ -596,16 +608,22 @@ export default function SearchEdit( { } } showHandle={ isSelected } > - { ( isButtonPositionInside || - isButtonPositionOutside || - hasOnlyButton ) && ( + { hasInstantSearch ? ( + renderTextField() + ) : ( <> - { renderTextField() } - { renderButton() } + { ( isButtonPositionInside || + isButtonPositionOutside || + hasOnlyButton ) && ( + <> + { renderTextField() } + { renderButton() } + > + ) } + + { hasNoButton && renderTextField() } > ) } - - { hasNoButton && renderTextField() } ); diff --git a/packages/block-library/src/search/index.js b/packages/block-library/src/search/index.js index 85770a23268cba..e823cf6177472b 100644 --- a/packages/block-library/src/search/index.js +++ b/packages/block-library/src/search/index.js @@ -1,8 +1,10 @@ /** * WordPress dependencies */ -import { __ } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { search as icon } from '@wordpress/icons'; +import { select } from '@wordpress/data'; +import { store as blockEditorStore } from '@wordpress/block-editor'; /** * Internal dependencies @@ -18,6 +20,41 @@ export { metadata, name }; export const settings = { icon, + __experimentalLabel( attributes, { clientId } ) { + const { label } = attributes; + const customName = attributes?.metadata?.name; + + // Check if the block is inside a Query Loop block. + const queryLoopBlockIds = select( + blockEditorStore + ).getBlockParentsByBlockName( clientId, 'core/query' ); + + // If the block is not inside a Query Loop block, return the block label. + if ( ! queryLoopBlockIds.length ) { + return customName || label; + } + + const queryLoopBlock = select( blockEditorStore ).getBlock( + queryLoopBlockIds[ 0 ] + ); + + // Check if the Query Loop block has enhanced pagination enabled and + // if the `__experimentalEnableSearchQueryBlock` flag is enabled. + const hasInstantSearch = !! ( + queryLoopBlock?.attributes?.enhancedPagination && + window?.__experimentalEnableSearchQueryBlock + ); + + if ( ! hasInstantSearch ) { + return customName || label; + } + + return sprintf( + /* translators: %s: The block label */ + __( '%s (Instant search enabled)' ), + customName || label || 'Search' + ); + }, example: { attributes: { buttonText: __( 'Search' ), label: __( 'Search' ) }, viewportWidth: 400, diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index 87e12f5d33d079..c6a0befe279bd2 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,26 @@ 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'] ); + $use_icon_button = ! empty( $attributes['buttonUseIcon'] ); + + // 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, which requires the enhanced pagination. + $gutenberg_experiments = get_option( 'gutenberg-experiments' ); + $instant_search_enabled = $enhanced_pagination && $gutenberg_experiments && array_key_exists( 'gutenberg-search-query-block', $gutenberg_experiments ); + + $show_button = true; + + if ( $instant_search_enabled ) { + $show_button = false; + // If the button position is no-button, ALSO hide the button. + } 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 = ''; @@ -90,6 +105,19 @@ function render_block_core_search( $attributes ) { $input->set_attribute( 'aria-hidden', 'true' ); $input->set_attribute( 'tabindex', '-1' ); } + + // Instant search is only enabled when both are true: + // 1. The block is a child of a Query Loop block. + // 2. The Query Loop block has the enhanced pagination feature enabled. + // + // Instant search functionality does not make sense without enhanced pagination + // because we might have to paginate the results of the search too! + if ( $instant_search_enabled ) { + wp_enqueue_script_module( '@wordpress/block-library/search/view' ); + + $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 +191,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 || $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( - array( - 'isSearchInputVisible' => $open_by_default, - 'inputId' => $input_id, - 'ariaLabelExpanded' => $aria_label_expanded, - 'ariaLabelCollapsed' => $aria_label_collapsed, - ) + $form_context = array( + 'isSearchInputInitiallyVisible' => $open_by_default, + 'inputId' => $input_id, + 'ariaLabelExpanded' => $aria_label_expanded, + 'ariaLabelCollapsed' => $aria_label_collapsed, ); - $form_directives = ' - data-wp-interactive="core/search" - ' . $form_context . ' + $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 ( $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( + 'search' => $search, + 'queryId' => $block->context['queryId'], + ) + ); + } + + $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..7492faa884993b 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -3,6 +3,9 @@ */ import { store, getContext, getElement } from '@wordpress/interactivity'; +/** @type {( () => void ) | null} */ +let supersedePreviousSearch = null; + const { actions } = store( 'core/search', { @@ -66,6 +69,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/packages/blocks/src/api/utils.js b/packages/blocks/src/api/utils.js index 1a215036496559..5bba5863893973 100644 --- a/packages/blocks/src/api/utils.js +++ b/packages/blocks/src/api/utils.js @@ -153,13 +153,19 @@ export function normalizeBlockType( blockTypeOrName ) { * @param {Object} blockType The block type. * @param {Object} attributes The values of the block's attributes. * @param {Object} context The intended use for the label. + * @param {string} clientId The block's client ID. * * @return {string} The block label. */ -export function getBlockLabel( blockType, attributes, context = 'visual' ) { +export function getBlockLabel( + blockType, + attributes, + context = 'visual', + clientId +) { const { __experimentalLabel: getLabel, title } = blockType; - const label = getLabel && getLabel( attributes, { context } ); + const label = getLabel && getLabel( attributes, { context, clientId } ); if ( ! label ) { return title; diff --git a/packages/e2e-test-utils-playwright/src/editor/index.ts b/packages/e2e-test-utils-playwright/src/editor/index.ts index 4ed32134f0979a..af38bf6b2d464f 100644 --- a/packages/e2e-test-utils-playwright/src/editor/index.ts +++ b/packages/e2e-test-utils-playwright/src/editor/index.ts @@ -29,6 +29,7 @@ import { setIsFixedToolbar } from './set-is-fixed-toolbar'; import { switchToLegacyCanvas } from './switch-to-legacy-canvas'; import { transformBlockTo } from './transform-block-to'; import { switchEditorTool } from './switch-editor-tool'; +import { openListView } from './open-list-view'; type EditorConstructorProps = { page: Page; @@ -92,4 +93,6 @@ export class Editor { switchToLegacyCanvas.bind( this ); /** @borrows transformBlockTo as this.transformBlockTo */ transformBlockTo: typeof transformBlockTo = transformBlockTo.bind( this ); + /** @borrows openListView as this.openListView */ + openListView: typeof openListView = openListView.bind( this ); } diff --git a/packages/e2e-test-utils-playwright/src/editor/open-list-view.ts b/packages/e2e-test-utils-playwright/src/editor/open-list-view.ts new file mode 100644 index 00000000000000..e8605fdaa637be --- /dev/null +++ b/packages/e2e-test-utils-playwright/src/editor/open-list-view.ts @@ -0,0 +1,30 @@ +/** + * Internal dependencies + */ +import type { Editor } from './index'; + +/** + * Clicks on the button in the header which opens Document Settings sidebar when + * it is closed. + * + * @param this + */ +export async function openListView( this: Editor ) { + const toggleButton = this.page + .getByRole( 'region', { name: 'Editor top bar' } ) + .getByRole( 'button', { + name: 'Document Overview', + disabled: false, + } ); + + const isClosed = + ( await toggleButton.getAttribute( 'aria-expanded' ) ) === 'false'; + + if ( isClosed ) { + await toggleButton.click(); + await this.page + .getByRole( 'region', { name: 'Document Overview' } ) + .getByRole( 'button', { name: 'Close' } ) + .waitFor(); + } +} 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..fd37610aa85d35 --- /dev/null +++ b/test/e2e/specs/interactivity/instant-search.spec.ts @@ -0,0 +1,715 @@ +/** + * 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' ); + + // disable the gutenberg-search-query-block experiment + await requestUtils.setGutenbergExperiments( [] ); + } ); + + 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` ) + ); + } ); + } ); + + test.describe( 'Editor', () => { + test.beforeEach( async ( { admin } ) => { + await admin.createNewPost( { + postType: 'post', + title: 'Instant Search Test', + } ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts(); + } ); + + test( 'should hide specific toolbar buttons when Search block is inside Query block with enhanced pagination', async ( { + editor, + page, + } ) => { + // Insert Query block with enhanced pagination enabled + await editor.insertBlock( { + name: 'core/query', + attributes: { + enhancedPagination: true, + query: { + inherit: false, + perPage: 2, + order: 'desc', + orderBy: 'date', + offset: 0, + }, + }, + innerBlocks: [ + { name: 'core/search' }, + { + name: 'core/post-template', + innerBlocks: [ + { name: 'core/post-title' }, + { name: 'core/post-excerpt' }, + ], + }, + ], + } ); + + // Select the Search block + const searchBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Search', + } ); + await editor.selectBlocks( searchBlock ); + + // Verify that the specific toolbar buttons are hidden + const toolbar = page.getByRole( 'toolbar', { + name: 'Block tools', + } ); + await expect( + toolbar.getByRole( 'button', { + name: 'Change button position', + } ) + ).toBeHidden(); + await expect( + toolbar.getByRole( 'button', { name: 'Use button with icon' } ) + ).toBeHidden(); + + // Select the Query Loop block and disable enhanced pagination + await editor.selectBlocks( + editor.canvas.getByRole( 'document', { + name: 'Block: Query Loop', + } ) + ); + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + await editorSettings + .getByRole( 'checkbox', { name: 'Reload full page' } ) + .click(); + + // Select the Search block again + await editor.selectBlocks( searchBlock ); + + // Verify that the toolbar buttons are now visible + await expect( + toolbar.getByRole( 'button', { + name: 'Change button position', + } ) + ).toBeVisible(); + await expect( + toolbar.getByRole( 'button', { name: 'Use button with icon' } ) + ).toBeVisible(); + } ); + + test( 'should update List View label when Search block becomes Instant Search', async ( { + editor, + page, + } ) => { + // Insert Query block with enhanced pagination enabled + await editor.insertBlock( { + name: 'core/query', + attributes: { + enhancedPagination: true, + query: { + inherit: false, + perPage: 2, + order: 'desc', + orderBy: 'date', + offset: 0, + }, + }, + innerBlocks: [ + { + name: 'core/search', + }, + { + name: 'core/post-template', + innerBlocks: [ + { name: 'core/post-title' }, + { name: 'core/post-excerpt' }, + ], + }, + ], + } ); + + // Select the Search block + const searchBlock = editor.canvas.getByRole( 'document', { + name: 'Block: Search', + } ); + await editor.selectBlocks( searchBlock ); + + // Open List View + await editor.openListView(); + const listView = page.getByRole( 'region', { + name: 'Document Overview', + } ); + await expect( listView ).toBeVisible(); + + // Verify that the Search block label includes "Instant search enabled" + await expect( + listView.getByText( 'Search (Instant search enabled)' ) + ).toBeVisible(); + + // Select the Query Loop block and disable enhanced pagination + await editor.selectBlocks( + editor.canvas.getByRole( 'document', { + name: 'Block: Query Loop', + } ) + ); + await editor.openDocumentSettingsSidebar(); + const editorSettings = page.getByRole( 'region', { + name: 'Editor settings', + } ); + await editorSettings + .getByRole( 'button', { name: 'Advanced' } ) + .click(); + await editorSettings + .getByRole( 'checkbox', { name: 'Reload full page' } ) + .click(); + + // Verify that the Search block label is back to normal + await expect( listView.getByText( 'Search' ) ).toBeVisible(); + await expect( + listView.getByText( 'Search (Instant search enabled)' ) + ).toBeHidden(); + } ); + } ); +} );
No results found.