Commit 4b6278b7a90 for woocommerce
commit 4b6278b7a906da3b102c39b8a70a04ef5233d056
Author: Taha Paksu <3295+tpaksu@users.noreply.github.com>
Date: Wed Oct 28 18:19:21 2026 +0300
Save a new shipping zone's name and region when adding a non-modal method (#70002)
* Save a new shipping zone's name and region when adding a non-modal method
* Add changefile(s) from automation for the following project(s): woocommerce
* Stabilize the existing-zone non-modal shipping method e2e test
* Reword the non-modal shipping method save comment
* [WOOPLUG-3626] Cover pending zone edits when adding a non-modal method
---------
Co-authored-by: woocommercebot <woocommercebot@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save b/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save
new file mode 100644
index 00000000000..072a43b0b41
--- /dev/null
+++ b/plugins/woocommerce/changelog/70002-fix-wooplug-3626-shipping-zone-nonmodal-save
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Keep a new shipping zone's name and regions when the first method added has settings but no settings modal.
diff --git a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
index 466d92d643a..fe3f2b38b31 100644
--- a/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
+++ b/plugins/woocommerce/client/legacy/js/admin/wc-shipping-zone-methods.js
@@ -655,8 +655,15 @@
shippingMethodView.highlightOnFocus( '.wc-shipping-modal-price' );
} else {
- shippingMethodView.model.trigger( 'change:methods' );
- shippingMethodView.model.trigger( 'saved:methods' );
+ // Non-modal methods are already added server-side; flush any pending zone edits and
+ // re-render, mirroring the modal settings-save path so new zones keep their locations.
+ if ( _.size( shippingMethodView.model.changes ) ) {
+ // Block the methods table across the async save, as the modal path does.
+ shippingMethodView.block();
+ shippingMethodView.model.save();
+ } else {
+ shippingMethodView.model.onSaveResponse( response, textStatus );
+ }
}
$( document.body ).trigger( 'init_tooltips' );
diff --git a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
index 085fe79d6f4..0ef0b94b0eb 100644
--- a/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
+++ b/plugins/woocommerce/tests/e2e/test-plugins/woocommerce-e2e-test-helper/woocommerce-e2e-test-helper.php
@@ -359,3 +359,75 @@ add_filter(
'__return_zero',
PHP_INT_MAX
);
+
+/*
+ * -----------------------------------------------------------------------------
+ * Shipping methods
+ * -----------------------------------------------------------------------------
+ *
+ * Registers a zone shipping method that supports instance settings but deliberately
+ * not 'instance-settings-modal', like UPS/USPS. Core ships no such method, so the
+ * shipping-zone save flow for non-modal methods has no coverage without one.
+ */
+add_action(
+ 'woocommerce_shipping_init',
+ function () {
+ if ( class_exists( 'WC_E2E_Nonmodal_Shipping_Method' ) ) {
+ return;
+ }
+
+ /**
+ * A minimal instance-based shipping method that omits 'instance-settings-modal'
+ * so it configures on a full settings page instead of the zones modal.
+ */
+ class WC_E2E_Nonmodal_Shipping_Method extends WC_Shipping_Method {
+ /**
+ * @param int $instance_id Instance id.
+ */
+ public function __construct( $instance_id = 0 ) {
+ $this->id = 'e2e_nonmodal';
+ $this->instance_id = absint( $instance_id );
+ $this->method_title = 'E2E Non-Modal Method';
+ $this->method_description = 'Instance settings without a modal, for save-flow testing.';
+ $this->supports = array( 'shipping-zones', 'instance-settings' );
+ $this->init();
+ }
+
+ /**
+ * Load settings and hook the save handler.
+ */
+ public function init() {
+ $this->instance_form_fields = array(
+ 'title' => array(
+ 'title' => 'Title',
+ 'type' => 'text',
+ 'default' => 'E2E Non-Modal Method',
+ ),
+ );
+ $this->title = $this->get_option( 'title' );
+ add_action( 'woocommerce_update_options_shipping_' . $this->id, array( $this, 'process_admin_options' ) );
+ }
+
+ /**
+ * @param array $package Shipping package.
+ */
+ public function calculate_shipping( $package = array() ) {
+ $this->add_rate(
+ array(
+ 'id' => $this->id . $this->instance_id,
+ 'label' => $this->title,
+ 'cost' => 0,
+ )
+ );
+ }
+ }
+ }
+);
+
+add_filter(
+ 'woocommerce_shipping_methods',
+ function ( $methods ) {
+ $methods['e2e_nonmodal'] = 'WC_E2E_Nonmodal_Shipping_Method';
+ return $methods;
+ }
+);
diff --git a/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts b/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
index d3f40d47c34..b721db9cb4b 100644
--- a/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/shipping/shipping-zones.spec.ts
@@ -191,3 +191,198 @@ test( 'saves an unsaved shipping zone when adding a method', async ( {
await deleteZoneById( restApi, createdZoneId );
}
} );
+
+// Regression: a method that has instance settings but no settings modal (UPS/USPS
+// style; here the "E2E Non-Modal Method" registered by the E2E test helper) must still
+// mark the form dirty and persist the new zone's name and region. The old flow lost both,
+// so the zone silently reverted to "Everywhere" on reload.
+test( 'saves an unsaved shipping zone when adding a non-modal method', async ( {
+ page,
+ restApi,
+} ) => {
+ const zoneName = `Unsaved non-modal zone ${ rand() }`;
+ let createdZoneId: number | undefined;
+
+ try {
+ await page.goto(
+ 'wp-admin/admin.php?page=wc-settings&tab=shipping&zone_id=new'
+ );
+
+ // Register before any interaction so an unexpected onbeforeunload prompt is caught.
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ await page.getByLabel( 'Zone name' ).fill( zoneName );
+ await page
+ .getByRole( 'combobox', { name: 'Start typing to filter zones' } )
+ .fill( 'United States' );
+ await page
+ .getByRole( 'checkbox', {
+ name: 'United States (US)',
+ exact: true,
+ } )
+ .click();
+ await page.keyboard.press( 'Escape' );
+ await expect( page.locator( '.select2-container--open' ) ).toBeHidden();
+
+ await expect( page.locator( '#submit' ) ).toBeEnabled();
+
+ await page
+ .getByRole( 'button', { name: 'Add shipping method' } )
+ .click();
+ await page.getByText( 'E2E Non-Modal Method' ).click();
+
+ const addMethodResponsePromise = page.waitForResponse( ( response ) =>
+ response
+ .url()
+ .includes( 'action=woocommerce_shipping_zone_add_method' )
+ );
+ const saveChangesResponsePromise = page.waitForResponse( ( response ) =>
+ response
+ .url()
+ .includes(
+ 'action=woocommerce_shipping_zone_methods_save_changes'
+ )
+ );
+ await page.getByRole( 'button', { name: 'Continue' } ).click();
+
+ const addMethodResponse = await addMethodResponsePromise;
+ createdZoneId = ( await addMethodResponse.json() ).data.zone_id;
+ // A non-modal method has no settings modal, so the add itself must flush the
+ // pending zone edits via save_changes.
+ await saveChangesResponsePromise;
+
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'E2E Non-Modal Method',
+ } )
+ ).toBeVisible();
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+
+ await page.reload();
+ await expect( page.getByLabel( 'Zone name' ) ).toHaveValue( zoneName );
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'E2E Non-Modal Method',
+ } )
+ ).toBeVisible();
+ expect( dialogMessages ).toEqual( [] );
+
+ // The region must survive instead of reverting to "Everywhere".
+ const locations = await restApi.get(
+ `${ WC_API_PATH }/shipping/zones/${ createdZoneId }/locations`
+ );
+ expect( locations.data ).toContainEqual(
+ expect.objectContaining( { code: 'US', type: 'country' } )
+ );
+ } finally {
+ await deleteZoneById( restApi, createdZoneId );
+ }
+} );
+
+// Regression: adding a non-modal method to an existing zone must flush the zone's
+// pending edits and re-render without dropping its other methods. The old flow kept
+// the unsaved name only in the browser, so it was lost on the next load.
+test( 'persists pending zone edits when adding a non-modal method to an existing zone', async ( {
+ page,
+ restApi,
+ zone,
+} ) => {
+ const renamedZone = `Renamed zone ${ rand() }`;
+
+ await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=shipping' );
+ await page
+ .getByText( zone.name )
+ .locator(
+ '~ td.wc-shipping-zone-actions a.wc-shipping-zone-action-edit'
+ )
+ .click();
+
+ // The edit screen binds the add-method modal after its methods table renders, so
+ // wait for the existing method, then retry opening the modal in case the first
+ // click lands before the handler is bound.
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'Flat rate',
+ } )
+ ).toBeVisible();
+
+ // Register before the edit so an unexpected onbeforeunload prompt is caught.
+ const dialogMessages: string[] = [];
+ page.on( 'dialog', async ( dialog ) => {
+ dialogMessages.push( dialog.message() );
+ await dialog.accept();
+ } );
+
+ // Leave the rename unsaved, so adding the method is what has to persist it.
+ await page.getByLabel( 'Zone name' ).fill( renamedZone );
+ await expect( page.locator( '#submit' ) ).toBeEnabled();
+
+ const addMethodButton = page.getByRole( 'button', {
+ name: 'Add shipping method',
+ } );
+ const createMethodHeading = page.getByRole( 'heading', {
+ name: 'Create shipping method',
+ } );
+ await expect( async () => {
+ if ( ! ( await createMethodHeading.isVisible() ) ) {
+ await addMethodButton.click();
+ }
+ await expect( createMethodHeading ).toBeVisible( { timeout: 3000 } );
+ } ).toPass( { timeout: 20000 } );
+ await page.getByText( 'E2E Non-Modal Method' ).click();
+
+ const addMethodResponsePromise = page.waitForResponse( ( response ) =>
+ response.url().includes( 'action=woocommerce_shipping_zone_add_method' )
+ );
+ const saveChangesResponsePromise = page.waitForResponse( ( response ) =>
+ response
+ .url()
+ .includes( 'action=woocommerce_shipping_zone_methods_save_changes' )
+ );
+ await page.getByRole( 'button', { name: 'Continue' } ).click();
+ await addMethodResponsePromise;
+ // A non-modal method has no settings modal, so the add itself must flush the
+ // pending zone edits via save_changes.
+ await saveChangesResponsePromise;
+
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'E2E Non-Modal Method',
+ } )
+ ).toBeVisible();
+ await expect( page.locator( '#submit' ) ).toBeDisabled();
+ await expect( page.locator( '.blockUI' ) ).toHaveCount( 0 );
+
+ await page.reload();
+ await expect( page.getByLabel( 'Zone name' ) ).toHaveValue( renamedZone );
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'Flat rate',
+ } )
+ ).toBeVisible();
+ await expect(
+ page.locator( '.wc-shipping-zone-method-title', {
+ hasText: 'E2E Non-Modal Method',
+ } )
+ ).toBeVisible();
+ expect( dialogMessages ).toEqual( [] );
+
+ const methods = await restApi.get(
+ `${ WC_API_PATH }/shipping/zones/${ zone.id }/methods`
+ );
+ expect(
+ methods.data.map(
+ ( method: { method_id: string } ) => method.method_id
+ )
+ ).toContain( 'e2e_nonmodal' );
+
+ const savedZone = await restApi.get(
+ `${ WC_API_PATH }/shipping/zones/${ zone.id }`
+ );
+ expect( savedZone.data.name ).toBe( renamedZone );
+} );