Commit a8c94e698ef for woocommerce
commit a8c94e698efcaaa27460ca156c1322a15b3671f2
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Thu Aug 6 09:17:03 2026 +0300
Fix variation pagination after dismissing the save warning (#66998)
* style: fix oxlint findings in variation admin files
Context: the variation admin script and its E2E spec carry four
long-standing oxlint findings (a shadowed callback parameter, a
useless regex escape, a test helper scoped inside its describe
block, and a redundant Boolean cast).
Problem: the staged-lint gate flags whole files, so any commit
touching these files is blocked until the pre-existing findings are
resolved.
Solution: rename the inner $.get callback parameter to page_html,
drop the unnecessary escape, hoist gotToVariationsTab to module
scope, and rely on plain truthiness in waitForFunction. No behavior
changes.
Refs #35554
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix: stop variation pagination after cancel
Context: the classic variable product editor asks merchants whether to
save dirty variation rows before changing variation pages.
Problem: dismissing that dialog removed the dirty marker
(check_for_changes() clears it for its legacy callers) and pagination
still moved to the requested page, so unsaved edits could be lost or
left unsaveable.
Solution: leave check_for_changes() untouched for its other callers,
but on the pagination path capture the dirty rows first; when the
prompt is dismissed, restore their dirty marker, reset the page
selectors to the current page, and abort navigation. Add E2E coverage
for a paginated variable product.
Refs #35554
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(admin): route programmatic variation navigation directly
Context: server-side variation mutations (save, cancel, remove, link
all, bulk edit) call go_to_page() to refresh the list after their
state change is already resolved. go_to_page() reached navigation by
simulating a merchant page change: set_page() sets the selector and
fires the 'change' event handled by page_selector().
Problem: page_selector() now honors a dismissed unsaved-changes
prompt by aborting navigation. On the programmatic path that prompt
can still appear when a mutation response arrives while a save is in
flight (the dirty markers clear only in the save success callback);
dismissing it would abort a refresh of already-changed server state,
leaving stale rows on screen, and accepting it would re-submit the
in-flight save, firing the save hooks twice.
Solution: split the navigation core into navigate() and call it from
go_to_page() directly, so the prompt and its abort live only on the
merchant-initiated path (selector changes and pagination arrows via
set_page()). Programmatic refreshes always run, never prompt, and no
caller flag is needed.
Programmatic refreshes no longer fire the 'change' event on the page
selector; the woocommerce_variations_loaded event still fires for
every page load.
Refs #35554
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(admin): run variations empty-state toggle after page load
Context: navigate() chains show_hide_variation_empty_state onto the
load_variations() promise, an idiom inherited from the former
page_selector() tail.
Problem: the callback was invoked while building the chain, so it ran
synchronously before the request and .then() received undefined — a
dead handler. The end state was coincidentally identical (the toggle
only reads data-total, already set by set_paginav()), but the chain
did not do what it said.
Solution: pass the function reference, matching reload(), so the
toggle runs after the page load resolves.
Refs #35554
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* style: use a literal string for percent stripping
Context: the bulk-edit percentage parser strips the percent sign with
a single-match regex, value.replace( /%/, '' ).
Problem: a plain character needs no pattern matching, and the coding
guidelines prefer readable alternatives over regular expressions.
Solution: replace the regex with the equivalent string literal; both
forms replace only the first occurrence.
Refs #35554
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
diff --git a/plugins/woocommerce/changelog/fix-35554-variation-pagination-cancel b/plugins/woocommerce/changelog/fix-35554-variation-pagination-cancel
new file mode 100644
index 00000000000..e48926e73f3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-35554-variation-pagination-cancel
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep merchants on the current variation page, with their unsaved edits intact, when the pagination save warning is dismissed.
diff --git a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
index 1d37ce9e4c5..5766f88956e 100644
--- a/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
+++ b/plugins/woocommerce/client/legacy/js/admin/meta-boxes-product-variation.js
@@ -111,10 +111,10 @@ jQuery( function ( $ ) {
'&action=edit&'
);
- $.get( this_page_url, function ( response ) {
+ $.get( this_page_url, function ( page_html ) {
$( '#variable_product_options' ).unblock();
$( '#variable_product_options_inner' ).replaceWith(
- $( response ).find(
+ $( page_html ).find(
'#variable_product_options_inner'
)
);
@@ -122,7 +122,7 @@ jQuery( function ( $ ) {
$(
'#product_attributes > .product_attributes'
).replaceWith(
- $( response ).find(
+ $( page_html ).find(
'#product_attributes > .product_attributes'
)
);
@@ -1287,7 +1287,7 @@ jQuery( function ( $ ) {
if ( value.indexOf( '%' ) >= 0 ) {
data.value =
accounting.unformat(
- value.replace( /\%/, '' ),
+ value.replace( '%', '' ),
woocommerce_admin.mon_decimal_point
) + '%';
} else {
@@ -1629,6 +1629,37 @@ jQuery( function ( $ ) {
.trigger( 'change' );
},
+ /**
+ * Load a variations page, bypassing the unsaved-changes prompt.
+ *
+ * Server-side mutations navigate after their state change is already
+ * resolved (saved, discarded, or in flight), so their refresh must run
+ * unconditionally. The prompt lives in page_selector(), on the
+ * merchant-initiated path only.
+ *
+ * @param {Int} page
+ */
+ navigate: function ( page ) {
+ var wrapper = $( '#variable_product_options' ).find(
+ '.woocommerce_variations'
+ );
+
+ // Callers pass the page as a string when read from data attributes.
+ page = parseInt( page, 10 ) || 1;
+
+ $( '.variations-pagenav .page-selector' ).val( page );
+
+ wc_meta_boxes_product_variations_pagenav.change_classes(
+ page,
+ parseInt( wrapper.attr( 'data-total_pages' ), 10 )
+ );
+ wc_meta_boxes_product_variations_ajax
+ .load_variations( page )
+ .then(
+ wc_meta_boxes_product_variations_ajax.show_hide_variation_empty_state
+ );
+ },
+
/**
* Navigate on variations pages
*
@@ -1640,7 +1671,7 @@ jQuery( function ( $ ) {
qty = qty || 0;
wc_meta_boxes_product_variations_pagenav.set_paginav( qty );
- wc_meta_boxes_product_variations_pagenav.set_page( page );
+ wc_meta_boxes_product_variations_pagenav.navigate( page );
},
/**
@@ -1650,20 +1681,22 @@ jQuery( function ( $ ) {
var selected = parseInt( $( this ).val(), 10 ),
wrapper = $( '#variable_product_options' ).find(
'.woocommerce_variations'
- );
+ ),
+ need_update = wrapper.find( '.variation-needs-update' ),
+ current_page = parseInt( wrapper.attr( 'data-page' ), 10 ) || 1;
$( '.variations-pagenav .page-selector' ).val( selected );
- wc_meta_boxes_product_variations_ajax.check_for_changes();
- wc_meta_boxes_product_variations_pagenav.change_classes(
- selected,
- parseInt( wrapper.attr( 'data-total_pages' ), 10 )
- );
- wc_meta_boxes_product_variations_ajax
- .load_variations( selected )
- .then(
- wc_meta_boxes_product_variations_ajax.show_hide_variation_empty_state()
- );
+ // This handler only runs for merchant-initiated page changes;
+ // server-side mutations refresh through navigate() directly.
+ if ( ! wc_meta_boxes_product_variations_ajax.check_for_changes() ) {
+ // Restore the dirty state cleared for legacy callers when the prompt is dismissed.
+ need_update.addClass( 'variation-needs-update' );
+ $( '.variations-pagenav .page-selector' ).val( current_page );
+ return;
+ }
+
+ wc_meta_boxes_product_variations_pagenav.navigate( selected );
},
/**
diff --git a/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts b/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
index 74e8c4dde5a..8a2a403cb28 100644
--- a/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/product/update-variations.spec.ts
@@ -17,6 +17,7 @@ const {
productAttributes,
sampleVariations,
createVariations,
+ generateVariationsFromAttributes,
} = utils;
const variationOnePrice = '9.99';
const variationTwoPrice = '11.99';
@@ -34,9 +35,54 @@ let productId_indivEdit: number,
productId_manageStock: number,
productId_variationDefaults: number,
productId_removeVariation: number,
+ productId_paginationCancel: number,
defaultVariation,
variationIds_indivEdit: number[];
+const paginatedProductAttributes = [
+ {
+ name: 'Colour',
+ visible: true,
+ variation: true,
+ options: [ 'Red', 'Green', 'Blue', 'Black' ],
+ },
+ {
+ name: 'Size',
+ visible: true,
+ variation: true,
+ options: [ 'Small', 'Medium', 'Large', 'XL' ],
+ },
+];
+
+const paginatedProductVariations = generateVariationsFromAttributes(
+ paginatedProductAttributes
+).map( ( values ) => ( {
+ regular_price: variationOnePrice,
+ attributes: values.map( ( option, index ) => ( {
+ name: paginatedProductAttributes[ index ].name,
+ option,
+ } ) ),
+} ) );
+
+async function gotToVariationsTab( page: Page ) {
+ await test.step( 'Click on the "Variations" tab.', async () => {
+ await expect( async () => {
+ await page
+ .getByRole( 'link', { name: 'Variations' } )
+ .last()
+ .click();
+
+ // Sometimes the click on link is too fast and the initial tab (General) is still visible
+ // so we need to wait make sure some content from the variations tab is visible.
+ const expandButton = page
+ .getByRole( 'link', { name: 'Expand' } )
+ .first();
+
+ await expect( expandButton ).toBeVisible();
+ } ).toPass();
+ } );
+}
+
test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
test.use( { storageState: ADMIN_STATE_PATH } );
@@ -96,6 +142,17 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
);
} );
+ await test.step( 'Create variable product for pagination cancel test', async () => {
+ productId_paginationCancel = await createVariableProduct(
+ paginatedProductAttributes
+ );
+
+ await createVariations(
+ productId_paginationCancel,
+ paginatedProductVariations
+ );
+ } );
+
await test.step( 'Hide variable product tour', async () => {
await showVariableProductTour( browser, false );
} );
@@ -105,25 +162,6 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
await deleteProductsAddedByTests();
} );
- async function gotToVariationsTab( page: Page ) {
- await test.step( 'Click on the "Variations" tab.', async () => {
- await expect( async () => {
- await page
- .getByRole( 'link', { name: 'Variations' } )
- .last()
- .click();
-
- // Sometimes the click on link is too fast and the initial tab (General) is still visible
- // so we need to wait make sure some content from the variations tab is visible.
- const expandButton = page
- .getByRole( 'link', { name: 'Expand' } )
- .first();
-
- await expect( expandButton ).toBeVisible();
- } ).toPass();
- } );
- }
-
test( 'can individually edit variations', async ( { page } ) => {
const variationRows = page.locator( '.woocommerce_variation' );
const firstVariation = variationRows.filter( {
@@ -359,6 +397,73 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
} );
} );
+ test( 'dismissed pagination warning keeps unsaved variation edits on the current page', async ( {
+ page,
+ } ) => {
+ await test.step( 'Go to the "Edit product" page.', async () => {
+ await page.goto(
+ `wp-admin/post.php?post=${ productId_paginationCancel }&action=edit#variable_product_options`
+ );
+ } );
+
+ await gotToVariationsTab( page );
+
+ const pageSelector = page.getByLabel( 'Select Page' ).first();
+
+ await test.step( 'Confirm the first variation page is selected.', async () => {
+ await expect( pageSelector ).toHaveValue( '1' );
+ } );
+
+ const firstVariation = page.locator( '.woocommerce_variation' ).first();
+ const unsavedPrice = '42.42';
+ const priceInput = firstVariation.getByRole( 'textbox', {
+ name: 'Regular price',
+ } );
+
+ await test.step( 'Expand the first variation and edit it without saving.', async () => {
+ await page.getByRole( 'link', { name: 'Expand' } ).first().click();
+
+ await priceInput.fill( unsavedPrice );
+ await expect( firstVariation ).toHaveClass(
+ /variation-needs-update/
+ );
+ } );
+
+ await test.step( 'Dismiss the save warning raised by paginating.', async () => {
+ // Awaited rather than handled via a `page.on( 'dialog', … )` listener (the
+ // convention elsewhere in this suite) because the assertions below are all
+ // true *before* the click too. Racing the click against the dialog promise
+ // is what proves the dialog was actually raised and dismissed; a listener
+ // that is not awaited would let this test pass without exercising the
+ // cancel path at all.
+ const dialogPromise = page
+ .waitForEvent( 'dialog' )
+ .then( async ( dialog ) => {
+ expect( dialog.type() ).toBe( 'confirm' );
+ await dialog.dismiss();
+ } );
+
+ await Promise.all( [
+ dialogPromise,
+ page
+ .locator( '.variations-pagenav .next-page' )
+ .first()
+ .click(),
+ ] );
+ } );
+
+ await test.step( 'Confirm the page, the edit and its dirty state are preserved.', async () => {
+ await expect( pageSelector ).toHaveValue( '1' );
+ await expect( firstVariation ).toHaveClass(
+ /variation-needs-update/
+ );
+ await expect( priceInput ).toHaveValue( unsavedPrice );
+ await expect(
+ page.getByRole( 'button', { name: 'Save changes' } )
+ ).toBeEnabled();
+ } );
+ } );
+
test( 'can manage stock levels', async ( { page } ) => {
await test.step( 'Go to the "Edit product" page.', async () => {
await page.goto(
@@ -418,7 +523,7 @@ test.describe( 'Update variations', { tag: tags.GUTENBERG }, () => {
await test.step( 'Click "Save changes"', async () => {
await page.getByRole( 'button', { name: 'Save changes' } ).click();
await page.waitForFunction(
- () => ! Boolean( document.querySelector( '.blockUI' ) )
+ () => ! document.querySelector( '.blockUI' )
);
} );