Commit d6f98d98971 for woocommerce
commit d6f98d98971367c73544d910f3ec66e69bb157a8
Author: Alefe Souza <contact@alefesouza.com>
Date: Thu Nov 12 10:33:48 2026 -0300
Open a pre-filled edit form when an address copy can't be saved (#70834)
diff --git a/plugins/woocommerce/changelog/wooairr-574-copy-address-prefill b/plugins/woocommerce/changelog/wooairr-574-copy-address-prefill
new file mode 100644
index 00000000000..b42db315736
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooairr-574-copy-address-prefill
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Pre-fill copied address forms, including conditional fields; refines the unreleased copy link.
+
diff --git a/plugins/woocommerce/includes/class-wc-form-handler.php b/plugins/woocommerce/includes/class-wc-form-handler.php
index 6cf67694c4d..ef64581f62e 100644
--- a/plugins/woocommerce/includes/class-wc-form-handler.php
+++ b/plugins/woocommerce/includes/class-wc-form-handler.php
@@ -391,6 +391,78 @@ class WC_Form_Handler {
);
}
+ /**
+ * Get the address type to fill an empty edit address form from, after a copy that couldn't be saved as is.
+ *
+ * Nothing is saved until the customer submits the form, which has its own nonce, and it only shows the
+ * current customer's own addresses, so this read-only request needs no nonce.
+ *
+ * @since 11.4.0
+ * @internal For use by WooCommerce only.
+ *
+ * @param string $address_type Address type being edited; 'billing' or 'shipping'.
+ * @return string The other address type, or an empty string when the form keeps its saved values.
+ */
+ public static function get_copy_address_form_source( $address_type ) {
+ $source_type = isset( $_GET['copy-address-from'] ) ? sanitize_key( wp_unslash( $_GET['copy-address-from'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only, see the description above.
+
+ if ( '' === $source_type || self::get_copy_address_source_type( $address_type ) !== $source_type ) {
+ return '';
+ }
+
+ if ( ! self::can_copy_address( $address_type, $source_type, get_current_user_id() ) ) {
+ return '';
+ }
+
+ $source_country = ( new WC_Customer( get_current_user_id() ) )->{"get_{$source_type}_country"}( 'edit' );
+
+ if ( ! self::is_copy_address_country_allowed( $address_type, $source_country ) ) {
+ return '';
+ }
+
+ return $source_type;
+ }
+
+ /**
+ * Check that a copied address's country is available for the address type it is copied into.
+ *
+ * @param string $target_type Address type being filled.
+ * @param string $country Country code of the copied address.
+ * @return bool
+ */
+ private static function is_copy_address_country_allowed( string $target_type, string $country ): bool {
+ $countries = 'shipping' === $target_type ? WC()->countries->get_shipping_countries() : WC()->countries->get_allowed_countries();
+
+ return array_key_exists( $country, $countries );
+ }
+
+ /**
+ * Get the address type that a copy into the given address type reads from.
+ *
+ * @param mixed $target_type Address type being filled.
+ * @return string 'shipping' for 'billing', 'billing' for 'shipping', or an empty string for any other value.
+ */
+ private static function get_copy_address_source_type( $target_type ): string {
+ $source_types = array(
+ 'billing' => 'shipping',
+ 'shipping' => 'billing',
+ );
+
+ return is_string( $target_type ) ? ( $source_types[ $target_type ] ?? '' ) : '';
+ }
+
+ /**
+ * Check that the target address is empty and the source address is saved, so a copy can't overwrite an address.
+ *
+ * @param string $target_type Address type being filled.
+ * @param string $source_type Address type being copied.
+ * @param int $user_id Customer ID.
+ * @return bool
+ */
+ private static function can_copy_address( string $target_type, string $source_type, int $user_id ): bool {
+ return ! wc_get_account_formatted_address( $target_type, $user_id ) && wc_get_account_formatted_address( $source_type, $user_id );
+ }
+
/**
* Copy the saved billing address into an empty shipping address, or vice versa, from the My Account addresses page.
*
@@ -421,14 +493,11 @@ class WC_Form_Handler {
}
$target_type = sanitize_key( wp_unslash( $_GET['copy-address'] ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- The nonce is verified below.
- $source_types = array(
- 'billing' => 'shipping',
- 'shipping' => 'billing',
- );
+ $source_type = self::get_copy_address_source_type( $target_type );
$redirect_url = wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) );
$nonce_value = isset( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : '';
- if ( ! isset( $source_types[ $target_type ] ) ) {
+ if ( '' === $source_type ) {
wc_add_notice( __( 'The address could not be copied. Please try again.', 'woocommerce' ), 'error' );
wp_safe_redirect( $redirect_url );
exit;
@@ -440,18 +509,15 @@ class WC_Form_Handler {
exit;
}
- $source_type = $source_types[ $target_type ];
-
- if ( wc_get_account_formatted_address( $target_type, $user_id ) || ! wc_get_account_formatted_address( $source_type, $user_id ) ) {
+ if ( ! self::can_copy_address( $target_type, $source_type, $user_id ) ) {
wp_safe_redirect( $redirect_url );
exit;
}
- $customer = new WC_Customer( $user_id );
- $source_country = 'billing' === $source_type ? $customer->get_billing_country( 'edit' ) : $customer->get_shipping_country( 'edit' );
- $target_countries = 'shipping' === $target_type ? WC()->countries->get_shipping_countries() : WC()->countries->get_allowed_countries();
+ $customer = new WC_Customer( $user_id );
+ $source_country = 'billing' === $source_type ? $customer->get_billing_country( 'edit' ) : $customer->get_shipping_country( 'edit' );
- if ( ! array_key_exists( $source_country, $target_countries ) ) {
+ if ( ! self::is_copy_address_country_allowed( $target_type, $source_country ) ) {
wc_add_notice( __( 'This address uses a country that is not available for the other address type. Please add the address manually.', 'woocommerce' ), 'error' );
wp_safe_redirect( $redirect_url );
exit;
@@ -466,6 +532,12 @@ class WC_Form_Handler {
// Read stored values, so getter filters are not saved into the other address.
$value = is_callable( array( $customer, $getter ) ) ? $customer->{$getter}( 'edit' ) : $customer->get_meta( $source_key, true, 'edit' );
+ // Keep what the target address already has, such as a billing email or phone, when the copied address has none.
+ if ( '' === $value ) {
+ $target_getter = "get_{$target_key}";
+ $value = is_callable( array( $customer, $target_getter ) ) ? $customer->{$target_getter}( 'edit' ) : $customer->get_meta( $target_key, true, 'edit' );
+ }
+
// Shipping has no email field, so fall back to the account email like the edit address form does.
if ( 'billing_email' === $target_key && ! $value ) {
$value = $customer->get_email( 'edit' );
@@ -496,7 +568,8 @@ class WC_Form_Handler {
$field_values = array();
foreach ( array_keys( $checkout_fields->get_fields_for_location( 'address' ) ) as $field_key ) {
- $field_values[ $field_key ] = $checkout_fields->get_field_from_object( $field_key, $customer, $source_type, false );
+ $value = $checkout_fields->get_field_from_object( $field_key, $customer, $source_type, false );
+ $field_values[ $field_key ] = '' === $value ? $checkout_fields->get_field_from_object( $field_key, $customer, $target_type, false ) : $value;
}
// Validate the additional fields against the target address, like the edit address form does.
@@ -514,10 +587,10 @@ class WC_Form_Handler {
}
/**
- * Fires before a customer's copied address is saved, so extensions can reject it by adding an error notice.
+ * Fires before a customer's copied address is saved, so extensions can stop the save by adding an error notice.
*
- * Unlike woocommerce_after_save_address_validation, there's no submitted form, so read the copied values from
- * the customer instead of the request.
+ * There's no submitted form, so read the copied values from the customer instead of the request. A stopped copy
+ * opens the edit address form filled with the copied address, and saving it runs woocommerce_after_save_address_validation.
*
* @since 11.4.0
*
@@ -528,8 +601,10 @@ class WC_Form_Handler {
*/
do_action( 'woocommerce_before_copy_address_save', $user_id, $target_type, $source_type, $customer );
+ // Open the edit form filled with the copied address instead, so the customer only fixes what the errors point to.
if ( 0 < wc_notice_count( 'error' ) ) {
- wp_safe_redirect( $redirect_url );
+ wc_add_notice( __( 'Review the copied address below, then save it.', 'woocommerce' ), 'notice' );
+ wp_safe_redirect( add_query_arg( 'copy-address-from', $source_type, wc_get_endpoint_url( 'edit-address', $target_type, wc_get_page_permalink( 'myaccount' ) ) ) );
exit;
}
diff --git a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
index 1c704482f09..c7ff53046a1 100644
--- a/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
+++ b/plugins/woocommerce/includes/shortcodes/class-wc-shortcode-my-account.php
@@ -206,7 +206,9 @@ class WC_Shortcode_My_Account {
public static function edit_address( $load_address = 'billing' ) {
$current_user = wp_get_current_user();
$load_address = sanitize_key( $load_address );
- $country = get_user_meta( get_current_user_id(), $load_address . '_country', true );
+ $copy_source = WC_Form_Handler::get_copy_address_form_source( $load_address );
+ $values_from = $copy_source ? $copy_source : $load_address;
+ $country = get_user_meta( get_current_user_id(), $values_from . '_country', true );
if ( ! $country ) {
$country = WC()->countries->get_base_country();
@@ -236,8 +238,18 @@ class WC_Shortcode_My_Account {
// Prepare values.
foreach ( $address as $key => $field ) {
+ $meta_key = $key;
- $value = get_user_meta( get_current_user_id(), $key, true );
+ if ( $copy_source && 0 === strpos( $key, $load_address . '_' ) ) {
+ $meta_key = $copy_source . substr( $key, strlen( $load_address ) );
+ }
+
+ $value = get_user_meta( get_current_user_id(), $meta_key, true );
+
+ // Keep what the edited address already has, such as a billing email or phone, when the copied address has none.
+ if ( '' === $value && $meta_key !== $key ) {
+ $value = get_user_meta( get_current_user_id(), $key, true );
+ }
if ( ! $value ) {
switch ( $key ) {
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
index 5179bd36c51..11a59cb59ee 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldsFrontend.php
@@ -186,9 +186,29 @@ class CheckoutFieldsFrontend {
* @return array Updated address fields.
*/
public function edit_address_fields( $address, $address_type ) {
- $customer = new WC_Customer( get_current_user_id() );
+ $customer = new WC_Customer( get_current_user_id() );
+ $copy_source = \WC_Form_Handler::get_copy_address_form_source( $address_type );
+ $copied_values = [];
+ $request_data = [];
+
+ if ( $copy_source ) {
+ foreach ( array_keys( $this->checkout_fields_controller->get_fields_for_location( 'address' ) ) as $key ) {
+ // Defaults are computed for the address being edited, rather than the copied address.
+ $value = $this->checkout_fields_controller->get_field_from_object( $key, $customer, $copy_source, false );
+ $copied_values[ $key ] = '' === $value ? $this->checkout_fields_controller->get_field_from_object( $key, $customer, $address_type ) : $value;
+ }
- $document_object = new DocumentObject();
+ $address_values = $copied_values;
+ foreach ( $address as $key => $field ) {
+ if ( 0 === strpos( $key, $address_type . '_' ) ) {
+ $address_values[ substr( $key, strlen( $address_type ) + 1 ) ] = $field['value'] ?? '';
+ }
+ }
+
+ $request_data = [ 'customer' => [ $address_type . '_address' => $address_values ] ];
+ }
+
+ $document_object = new DocumentObject( $request_data );
$document_object->set_customer( $customer );
$document_object->set_context( $address_type . '_address' );
$fields = $this->checkout_fields_controller->get_contextual_fields_for_location( 'address', $document_object );
@@ -196,7 +216,7 @@ class CheckoutFieldsFrontend {
foreach ( $fields as $key => $field ) {
$field_key = CheckoutFields::get_group_key( $address_type ) . $key;
$address[ $field_key ] = $field;
- $address[ $field_key ]['value'] = $this->checkout_fields_controller->get_field_from_object( $key, $customer, $address_type );
+ $address[ $field_key ]['value'] = $copied_values[ $key ] ?? $this->checkout_fields_controller->get_field_from_object( $key, $customer, $address_type );
$address[ $field_key ] = $this->checkout_fields_controller->prepare_form_field( $address[ $field_key ] );
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
index 4cec4e95319..5c4e42c3df4 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-form-handler-test.php
@@ -569,7 +569,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
);
$this->prepare_copy_address_request( 'shipping' );
- $this->dispatch_copy_address_expecting_redirect();
+ $this->dispatch_copy_address_expecting_redirect( $this->get_copy_address_form_url( 'shipping', 'billing' ) );
$this->assertSame( 'Extension rejected the address.', wc_get_notices( 'error' )[0]['notice'] ?? null );
$this->assertEmpty( wc_get_notices( 'success' ), 'A rejected copy should not show a success notice.' );
@@ -590,6 +590,27 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$this->assertSame( 'copy-address-customer@example.test', ( new WC_Customer( $customer->get_id() ) )->get_billing_email() );
}
+ /**
+ * @testdox copy_address() keeps the billing email and phone already saved on an empty billing address when the shipping address has none.
+ *
+ * @covers WC_Form_Handler::copy_address()
+ */
+ public function test_copy_address_keeps_target_email_and_phone(): void {
+ $customer = $this->create_customer_with_address( 'shipping' );
+ $customer->set_shipping_phone( '' );
+ $customer->set_billing_email( 'invoices@example.test' );
+ $customer->set_billing_phone( '4155550199' );
+ $customer->save();
+
+ $this->prepare_copy_address_request( 'billing' );
+ $this->dispatch_copy_address_expecting_redirect();
+
+ $saved = new WC_Customer( $customer->get_id() );
+ $this->assertSame( '456 Madison Avenue', $saved->get_billing_address_1(), 'The shipping address should be copied.' );
+ $this->assertSame( 'invoices@example.test', $saved->get_billing_email(), 'The saved billing email should not be replaced by the account email.' );
+ $this->assertSame( '4155550199', $saved->get_billing_phone(), 'The saved billing phone should not be cleared.' );
+ }
+
/**
* @testdox copy_address() leaves an address that is already set up untouched.
*
@@ -754,7 +775,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
);
$this->prepare_copy_address_request( 'billing' );
- $this->dispatch_copy_address_expecting_redirect();
+ $this->dispatch_copy_address_expecting_redirect( $this->get_copy_address_form_url( 'billing', 'shipping' ) );
$this->assertSame( 'Phone is a required field.', wc_get_notices( 'error' )[0]['notice'] ?? null );
$this->assert_copy_address_rejected( $customer->get_id(), 'billing' );
@@ -771,7 +792,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$customer->save();
$this->prepare_copy_address_request( 'shipping' );
- $this->dispatch_copy_address_expecting_redirect();
+ $this->dispatch_copy_address_expecting_redirect( $this->get_copy_address_form_url( 'shipping', 'billing' ) );
$this->assertSame( 'Please enter a valid postcode / ZIP.', wc_get_notices( 'error' )[0]['notice'] ?? null );
$this->assert_copy_address_rejected( $customer->get_id(), 'shipping' );
@@ -802,6 +823,30 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$this->assertSame( '1234', $checkout_fields->get_field_from_object( 'test-namespace/gate-code', new WC_Customer( $customer->get_id() ), 'shipping' ) );
}
+ /**
+ * @testdox copy_address() keeps a Checkout block address field already saved on the empty target address when the copied address has no value.
+ *
+ * @covers WC_Form_Handler::copy_address()
+ */
+ public function test_copy_address_keeps_target_additional_checkout_field(): void {
+ $this->register_checkout_field(
+ array(
+ 'id' => 'test-namespace/gate-code',
+ 'label' => 'Gate code',
+ 'location' => 'address',
+ )
+ );
+ $customer = $this->create_customer_with_address( 'billing' );
+ $checkout_fields = Package::container()->get( CheckoutFields::class );
+ $checkout_fields->persist_field_for_customer( 'test-namespace/gate-code', '5678', $customer, 'shipping' );
+ $customer->save();
+
+ $this->prepare_copy_address_request( 'shipping' );
+ $this->dispatch_copy_address_expecting_redirect();
+
+ $this->assertSame( '5678', $checkout_fields->get_field_from_object( 'test-namespace/gate-code', new WC_Customer( $customer->get_id() ), 'shipping' ), 'The saved shipping gate code should not be cleared.' );
+ }
+
/**
* @testdox copy_address() refuses to copy when a required Checkout block address field is empty.
*
@@ -819,12 +864,204 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$customer = $this->create_customer_with_address( 'billing' );
$this->prepare_copy_address_request( 'shipping' );
- $this->dispatch_copy_address_expecting_redirect();
+ $this->dispatch_copy_address_expecting_redirect( $this->get_copy_address_form_url( 'shipping', 'billing' ) );
$this->assertSame( '<strong>Gate code</strong> is required', wc_get_notices( 'error' )[0]['notice'] ?? null );
$this->assert_copy_address_rejected( $customer->get_id(), 'shipping' );
}
+ /**
+ * @testdox The edit address form for an empty billing address is filled from the shipping address after a refused copy.
+ *
+ * @covers WC_Shortcode_My_Account::edit_address()
+ * @covers WC_Form_Handler::get_copy_address_form_source()
+ */
+ public function test_edit_address_form_is_filled_from_copy_source(): void {
+ $customer = $this->create_customer_with_address( 'shipping' );
+ $customer->set_shipping_phone( '' );
+ $customer->save();
+
+ $fields = $this->get_edit_address_form_fields( 'billing', 'shipping' );
+
+ foreach ( array( 'first_name', 'last_name', 'company', 'address_1', 'address_2', 'city', 'state', 'postcode', 'country' ) as $field ) {
+ $this->assertSame( $customer->{"get_shipping_{$field}"}(), $fields[ "billing_{$field}" ]['value'] ?? null, "billing_{$field} should be filled from the shipping address." );
+ }
+ $this->assertSame( '', $fields['billing_phone']['value'] ?? null, 'The missing phone should stay empty for the customer to fill in.' );
+ $this->assertSame( 'copy-address-customer@example.test', $fields['billing_email']['value'] ?? null, 'The email should fall back to the account email.' );
+ $this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->get_billing_address_1(), 'Filling the form should not save the billing address.' );
+ }
+
+ /**
+ * @testdox The edit address form keeps the billing email and phone already saved on an empty billing address when the shipping address has none.
+ *
+ * @covers WC_Shortcode_My_Account::edit_address()
+ */
+ public function test_edit_address_form_keeps_edited_address_email_and_phone(): void {
+ $customer = $this->create_customer_with_address( 'shipping' );
+ $customer->set_shipping_phone( '' );
+ $customer->set_billing_email( 'invoices@example.test' );
+ $customer->set_billing_phone( '4155550199' );
+ $customer->save();
+
+ $fields = $this->get_edit_address_form_fields( 'billing', 'shipping' );
+
+ $this->assertSame( '456 Madison Avenue', $fields['billing_address_1']['value'] ?? null, 'The form should be filled from the shipping address.' );
+ $this->assertSame( 'invoices@example.test', $fields['billing_email']['value'] ?? null, 'The saved billing email should be shown, not the account email.' );
+ $this->assertSame( '4155550199', $fields['billing_phone']['value'] ?? null, 'The saved billing phone should be shown.' );
+ }
+
+ /**
+ * @testdox The edit address form keeps its saved values when $scenario.
+ *
+ * @testWith ["the copy-address-from arg is missing", ""]
+ * ["the copy-address-from arg names the edited address type", "billing"]
+ * ["the copy-address-from arg is unknown", "contact"]
+ * @covers WC_Form_Handler::get_copy_address_form_source()
+ *
+ * @param string $scenario Scenario description.
+ * @param string $source_type Value of the copy-address-from query arg.
+ */
+ public function test_edit_address_form_ignores_invalid_copy_source( string $scenario, string $source_type ): void {
+ $this->create_customer_with_address( 'shipping' );
+
+ $fields = $this->get_edit_address_form_fields( 'billing', $source_type );
+
+ $this->assertSame( '', $fields['billing_address_1']['value'] ?? null, "The billing form should not be filled when {$scenario}." );
+ }
+
+ /**
+ * @testdox The edit address form isn't filled from the other address when the edited address is already saved.
+ *
+ * @covers WC_Form_Handler::get_copy_address_form_source()
+ */
+ public function test_edit_address_form_is_not_filled_over_a_saved_address(): void {
+ $customer = $this->create_customer_with_address( 'shipping' );
+ foreach ( self::provide_address_types()['billing'][1] as $field => $value ) {
+ $customer->{"set_billing_{$field}"}( $value );
+ }
+ $customer->save();
+
+ $fields = $this->get_edit_address_form_fields( 'billing', 'shipping' );
+
+ $this->assertSame( '123 Market Street', $fields['billing_address_1']['value'] ?? null, 'A saved billing address should be shown as is.' );
+ }
+
+ /**
+ * @testdox The edit address form isn't filled from the other address when its country isn't available for the edited address type.
+ *
+ * @covers WC_Form_Handler::get_copy_address_form_source()
+ */
+ public function test_edit_address_form_is_not_filled_from_country_not_available(): void {
+ $this->create_customer_with_address( 'billing' );
+ add_filter(
+ 'woocommerce_countries_shipping_countries',
+ function () {
+ return array( 'CA' => 'Canada' );
+ }
+ );
+
+ $fields = $this->get_edit_address_form_fields( 'shipping', 'billing' );
+
+ $this->assertSame( '', $fields['shipping_address_1']['value'] ?? null, 'A billing address in a country the store does not ship to should not fill the shipping form.' );
+ }
+
+ /**
+ * @testdox The edit address form fills Checkout block address fields from the other address after a refused copy.
+ *
+ * @covers \Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsFrontend::edit_address_fields()
+ */
+ public function test_edit_address_form_fills_additional_checkout_fields_from_copy_source(): void {
+ $this->register_checkout_field(
+ array(
+ 'id' => 'test-namespace/gate-code',
+ 'label' => 'Gate code',
+ 'location' => 'address',
+ )
+ );
+ $customer = $this->create_customer_with_address( 'billing' );
+ $checkout_fields = Package::container()->get( CheckoutFields::class );
+ $checkout_fields->persist_field_for_customer( 'test-namespace/gate-code', '1234', $customer, 'billing' );
+ $customer->save();
+
+ $fields = $this->get_edit_address_form_fields( 'shipping', 'billing' );
+
+ $this->assertSame( '1234', $fields['_wc_shipping/test-namespace/gate-code']['value'] ?? null, 'The additional field should be filled from the billing address.' );
+ }
+
+ /**
+ * @testdox The copied edit form evaluates conditional fields against its displayed values in the destination context.
+ *
+ * @testWith ["shipping", "billing", "country", "US"]
+ * ["billing", "shipping", "country", "US"]
+ * ["shipping", "billing", "test-namespace/gate-code", "1234"]
+ * ["billing", "shipping", "test-namespace/gate-code", "1234"]
+ * @covers \Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsFrontend::edit_address_fields()
+ *
+ * @param string $source_type Address type copied from.
+ * @param string $target_type Address type being edited.
+ * @param string $condition_field Field controlling visibility and whether the field is required.
+ * @param string $condition_value Value that makes the field visible and required.
+ */
+ public function test_edit_address_form_evaluates_conditions_against_copied_values( string $source_type, string $target_type, string $condition_field, string $condition_value ): void {
+ $this->register_checkout_field(
+ array(
+ 'id' => 'test-namespace/gate-code',
+ 'label' => 'Gate code',
+ 'location' => 'address',
+ )
+ );
+ $condition = array( 'properties' => array( $condition_field => array( 'const' => $condition_value ) ) );
+ $this->register_checkout_field(
+ array(
+ 'id' => 'test-namespace/tax-id',
+ 'label' => 'Tax ID',
+ 'location' => 'address',
+ 'hidden' => array( 'customer' => array( 'properties' => array( 'address' => array( 'not' => $condition ) ) ) ),
+ 'required' => array( 'customer' => array( 'properties' => array( $target_type . '_address' => $condition ) ) ),
+ )
+ );
+ $customer = $this->create_customer_with_address( $source_type );
+ $checkout_fields = Package::container()->get( CheckoutFields::class );
+ $checkout_fields->persist_field_for_customer( 'test-namespace/gate-code', '1234', $customer, $source_type );
+ $customer->save();
+
+ $fields = $this->get_edit_address_form_fields( $target_type, $source_type );
+ $field_key = CheckoutFields::get_group_key( $target_type ) . 'test-namespace/tax-id';
+
+ $this->assertArrayHasKey( $field_key, $fields, 'A conditional field matching the copied values should be visible.' );
+ $this->assertTrue( $fields[ $field_key ]['required'], 'Required rules should use the copied values in the destination address context.' );
+ $this->assertSame( '', $fields[ $field_key ]['value'], 'The missing value should be left for the customer to fill in.' );
+ $this->assertSame( '', ( new WC_Customer( $customer->get_id() ) )->{"get_{$target_type}_country"}(), 'Rendering the copied form should not save the destination address.' );
+ }
+
+ /**
+ * @testdox The edit address form gives a Checkout block address field the edited address's default when the copied address has no value.
+ *
+ * @covers \Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsFrontend::edit_address_fields()
+ */
+ public function test_edit_address_form_uses_edited_address_default_for_empty_copied_field(): void {
+ $this->register_checkout_field(
+ array(
+ 'id' => 'test-namespace/gate-code',
+ 'label' => 'Gate code',
+ 'location' => 'address',
+ )
+ );
+ add_filter(
+ 'woocommerce_get_default_value_for_test-namespace/gate-code',
+ function ( $value, $group ) {
+ return "{$group} default";
+ },
+ 10,
+ 2
+ );
+ $this->create_customer_with_address( 'billing' );
+
+ $fields = $this->get_edit_address_form_fields( 'shipping', 'billing' );
+
+ $this->assertSame( 'shipping default', $fields['_wc_shipping/test-namespace/gate-code']['value'] ?? null, 'The default should be computed for the shipping address being edited.' );
+ }
+
/**
* @testdox save_account_details() saves other account fields when an email-like display name is unchanged.
*
@@ -1226,16 +1463,18 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
}
/**
- * Dispatches the copy address handler and expects a redirect back to Addresses.
+ * Dispatches the copy address handler and expects a redirect, by default back to Addresses.
+ *
+ * @param string $expected_url Expected redirect URL; Addresses when empty.
*/
- private function dispatch_copy_address_expecting_redirect(): void {
+ private function dispatch_copy_address_expecting_redirect( string $expected_url = '' ): void {
try {
WC_Form_Handler::copy_address();
} catch ( RuntimeException $e ) {
$this->assertSame(
- wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ),
+ esc_url_raw( $expected_url ? $expected_url : wc_get_endpoint_url( 'edit-address', '', wc_get_page_permalink( 'myaccount' ) ) ),
$e->getMessage(),
- 'Copying an address should redirect to Addresses.'
+ 'Copying an address should redirect to the expected page.'
);
return;
}
@@ -1243,6 +1482,43 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
$this->fail( 'Expected copy_address() to redirect. Notices: ' . wp_json_encode( wc_get_notices() ) );
}
+ /**
+ * Gets the edit address form URL that a refused copy redirects to.
+ *
+ * @param string $target_type Address type the copy targeted.
+ * @param string $source_type Address type the copy read from.
+ * @return string
+ */
+ private function get_copy_address_form_url( string $target_type, string $source_type ): string {
+ return add_query_arg( 'copy-address-from', $source_type, wc_get_endpoint_url( 'edit-address', $target_type, wc_get_page_permalink( 'myaccount' ) ) );
+ }
+
+ /**
+ * Renders the edit address form and returns the fields it was given.
+ *
+ * @param string $address_type Address type to edit.
+ * @param string $source_type Value of the copy-address-from query arg, or an empty string to leave it out.
+ * @return array<string,array<string,mixed>>
+ */
+ private function get_edit_address_form_fields( string $address_type, string $source_type ): array {
+ $_GET = $source_type ? array( 'copy-address-from' => $source_type ) : array();
+ $fields = array();
+ add_filter(
+ 'woocommerce_address_to_edit',
+ function ( $address ) use ( &$fields ) {
+ $fields = $address;
+ return $address;
+ },
+ PHP_INT_MAX
+ );
+
+ ob_start();
+ WC_Shortcode_My_Account::edit_address( $address_type );
+ ob_end_clean();
+
+ return $fields;
+ }
+
/**
* Asserts that a copy was refused: no success notice and the target address is still empty.
*
@@ -1250,6 +1526,7 @@ class WC_Form_Handler_Test extends WC_Unit_Test_Case {
* @param string $target_type Address type the copy targeted.
*/
private function assert_copy_address_rejected( int $customer_id, string $target_type ): void {
+ $this->assertSame( 'Review the copied address below, then save it.', wc_get_notices( 'notice' )[0]['notice'] ?? null, 'A refused copy should ask the customer to review the edit form.' );
$this->assertEmpty( wc_get_notices( 'success' ), 'A refused copy should not show a success notice.' );
$this->assertSame( '', ( new WC_Customer( $customer_id ) )->{"get_{$target_type}_address_1"}(), "The {$target_type} address should not be saved." );
}