Commit d0196346674 for woocommerce
commit d0196346674b7c0daa248f5869596838a97c91d5
Author: Tom Cafferkey <tjcafferkey@gmail.com>
Date: Fri Oct 23 15:38:59 2026 +0100
Paginate order notes (#70012)
* Add opt-in pagination for order notes APIs
* Add changelog entry for order notes API pagination
* UI pagination
* Remove stale PHPStan baseline entries for order notes
* Remove second changelog
* Update plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* move test
* Limit AJAX order note responses to the first page
* margin right
* Make order item AJAX notes pagination opt-in
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/add-order-notes-pagination-ui b/plugins/woocommerce/changelog/add-order-notes-pagination-ui
new file mode 100644
index 00000000000..c19c5b272fc
--- /dev/null
+++ b/plugins/woocommerce/changelog/add-order-notes-pagination-ui
@@ -0,0 +1,4 @@
+Significance: minor
+Type: enhancement
+
+Browse order notes ten at a time with page navigation in the order editor.
diff --git a/plugins/woocommerce/client/legacy/css/admin.scss b/plugins/woocommerce/client/legacy/css/admin.scss
index 5a60cd3b9b4..5d287d21f69 100644
--- a/plugins/woocommerce/client/legacy/css/admin.scss
+++ b/plugins/woocommerce/client/legacy/css/admin.scss
@@ -2315,7 +2315,7 @@ body.woocommerce_page_wc-customer-stock-notifications {
margin: 0;
padding: 0;
- button {
+ button:not([data-page-action]) {
margin: 1px;
vertical-align: top;
}
@@ -3082,6 +3082,82 @@ ul.order_notes {
}
}
+.order-notes-pagination {
+ border-top: 1px solid $gray-200;
+ padding: 12px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 4px;
+ color: $subtext;
+ font-size: 11px;
+
+ &[hidden] {
+ display: none;
+ }
+
+ .order-notes-page-controls {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ }
+
+ button {
+ background: none;
+ border: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0;
+ margin: 0;
+ width: 24px;
+ height: 28px;
+ color: $gray-900;
+ cursor: pointer;
+
+ &:disabled {
+ color: $gray-400;
+ cursor: default;
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--wp-admin-theme-color, #2271b1);
+ outline-offset: 1px;
+ }
+ }
+
+ #order-notes-page {
+ width: 30px;
+ min-height: 28px;
+ padding: 0 2px;
+ margin: 0;
+ text-align: center;
+ appearance: textfield;
+ margin-right: 2px;
+
+ &::-webkit-inner-spin-button,
+ &::-webkit-outer-spin-button {
+ appearance: none;
+ margin: 0;
+ }
+ }
+
+ .order-notes-total-pages {
+ white-space: nowrap;
+ }
+
+ &:dir(rtl) .dashicons,
+ &:dir(rtl) svg {
+ transform: scaleX(-1);
+ }
+}
+
+.order-notes-error {
+ padding: 0 12px 12px;
+ color: $red;
+}
+
.add_note {
border-bottom: 1px solid $gray-200;
padding: 16px 12px;
@@ -8260,7 +8336,7 @@ table.bar_chart {
clear: both;
}
- #poststuff #woocommerce-order-notes .inside button {
+ #poststuff #woocommerce-order-notes .inside button:not([data-page-action]) {
margin: 0 1px 1px;
}
@@ -8282,7 +8358,7 @@ table.bar_chart {
}
}
- #poststuff #woocommerce-order-notes .inside button {
+ #poststuff #woocommerce-order-notes .inside button:not([data-page-action]) {
line-height: 40px;
}
}
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
index 0d6d7daa515..197b087b2ce 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-order.js
@@ -593,10 +593,7 @@ jQuery( function ( $ ) {
$( '#woocommerce-order-items' ).find( '.inside' ).append( response.data.html );
// Update notes.
- const notesEl = document.querySelector( '#woocommerce-order-notes ul.order_notes' );
- if ( notesEl && response.data && typeof response.data.notes_html === 'string' ) {
- notesEl.outerHTML = response.data.notes_html;
- }
+ wc_meta_boxes_order_notes.load_notes( 1 );
wc_meta_boxes_order_items.reloaded_items();
wc_meta_boxes_order_items.unblock();
@@ -622,6 +619,7 @@ jQuery( function ( $ ) {
var data = $.extend( {}, wc_meta_boxes_order_items.get_taxable_address(), {
action : 'woocommerce_remove_order_coupon',
+ paginate_notes: true,
dataType : 'json',
order_id : woocommerce_admin_meta_boxes.post_id,
security : woocommerce_admin_meta_boxes.order_item_nonce,
@@ -637,8 +635,7 @@ jQuery( function ( $ ) {
// Update notes.
if ( response.data.notes_html ) {
- $( 'ul.order_notes' ).empty();
- $( 'ul.order_notes' ).append( $( response.data.notes_html ).find( 'li' ) );
+ wc_meta_boxes_order_notes.load_notes( 1 );
}
wc_meta_boxes_order_items.reloaded_items();
@@ -855,6 +852,7 @@ jQuery( function ( $ ) {
order_id : woocommerce_admin_meta_boxes.post_id,
order_item_ids: order_item_id,
action : 'woocommerce_remove_order_item',
+ paginate_notes: true,
security : woocommerce_admin_meta_boxes.order_item_nonce
} );
@@ -875,10 +873,7 @@ jQuery( function ( $ ) {
$( '#woocommerce-order-items' ).find( '.inside' ).append( response.data.html );
// Update notes.
- const notesEl = document.querySelector( '#woocommerce-order-notes ul.order_notes' );
- if ( notesEl && response.data && typeof response.data.notes_html === 'string' ) {
- notesEl.outerHTML = response.data.notes_html;
- }
+ wc_meta_boxes_order_notes.load_notes( 1 );
wc_meta_boxes_order_items.reloaded_items();
wc_meta_boxes_order_items.unblock();
@@ -1027,6 +1022,7 @@ jQuery( function ( $ ) {
order_id: woocommerce_admin_meta_boxes.post_id,
items: $( 'table.woocommerce_order_items :input[name], .wc-order-totals-items :input[name]' ).serialize(),
action: 'woocommerce_save_order_items',
+ paginate_notes: true,
security: woocommerce_admin_meta_boxes.order_item_nonce
};
@@ -1045,8 +1041,7 @@ jQuery( function ( $ ) {
// Update notes.
if ( response.data.notes_html ) {
- $( 'ul.order_notes' ).empty();
- $( 'ul.order_notes' ).append( $( response.data.notes_html ).find( 'li' ) );
+ wc_meta_boxes_order_notes.load_notes( 1 );
}
wc_meta_boxes_order_items.reloaded_items();
@@ -1776,6 +1771,7 @@ jQuery( function ( $ ) {
var data = $.extend( {}, wc_meta_boxes_order_items.get_taxable_address(), {
action : 'woocommerce_add_order_item',
+ paginate_notes: true,
order_id : woocommerce_admin_meta_boxes.post_id,
security : woocommerce_admin_meta_boxes.order_item_nonce,
data : add_items
@@ -1798,10 +1794,7 @@ jQuery( function ( $ ) {
$( '#woocommerce-order-items' ).find( '.inside' ).append( response.data.html );
// Update notes.
- const notesEl = document.querySelector( '#woocommerce-order-notes ul.order_notes' );
- if ( notesEl && response.data && typeof response.data.notes_html === 'string' ) {
- notesEl.outerHTML = response.data.notes_html;
- }
+ wc_meta_boxes_order_notes.load_notes( 1 );
wc_meta_boxes_order_items.reloaded_items();
wc_meta_boxes_order_items.unblock();
@@ -1894,6 +1887,9 @@ jQuery( function ( $ ) {
* Order Notes Panel
*/
var wc_meta_boxes_order_notes = {
+ loading: false,
+ pending_page: null,
+
init: function() {
$( '#woocommerce-order-notes' )
.on( 'click', 'button.add_note', this.add_order_note )
@@ -1902,6 +1898,128 @@ jQuery( function ( $ ) {
// Sync the CTA + helper link to reflect the current select state on load.
$( 'select#order_note_type' ).trigger( 'change' );
+
+ var panel = document.getElementById( 'woocommerce-order-notes' );
+ if ( ! panel || panel.dataset.paginationInitialized ) {
+ return;
+ }
+ panel.dataset.paginationInitialized = 'true';
+
+ panel.addEventListener( 'click', function( event ) {
+ if ( event.target.closest( '.order-notes-retry' ) ) {
+ wc_meta_boxes_order_notes.load_notes( Number( panel.querySelector( '.order-notes-error' ).dataset.page ) );
+ return;
+ }
+ var button = event.target.closest( '[data-page-action]' );
+ if ( ! button || button.disabled ) {
+ return;
+ }
+ var pagination = button.closest( '.order-notes-pagination' );
+ var currentPage = Number( pagination.dataset.page );
+ var pages = {
+ first: 1,
+ previous: currentPage - 1,
+ next: currentPage + 1,
+ last: Number( pagination.dataset.totalPages )
+ };
+ wc_meta_boxes_order_notes.load_notes( pages[ button.dataset.pageAction ] );
+ } );
+
+ panel.addEventListener( 'change', function( event ) {
+ if ( event.target.id === 'order-notes-page' ) {
+ wc_meta_boxes_order_notes.change_page( event.target );
+ }
+ } );
+ panel.addEventListener( 'keydown', function( event ) {
+ if ( event.target.id === 'order-notes-page' && event.key === 'Enter' ) {
+ event.preventDefault();
+ wc_meta_boxes_order_notes.change_page( event.target );
+ }
+ } );
+ },
+
+ change_page: function( input ) {
+ var pagination = input.closest( '.order-notes-pagination' );
+ var currentPage = Number( pagination.dataset.page );
+ var requestedPage = Number( input.value );
+ var page = Number.isFinite( requestedPage )
+ ? Math.max( 1, Math.min( Math.trunc( requestedPage ), Number( input.max ) ) )
+ : currentPage;
+ input.value = page;
+ if ( page !== currentPage ) {
+ wc_meta_boxes_order_notes.load_notes( page );
+ }
+ },
+
+ load_notes: function( page ) {
+ var panel = document.getElementById( 'woocommerce-order-notes' );
+ var list = panel && panel.querySelector( '.order-notes-list' );
+ if ( ! list ) {
+ return;
+ }
+ if ( wc_meta_boxes_order_notes.loading ) {
+ wc_meta_boxes_order_notes.pending_page = page || Number( panel.querySelector( '.order-notes-pagination' ).dataset.page );
+ return;
+ }
+
+ var pagination = panel.querySelector( '.order-notes-pagination' );
+ var activeElement = document.activeElement;
+ var restoreFocus = !! activeElement && pagination.contains( activeElement );
+ var pageAction = activeElement && activeElement.dataset ? activeElement.dataset.pageAction : undefined;
+ var error = panel.querySelector( '.order-notes-error' );
+ var requestedPage = page || Number( pagination.dataset.page );
+ var show_error = function() {
+ error.dataset.page = requestedPage;
+ error.hidden = false;
+ panel.querySelector( '#order-notes-page' ).value = pagination.dataset.page;
+ };
+ error.hidden = true;
+ list.setAttribute( 'aria-busy', 'true' );
+ wc_meta_boxes_order_notes.loading = true;
+ $( panel ).block( {
+ message: null,
+ overlayCSS: { background: '#fff', opacity: 0.6 }
+ } );
+
+ $.ajax( {
+ url: woocommerce_admin_meta_boxes.ajax_url,
+ type: 'POST',
+ dataType: 'json',
+ data: {
+ action: 'woocommerce_get_order_notes',
+ order_id: woocommerce_admin_meta_boxes.post_id,
+ page: requestedPage,
+ security: woocommerce_admin_meta_boxes.get_order_notes_nonce
+ },
+ success: function( response ) {
+ if ( ! response || ! response.success || ! response.data || typeof response.data.html !== 'string' ) {
+ show_error();
+ return;
+ }
+ list.innerHTML = response.data.html;
+ if ( window.wp && window.wp.a11y && typeof window.wp.a11y.speak === 'function' ) {
+ var announcement = list.querySelector( '.order-notes-page-announcement' ).textContent.trim();
+ var count = list.querySelector( '.order-notes-count' ).textContent.trim();
+ window.wp.a11y.speak( announcement + ' ' + count, 'polite' );
+ }
+ },
+ error: show_error,
+ complete: function() {
+ wc_meta_boxes_order_notes.loading = false;
+ list.removeAttribute( 'aria-busy' );
+ $( panel ).unblock();
+ if ( restoreFocus && ( document.activeElement === document.body || document.activeElement === activeElement ) ) {
+ var button = pageAction && list.querySelector( '[data-page-action="' + pageAction + '"]' );
+ var target = button && ! button.disabled ? button : list.querySelector( '#order-notes-page' );
+ target.focus();
+ }
+ if ( wc_meta_boxes_order_notes.pending_page !== null ) {
+ var nextPage = wc_meta_boxes_order_notes.pending_page;
+ wc_meta_boxes_order_notes.pending_page = null;
+ wc_meta_boxes_order_notes.load_notes( nextPage );
+ }
+ }
+ } );
},
update_note_type_ui: function() {
@@ -1939,9 +2057,11 @@ jQuery( function ( $ ) {
};
$.post( woocommerce_admin_meta_boxes.ajax_url, data, function( response ) {
- $( 'ul.order_notes .no-items' ).remove();
- $( 'ul.order_notes' ).prepend( response );
- $( '#woocommerce-order-notes' ).unblock();
+ if ( ! response || '-1' === response.trim() ) {
+ $( '#woocommerce-order-notes' ).unblock();
+ return;
+ }
+ wc_meta_boxes_order_notes.load_notes( 1 );
$( '#add_order_note' ).val( '' );
// Announce the result to screen readers (WCAG 4.1.3).
@@ -1959,7 +2079,9 @@ jQuery( function ( $ ) {
note_type: data.note_type || 'private',
status: $( '#order_status' ).val()
} );
- });
+ }).fail( function() {
+ $( '#woocommerce-order-notes' ).unblock();
+ } );
return false;
},
@@ -1986,7 +2108,11 @@ jQuery( function ( $ ) {
security: woocommerce_admin_meta_boxes.delete_order_note_nonce
};
- $.post( woocommerce_admin_meta_boxes.ajax_url, data, function() {
+ $.post( woocommerce_admin_meta_boxes.ajax_url, data, function( response ) {
+ $( note ).unblock();
+ if ( '-1' === String( response ).trim() ) {
+ return;
+ }
if ( window.wcTracks && window.wcTracks.recordEvent ) {
var noteType = note.hasClass( 'customer-note' ) ? 'customer' : 'private';
var dateStr = note.find( '.exact-date' ).attr( 'title' );
@@ -2001,12 +2127,10 @@ jQuery( function ( $ ) {
seconds_since_added: seconds
} );
}
- $( note ).remove();
- var $list = $( 'ul.order_notes' );
- if ( 0 === $list.find( 'li.note' ).length ) {
- $list.append( $( '<li class="no-items"></li>' ).text( woocommerce_admin_meta_boxes.i18n_no_notes_yet ) );
- }
- });
+ wc_meta_boxes_order_notes.load_notes();
+ }).fail( function() {
+ $( note ).unblock();
+ } );
}
return false;
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index c9ac1862db5..39accd8a333 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -700,6 +700,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
'grant_access_nonce' => wp_create_nonce( 'grant-access' ),
'revoke_access_nonce' => wp_create_nonce( 'revoke-access' ),
'add_order_note_nonce' => wp_create_nonce( 'add-order-note' ),
+ 'get_order_notes_nonce' => wp_create_nonce( 'get-order-notes' ),
'delete_order_note_nonce' => wp_create_nonce( 'delete-order-note' ),
'calendar_image' => WC()->plugin_url() . '/assets/images/calendar.png',
'post_id' => $this->is_order_meta_box_screen( $screen_id ) && isset( $order_or_post_object ) ? \Automattic\WooCommerce\Utilities\OrderUtil::get_post_or_order_id( $order_or_post_object ) : $post_id,
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-notes.php b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-notes.php
index e4ba9f52e00..a28d19688dd 100644
--- a/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-notes.php
+++ b/plugins/woocommerce/includes/admin/meta-boxes/class-wc-meta-box-order-notes.php
@@ -26,13 +26,8 @@ class WC_Meta_Box_Order_Notes {
$order_id = $post->ID;
}
- $args = array( 'order_id' => $order_id );
-
- if ( 0 !== $order_id ) {
- $notes = wc_get_order_notes( $args );
- } else {
- $notes = array();
- }
+ $pagination = self::get_page( (int) $order_id );
+ $notes = $pagination['notes'];
$private_button_label = __( 'Add private note', 'woocommerce' );
$customer_button_label = __( 'Send note to customer →', 'woocommerce' );
@@ -69,7 +64,81 @@ class WC_Meta_Box_Order_Notes {
<button type="button" class="add_note button"><?php echo esc_html( $private_button_label ); ?></button>
</p>
</div>
+ <div class="order-notes-list">
+ <?php
+ include __DIR__ . '/views/html-order-notes.php';
+ include __DIR__ . '/views/html-order-notes-pagination.php';
+ ?>
+ </div>
+ <p class="order-notes-error" role="alert" hidden>
+ <?php esc_html_e( 'Could not load order notes.', 'woocommerce' ); ?>
+ <button type="button" class="button-link order-notes-retry"><?php esc_html_e( 'Try again', 'woocommerce' ); ?></button>
+ </p>
<?php
+ }
+
+ /**
+ * Get a page of notes and its pagination details.
+ *
+ * @since 11.3.0
+ * @param int $order_id Order ID.
+ * @param int $page Requested page.
+ * @return array
+ */
+ public static function get_page( int $order_id, int $page = 1 ): array {
+ $total = 0;
+ if ( $order_id > 0 ) {
+ remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10 );
+ $total = (int) get_comments(
+ array(
+ 'post_id' => $order_id,
+ 'type' => 'order_note',
+ 'status' => 'approve',
+ 'count' => true,
+ )
+ );
+
+ add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
+ }
+
+ $total_pages = max( 1, (int) ceil( $total / 10 ) );
+ $page = max( 1, min( $page, $total_pages ) );
+ $notes = $total > 0 ? wc_get_order_notes(
+ array(
+ 'order_id' => $order_id,
+ 'limit' => 10,
+ 'page' => $page,
+ )
+ ) : array();
+
+ return compact( 'notes', 'total', 'page', 'total_pages' );
+ }
+
+ /**
+ * Render a page of notes for an authenticated AJAX request.
+ *
+ * @since 11.3.0
+ */
+ public static function get_order_notes(): void {
+ check_ajax_referer( 'get-order-notes', 'security' );
+
+ $order_id = isset( $_POST['order_id'] ) && is_scalar( $_POST['order_id'] ) ? absint( wp_unslash( (string) $_POST['order_id'] ) ) : 0;
+ // phpcs:ignore WordPress.WP.Capabilities.Unknown -- WooCommerce maps this per-order meta capability.
+ if ( ! $order_id || ! current_user_can( 'edit_shop_order', $order_id ) ) {
+ wp_send_json_error( null, 403 );
+ }
+
+ if ( ! wc_get_order( $order_id ) ) {
+ wp_send_json_error( null, 404 );
+ }
+
+ $page = isset( $_POST['page'] ) && is_scalar( $_POST['page'] ) ? absint( wp_unslash( (string) $_POST['page'] ) ) : 1;
+ $pagination = self::get_page( $order_id, $page );
+ $notes = $pagination['notes'];
+
+ ob_start();
include __DIR__ . '/views/html-order-notes.php';
+ include __DIR__ . '/views/html-order-notes-pagination.php';
+ wp_send_json_success( array( 'html' => ob_get_clean() ) );
}
}
diff --git a/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-notes-pagination.php b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-notes-pagination.php
new file mode 100644
index 00000000000..db7312cec28
--- /dev/null
+++ b/plugins/woocommerce/includes/admin/meta-boxes/views/html-order-notes-pagination.php
@@ -0,0 +1,58 @@
+<?php
+/**
+ * Order notes pagination for the meta box.
+ *
+ * @package WooCommerce\Admin
+ * @var array $pagination Pagination details.
+ * @var array $notes Notes on this page.
+ */
+
+declare( strict_types = 1 );
+
+defined( 'ABSPATH' ) || exit;
+
+?>
+<nav class="order-notes-pagination" aria-label="<?php esc_attr_e( 'Order notes pagination', 'woocommerce' ); ?>" data-page="<?php echo esc_attr( $pagination['page'] ); ?>" data-total-pages="<?php echo esc_attr( $pagination['total_pages'] ); ?>" <?php echo $pagination['total'] > 10 ? '' : 'hidden'; ?>>
+ <span class="screen-reader-text order-notes-page-announcement">
+ <?php
+ printf(
+ /* translators: %1$s: current page of notes, %2$s: total number of pages of notes. */
+ esc_html__( 'Page %1$s of %2$s.', 'woocommerce' ),
+ esc_html( number_format_i18n( $pagination['page'] ) ),
+ esc_html( number_format_i18n( $pagination['total_pages'] ) )
+ );
+ ?>
+ </span>
+ <span class="order-notes-count" aria-live="polite" aria-atomic="true">
+ <?php
+ printf(
+ /* translators: %1$s: number of notes on this page, %2$s: total number of notes. */
+ esc_html__( '%1$s of %2$s notes', 'woocommerce' ),
+ esc_html( number_format_i18n( count( $notes ) ) ),
+ esc_html( number_format_i18n( $pagination['total'] ) )
+ );
+ ?>
+ </span>
+ <div class="order-notes-page-controls">
+ <button type="button" data-page-action="first" aria-label="<?php esc_attr_e( 'First page of notes', 'woocommerce' ); ?>" <?php disabled( 1, $pagination['page'] ); ?>>
+ <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m11 6-6 6 6 6m7-12-6 6 6 6" fill="none" stroke="currentColor" stroke-width="1.8" /></svg>
+ </button>
+ <button type="button" data-page-action="previous" aria-label="<?php esc_attr_e( 'Previous page of notes', 'woocommerce' ); ?>" <?php disabled( 1, $pagination['page'] ); ?>>
+ <span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>
+ </button>
+ <label class="screen-reader-text" for="order-notes-page"><?php esc_html_e( 'Current page of notes', 'woocommerce' ); ?></label>
+ <input type="number" id="order-notes-page" min="1" max="<?php echo esc_attr( $pagination['total_pages'] ); ?>" value="<?php echo esc_attr( $pagination['page'] ); ?>" inputmode="numeric" />
+ <span class="order-notes-total-pages">
+ <?php
+ /* translators: %s: total number of pages of notes. */
+ printf( esc_html__( 'of %s', 'woocommerce' ), esc_html( number_format_i18n( $pagination['total_pages'] ) ) );
+ ?>
+ </span>
+ <button type="button" data-page-action="next" aria-label="<?php esc_attr_e( 'Next page of notes', 'woocommerce' ); ?>" <?php disabled( $pagination['total_pages'], $pagination['page'] ); ?>>
+ <span class="dashicons dashicons-arrow-right-alt2" aria-hidden="true"></span>
+ </button>
+ <button type="button" data-page-action="last" aria-label="<?php esc_attr_e( 'Last page of notes', 'woocommerce' ); ?>" <?php disabled( $pagination['total_pages'], $pagination['page'] ); ?>>
+ <svg width="24" height="24" viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="m6 6 6 6-6 6m7-12 6 6-6 6" fill="none" stroke="currentColor" stroke-width="1.8" /></svg>
+ </button>
+ </div>
+</nav>
diff --git a/plugins/woocommerce/includes/class-wc-ajax.php b/plugins/woocommerce/includes/class-wc-ajax.php
index d761f4f31f3..17db983ccaf 100644
--- a/plugins/woocommerce/includes/class-wc-ajax.php
+++ b/plugins/woocommerce/includes/class-wc-ajax.php
@@ -187,6 +187,7 @@ class WC_AJAX {
'save_order_items',
'load_order_items',
'add_order_note',
+ 'get_order_notes',
'delete_order_note',
'json_search_order_metakeys',
'json_search_tax_rates',
@@ -1324,7 +1325,12 @@ class WC_AJAX {
$items_html = ob_get_clean();
ob_start();
- $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ if ( isset( $_POST['paginate_notes'] ) ) {
+ $pagination = WC_Meta_Box_Order_Notes::get_page( (int) $order_id );
+ $notes = $pagination['notes'];
+ } else {
+ $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ }
include __DIR__ . '/admin/meta-boxes/views/html-order-notes.php';
$notes_html = ob_get_clean();
@@ -1559,7 +1565,12 @@ class WC_AJAX {
$response['html'] = ob_get_clean();
ob_start();
- $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ if ( isset( $_POST['paginate_notes'] ) ) {
+ $pagination = WC_Meta_Box_Order_Notes::get_page( (int) $order_id );
+ $notes = $pagination['notes'];
+ } else {
+ $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ }
include __DIR__ . '/admin/meta-boxes/views/html-order-notes.php';
$response['notes_html'] = ob_get_clean();
@@ -1669,7 +1680,12 @@ class WC_AJAX {
$items_html = ob_get_clean();
ob_start();
- $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ if ( isset( $_POST['paginate_notes'] ) ) {
+ $pagination = WC_Meta_Box_Order_Notes::get_page( (int) $order_id );
+ $notes = $pagination['notes'];
+ } else {
+ $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ }
include __DIR__ . '/admin/meta-boxes/views/html-order-notes.php';
$notes_html = ob_get_clean();
@@ -1779,7 +1795,12 @@ class WC_AJAX {
$items_html = ob_get_clean();
ob_start();
- $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ if ( isset( $_POST['paginate_notes'] ) ) {
+ $pagination = WC_Meta_Box_Order_Notes::get_page( (int) $order_id );
+ $notes = $pagination['notes'];
+ } else {
+ $notes = wc_get_order_notes( array( 'order_id' => $order_id ) );
+ }
include __DIR__ . '/admin/meta-boxes/views/html-order-notes.php';
$notes_html = ob_get_clean();
@@ -1884,6 +1905,16 @@ class WC_AJAX {
wp_die();
}
+ /**
+ * Get a rendered page of order notes via AJAX.
+ *
+ * @since 11.3.0
+ * @return void
+ */
+ public static function get_order_notes() {
+ WC_Meta_Box_Order_Notes::get_order_notes();
+ }
+
/**
* Delete order note via ajax.
*
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
index 54ed6e36fc5..81f3d035ea1 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
@@ -63,9 +63,31 @@ class WC_REST_Order_Notes_V2_Controller extends WC_REST_Order_Notes_V1_Controlle
);
}
+ $paginate = $request->has_param( 'page' ) || $request->has_param( 'per_page' );
+ $per_page = $request['per_page'] ?? 10;
+ $total = 0;
+ if ( $paginate ) {
+ $args['number'] = $per_page;
+ $args['offset'] = ( ( $request['page'] ?? 1 ) - 1 ) * $args['number'];
+ $args['orderby'] = 'comment_ID';
+ $args['no_found_rows'] = false;
+ }
+
remove_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
- $notes = get_comments( $args );
+ if ( $paginate ) {
+ $query = new WP_Comment_Query();
+ $notes = (array) $query->query( $args );
+ $total = (int) $query->found_comments;
+
+ if ( 0 === $total ) {
+ unset( $args['number'], $args['offset'] );
+ $args['count'] = true;
+ $total = (int) ( new WP_Comment_Query() )->query( $args );
+ }
+ } else {
+ $notes = (array) get_comments( $args );
+ }
add_filter( 'comments_clauses', array( 'WC_Comments', 'exclude_order_comments' ), 10, 1 );
@@ -76,7 +98,23 @@ class WC_REST_Order_Notes_V2_Controller extends WC_REST_Order_Notes_V1_Controlle
$data[] = $order_note;
}
- return rest_ensure_response( $data );
+ $response = rest_ensure_response( $data );
+ if ( $paginate ) {
+ $page = $request['page'] ?? 1;
+ $max_pages = (int) ceil( $total / $per_page );
+ $response->header( 'X-WP-Total', (string) $total );
+ $response->header( 'X-WP-TotalPages', (string) $max_pages );
+
+ $base = add_query_arg( $request->get_query_params(), rest_url( sprintf( '%s/orders/%d/notes', $this->namespace, $order->get_id() ) ) );
+ if ( $page > 1 && $max_pages > 0 ) {
+ $response->link_header( 'prev', add_query_arg( 'page', min( $page - 1, $max_pages ), $base ) );
+ }
+ if ( $page < $max_pages ) {
+ $response->link_header( 'next', add_query_arg( 'page', $page + 1, $base ) );
+ }
+ }
+
+ return $response;
}
/**
@@ -166,9 +204,9 @@ class WC_REST_Order_Notes_V2_Controller extends WC_REST_Order_Notes_V1_Controlle
* @return array
*/
public function get_collection_params() {
- $params = array();
- $params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
- $params['type'] = array(
+ $params = array();
+ $params['context'] = $this->get_context_param( array( 'default' => 'view' ) );
+ $params['type'] = array(
'default' => 'any',
'description' => __( 'Limit result to customers or internal notes.', 'woocommerce' ),
'type' => 'string',
@@ -176,6 +214,19 @@ class WC_REST_Order_Notes_V2_Controller extends WC_REST_Order_Notes_V1_Controlle
'sanitize_callback' => 'sanitize_key',
'validate_callback' => 'rest_validate_request_arg',
);
+ $params['page'] = array(
+ 'description' => __( 'Page of order notes to retrieve. Pagination is opt-in.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
+ $params['per_page'] = array(
+ 'description' => __( 'Maximum number of order notes per page.', 'woocommerce' ),
+ 'type' => 'integer',
+ 'minimum' => 1,
+ 'maximum' => 100,
+ 'validate_callback' => 'rest_validate_request_arg',
+ );
return $params;
}
diff --git a/plugins/woocommerce/includes/wc-order-functions.php b/plugins/woocommerce/includes/wc-order-functions.php
index 8b0cb81aea0..111ddb10d75 100644
--- a/plugins/woocommerce/includes/wc-order-functions.php
+++ b/plugins/woocommerce/includes/wc-order-functions.php
@@ -1225,6 +1225,8 @@ function wc_get_order_note( $data ) {
*
* @type string $limit Maximum number of notes to retrieve.
* Default empty (no limit).
+ * @type int $page Page of notes to retrieve (starting at 1). Use with limit.
+ * Default 1.
* @type int $order_id Limit results to those affiliated with a given order ID.
* Default 0.
* @type array $order__in Array of order IDs to include affiliated notes for.
@@ -1246,6 +1248,7 @@ function wc_get_order_note( $data ) {
function wc_get_order_notes( $args ) {
$key_mapping = array(
'limit' => 'number',
+ 'page' => 'paged',
'order_id' => 'post_id',
'order__in' => 'post__in',
'order__not_in' => 'post__not_in',
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index c3b055fb149..56a1883b23b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -25200,12 +25200,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-network-orders-v2-controller.php
- -
- message: '#^Argument of an invalid type array\<int\|WP_Comment\>\|int supplied for foreach, only iterables are supported\.$#'
- identifier: foreach.nonIterable
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
-
-
message: '#^Function remove_filter invoked with 4 parameters, 2\-3 required\.$#'
identifier: arguments.count
@@ -25242,12 +25236,6 @@ parameters:
count: 1
path: includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
- -
- message: '#^Parameter \#1 \$note of method WC_REST_Order_Notes_V2_Controller\:\:prepare_item_for_response\(\) expects WP_Comment, int\|WP_Comment given\.$#'
- identifier: argument.type
- count: 1
- path: includes/rest-api/Controllers/Version2/class-wc-rest-order-notes-v2-controller.php
-
-
message: '#^Call to an undefined method WC_Data\:\:get_parent_id\(\)\.$#'
identifier: method.notFound
diff --git a/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-notes-test.php b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-notes-test.php
new file mode 100644
index 00000000000..cfc1fb4bbfe
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/admin/meta-boxes/class-wc-meta-box-order-notes-test.php
@@ -0,0 +1,83 @@
+<?php
+/**
+ * Tests for paginated notes in the order editor.
+ *
+ * @package WooCommerce\Tests\Admin
+ */
+
+declare( strict_types = 1 );
+
+/**
+ * Tests for WC_Meta_Box_Order_Notes.
+ */
+class WC_Meta_Box_Order_Notes_Test extends WC_Unit_Test_Case {
+
+ /**
+ * @testdox Should return ten notes per page in newest-first order, scoped to the order.
+ */
+ public function test_get_page(): void {
+ $order = wc_create_order();
+ $ids = array();
+ for ( $i = 1; $i <= 21; $i++ ) {
+ $ids[] = $order->add_order_note( 'Note ' . $i );
+ }
+ $other_order = wc_create_order();
+ $other_order->add_order_note( 'Another order' );
+
+ $first = WC_Meta_Box_Order_Notes::get_page( $order->get_id() );
+ $second = WC_Meta_Box_Order_Notes::get_page( $order->get_id(), 2 );
+ $last = WC_Meta_Box_Order_Notes::get_page( $order->get_id(), 3 );
+
+ $this->assertCount( 10, $first['notes'], 'The first page should contain ten notes.' );
+ $this->assertSame( 21, $first['total'], 'Only this order should contribute to the count.' );
+ $this->assertSame( 3, $first['total_pages'], 'Twenty-one notes should span three pages.' );
+ $this->assertSame( array_reverse( array_slice( $ids, 11 ) ), array_column( $first['notes'], 'id' ), 'The first page should show the newest notes.' );
+ $this->assertSame( array_reverse( array_slice( $ids, 1, 10 ) ), array_column( $second['notes'], 'id' ), 'The next page should not overlap the first.' );
+ $this->assertSame( array( $ids[0] ), array_column( $last['notes'], 'id' ), 'The final page should contain the remaining note.' );
+ }
+
+ /**
+ * @testdox Should move back to the last available page after its final note is deleted.
+ */
+ public function test_deleted_last_page(): void {
+ $order = wc_create_order();
+ for ( $i = 0; $i < 11; $i++ ) {
+ $order->add_order_note( 'Note ' . $i );
+ }
+ $last = WC_Meta_Box_Order_Notes::get_page( $order->get_id(), 2 );
+ wc_delete_order_note( reset( $last['notes'] )->id );
+
+ $result = WC_Meta_Box_Order_Notes::get_page( $order->get_id(), 2 );
+
+ $this->assertSame( 1, $result['page'], 'An empty final page should fall back to the preceding page.' );
+ $this->assertCount( 10, $result['notes'], 'The remaining notes should fill the first page.' );
+ $this->assertSame( 10, $result['total'], 'The total should reflect the deletion.' );
+ }
+
+ /**
+ * @testdox Should return an empty first page for an order with no notes or an unsaved order.
+ */
+ public function test_empty_order(): void {
+ $order = wc_create_order();
+ foreach ( array( 0, $order->get_id() ) as $order_id ) {
+ $result = WC_Meta_Box_Order_Notes::get_page( $order_id, 50 );
+ $this->assertCount( 0, $result['notes'], 'An empty order should not load notes from another order.' );
+ $this->assertSame( 0, $result['total'], 'An empty order should have no notes.' );
+ $this->assertSame( 1, $result['page'], 'An empty order should remain on page one.' );
+ $this->assertSame( 1, $result['total_pages'], 'The page input should have a valid minimum and maximum.' );
+ }
+ }
+
+ /**
+ * @testdox Should clamp a page below one to the first page.
+ */
+ public function test_negative_page(): void {
+ $order = wc_create_order();
+ $order->add_order_note( 'A note' );
+
+ $result = WC_Meta_Box_Order_Notes::get_page( $order->get_id(), -1 );
+
+ $this->assertSame( 1, $result['page'], 'Negative pages should load the first page.' );
+ $this->assertCount( 1, $result['notes'], 'The first page should contain the note.' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
index 5aa7d1da368..b818b345a22 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-ajax-test.php
@@ -36,6 +36,143 @@ class WC_AJAX_Test extends \WP_Ajax_UnitTestCase {
remove_action( 'admin_init', '_maybe_update_themes' );
}
+ /**
+ * @testdox Should return a rendered page of notes with pagination for an order editor.
+ */
+ public function test_get_order_notes_page(): void {
+ $this->_setRole( 'administrator' );
+ $order = wc_create_order();
+ for ( $i = 1; $i <= 11; $i++ ) {
+ $order->add_order_note( 'Pagination note ' . $i );
+ }
+
+ $_POST['security'] = wp_create_nonce( 'get-order-notes' );
+ $_POST['order_id'] = $order->get_id();
+ $_POST['page'] = 2;
+
+ $response = $this->do_ajax( 'woocommerce_get_order_notes' );
+
+ $this->assertTrue( $response['success'], 'An order editor should be able to load notes.' );
+ $this->assertStringContainsString( 'Pagination note 1</p>', $response['data']['html'], 'The second page should contain the oldest note.' );
+ $this->assertStringNotContainsString( 'Pagination note 2</p>', $response['data']['html'], 'The response should not contain notes from the first page.' );
+ $this->assertStringContainsString( '1 of 11 notes', $response['data']['html'], 'The count should reflect the current page.' );
+ $this->assertStringContainsString( 'data-page="2"', $response['data']['html'], 'The response should identify the current page.' );
+ }
+
+ /**
+ * @testdox Should deny access to order notes to a customer with a valid nonce.
+ */
+ public function test_get_order_notes_denies_customer(): void {
+ $this->_setRole( 'customer' );
+ $order = wc_create_order();
+ $order->add_order_note( 'Private order note' );
+ $_POST['security'] = wp_create_nonce( 'get-order-notes' );
+ $_POST['order_id'] = $order->get_id();
+
+ $response = $this->do_ajax( 'woocommerce_get_order_notes' );
+
+ $this->assertFalse( $response['success'], 'Customers must not be able to read private notes.' );
+ $this->assertEmpty( $response['data'], 'An unauthorized response must not disclose note content.' );
+ }
+
+ /**
+ * @testdox Should reject a request for order notes without a valid nonce.
+ */
+ public function test_get_order_notes_requires_nonce(): void {
+ $this->_setRole( 'administrator' );
+ $_POST['order_id'] = wc_create_order()->get_id();
+ $_POST['security'] = 'invalid';
+
+ $response = $this->do_ajax( 'woocommerce_get_order_notes' );
+
+ $this->assertNotTrue( $response['success'] ?? false, 'A request with an invalid nonce must not succeed.' );
+ }
+
+ /**
+ * @testdox Should paginate item mutation notes only when the request opts in.
+ * @param string $action AJAX action without the WooCommerce prefix.
+ * @param bool $paginate Whether to request paginated notes.
+ * @testWith ["add_order_item", false]
+ * ["add_order_item", true]
+ * ["remove_order_coupon", false]
+ * ["remove_order_coupon", true]
+ * ["remove_order_item", false]
+ * ["remove_order_item", true]
+ * ["save_order_items", false]
+ * ["save_order_items", true]
+ */
+ public function test_order_item_notes_pagination_is_opt_in( string $action, bool $paginate ): void {
+ $this->_setRole( 'administrator' );
+ $order = wc_create_order();
+ $product = WC_Helper_Product::create_simple_product();
+ $item_id = $order->add_product( $product );
+ for ( $i = 1; $i <= 11; $i++ ) {
+ $order->add_order_note( 'Compatibility note ' . $i );
+ }
+
+ $_POST['order_id'] = $order->get_id();
+ $_POST['security'] = wp_create_nonce( 'order-item' );
+ unset( $_POST['paginate_notes'] );
+ if ( $paginate ) {
+ $_POST['paginate_notes'] = true;
+ }
+
+ switch ( $action ) {
+ case 'add_order_item':
+ $_POST['data'] = array(
+ array(
+ 'id' => $product->get_id(),
+ 'qty' => 1,
+ ),
+ );
+ break;
+ case 'remove_order_coupon':
+ $coupon = new WC_Coupon();
+ $coupon->set_code( 'notes-pagination' );
+ $coupon->save();
+ $order->apply_coupon( $coupon );
+ $_POST['coupon'] = $coupon->get_code();
+ break;
+ case 'remove_order_item':
+ $_POST['order_item_ids'] = array( $item_id );
+ break;
+ case 'save_order_items':
+ $_POST['items'] = '';
+ break;
+ }
+
+ // An Error bypasses endpoint catch blocks, matching wp_die() exiting a real request.
+ $stop_request = new Error( 'AJAX response sent.' );
+ $die_handler = function () use ( $stop_request ) {
+ return function () use ( $stop_request ) {
+ $this->_last_response .= ob_get_clean();
+ throw $stop_request;
+ };
+ };
+ add_filter( 'wp_die_ajax_handler', $die_handler, PHP_INT_MAX );
+ try {
+ $this->do_ajax( 'woocommerce_' . $action );
+ } catch ( Error $error ) {
+ if ( $error !== $stop_request ) {
+ throw $error;
+ }
+ } finally {
+ remove_filter( 'wp_die_ajax_handler', $die_handler, PHP_INT_MAX );
+ }
+ $response = json_decode( $this->_last_response, true );
+ $this->_last_response = false;
+
+ $this->assertTrue( $response['success'], 'The order item mutation should succeed.' );
+ $html = $response['data']['notes_html'];
+ $this->assertStringContainsString( 'Compatibility note 11</p>', $html, 'The newest fixture note should be included.' );
+ if ( $paginate ) {
+ $this->assertSame( 10, substr_count( $html, '<li rel=' ), 'An opted-in response should contain one page of notes.' );
+ $this->assertStringNotContainsString( 'Compatibility note 1</p>', $html, 'An opted-in response should omit the oldest note.' );
+ } else {
+ $this->assertStringContainsString( 'Compatibility note 1</p>', $html, 'Legacy requests should receive the oldest note too.' );
+ }
+ }
+
/**
* Stock should not be reduced from AJAX when an item is added to an order.
*/
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-notes-controller-tests.php b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-notes-controller-tests.php
new file mode 100644
index 00000000000..7336606bb11
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/Controllers/Version3/class-wc-rest-order-notes-controller-tests.php
@@ -0,0 +1,68 @@
+<?php
+declare( strict_types=1 );
+
+/**
+ * Tests for paginating order notes in the v2 and v3 REST APIs.
+ */
+class WC_REST_Order_Notes_Controller_Tests extends WC_REST_Unit_Test_Case {
+
+ /**
+ * @testdox Should return paginated notes with totals and navigation while preserving unpaginated responses.
+ */
+ public function test_get_items_pagination(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->add_order_note( 'First internal note' );
+ add_filter( 'woocommerce_email_log_add_order_note', '__return_false' );
+ try {
+ $order->add_order_note( 'Customer note', 1 );
+ } finally {
+ remove_filter( 'woocommerce_email_log_add_order_note', '__return_false' );
+ }
+ $order->add_order_note( 'Last internal note' );
+
+ foreach ( array( 'wc/v2', 'wc/v3' ) as $namespace ) {
+ $controller = 'wc/v2' === $namespace ? new WC_REST_Order_Notes_V2_Controller() : new WC_REST_Order_Notes_Controller();
+ $path = "/{$namespace}/orders/{$order->get_id()}/notes";
+ $request = new WP_REST_Request( 'GET', $path );
+ $request->set_param( 'order_id', $order->get_id() );
+ $request->set_param( 'page', 2 );
+ $request->set_param( 'per_page', 1 );
+
+ $response = $controller->get_items( $request );
+ $this->assertSame( 'Customer note', $response->get_data()[0]['note'] );
+ $this->assertSame( '3', $response->get_headers()['X-WP-Total'] );
+ $this->assertSame( '3', $response->get_headers()['X-WP-TotalPages'] );
+ $this->assertStringContainsString( 'page=1', $response->get_headers()['Link'] );
+ $this->assertStringContainsString( 'page=3', $response->get_headers()['Link'] );
+
+ $request->set_param( 'type', 'internal' );
+ $response = $controller->get_items( $request );
+ $this->assertSame( 'First internal note', $response->get_data()[0]['note'] );
+ $this->assertSame( '2', $response->get_headers()['X-WP-Total'] );
+
+ $request->set_param( 'type', 'customer' );
+ $response = $controller->get_items( $request );
+ $this->assertSame( array(), $response->get_data() );
+ $this->assertSame( '1', $response->get_headers()['X-WP-Total'] );
+
+ $request->set_param( 'type', 'internal' );
+ $request->set_param( 'page', 5 );
+ $response = $controller->get_items( $request );
+ $this->assertSame( array(), $response->get_data() );
+ $this->assertSame( '2', $response->get_headers()['X-WP-Total'] );
+
+ $request = new WP_REST_Request( 'GET', $path );
+ $request->set_param( 'order_id', $order->get_id() );
+ $request->set_param( 'page', 1 );
+ $response = $controller->get_items( $request );
+ $this->assertCount( 3, $response->get_data() );
+ $this->assertSame( '1', $response->get_headers()['X-WP-TotalPages'] );
+
+ $request = new WP_REST_Request( 'GET', $path );
+ $request->set_param( 'order_id', $order->get_id() );
+ $response = $controller->get_items( $request );
+ $this->assertCount( 3, $response->get_data() );
+ $this->assertArrayNotHasKey( 'X-WP-Total', $response->get_headers() );
+ }
+ }
+}
diff --git a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
index 2ef95e266c7..60ff29f43f6 100644
--- a/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
+++ b/plugins/woocommerce/tests/php/includes/wc-order-functions-test.php
@@ -793,6 +793,40 @@ class WC_Order_Functions_Test extends \WC_Unit_Test_Case {
);
}
+ /**
+ * @testdox Should page order notes without changing unpaginated results or type filtering.
+ */
+ public function test_wc_get_order_notes_pagination(): void {
+ $order = WC_Helper_Order::create_order();
+ $order->add_order_note( 'First internal note' );
+ add_filter( 'woocommerce_email_log_add_order_note', '__return_false' );
+ try {
+ $order->add_order_note( 'Customer note', 1 );
+ } finally {
+ remove_filter( 'woocommerce_email_log_add_order_note', '__return_false' );
+ }
+ $order->add_order_note( 'Last internal note' );
+
+ $args = array(
+ 'order_id' => $order->get_id(),
+ 'limit' => 1,
+ );
+ $this->assertSame( 'Last internal note', wc_get_order_notes( $args + array( 'page' => 1 ) )[0]->content );
+ $this->assertSame( 'Customer note', wc_get_order_notes( $args + array( 'page' => 2 ) )[0]->content );
+ $this->assertSame( 'First internal note', wc_get_order_notes( $args + array( 'page' => 3 ) )[0]->content );
+ $this->assertEmpty( wc_get_order_notes( $args + array( 'page' => 4 ) ) );
+ $this->assertCount( 3, wc_get_order_notes( array( 'order_id' => $order->get_id() ) ) );
+ $this->assertSame(
+ 'First internal note',
+ wc_get_order_notes(
+ $args + array(
+ 'page' => 2,
+ 'type' => 'internal',
+ )
+ )[0]->content
+ );
+ }
+
/**
* @testdox Should show an order note's time in the current site timezone after the timezone changes.
*