diff --git a/lib/experimental/blocks.php b/lib/experimental/blocks.php index 6990f1dab9c2c..99a16ad302ed3 100644 --- a/lib/experimental/blocks.php +++ b/lib/experimental/blocks.php @@ -151,6 +151,41 @@ function gutenberg_block_core_query_add_url_filtering( $query, $block ) { } add_filter( 'query_loop_block_query_vars', 'gutenberg_block_core_query_add_url_filtering', 10, 2 ); +/** + * Adds the search query to Query blocks for the inherited queries if the instant search experiment is enabled. + * + * @param WP_Query $query The query object. + * @return void + */ +function gutenberg_block_core_query_add_search_query_filtering( $query ) { + + // if the query is not the main query, return + if ( $query->is_admin() || ! $query->is_main_query() ) { + return; + } + + // 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; + } + + // Get the search key from the URL + $search_key = 'instant-search'; + if ( ! isset( $_GET[ $search_key ] ) ) { + return; + } + + // Add the search parameter to the query + $query->set( 's', sanitize_text_field( $_GET[ $search_key ] ) ); +} + +add_action( + 'pre_get_posts', + 'gutenberg_block_core_query_add_search_query_filtering' +); + /** * Additional data to expose to the view script module in the Form block. */ diff --git a/packages/block-library/src/search/index.php b/packages/block-library/src/search/index.php index e042f0cbcdc7a..a44d91b60bd61 100644 --- a/packages/block-library/src/search/index.php +++ b/packages/block-library/src/search/index.php @@ -112,7 +112,7 @@ function render_block_core_search( $attributes, $content, $block ) { 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-bind--value', 'state.searchGetter' ); $input->set_attribute( 'data-wp-on-async--input', 'actions.updateSearch' ); } } @@ -214,21 +214,45 @@ function render_block_core_search( $attributes, $content, $block ) { if ( $enhanced_pagination && $instant_search_enabled && isset( $block->context['queryId'] ) ) { - $search = ''; + $form_directives .= ' data-wp-on--submit="actions.handleSearchSubmit"'; + + // Get the canonical URL without pagination + $canonical_url_no_pagination = get_pagenum_link( 1 ); + + // If we're on a singular post/page, use its permalink instead + if ( is_singular() ) { + $canonical_url_no_pagination = get_permalink(); + } + + wp_interactivity_config( 'core/search', array( 'canonicalURL' => $canonical_url_no_pagination ) ); + + $query_id = $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'] ] ); + $is_inherited = isset( $block->context['query']['inherit'] ) && $block->context['query']['inherit'] && ! empty( $query_id ); + + // Inherited query: `instant-search=` + // Custom query: `instant-search-=` + $search_key = $is_inherited ? 'instant-search' : 'instant-search-' . $query_id; + + // If the query is defined in the URL, it overrides the block context value. + $search = empty( $_GET[ $search_key ] ) ? $search : sanitize_text_field( $_GET[ $search_key ] ); + + if ( $is_inherited ) { + wp_interactivity_state( 'core/search', array( 'search' => $search ) ); + } $form_context = array_merge( $form_context, array( - 'search' => $search, - 'queryId' => $block->context['queryId'], + 'search' => $search, + 'queryId' => $query_id, + 'isInherited' => $is_inherited, ) ); } diff --git a/packages/block-library/src/search/view.js b/packages/block-library/src/search/view.js index d280d355322af..1e4b3374c0326 100644 --- a/packages/block-library/src/search/view.js +++ b/packages/block-library/src/search/view.js @@ -1,7 +1,12 @@ /** * WordPress dependencies */ -import { store, getContext, getElement } from '@wordpress/interactivity'; +import { + store, + getContext, + getElement, + getConfig, +} from '@wordpress/interactivity'; /** @type {( () => void ) | null} */ let supersedePreviousSearch = null; @@ -43,6 +48,10 @@ const { state, actions } = store( } return ctx.isSearchInputVisible; }, + get searchGetter() { + const { isInherited, search } = getContext(); + return isInherited ? state.search : search; + }, }, actions: { openSearchInput( event ) { @@ -80,17 +89,24 @@ const { state, actions } = store( actions.closeSearchInput(); } }, + handleSearchSubmit( e ) { + e.preventDefault(); + }, *updateSearch( e ) { const { value } = e.target; - const ctx = getContext(); - // Don't navigate if the search didn't really change. - if ( value === ctx.search ) { + if ( value === state.searchGetter ) { return; } - ctx.search = value; + const ctx = getContext(); + + if ( ctx.isInherited ) { + state.search = value; + } else { + ctx.search = value; + } // Debounce the search by 300ms to prevent multiple navigations. supersedePreviousSearch?.(); @@ -110,18 +126,31 @@ const { state, actions } = store( return; } - const url = new URL( window.location.href ); + let 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 - ); + if ( ctx.isInherited ) { + // Get the canonical URL from the config + const { canonicalURL } = getConfig( 'core/search' ); + + // Make sure we reset the pagination. + url = new URL( canonicalURL ); + url.searchParams.set( 'instant-search', value ); + } else { + // 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' ); + // Make sure we reset the pagination. + url.searchParams.set( `query-${ queryId }-page`, '1' ); + } + } else if ( ctx.isInherited ) { + // Reset global search for inherited queries + url.searchParams.delete( 'instant-search' ); + url.searchParams.delete( 'paged' ); } else { // Reset specific search for non-inherited queries url.searchParams.delete( diff --git a/packages/e2e-test-utils-playwright/src/request-utils/index.ts b/packages/e2e-test-utils-playwright/src/request-utils/index.ts index f6818945e1693..364f5369564ac 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/index.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/index.ts @@ -16,7 +16,11 @@ import { listMedia, uploadMedia, deleteMedia, deleteAllMedia } from './media'; import { createUser, deleteAllUsers } from './users'; import { setupRest, rest, getMaxBatchSize, batchRest } from './rest'; import { getPluginsMap, activatePlugin, deactivatePlugin } from './plugins'; -import { deleteAllTemplates, createTemplate } from './templates'; +import { + deleteAllTemplates, + createTemplate, + updateOrCreateTemplate, +} from './templates'; import { activateTheme, getCurrentThemeGlobalStylesPostId, @@ -175,6 +179,9 @@ class RequestUtils { deleteAllTemplates.bind( this ); /** @borrows createTemplate as this.createTemplate */ createTemplate: typeof createTemplate = createTemplate.bind( this ); + /** @borrows updateOrCreateTemplate as this.updateOrCreateTemplate */ + updateOrCreateTemplate: typeof updateOrCreateTemplate = + updateOrCreateTemplate.bind( this ); /** @borrows resetPreferences as this.resetPreferences */ resetPreferences: typeof resetPreferences = resetPreferences.bind( this ); /** @borrows listMedia as this.listMedia */ diff --git a/packages/e2e-test-utils-playwright/src/request-utils/templates.ts b/packages/e2e-test-utils-playwright/src/request-utils/templates.ts index 76ae7022e9b7a..6be65978ae48c 100644 --- a/packages/e2e-test-utils-playwright/src/request-utils/templates.ts +++ b/packages/e2e-test-utils-playwright/src/request-utils/templates.ts @@ -8,6 +8,8 @@ type TemplateType = 'wp_template' | 'wp_template_part'; interface Template { wp_id: number; id: string; + title: string; + slug: string; } interface CreateTemplatePayload { @@ -80,4 +82,40 @@ async function createTemplate( return template; } -export { deleteAllTemplates, createTemplate }; +/** + * Updates a template using the REST API. + * + * @param this + * @param type Template type. + * @param payload Template attributes. + */ +async function updateOrCreateTemplate( + this: RequestUtils, + type: TemplateType, + payload: CreateTemplatePayload +) { + const path = PATH_MAPPING[ type ]; + + if ( ! path ) { + throw new Error( `Unsupported template type: ${ type }.` ); + } + + const templates = await this.rest< Template[] >( { path } ); + + const template = templates.find( ( t ) => t.slug === payload.slug ); + + // If the template is not found, create it. + if ( ! template ) { + return createTemplate.bind( this )( type, payload ); + } + + const updatedTemplate = await this.rest< Template >( { + method: 'POST', + path: `${ PATH_MAPPING[ type ] }/${ template.id }`, + params: { ...payload, type, status: 'publish', is_wp_suggestion: true }, + } ); + + return updatedTemplate; +} + +export { deleteAllTemplates, createTemplate, updateOrCreateTemplate }; diff --git a/test/e2e/specs/interactivity/instant-search.spec.ts b/test/e2e/specs/interactivity/instant-search.spec.ts index 6ce62e7291931..8182596a36ceb 100644 --- a/test/e2e/specs/interactivity/instant-search.spec.ts +++ b/test/e2e/specs/interactivity/instant-search.spec.ts @@ -548,6 +548,376 @@ test.describe( 'Instant Search', () => { } ); } ); + test.describe( 'Inherited (Default) Query', () => { + test.beforeEach( async ( { page } ) => { + // Navigate to the home page + await page.goto( '/' ); + } ); + + test.beforeAll( async ( { requestUtils } ) => { + // Edit the Home template instead of creating a new page + await requestUtils.updateOrCreateTemplate( 'wp_template', { + slug: 'home', + content: ` + +
+ + + + + + + + + + + + +

No results found.

+ + +
+`, + } ); + } ); + + 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=Unique' ) + ); + + // Verify 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( 'default-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( /instant-search=Test/ ); + + // Clear search and verify parameter is removed + await page.locator( 'input[type="search"]' ).fill( '' ); + await expect( page ).not.toHaveURL( /instant-search=/ ); + } ); + + test( 'should handle search debouncing', async ( { page } ) => { + let responseCount = 0; + + // Monitor the number of requests + page.on( 'response', ( response ) => { + if ( response.url().includes( 'instant-search=' ) ) { + responseCount++; + } + } ); + + // Type quickly and wait for the response + let responsePromise = page.waitForResponse( ( response ) => { + return ( + response.url().includes( 'instant-search=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( /instant-search=Test/ ); + + responsePromise = page.waitForResponse( ( response ) => { + return response.url().includes( 'instant-search=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' ); + + // Check that the url contains either `?paged=2` or `/page/2/`. If the + // site has the `pretty` permalink structure, the url will contain + // `/page/2/` instead of `?paged=2`. + await expect( page ).toHaveURL( /(?:paged=2|\/page\/2\/)/ ); + + // Search and verify we're back to first page + await page.locator( 'input[type="search"]' ).fill( 'Test' ); + await expect( page ).not.toHaveURL( /paged=2/ ); + + // Now we're back on the first page, so the URL should just contain the search parameter + await expect( page ).toHaveURL( /\?instant-search=Test/ ); + } ); + + 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=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=Unique' ) + ); + + // Pagination numbers should not be visible with single result + await expect( + page.locator( '.wp-block-query-pagination-numbers' ) + ).toBeHidden(); + } ); + } ); + + test.describe( 'Multiple Inherited and Custom Queries', () => { + test( 'should keep the search state in sync across multiple inherited queries', async ( { + page, + requestUtils, + } ) => { + await requestUtils.updateOrCreateTemplate( 'wp_template', { + slug: 'home', + content: ` + +
+ + + + + + + + + + + + +

No results found.

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

No results found.

+ + +
+ `, + } ); + + await page.goto( '/' ); + + // Get search inputs + const firstQuerySearch = page.getByLabel( '1st-instant-search' ); + const secondQuerySearch = page.getByLabel( '2nd-instant-search' ); + + // Search for "Unique" in the first query + await firstQuerySearch.fill( 'Unique' ); + + // Verify that the URL has been updated with the search parameter + await expect( page ).toHaveURL( /instant-search=Unique/ ); + + // Verify that the second query search input has the same value + await expect( secondQuerySearch ).toHaveValue( 'Unique' ); + + // Verify that the first query has only one post which is the "Unique" post + const firstQueryPosts = page + .getByTestId( 'default-query-1' ) + .getByRole( 'heading', { level: 3 } ); + await expect( firstQueryPosts ).toHaveCount( 1 ); + await expect( firstQueryPosts ).toContainText( 'Unique Post' ); + + // Verify that the second query also has only one post which is the "Unique" post + const secondQueryPosts = page + .getByTestId( 'default-query-2' ) + .getByRole( 'heading', { level: 3 } ); + await expect( secondQueryPosts ).toHaveCount( 1 ); + await expect( secondQueryPosts ).toContainText( 'Unique Post' ); + } ); + + test( 'should handle searches independently when a Default and a Custom query are placed in a home template', async ( { + page, + requestUtils, + } ) => { + // Set up: Add one inherited and one custom query to the home template + await requestUtils.updateOrCreateTemplate( 'wp_template', { + slug: 'home', + content: ` + +
+ + + + + + + + + + + + +

No results found.

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

No results found.

+ + +
+ `, + } ); + + await page.goto( '/' ); + + // Get search inputs + const defaultQuerySearch = page.getByLabel( + 'Default Query Search' + ); + const customQuerySearch = page.getByLabel( 'Custom Query Search' ); + + // Search for "Unique" in the default query + await defaultQuerySearch.fill( 'Unique' ); + + // Verify that the URL has been updated with the search parameter + await expect( page ).toHaveURL( /instant-search=Unique/ ); + + // Verify that the custom query search input has no value + await expect( customQuerySearch ).toHaveValue( '' ); + + // Verify that the default query has only one post which is the "Unique" post + const defaultQueryPosts = page + .getByTestId( 'default-query' ) + .getByRole( 'heading', { level: 3 } ); + await expect( defaultQueryPosts ).toHaveCount( 1 ); + await expect( defaultQueryPosts ).toContainText( 'Unique Post' ); + + // Verify that the custom query shows exactly 2 posts: First Test Post and Second Test Post + const customQuery = page.getByTestId( 'custom-query' ); + const posts = customQuery.getByRole( 'heading', { level: 3 } ); + await expect( posts ).toHaveCount( 2 ); + await expect( posts ).toContainText( [ + 'First Test Post', + 'Second Test Post', + ] ); + + // Search for "Third" in the custom query + await customQuerySearch.fill( 'Third' ); + + // Verify that the URL has been updated with the search parameter + await expect( page ).toHaveURL( + /instant-search=Unique&instant-search-2222=Third/ + ); + + // Verify that the default query search input still has "Unique" + await expect( defaultQuerySearch ).toHaveValue( 'Unique' ); + + // Verify that the default query has only one post which is the "Unique" post + await expect( defaultQueryPosts ).toHaveCount( 1 ); + await expect( defaultQueryPosts ).toContainText( 'Unique Post' ); + + // Verify that the custom query has only one post which is the "Third Test Post" + const customQueryPosts = page + .getByTestId( 'custom-query' ) + .getByRole( 'heading', { level: 3 } ); + await expect( customQueryPosts ).toHaveCount( 1 ); + await expect( customQueryPosts ).toContainText( 'Third Test Post' ); + + // Clear default query search + await defaultQuerySearch.fill( '' ); + await expect( page ).not.toHaveURL( /instant-search=Unique/ ); + await expect( page ).toHaveURL( /instant-search-2222=Third/ ); + + // Clear custom query search + await customQuerySearch.fill( '' ); + await expect( page ).not.toHaveURL( /instant-search-2222=Third/ ); + } ); + } ); + test.describe( 'Editor', () => { test.beforeEach( async ( { admin } ) => { await admin.createNewPost( { @@ -612,13 +982,15 @@ test.describe( 'Instant Search', () => { name: 'Editor settings', } ); + const blockCard = editorSettings.locator( + '.block-editor-block-card' + ); + // Check that the Search block is renamed to "Instant Search" in the Inspector Controls title await editor.canvas .getByRole( 'document', { name: 'Block: Search' } ) .click(); - await expect( editorSettings ).toContainText( - 'Instant Search (Search)' - ); + await expect( blockCard ).toContainText( 'Instant Search' ); // Select the Query Loop block and open the Advanced View and disable enhanced pagination await editor.selectBlocks( @@ -638,10 +1010,8 @@ test.describe( 'Instant Search', () => { await editor.canvas .getByRole( 'document', { name: 'Block: Search' } ) .click(); - await expect( editorSettings ).toContainText( 'Search' ); - await expect( editorSettings ).not.toContainText( - 'Instant Search (Search)' - ); + await expect( blockCard ).toContainText( 'Search' ); + await expect( blockCard ).not.toContainText( 'Instant Search' ); // Check that the Search block is renamed back to "Search" in the List View await expect( listView.getByText( 'Search' ) ).toBeVisible();