From 3ddc342f4aa29d1818fd10cb81a578e5eecced17 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 30 Jul 2026 10:50:47 +0530 Subject: [PATCH 1/4] fix(menus): paginate nav menu quick search --- src/js/_enqueues/lib/nav-menu.js | 258 +++++++++++++----- src/wp-admin/includes/nav-menu.php | 114 +++++++- tests/e2e/specs/nav-menu-quick-search.test.js | 70 +++++ .../tests/menu/wpAjaxMenuQuickSearch.php | 97 +++++++ tests/qunit/wp-admin/js/nav-menu.js | 115 +++++++- 5 files changed, 575 insertions(+), 79 deletions(-) create mode 100644 tests/e2e/specs/nav-menu-quick-search.test.js diff --git a/src/js/_enqueues/lib/nav-menu.js b/src/js/_enqueues/lib/nav-menu.js index 79917c8447f1a..4e9b1cd368753 100644 --- a/src/js/_enqueues/lib/nav-menu.js +++ b/src/js/_enqueues/lib/nav-menu.js @@ -34,7 +34,6 @@ menusChanged : false, isRTL: !! ( 'undefined' != typeof isRtl && isRtl ), negateIfRTL: ( 'undefined' != typeof isRtl && isRtl ) ? -1 : 1, - lastSearch: '', // Functions that run on init. init : function() { @@ -1380,71 +1379,176 @@ }, attachQuickSearchListeners : function() { - var searchTimer; - // Prevent form submission. $( '#nav-menu-meta' ).on( 'submit', function( event ) { event.preventDefault(); }); $( '#nav-menu-meta' ).on( 'input', '.quick-search', function() { - var $this = $( this ); + var input = $( this ), + panel = input.parents( '.tabs-panel' ), + checklist = $( '.categorychecklist', panel ), + loadMore = $( '.quick-search-load-more', panel ), + previousState = panel.data( 'quick-search-state' ) || {}, + q = input.val(), + state; + + input.attr( 'autocomplete', 'off' ); + + if ( previousState.query === q ) { + return; + } + + if ( previousState.timer ) { + clearTimeout( previousState.timer ); + } + + state = { + query: q, + page: 0, + hasMore: false, + loading: false, + retry: false + }; + panel.data( 'quick-search-state', state ); - $this.attr( 'autocomplete', 'off' ); + if ( previousState.request ) { + previousState.request.abort(); + } - if ( searchTimer ) { - clearTimeout( searchTimer ); + $( '.spinner', panel ).removeClass( 'is-active' ); + checklist.removeAttr( 'aria-busy' ).empty(); + loadMore + .removeAttr( 'aria-disabled' ) + .removeClass( 'disabled' ) + .prop( 'hidden', true ) + .text( loadMore.data( 'load-more-text' ) ); + + if ( q.length <= 1 ) { + wp.a11y.speak( wp.i18n.__( 'Search results cleared' ) ); + return; } - searchTimer = setTimeout( function() { - api.updateQuickSearchResults( $this ); + state.timer = setTimeout( function() { + state.timer = null; + api.updateQuickSearchResults( input ); }, 500 ); - }).on( 'blur', '.quick-search', function() { - api.lastSearch = ''; + }).on( 'click', '.quick-search-load-more', function() { + var panel = $( this ).parents( '.tabs-panel' ), + state = panel.data( 'quick-search-state' ); + + api.updateQuickSearchResults( panel.find( '.quick-search' ), ! state || ! state.retry ); }); }, - updateQuickSearchResults : function(input) { - var panel, params, - minSearchLength = 1, + /** + * Updates menu quick search results. + * + * @param {jQuery} input Search input. + * @param {boolean} append Whether to append the next page of results. + */ + updateQuickSearchResults : function( input, append ) { + var params, request, previousState, firstNewItem, loadMoreHadFocus, q = input.val(), - pageSearchChecklist = $( '#page-search-checklist' ); + panel = input.parents( '.tabs-panel' ), + checklist = $( '.categorychecklist', panel ), + loadMore = $( '.quick-search-load-more', panel ), + state = panel.data( 'quick-search-state' ), + itemCount = checklist.children( 'li' ).length, + paged; + + if ( ! append && ( ! state || state.query !== q ) ) { + previousState = state; + state = { + query: q, + page: 0, + hasMore: false, + loading: false, + retry: false + }; + panel.data( 'quick-search-state', state ); + loadMore + .removeAttr( 'aria-disabled' ) + .removeClass( 'disabled' ) + .prop( 'hidden', true ) + .text( loadMore.data( 'load-more-text' ) ); + + if ( previousState && previousState.timer ) { + clearTimeout( previousState.timer ); + } + if ( previousState && previousState.request ) { + previousState.request.abort(); + } + } - /* - * Avoid a new Ajax search when the pressed key (e.g. arrows) - * doesn't change the searched term. - */ - if ( api.lastSearch == q ) { + if ( ! state || state.loading || ( append && ( state.query !== q || ! state.hasMore ) ) ) { return; } - /* - * Reset results when search is less than or equal to - * minimum characters for searched term. - */ - if ( q.length <= minSearchLength ) { - pageSearchChecklist.empty(); + if ( q.length <= 1 ) { + checklist.removeAttr( 'aria-busy' ).empty(); + $( '.spinner', panel ).removeClass( 'is-active' ); wp.a11y.speak( wp.i18n.__( 'Search results cleared' ) ); return; } - api.lastSearch = q; - - panel = input.parents('.tabs-panel'); + paged = append ? state.page + 1 : 1; params = { 'action': 'menu-quick-search', 'response-format': 'markup', - 'menu': $('#menu').val(), - 'menu-settings-column-nonce': $('#menu-settings-column-nonce').val(), + 'menu': $( '#menu' ).val(), + 'menu-settings-column-nonce': $( '#menu-settings-column-nonce' ).val(), 'q': q, - 'type': input.attr('name') + 'type': input.attr( 'name' ), + 'paged': paged }; + state.loading = true; + state.retry = false; + checklist.attr( 'aria-busy', 'true' ); $( '.spinner', panel ).addClass( 'is-active' ); + loadMore + .attr( 'aria-disabled', 'true' ) + .addClass( 'disabled' ) + .text( loadMore.data( 'load-more-text' ) ); - $.post( ajaxurl, params, function(menuMarkup) { - api.processQuickSearchQueryResponse(menuMarkup, params, panel); + request = $.post( ajaxurl, params ).done( function( menuMarkup, textStatus, response ) { + var added; + + if ( panel.data( 'quick-search-state' ) !== state || state.request !== request ) { + return; + } + + loadMoreHadFocus = loadMore.is( ':focus' ); + added = api.processQuickSearchQueryResponse( menuMarkup, params, panel, append ); + state.page = paged; + state.hasMore = '1' === response.getResponseHeader( 'X-WP-Menu-Quick-Search-Has-More' ); + loadMore.prop( 'hidden', ! state.hasMore ); + + if ( ! state.hasMore && loadMoreHadFocus ) { + firstNewItem = checklist.children( 'li' ).eq( append ? itemCount : 0 ).find( ':input' ).first(); + ( added ? firstNewItem : input ).trigger( 'focus' ); + } + }).fail( function( response, status ) { + if ( 'abort' !== status && panel.data( 'quick-search-state' ) === state ) { + loadMore.prop( 'hidden', false ); + if ( ! append ) { + state.retry = true; + loadMore.text( wp.i18n.__( 'Try again' ) ); + } + wp.a11y.speak( wp.i18n.__( 'An error occurred. Please try again.' ), 'assertive' ); + } + }).always( function() { + if ( panel.data( 'quick-search-state' ) === state && state.request === request ) { + state.loading = false; + state.request = null; + checklist.removeAttr( 'aria-busy' ); + $( '.spinner', panel ).removeClass( 'is-active' ); + loadMore.removeAttr( 'aria-disabled' ).removeClass( 'disabled' ); + } }); + + state.request = request; }, addCustomLink : function( processMethod ) { @@ -1764,64 +1868,84 @@ }, /** - * Process the quick search response into a search result + * Process the quick search response into search results. * - * @param string resp The server response to the query. - * @param object req The request arguments. - * @param jQuery panel The tabs panel we're searching in. + * @param {string} resp The server response to the query. + * @param {Object} req The request arguments. + * @param {jQuery} panel The tabs panel being searched. + * @param {boolean} append Whether to append the results. + * @return {number} Number of results added. */ - processQuickSearchQueryResponse : function(resp, req, panel) { - var matched, newID, - takenIDs = {}, - form = document.getElementById('nav-menu-meta'), - pattern = /menu-item[(\[^]\]*/, - $items = $('
').html(resp).find('li'), - wrapper = panel.closest( '.accordion-section-content' ), - selectAll = wrapper.find( '.button-controls .select-all' ), - $item; - - if( ! $items.length ) { - let noResults = wp.i18n.__( 'No results found.' ); - const li = $( '
  • ' ); - const p = $( '

    ', { text: noResults } ); - li.append( p ); - $('.categorychecklist', panel).empty().append( li ); - $( '.spinner', panel ).removeClass( 'is-active' ); + processQuickSearchQueryResponse : function( resp, req, panel, append ) { + var matched, newID, message, + takenIDs = {}, + form = document.getElementById( 'nav-menu-meta' ), + pattern = /menu-item[(\[^]\]*/, + $items = $( '

    ' ).html( resp ).find( 'li' ), + wrapper = panel.closest( '.accordion-section-content' ), + checklist = $( '.categorychecklist', panel ), + selectAll = wrapper.find( '.button-controls .select-all' ), + $item; + + if ( ! $items.length ) { + if ( append ) { + wp.a11y.speak( wp.i18n.__( 'No more results found.' ) ); + return 0; + } + + message = wp.i18n.__( 'No results found.' ); + checklist.empty().append( + $( '
  • ' ).append( $( '

    ', { text: message } ) ) + ); wrapper.addClass( 'has-no-menu-item' ); - wp.a11y.speak( noResults, 'assertive' ); - return; + wp.a11y.speak( message, 'assertive' ); + return 0; } - $items.each(function(){ - $item = $(this); + $items.each( function() { + $item = $( this ); // Make a unique DB ID number. - matched = pattern.exec($item.html()); + matched = pattern.exec( $item.html() ); if ( matched && matched[1] ) { newID = matched[1]; - while( form.elements['menu-item[' + newID + '][menu-item-type]'] || takenIDs[ newID ] ) { + while ( form.elements['menu-item[' + newID + '][menu-item-type]'] || takenIDs[ newID ] ) { newID--; } - takenIDs[newID] = true; + takenIDs[ newID ] = true; if ( newID != matched[1] ) { - $item.html( $item.html().replace(new RegExp( - 'menu-item\\[' + matched[1] + '\\]', 'g'), + $item.html( $item.html().replace( new RegExp( + 'menu-item\\[' + matched[1] + '\\]', 'g' ), 'menu-item[' + newID + ']' ) ); } } }); - $('.categorychecklist', panel).html( $items ); - wp.a11y.speak( wp.i18n.sprintf( wp.i18n.__( '%d Search Results Found' ), $items.length ), 'assertive' ); - $( '.spinner', panel ).removeClass( 'is-active' ); + if ( append ) { + checklist.append( $items ); + message = wp.i18n.sprintf( + wp.i18n._n( '%d additional search result loaded.', '%d additional search results loaded.', $items.length ), + $items.length + ); + } else { + checklist.html( $items ); + message = wp.i18n.sprintf( + wp.i18n._n( '%d search result found.', '%d search results found.', $items.length ), + $items.length + ); + } + + wp.a11y.speak( message ); wrapper.removeClass( 'has-no-menu-item' ); if ( selectAll.is( ':checked' ) ) { selectAll.prop( 'checked', false ); } + + return $items.length; }, /** diff --git a/src/wp-admin/includes/nav-menu.php b/src/wp-admin/includes/nav-menu.php index 70263a2034807..56388c3b5d2e3 100644 --- a/src/wp-admin/includes/nav-menu.php +++ b/src/wp-admin/includes/nav-menu.php @@ -26,6 +26,8 @@ function _wp_ajax_menu_quick_search( $request = array() ) { $object_type = $request['object_type'] ?? ''; $query = $request['q'] ?? ''; $response_format = $request['response-format'] ?? ''; + $is_paginated = isset( $request['paged'] ); + $paged = max( 1, absint( $request['paged'] ?? 1 ) ); if ( ! $response_format || ! in_array( $response_format, array( 'json', 'markup' ), true ) ) { $response_format = 'json'; @@ -92,6 +94,11 @@ function _wp_ajax_menu_quick_search( $request = array() ) { 's' => $query, 'search_columns' => array( 'post_title' ), ); + + if ( $is_paginated ) { + $query_args['paged'] = $paged; + } + /** * Filter the menu quick search arguments. * @@ -104,6 +111,7 @@ function _wp_ajax_menu_quick_search( $request = array() ) { * @type boolean $update_post_meta_cache Whether to update post meta cache. Default false. * @type boolean $update_post_term_cache Whether to update post term cache. Default false. * @type int $posts_per_page Number of posts to return. Default 10. + * @type int $paged Current page of results, if requested. * @type string $post_type Type of post to return. * @type string $s Search query. * @type array $search_columns Which post table columns to query. @@ -116,14 +124,53 @@ function _wp_ajax_menu_quick_search( $request = array() ) { $args = array_merge( $args, (array) $post_type_obj->_default_query ); } + if ( $is_paginated ) { + $posts_per_page = (int) ( $args['posts_per_page'] ?? 10 ); + + // Query one extra post to determine whether another page exists. + if ( 0 < $posts_per_page ) { + $args['offset'] = $posts_per_page * ( $paged - 1 ); + $args['posts_per_page'] = $posts_per_page + 1; + } + + $args['_wp_menu_quick_search'] = true; + $orderby_filter = static function ( $orderby, $query ) { + global $wpdb; + + if ( ! $query->get( '_wp_menu_quick_search' ) ) { + return $orderby; + } + + $order = 'ASC' === strtoupper( $query->get( 'order' ) ) ? 'ASC' : 'DESC'; + + return $orderby ? "$orderby, {$wpdb->posts}.ID $order" : "{$wpdb->posts}.ID $order"; + }; + add_filter( 'posts_orderby', $orderby_filter, 10, 2 ); + } + $search_results_query = new WP_Query( $args ); - if ( ! $search_results_query->have_posts() ) { - return; + + if ( $is_paginated ) { + remove_filter( 'posts_orderby', $orderby_filter ); + } + + $posts = $search_results_query->posts; + $has_more = false; + + if ( $is_paginated && 0 < $posts_per_page ) { + $has_more = count( $posts ) > $posts_per_page; + $posts = array_slice( $posts, 0, $posts_per_page ); + } + + if ( $is_paginated && ! headers_sent() ) { + header( 'X-WP-Menu-Quick-Search-Has-More: ' . (int) $has_more ); } - while ( $search_results_query->have_posts() ) { - $post = $search_results_query->next_post(); + if ( empty( $posts ) ) { + return; + } + foreach ( $posts as $post ) { if ( 'markup' === $response_format ) { $var_by_ref = $post->ID; echo walk_nav_menu_tree( @@ -143,15 +190,48 @@ function _wp_ajax_menu_quick_search( $request = array() ) { } } } elseif ( 'taxonomy' === $matches[1] ) { - $terms = get_terms( - array( - 'taxonomy' => $matches[2], - 'name__like' => $query, - 'number' => 10, - 'hide_empty' => false, - ) + $terms_per_page = 10; + $term_query_args = array( + 'taxonomy' => $matches[2], + 'name__like' => $query, + 'number' => $terms_per_page, + 'hide_empty' => false, ); + if ( $is_paginated ) { + // Query one extra term to determine whether another page exists. + $term_query_args['number'] = $terms_per_page + 1; + $term_query_args['offset'] = $terms_per_page * ( $paged - 1 ); + $term_query_args['_wp_menu_quick_search'] = true; + $get_terms_orderby_filter = static function ( $orderby, $args ) { + if ( empty( $args['_wp_menu_quick_search'] ) ) { + return $orderby; + } + + $order = 'DESC' === strtoupper( $args['order'] ) ? 'DESC' : 'ASC'; + + return $orderby ? "$orderby $order, t.term_id" : 't.term_id'; + }; + add_filter( 'get_terms_orderby', $get_terms_orderby_filter, 10, 2 ); + } + + $terms = get_terms( $term_query_args ); + + if ( $is_paginated ) { + remove_filter( 'get_terms_orderby', $get_terms_orderby_filter ); + } + + $has_more = false; + + if ( $is_paginated && ! is_wp_error( $terms ) ) { + $has_more = count( $terms ) > $terms_per_page; + $terms = array_slice( $terms, 0, $terms_per_page ); + } + + if ( $is_paginated && ! headers_sent() ) { + header( 'X-WP-Menu-Quick-Search-Has-More: ' . (int) $has_more ); + } + if ( empty( $terms ) || is_wp_error( $terms ) ) { return; } @@ -737,6 +817,12 @@ class="categorychecklist form-no-clear"

  • +
    " @@ -1113,6 +1199,12 @@ class="categorychecklist form-no-clear"
  • +

    "> diff --git a/tests/e2e/specs/nav-menu-quick-search.test.js b/tests/e2e/specs/nav-menu-quick-search.test.js new file mode 100644 index 0000000000000..877512e7698c8 --- /dev/null +++ b/tests/e2e/specs/nav-menu-quick-search.test.js @@ -0,0 +1,70 @@ +/** + * WordPress dependencies + */ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +test.describe( 'Navigation menu quick search', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts( 'pages' ); + + const titles = [ + ...Array.from( { length: 10 }, ( value, index ) => `Ticket 38224 exact ten ${ index }` ), + ...Array.from( { length: 11 }, ( value, index ) => `Ticket 38224 eleven ${ index }` ), + ]; + + await Promise.all( + titles.map( ( title ) => + requestUtils.rest( { + method: 'POST', + path: '/wp/v2/pages', + data: { title, status: 'publish' }, + } ) + ) + ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deleteAllPosts( 'pages' ); + } ); + + test( 'returns pagination metadata', async ( { admin, page } ) => { + await admin.visitAdminPage( '/' ); + + const search = ( query, paged = 1 ) => + page.evaluate( + async ( request ) => { + const response = await fetch( window.ajaxurl, { + method: 'POST', + body: new URLSearchParams( { + action: 'menu-quick-search', + 'response-format': 'markup', + type: 'quick-search-posttype-page', + q: request.query, + paged: request.paged, + } ), + } ); + const markup = await response.text(); + const document = new DOMParser().parseFromString( `

    `, 'text/html' ); + + return { + hasMore: response.headers.get( 'X-WP-Menu-Quick-Search-Has-More' ), + results: document.querySelectorAll( 'li' ).length, + }; + }, + { query, paged: paged.toString() } + ); + + await expect( search( 'Ticket 38224 exact ten' ) ).resolves.toEqual( { + hasMore: '0', + results: 10, + } ); + await expect( search( 'Ticket 38224 eleven' ) ).resolves.toEqual( { + hasMore: '1', + results: 10, + } ); + await expect( search( 'Ticket 38224 eleven', 2 ) ).resolves.toEqual( { + hasMore: '0', + results: 1, + } ); + } ); +} ); diff --git a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php index 8cdcb2e04aefa..b5ae7b6d72501 100644 --- a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php +++ b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php @@ -42,6 +42,103 @@ public function test_search_returns_results_for_pages() { $this->assertCount( 3, $results ); } + /** + * Test that post search results can be paginated. + * + * @ticket 38224 + */ + public function test_search_paginates_post_results() { + require_once ABSPATH . 'wp-admin/includes/nav-menu.php'; + + $post_ids = self::factory()->post->create_many( + 21, + array( + 'post_type' => 'page', + 'post_status' => 'publish', + 'post_title' => 'Ticket 38224 post result', + ) + ); + + $request = array( + 'type' => 'quick-search-posttype-page', + 'q' => 'Ticket 38224 post result', + 'posts_per_page' => 1, + 'response-format' => 'json', + ); + $pages = array(); + + foreach ( array( 1, 2, 3 ) as $paged ) { + $request['paged'] = $paged; + $output = get_echo( '_wp_ajax_menu_quick_search', array( $request ) ); + $results = array_map( 'json_decode', explode( "\n", trim( $output ) ) ); + $pages[ $paged ] = wp_list_pluck( $results, 'ID' ); + } + + $this->assertCount( 10, $pages[1] ); + $this->assertCount( 10, $pages[2] ); + $this->assertCount( 1, $pages[3] ); + $this->assertSame( array_reverse( $post_ids ), array_merge( $pages[1], $pages[2], $pages[3] ) ); + + $request['paged'] = 0; + $output = get_echo( '_wp_ajax_menu_quick_search', array( $request ) ); + $results = array_map( 'json_decode', explode( "\n", trim( $output ) ) ); + $this->assertSame( $pages[1], wp_list_pluck( $results, 'ID' ) ); + + unset( $request['paged'] ); + $output = get_echo( '_wp_ajax_menu_quick_search', array( $request ) ); + $results = array_map( 'json_decode', explode( "\n", trim( $output ) ) ); + $this->assertCount( 10, $results ); + } + + /** + * Test that taxonomy search results can be paginated. + * + * @ticket 38224 + */ + public function test_search_paginates_taxonomy_results() { + register_taxonomy( 'wptests_tax_38224', 'post' ); + + $term_ids = array(); + for ( $i = 1; $i <= 21; $i++ ) { + $term_ids[] = self::factory()->term->create( + array( + 'taxonomy' => 'wptests_tax_38224', + 'name' => sprintf( 'Ticket 38224 term %02d', $i ), + ) + ); + } + + $request = array( + 'type' => 'quick-search-taxonomy-wptests_tax_38224', + 'q' => 'Ticket 38224 term', + 'response-format' => 'json', + ); + $pages = array(); + + foreach ( array( 1, 2, 3 ) as $paged ) { + $request['paged'] = $paged; + $output = get_echo( '_wp_ajax_menu_quick_search', array( $request ) ); + $results = array_map( 'json_decode', explode( "\n", trim( $output ) ) ); + $pages[ $paged ] = wp_list_pluck( $results, 'ID' ); + } + + $this->assertCount( 10, $pages[1] ); + $this->assertCount( 10, $pages[2] ); + $this->assertCount( 1, $pages[3] ); + $this->assertSame( $term_ids, array_merge( $pages[1], $pages[2], $pages[3] ) ); + + $terms_filter = static function () use ( $term_ids ) { + return array_map( 'get_term', $term_ids ); + }; + add_filter( 'get_terms', $terms_filter ); + unset( $request['paged'] ); + $output = get_echo( '_wp_ajax_menu_quick_search', array( $request ) ); + remove_filter( 'get_terms', $terms_filter ); + + $results = array_map( 'json_decode', explode( "\n", trim( $output ) ) ); + $this->assertCount( 21, $results, 'Unpaginated responses should preserve filtered results.' ); + } + /** * Test that search only returns results for posts with term in title. * diff --git a/tests/qunit/wp-admin/js/nav-menu.js b/tests/qunit/wp-admin/js/nav-menu.js index c630d078297de..644cde04f79a5 100644 --- a/tests/qunit/wp-admin/js/nav-menu.js +++ b/tests/qunit/wp-admin/js/nav-menu.js @@ -1,4 +1,4 @@ -/*global wpNavMenu */ +/*global wpNavMenu, wp */ ( function( QUnit, $ ) { QUnit.module( 'nav-menu' ); var assert, @@ -92,5 +92,118 @@ } ); + QUnit.test( 'Quick search appends pages and retries failed requests.', function( assert ) { + var originalPost = $.post, + originalSpeak = wp.a11y.speak, + originalAjaxurl = window.ajaxurl, + requests = [], + messages = [], + fixture = $( '#qunit-fixture' ), + clock = this.clock, + panel, input, checklist, loadMore, state; + + fixture.html( + '' + ); + + panel = fixture.find( '.tabs-panel' ); + input = panel.find( '.quick-search' ); + checklist = panel.find( '.categorychecklist' ); + loadMore = panel.find( '.quick-search-load-more' ); + + wp.a11y.speak = function( message ) { + messages.push( message ); + }; + window.ajaxurl = '/'; + $.post = function( url, params ) { + var request = $.Deferred(); + + request.params = params; + request.hasMore = '0'; + request.getResponseHeader = function() { + return request.hasMore; + }; + request.abort = function() { + request.aborted = true; + }; + requests.push( request ); + return request; + }; + + wpNavMenu.attachQuickSearchListeners(); + wpNavMenu.updateQuickSearchResults( input ); + assert.strictEqual( loadMore.attr( 'aria-disabled' ), 'true', 'Loading state is exposed to assistive technology.' ); + requests[0].reject( requests[0], 'error' ); + + assert.notOk( loadMore.attr( 'aria-disabled' ), 'Loading state is removed after the request.' ); + assert.notOk( loadMore.prop( 'hidden' ), 'Retry is shown after an initial failure.' ); + assert.strictEqual( loadMore.text(), 'Try again', 'The retry control has an accurate label.' ); + + loadMore.trigger( 'focus' ).trigger( 'click' ); + requests[1].resolve( '
  • ', 'success', requests[1] ); + assert.strictEqual( panel.data( 'quick-search-state' ).page, 1, 'Direct calls initialize search state.' ); + assert.strictEqual( document.activeElement, checklist.find( 'input' ).get( 0 ), 'Retry success repairs focus.' ); + + input.val( 'active term' ); + wpNavMenu.updateQuickSearchResults( input ); + input.val( 'x' ); + wpNavMenu.updateQuickSearchResults( input ); + assert.ok( requests[2].aborted, 'A direct short query aborts its request.' ); + assert.notOk( panel.find( '.spinner' ).hasClass( 'is-active' ), 'A direct short query clears the spinner.' ); + + input.val( 'old term' ).trigger( 'input' ); + clock.tick( 500 ); + input.val( 'term' ).trigger( 'input' ); + clock.tick( 500 ); + + state = panel.data( 'quick-search-state' ); + assert.ok( requests[3].aborted, 'Changing the query aborts its request.' ); + + requests[4].hasMore = '1'; + requests[4].resolve( '
  • ', 'success', requests[4] ); + requests[3].resolve( '
  • ', 'success', requests[3] ); + + assert.strictEqual( checklist.find( 'input' ).val(), '1', 'A stale response is ignored.' ); + assert.strictEqual( state.page, 1, 'The first page is recorded after success.' ); + assert.notOk( loadMore.prop( 'hidden' ), 'Load more is shown when another page exists.' ); + assert.strictEqual( messages[3], '1 search result found.', 'The initial results are announced.' ); + + wpNavMenu.updateQuickSearchResults( input, true ); + wpNavMenu.updateQuickSearchResults( input, true ); + assert.strictEqual( requests.length, 6, 'A second request is blocked while a page is loading.' ); + requests[5].reject( requests[5], 'error' ); + + assert.strictEqual( state.page, 1, 'A failed request does not advance the page.' ); + assert.notOk( loadMore.prop( 'hidden' ), 'Load more remains available after a failure.' ); + assert.strictEqual( checklist.children().length, 1, 'A failed request preserves existing results.' ); + + loadMore.trigger( 'focus' ); + wpNavMenu.updateQuickSearchResults( input, true ); + requests[6].resolve( + '
  • ', + 'success', + requests[6] + ); + + assert.strictEqual( requests[6].params.paged, 2, 'A retry requests the same page.' ); + assert.strictEqual( checklist.children().length, 3, 'The next page is appended.' ); + assert.ok( loadMore.prop( 'hidden' ), 'Load more is hidden after the final page.' ); + assert.strictEqual( document.activeElement, checklist.find( 'input' ).eq( 1 ).get( 0 ), 'Focus moves to the first new result.' ); + assert.strictEqual( messages.pop(), '2 additional search results loaded.', 'The added results are announced.' ); + + $.post = originalPost; + wp.a11y.speak = originalSpeak; + window.ajaxurl = originalAjaxurl; + } ); } )( window.QUnit, jQuery ); From faf69f1e82cd508673b999170767de9140c37ed5 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 30 Jul 2026 11:05:22 +0530 Subject: [PATCH 2/4] test(menus): load nav menu helpers explicitly --- tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php index b5ae7b6d72501..35ffe2bb81db6 100644 --- a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php +++ b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php @@ -96,6 +96,8 @@ public function test_search_paginates_post_results() { * @ticket 38224 */ public function test_search_paginates_taxonomy_results() { + require_once ABSPATH . 'wp-admin/includes/nav-menu.php'; + register_taxonomy( 'wptests_tax_38224', 'post' ); $term_ids = array(); From 6abc2dd54e4b2885d7241d2b2f54a30fd8191e79 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 30 Jul 2026 11:23:09 +0530 Subject: [PATCH 3/4] test(menus): clarify server-defined page limit --- tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php index 35ffe2bb81db6..4b7c82227806f 100644 --- a/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php +++ b/tests/phpunit/tests/menu/wpAjaxMenuQuickSearch.php @@ -59,6 +59,7 @@ public function test_search_paginates_post_results() { ) ); + // Request-controlled page sizes should not alter the server-defined limit. $request = array( 'type' => 'quick-search-posttype-page', 'q' => 'Ticket 38224 post result', @@ -74,7 +75,7 @@ public function test_search_paginates_post_results() { $pages[ $paged ] = wp_list_pluck( $results, 'ID' ); } - $this->assertCount( 10, $pages[1] ); + $this->assertCount( 10, $pages[1], 'Request-controlled page sizes should be ignored.' ); $this->assertCount( 10, $pages[2] ); $this->assertCount( 1, $pages[3] ); $this->assertSame( array_reverse( $post_ids ), array_merge( $pages[1], $pages[2], $pages[3] ) ); From d8a903ed59db8a286ca95d0f8e48b97ac68be6f3 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 30 Jul 2026 12:37:40 +0530 Subject: [PATCH 4/4] fix(menus): stabilize quick search pagination --- src/js/_enqueues/lib/nav-menu.js | 9 ++--- tests/e2e/specs/nav-menu-quick-search.test.js | 4 +-- tests/qunit/wp-admin/js/nav-menu.js | 33 +++++++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/js/_enqueues/lib/nav-menu.js b/src/js/_enqueues/lib/nav-menu.js index 4e9b1cd368753..ea2561b403bfb 100644 --- a/src/js/_enqueues/lib/nav-menu.js +++ b/src/js/_enqueues/lib/nav-menu.js @@ -1512,7 +1512,10 @@ .addClass( 'disabled' ) .text( loadMore.data( 'load-more-text' ) ); - request = $.post( ajaxurl, params ).done( function( menuMarkup, textStatus, response ) { + request = $.post( ajaxurl, params ); + state.request = request; + + request.done( function( menuMarkup, textStatus, response ) { var added; if ( panel.data( 'quick-search-state' ) !== state || state.request !== request ) { @@ -1547,8 +1550,6 @@ loadMore.removeAttr( 'aria-disabled' ).removeClass( 'disabled' ); } }); - - state.request = request; }, addCustomLink : function( processMethod ) { @@ -1880,7 +1881,7 @@ var matched, newID, message, takenIDs = {}, form = document.getElementById( 'nav-menu-meta' ), - pattern = /menu-item[(\[^]\]*/, + pattern = /menu-item\[(-?\d+)\]/, $items = $( '
    ' ).html( resp ).find( 'li' ), wrapper = panel.closest( '.accordion-section-content' ), checklist = $( '.categorychecklist', panel ), diff --git a/tests/e2e/specs/nav-menu-quick-search.test.js b/tests/e2e/specs/nav-menu-quick-search.test.js index 877512e7698c8..f7eea2a8562c8 100644 --- a/tests/e2e/specs/nav-menu-quick-search.test.js +++ b/tests/e2e/specs/nav-menu-quick-search.test.js @@ -8,8 +8,8 @@ test.describe( 'Navigation menu quick search', () => { await requestUtils.deleteAllPosts( 'pages' ); const titles = [ - ...Array.from( { length: 10 }, ( value, index ) => `Ticket 38224 exact ten ${ index }` ), - ...Array.from( { length: 11 }, ( value, index ) => `Ticket 38224 eleven ${ index }` ), + ...Array.from( { length: 10 }, ( _value, index ) => `Ticket 38224 exact ten ${ index }` ), + ...Array.from( { length: 11 }, ( _value, index ) => `Ticket 38224 eleven ${ index }` ), ]; await Promise.all( diff --git a/tests/qunit/wp-admin/js/nav-menu.js b/tests/qunit/wp-admin/js/nav-menu.js index 644cde04f79a5..8bbdadca0ef94 100644 --- a/tests/qunit/wp-admin/js/nav-menu.js +++ b/tests/qunit/wp-admin/js/nav-menu.js @@ -170,7 +170,7 @@ assert.ok( requests[3].aborted, 'Changing the query aborts its request.' ); requests[4].hasMore = '1'; - requests[4].resolve( '
  • ', 'success', requests[4] ); + requests[4].resolve( '
  • ', 'success', requests[4] ); requests[3].resolve( '
  • ', 'success', requests[3] ); assert.strictEqual( checklist.find( 'input' ).val(), '1', 'A stale response is ignored.' ); @@ -190,17 +190,46 @@ loadMore.trigger( 'focus' ); wpNavMenu.updateQuickSearchResults( input, true ); requests[6].resolve( - '
  • ', + '
  • ' + + '
  • ', 'success', requests[6] ); assert.strictEqual( requests[6].params.paged, 2, 'A retry requests the same page.' ); assert.strictEqual( checklist.children().length, 3, 'The next page is appended.' ); + assert.deepEqual( + checklist.find( 'input' ).map( function() { + return this.name; + } ).get(), + [ + 'menu-item[-1][menu-item-type]', + 'menu-item[-2][menu-item-type]', + 'menu-item[-3][menu-item-type]' + ], + 'Appended results have unique menu item IDs.' + ); assert.ok( loadMore.prop( 'hidden' ), 'Load more is hidden after the final page.' ); assert.strictEqual( document.activeElement, checklist.find( 'input' ).eq( 1 ).get( 0 ), 'Focus moves to the first new result.' ); assert.strictEqual( messages.pop(), '2 additional search results loaded.', 'The added results are announced.' ); + $.post = function() { + var request = $.Deferred(); + + request.getResponseHeader = function() { + return '0'; + }; + request.resolve( '
  • ', 'success', request ); + return request; + }; + input.val( 'synchronous term' ); + wpNavMenu.updateQuickSearchResults( input ); + state = panel.data( 'quick-search-state' ); + + assert.strictEqual( checklist.find( 'input' ).val(), 'synchronous', 'A synchronous response is processed.' ); + assert.notOk( state.loading, 'A synchronous response clears the loading state.' ); + assert.strictEqual( state.request, null, 'A completed synchronous request is not retained.' ); + $.post = originalPost; wp.a11y.speak = originalSpeak; window.ajaxurl = originalAjaxurl;