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' )
 			);
 		} );