Commit f0c8876ecd7 for woocommerce
commit f0c8876ecd7c85e2fdaf7750715efddc46a6ae3a
Author: Tung Du <dinhtungdu@gmail.com>
Date: Tue Jul 28 14:22:06 2026 +0700
Add sale date fields to the Product Quick edit form (#66931)
* Revert "Fix Quick Edit keeping expired sale dates when prices change (#66726)"
This reverts commit 0447abbd99924b72ab4f43c67e2379a027ff5bfa.
* Revert "Fix Quick Edit removing scheduled sale dates (#66648)"
This reverts commit f21f1796ae96f1f2783825f97cce4285b8fcf4a8.
* Add sale dates to product Quick Edit
* chore: changelog
* fix: unit test
diff --git a/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates b/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
deleted file mode 100644
index 92e9a467f6d..00000000000
--- a/plugins/woocommerce/changelog/fix-29040-quick-edit-sale-dates
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Preserve scheduled sale dates when updating product prices with Quick Edit.
diff --git a/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates b/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
deleted file mode 100644
index 497b4e70355..00000000000
--- a/plugins/woocommerce/changelog/fix-66718-quick-edit-expired-sale-dates
+++ /dev/null
@@ -1,4 +0,0 @@
-Significance: patch
-Type: fix
-
-Clear expired scheduled sale dates during Quick Edit so a new sale price takes effect immediately.
diff --git a/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates b/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates
new file mode 100644
index 00000000000..6eea5f286a3
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wc-66768-quick-edit-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add explicit sale date fields to product Quick Edit and save schedules consistently with the full product editor.
diff --git a/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates b/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates
new file mode 100644
index 00000000000..cc68e4d77a1
--- /dev/null
+++ b/plugins/woocommerce/changelog/wc-66768-quick-edit-sale-dates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: add
+
+Show sale date fields in the product quick edit form.
diff --git a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
index a4faa6e391c..eee4e8880e2 100644
--- a/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
+++ b/plugins/woocommerce/client/legacy/js/admin/quick-edit.js
@@ -1,6 +1,29 @@
/*global inlineEditPost, woocommerce_admin, woocommerce_quick_edit */
jQuery(
function( $ ) {
+ function init_sale_datepickers( $row ) {
+ var $date_from = $row.find( 'input[name="_sale_price_dates_from"]' ),
+ $date_to = $row.find( 'input[name="_sale_price_dates_to"]' );
+
+ function sync_date_limits() {
+ $date_from.datepicker( 'option', 'maxDate', $date_to.datepicker( 'getDate' ) );
+ $date_to.datepicker( 'option', 'minDate', $date_from.datepicker( 'getDate' ) );
+ }
+
+ $date_from.add( $date_to ).datepicker( {
+ defaultDate: '',
+ dateFormat: 'yy-mm-dd',
+ numberOfMonths: 1,
+ showButtonPanel: true,
+ onSelect: function() {
+ sync_date_limits();
+ $( this ).trigger( 'change' );
+ }
+ } );
+
+ sync_date_limits();
+ }
+
$( '#the-list' ).on(
'click',
'.editinline',
@@ -14,24 +37,26 @@ jQuery(
var $wc_inline_data = $( '#woocommerce_inline_' + post_id );
- var sku = $wc_inline_data.find( '.sku' ).text(),
- regular_price = $wc_inline_data.find( '.regular_price' ).text(),
- sale_price = $wc_inline_data.find( '.sale_price ' ).text(),
- weight = $wc_inline_data.find( '.weight' ).text(),
- length = $wc_inline_data.find( '.length' ).text(),
- width = $wc_inline_data.find( '.width' ).text(),
- height = $wc_inline_data.find( '.height' ).text(),
- shipping_class = $wc_inline_data.find( '.shipping_class' ).text(),
- visibility = $wc_inline_data.find( '.visibility' ).text(),
- stock_status = $wc_inline_data.find( '.stock_status' ).text(),
- stock = $wc_inline_data.find( '.stock' ).text(),
- featured = $wc_inline_data.find( '.featured' ).text(),
- manage_stock = $wc_inline_data.find( '.manage_stock' ).text(),
- menu_order = $wc_inline_data.find( '.menu_order' ).text(),
- tax_status = $wc_inline_data.find( '.tax_status' ).text(),
- tax_class = $wc_inline_data.find( '.tax_class' ).text(),
- backorders = $wc_inline_data.find( '.backorders' ).text(),
- product_type = $wc_inline_data.find( '.product_type' ).text();
+ var sku = $wc_inline_data.find( '.sku' ).text(),
+ regular_price = $wc_inline_data.find( '.regular_price' ).text(),
+ sale_price = $wc_inline_data.find( '.sale_price ' ).text(),
+ sale_date_from = $wc_inline_data.find( '.sale_price_dates_from' ).text(),
+ sale_date_to = $wc_inline_data.find( '.sale_price_dates_to' ).text(),
+ weight = $wc_inline_data.find( '.weight' ).text(),
+ length = $wc_inline_data.find( '.length' ).text(),
+ width = $wc_inline_data.find( '.width' ).text(),
+ height = $wc_inline_data.find( '.height' ).text(),
+ shipping_class = $wc_inline_data.find( '.shipping_class' ).text(),
+ visibility = $wc_inline_data.find( '.visibility' ).text(),
+ stock_status = $wc_inline_data.find( '.stock_status' ).text(),
+ stock = $wc_inline_data.find( '.stock' ).text(),
+ featured = $wc_inline_data.find( '.featured' ).text(),
+ manage_stock = $wc_inline_data.find( '.manage_stock' ).text(),
+ menu_order = $wc_inline_data.find( '.menu_order' ).text(),
+ tax_status = $wc_inline_data.find( '.tax_status' ).text(),
+ tax_class = $wc_inline_data.find( '.tax_class' ).text(),
+ backorders = $wc_inline_data.find( '.backorders' ).text(),
+ product_type = $wc_inline_data.find( '.product_type' ).text();
var formatted_regular_price = regular_price.replace( '.', woocommerce_admin.mon_decimal_point ),
formatted_sale_price = sale_price.replace( '.', woocommerce_admin.mon_decimal_point );
@@ -45,6 +70,11 @@ jQuery(
$( 'input[name="_sku"]', '.inline-edit-row' ).val( sku );
$( 'input[name="_regular_price"]', '.inline-edit-row' ).val( formatted_regular_price );
$( 'input[name="_sale_price"]', '.inline-edit-row' ).val( formatted_sale_price );
+ $( 'input[name="_sale_price_dates_from"]', '.inline-edit-row' ).val( sale_date_from );
+ $( 'input[name="_sale_price_dates_to"]', '.inline-edit-row' ).val( sale_date_to );
+ setTimeout( function() {
+ init_sale_datepickers( $( '#edit-' + post_id ) );
+ }, 0 );
$( 'input[name="_weight"]', '.inline-edit-row' ).val( weight );
$( 'input[name="_length"]', '.inline-edit-row' ).val( length );
$( 'input[name="_width"]', '.inline-edit-row' ).val( width );
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
index 2c74470553a..54367f172b0 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-assets.php
@@ -519,7 +519,7 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
// Products.
if ( in_array( $screen_id, array( 'edit-product' ) ) ) {
- wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'woocommerce_admin' ), $version );
+ wp_enqueue_script( 'woocommerce_quick-edit', WC()->plugin_url() . '/assets/js/admin/quick-edit' . $suffix . '.js', array( 'jquery', 'jquery-ui-datepicker', 'woocommerce_admin' ), $version, false );
$params = array(
'strings' => array(
diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
index e54dd0b1717..adcd8af7472 100644
--- a/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
+++ b/plugins/woocommerce/includes/admin/class-wc-admin-post-types.php
@@ -450,37 +450,54 @@ class WC_Admin_Post_Types {
$product->set_featured( isset( $request_data['_featured'] ) );
if ( $product->is_type( ProductType::SIMPLE ) || $product->is_type( ProductType::EXTERNAL ) ) {
- // The Quick Edit form is prefilled with view-context (filtered) prices, so detecting
- // an actual edit requires comparing the submission against those same values.
- $old_regular_price = $this->normalize_price_for_comparison( $product->get_regular_price() );
- $old_sale_price = $this->normalize_price_for_comparison( $product->get_sale_price() );
if ( isset( $request_data['_regular_price'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
- $new_regular_price = ( '' === $request_data['_regular_price'] ) ? '' : wc_format_decimal( $request_data['_regular_price'] );
- $product->set_regular_price( $new_regular_price );
+ $regular_price = ( '' === $request_data['_regular_price'] ) ? '' : wc_format_decimal( $request_data['_regular_price'] );
+ $product->set_regular_price( $regular_price );
}
if ( isset( $request_data['_sale_price'] ) ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
- $new_sale_price = ( '' === $request_data['_sale_price'] ) ? '' : wc_format_decimal( $request_data['_sale_price'] );
- $product->set_sale_price( $new_sale_price );
+ $sale_price = ( '' === $request_data['_sale_price'] ) ? '' : wc_format_decimal( $request_data['_sale_price'] );
+ $product->set_sale_price( $sale_price );
}
- $regular_price = $product->get_regular_price( 'edit' );
- $sale_price = $product->get_sale_price( 'edit' );
+ // Match the full product editor's date parsing and site-timezone behavior.
+ if ( isset( $request_data['_sale_price_dates_from'] ) ) {
+ $date_on_sale_from = '';
+ if ( is_string( $request_data['_sale_price_dates_from'] ) ) {
+ /**
+ * Sanitized sale start date.
+ *
+ * @var string $date_on_sale_from
+ */
+ $date_on_sale_from = wc_clean( wp_unslash( $request_data['_sale_price_dates_from'] ) );
+ }
- // Only a submitted field can be a changed field.
- $regular_price_changed = isset( $request_data['_regular_price'] ) && $this->normalize_price_for_comparison( $regular_price ) !== $old_regular_price;
- $sale_price_changed = isset( $request_data['_sale_price'] ) && $this->normalize_price_for_comparison( $sale_price ) !== $old_sale_price;
+ if ( ! empty( $date_on_sale_from ) ) {
+ $date_on_sale_from = date( 'Y-m-d 00:00:00', (int) strtotime( $date_on_sale_from ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+ }
- $price_changed = $regular_price_changed || $sale_price_changed;
- $sale_date_to = $product->get_date_on_sale_to( 'edit' );
- $sale_has_ended = $sale_date_to && $sale_date_to->getTimestamp() < time();
+ $product->set_date_on_sale_from( $date_on_sale_from );
+ }
- if ( $price_changed && ( '' === $sale_price || $sale_price >= $regular_price || $sale_has_ended ) ) {
- $product->set_date_on_sale_to( '' );
- $product->set_date_on_sale_from( '' );
+ if ( isset( $request_data['_sale_price_dates_to'] ) ) {
+ $date_on_sale_to = '';
+ if ( is_string( $request_data['_sale_price_dates_to'] ) ) {
+ /**
+ * Sanitized sale end date.
+ *
+ * @var string $date_on_sale_to
+ */
+ $date_on_sale_to = wc_clean( wp_unslash( $request_data['_sale_price_dates_to'] ) );
+ }
+
+ if ( ! empty( $date_on_sale_to ) ) {
+ $date_on_sale_to = date( 'Y-m-d 23:59:59', (int) strtotime( $date_on_sale_to ) ); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date
+ }
+
+ $product->set_date_on_sale_to( $date_on_sale_to );
}
}
@@ -524,20 +541,6 @@ class WC_Admin_Post_Types {
do_action( 'woocommerce_product_quick_edit_save', $product );
}
- /**
- * Normalize a price value for change detection during Quick Edit.
- *
- * View-context prices pass through the woocommerce_product_get_regular_price and
- * woocommerce_product_get_sale_price filters, which may return non-scalar values
- * or differently formatted numbers.
- *
- * @param mixed $price Raw price value.
- * @return string Normalized price string.
- */
- private function normalize_price_for_comparison( $price ): string {
- return wc_format_decimal( is_scalar( $price ) ? (string) $price : '', false, true );
- }
-
/**
* Bulk edit.
*
diff --git a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
index fad7a879c64..35eb8117ba7 100644
--- a/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
+++ b/plugins/woocommerce/includes/admin/list-tables/class-wc-admin-list-table-products.php
@@ -263,6 +263,19 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
'<div class="cogs_value">' . esc_html( $this->object->get_cogs_value() ?? '0' ) . '</div>' :
'';
+ /**
+ * Product represented by the current list-table row.
+ * Narrow the inherited object type without adding a PHPStan baseline entry.
+ * In future we should correct the type of $this->object.
+ *
+ * @var WC_Product $product
+ */
+ $product = $this->object;
+ $sale_date_from = $product->get_date_on_sale_from( 'edit' );
+ $sale_date_to = $product->get_date_on_sale_to( 'edit' );
+ $sale_date_from = $sale_date_from ? date_i18n( 'Y-m-d', $sale_date_from->getOffsetTimestamp() ) : '';
+ $sale_date_to = $sale_date_to ? date_i18n( 'Y-m-d', $sale_date_to->getOffsetTimestamp() ) : '';
+
// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped -- the COGS value is already escaped.
/* Custom inline data for woocommerce. */
echo '
@@ -272,6 +285,8 @@ class WC_Admin_List_Table_Products extends WC_Admin_List_Table {
<div class="global_unique_id">' . esc_html( $this->object->get_global_unique_id() ) . '</div>
<div class="regular_price">' . esc_html( $this->object->get_regular_price() ) . '</div>
<div class="sale_price">' . esc_html( $this->object->get_sale_price() ) . '</div>
+ <div class="sale_price_dates_from">' . esc_html( $sale_date_from ) . '</div>
+ <div class="sale_price_dates_to">' . esc_html( $sale_date_to ) . '</div>
<div class="weight">' . esc_html( $this->object->get_weight() ) . '</div>
<div class="length">' . esc_html( $this->object->get_length() ) . '</div>
<div class="width">' . esc_html( $this->object->get_width() ) . '</div>
diff --git a/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php b/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
index 62c74d68f46..e5e6785b727 100644
--- a/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
+++ b/plugins/woocommerce/includes/admin/views/html-quick-edit-product.php
@@ -46,6 +46,30 @@ defined( 'ABSPATH' ) || exit;
</span>
</label>
<br class="clear" />
+ <?php
+ /**
+ * Hook to customize the regular expression that validates dates entered in the WooCommerce admin editors.
+ *
+ * @since 3.0.0
+ *
+ * @param string $pattern Default pattern to use.
+ */
+ $date_input_html_pattern = apply_filters( 'woocommerce_date_input_html_pattern', '[0-9]{4}-(0[1-9]|1[012])-(0[1-9]|1[0-9]|2[0-9]|3[01])' );
+ ?>
+ <label>
+ <span class="title"><?php esc_html_e( 'Sale start', 'woocommerce' ); ?></span>
+ <span class="input-text-wrap">
+ <input type="text" name="_sale_price_dates_from" class="text sale_price_dates_from" placeholder="<?php echo esc_attr_x( 'From…', 'placeholder', 'woocommerce' ); ?> YYYY-MM-DD" value="" maxlength="10" pattern="<?php echo esc_attr( $date_input_html_pattern ); ?>">
+ </span>
+ </label>
+ <br class="clear" />
+ <label>
+ <span class="title"><?php esc_html_e( 'Sale end', 'woocommerce' ); ?></span>
+ <span class="input-text-wrap">
+ <input type="text" name="_sale_price_dates_to" class="text sale_price_dates_to" placeholder="<?php echo esc_attr_x( 'To…', 'placeholder', 'woocommerce' ); ?> YYYY-MM-DD" value="" maxlength="10" pattern="<?php echo esc_attr( $date_input_html_pattern ); ?>">
+ </span>
+ </label>
+ <br class="clear" />
</div>
<?php if ( wc_get_container()->get( CostOfGoodsSoldController::class )->feature_is_enabled() ) : ?>
diff --git a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
index f73f89e9ae5..a9220c36d20 100644
--- a/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
+++ b/plugins/woocommerce/tests/php/includes/admin/class-wc-admin-post-types-test.php
@@ -7,6 +7,8 @@
declare( strict_types = 1 );
+use Automattic\WooCommerce\Enums\ProductType;
+
/**
* Class WC_Admin_Post_Types_Test.
*/
@@ -60,310 +62,235 @@ class WC_Admin_Post_Types_Test extends WC_Unit_Test_Case {
}
/**
- * @testdox Quick Edit preserves sale dates when a valid product price changes.
- * @dataProvider valid_price_changes_provider
+ * @testdox Quick Edit explicitly saves active, future, expired, and empty sale schedules for supported product types.
+ * @dataProvider explicit_schedule_provider
*
- * @param int $start_offset Sale start offset from the current time.
- * @param int $end_offset Sale end offset from the current time.
- * @param string $new_regular_price New regular price.
- * @param string $new_sale_price New sale price.
+ * @param string $product_type Product type.
+ * @param string|null $initial_from Initial sale start date.
+ * @param string|null $initial_to Initial sale end date.
+ * @param string $submitted_from Submitted sale start date.
+ * @param string $submitted_to Submitted sale end date.
+ * @param string|null $expected_from Expected sale start date.
+ * @param string|null $expected_to Expected sale end date.
*/
- public function test_quick_edit_preserves_sale_dates_when_prices_change( int $start_offset, int $end_offset, string $new_regular_price, string $new_sale_price ): void {
- $now = time();
- $product = WC_Helper_Product::create_simple_product();
- $sale_date_from = gmdate( 'Y-m-d H:i:s', $now + $start_offset );
- $sale_date_to = gmdate( 'Y-m-d H:i:s', $now + $end_offset );
- $original_sale_from = wc_string_to_datetime( $sale_date_from );
- $original_sale_to = wc_string_to_datetime( $sale_date_to );
-
+ public function test_quick_edit_saves_explicit_sale_schedule(
+ string $product_type,
+ ?string $initial_from,
+ ?string $initial_to,
+ string $submitted_from,
+ string $submitted_to,
+ ?string $expected_from,
+ ?string $expected_to
+ ): void {
+ $product = $this->create_product( $product_type );
$product->set_regular_price( '100' );
$product->set_sale_price( '80' );
- $product->set_date_on_sale_from( $sale_date_from );
- $product->set_date_on_sale_to( $sale_date_to );
+ $product->set_date_on_sale_from( $initial_from );
+ $product->set_date_on_sale_to( $initial_to );
$product->save();
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => $new_regular_price,
- '_sale_price' => $new_sale_price,
- '_stock_status' => 'instock',
+ $this->quick_edit(
+ $product,
+ array(
+ '_regular_price' => '100',
+ '_sale_price' => '70',
+ '_sale_price_dates_from' => $submitted_from,
+ '_sale_price_dates_to' => $submitted_to,
+ )
);
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- $updated_product = wc_get_product( $product->get_id() );
- $updated_sale_from = $updated_product->get_date_on_sale_from( 'edit' );
- $updated_sale_to = $updated_product->get_date_on_sale_to( 'edit' );
+ $updated_product = wc_get_product( $product->get_id() );
- $this->assertSame( $new_regular_price, $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
- $this->assertSame( $new_sale_price, $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_sale_from, 'Quick Edit should preserve the sale start date.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_sale_to, 'Quick Edit should preserve the sale end date.' );
- $this->assertSame( $original_sale_from->getTimestamp(), $updated_sale_from->getTimestamp(), 'The sale start date should remain unchanged.' );
- $this->assertSame( $original_sale_to->getTimestamp(), $updated_sale_to->getTimestamp(), 'The sale end date should remain unchanged.' );
+ $this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the submitted sale price.' );
+ $this->assert_date( $expected_from, $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+ $this->assert_date( $expected_to, $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
}
/**
- * @testdox Quick Edit clears sale dates when a price change ends the sale.
- * @dataProvider sale_ending_price_changes_provider
+ * Provides sale schedule states and supported product types.
*
- * @param string $new_regular_price New regular price.
- * @param string $new_sale_price New sale price.
+ * @return array<string, array{string, ?string, ?string, string, string, ?string, ?string}>
*/
- public function test_quick_edit_clears_sale_dates_when_sale_ends( string $new_regular_price, string $new_sale_price ): void {
- $product = WC_Helper_Product::create_simple_product();
-
- $product->set_regular_price( '100' );
- $product->set_sale_price( '80' );
- $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
- $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() + DAY_IN_SECONDS ) );
- $product->save();
-
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => $new_regular_price,
- '_sale_price' => $new_sale_price,
- '_stock_status' => 'instock',
+ public static function explicit_schedule_provider(): array {
+ return array(
+ 'active simple schedule' => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2099-12-31 23:59:59', '2000-01-01', '2099-12-31', '2000-01-01 00:00:00', '2099-12-31 23:59:59' ),
+ 'future external schedule' => array( ProductType::EXTERNAL, '2098-01-01 00:00:00', '2098-12-31 23:59:59', '2098-02-01', '2098-11-30', '2098-02-01 00:00:00', '2098-11-30 23:59:59' ),
+ 'expired simple schedule' => array( ProductType::SIMPLE, '2000-01-01 00:00:00', '2001-01-01 23:59:59', '2000-01-01', '2001-01-01', '2000-01-01 00:00:00', '2001-01-01 23:59:59' ),
+ 'empty external schedule' => array( ProductType::EXTERNAL, null, null, '', '', null, null ),
);
-
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- $updated_product = wc_get_product( $product->get_id() );
-
- $this->assertSame( '', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should leave the product without a sale price.' );
- $this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the start date when the sale ends.' );
- $this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the end date when the sale ends.' );
}
/**
- * @testdox Quick Edit clears expired sale dates when prices change so the new sale takes effect.
- * @dataProvider expired_schedule_price_changes_provider
- *
- * @param string $new_regular_price New regular price.
- * @param string $new_sale_price New sale price.
+ * @testdox Quick Edit preserves explicit sale dates when only prices are submitted.
*/
- public function test_quick_edit_clears_expired_sale_dates_when_prices_change( string $new_regular_price, string $new_sale_price ): void {
- $product = WC_Helper_Product::create_simple_product();
-
+ public function test_quick_edit_price_only_request_preserves_sale_dates(): void {
+ $product = $this->create_product( ProductType::SIMPLE );
$product->set_regular_price( '100' );
$product->set_sale_price( '80' );
- $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
- $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+ $product->set_date_on_sale_from( '2000-01-01 00:00:00' );
+ $product->set_date_on_sale_to( '2001-01-01 23:59:59' );
$product->save();
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => $new_regular_price,
- '_sale_price' => $new_sale_price,
- '_stock_status' => 'instock',
+ $this->quick_edit(
+ $product,
+ array(
+ '_regular_price' => '90',
+ '_sale_price' => '70',
+ )
);
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
$updated_product = wc_get_product( $product->get_id() );
- $this->assertSame( $new_regular_price, $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
- $this->assertSame( $new_sale_price, $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
- $this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the expired sale start date.' );
- $this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the expired sale end date.' );
- $this->assertTrue( $updated_product->is_on_sale( 'edit' ), 'The product should be on sale once the expired schedule is cleared.' );
- $this->assertSame( $new_sale_price, $updated_product->get_price( 'edit' ), 'The active price should be the new sale price.' );
+ $this->assertSame( '90', $updated_product->get_regular_price( 'edit' ), 'Quick Edit should persist the regular price.' );
+ $this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should persist the sale price.' );
+ $this->assert_date( '2000-01-01 00:00:00', $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+ $this->assert_date( '2001-01-01 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
}
/**
- * @testdox Quick Edit preserves expired sale dates when prices are not edited, even if display filters reformat prices.
+ * @testdox Quick Edit updates sale dates without requiring price fields.
*/
- public function test_quick_edit_preserves_expired_sale_dates_when_prices_not_edited(): void {
- // Simulates extensions (currency switchers, wholesale, dynamic pricing) that filter
- // prices in view context, reshaping the values the Quick Edit form is prefilled with.
- $format_price = function ( $value ) {
- return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
- };
- add_filter( 'woocommerce_product_get_regular_price', $format_price );
- add_filter( 'woocommerce_product_get_sale_price', $format_price );
-
- $product = WC_Helper_Product::create_simple_product();
-
+ public function test_quick_edit_date_only_request_updates_sale_dates(): void {
+ $product = $this->create_product( ProductType::SIMPLE );
$product->set_regular_price( '100' );
$product->set_sale_price( '80' );
- $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
- $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
$product->save();
- // A stock-only Quick Edit: the price inputs hold the filtered display values, submitted back unchanged.
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => '100.00',
- '_sale_price' => '80.00',
- '_stock_status' => 'outofstock',
+ $this->quick_edit(
+ $product,
+ array(
+ '_sale_price_dates_from' => '2098-03-04',
+ '_sale_price_dates_to' => '2098-04-05',
+ )
);
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- remove_filter( 'woocommerce_product_get_regular_price', $format_price );
- remove_filter( 'woocommerce_product_get_sale_price', $format_price );
-
$updated_product = wc_get_product( $product->get_id() );
- $this->assertSame( '100.00', $updated_product->get_regular_price( 'edit' ), 'The submitted display-formatted regular price is what gets persisted.' );
- $this->assertSame( '80.00', $updated_product->get_sale_price( 'edit' ), 'The submitted display-formatted sale price is what gets persisted.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price was edited.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price was edited.' );
- $this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by an edit that did not touch prices.' );
+ $this->assertSame( '100', $updated_product->get_regular_price( 'edit' ), 'Quick Edit should preserve the regular price.' );
+ $this->assertSame( '80', $updated_product->get_sale_price( 'edit' ), 'Quick Edit should preserve the sale price.' );
+ $this->assert_date( '2098-03-04 00:00:00', $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+ $this->assert_date( '2098-04-05 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
}
/**
- * @testdox Quick Edit preserves expired sale dates when a converting display filter round-trips unedited prices.
+ * @testdox Quick Edit intentionally clears submitted empty sale dates.
*/
- public function test_quick_edit_preserves_expired_sale_dates_with_converting_filter(): void {
- // Simulates a value-converting filter (currency switcher, dynamic pricing) that is
- // active during both the form render and the save request.
- $convert_price = function ( $value ) {
- return '' === $value || null === $value ? $value : (string) ( 2 * (float) $value );
- };
- add_filter( 'woocommerce_product_get_regular_price', $convert_price );
- add_filter( 'woocommerce_product_get_sale_price', $convert_price );
-
- $product = WC_Helper_Product::create_simple_product();
-
- $product->set_regular_price( '100' );
- $product->set_sale_price( '80' );
- $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
- $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
+ public function test_quick_edit_clears_sale_dates(): void {
+ $product = $this->create_product( ProductType::SIMPLE );
+ $product->set_date_on_sale_from( '2000-01-01 00:00:00' );
+ $product->set_date_on_sale_to( '2099-12-31 23:59:59' );
$product->save();
- // A stock-only Quick Edit: the form displayed the converted values and submits them back unchanged.
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => '200',
- '_sale_price' => '160',
- '_stock_status' => 'outofstock',
+ $this->quick_edit(
+ $product,
+ array(
+ '_sale_price_dates_from' => '',
+ '_sale_price_dates_to' => '',
+ )
);
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- remove_filter( 'woocommerce_product_get_regular_price', $convert_price );
- remove_filter( 'woocommerce_product_get_sale_price', $convert_price );
-
$updated_product = wc_get_product( $product->get_id() );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date under a converting display filter.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date under a converting display filter.' );
- $this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a round-trip of converted display prices.' );
+ $this->assertNull( $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should clear the sale start date.' );
+ $this->assertNull( $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should clear the sale end date.' );
}
/**
- * @testdox Quick Edit survives a display filter returning a non-scalar value.
+ * @testdox Quick Edit normalizes invalid sale dates like the full product editor.
*/
- public function test_quick_edit_survives_non_scalar_price_filter(): void {
- $break_price = function () {
- return new stdClass();
- };
- add_filter( 'woocommerce_product_get_regular_price', $break_price );
- add_filter( 'woocommerce_product_get_sale_price', $break_price );
-
- $product = WC_Helper_Product::create_simple_product();
-
- $product->set_regular_price( '100' );
- $product->set_sale_price( '80' );
- $product->save();
-
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_regular_price' => '90',
- '_sale_price' => '70',
- '_stock_status' => 'instock',
+ public function test_quick_edit_matches_full_editor_invalid_date_behavior(): void {
+ $product = $this->create_product( ProductType::SIMPLE );
+
+ $this->quick_edit(
+ $product,
+ array(
+ '_sale_price_dates_from' => 'invalid-date',
+ '_sale_price_dates_to' => 'invalid-date',
+ )
);
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- remove_filter( 'woocommerce_product_get_regular_price', $break_price );
- remove_filter( 'woocommerce_product_get_sale_price', $break_price );
-
$updated_product = wc_get_product( $product->get_id() );
- $this->assertSame( '90', $updated_product->get_regular_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
- $this->assertSame( '70', $updated_product->get_sale_price( 'edit' ), 'Submitted prices should persist even when a display filter returns a non-scalar value.' );
+ // Like the full editor, an invalid start becomes timestamp 0, which WC_Data treats as empty on reload.
+ $this->assert_date( null, $updated_product->get_date_on_sale_from( 'edit' ), 'start' );
+ $this->assert_date( '1970-01-01 23:59:59', $updated_product->get_date_on_sale_to( 'edit' ), 'end' );
}
/**
- * @testdox Quick Edit preserves expired sale dates when price fields are absent from the request.
+ * @testdox Quick Edit stores sale boundaries in the site timezone.
*/
- public function test_quick_edit_preserves_expired_sale_dates_when_price_fields_not_submitted(): void {
- $format_price = function ( $value ) {
- return '' === $value || null === $value ? $value : number_format( (float) $value, 2, '.', '' );
- };
- add_filter( 'woocommerce_product_get_regular_price', $format_price );
- add_filter( 'woocommerce_product_get_sale_price', $format_price );
-
- $product = WC_Helper_Product::create_simple_product();
-
- $product->set_regular_price( '100' );
- $product->set_sale_price( '80' );
- $product->set_date_on_sale_from( gmdate( 'Y-m-d H:i:s', time() - 3 * DAY_IN_SECONDS ) );
- $product->set_date_on_sale_to( gmdate( 'Y-m-d H:i:s', time() - DAY_IN_SECONDS ) );
- $product->save();
-
- $_REQUEST = array(
- 'woocommerce_quick_edit' => '1',
- 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
- '_stock_status' => 'instock',
- );
-
- $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
-
- remove_filter( 'woocommerce_product_get_regular_price', $format_price );
- remove_filter( 'woocommerce_product_get_sale_price', $format_price );
-
- $updated_product = wc_get_product( $product->get_id() );
-
- $this->assertSame( '100', $updated_product->get_regular_price( 'edit' ), 'An absent regular price field should leave the stored regular price untouched.' );
- $this->assertSame( '80', $updated_product->get_sale_price( 'edit' ), 'An absent sale price field should leave the stored sale price untouched.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_from( 'edit' ), 'Quick Edit should preserve the expired sale start date when no price fields were submitted.' );
- $this->assertInstanceOf( WC_DateTime::class, $updated_product->get_date_on_sale_to( 'edit' ), 'Quick Edit should preserve the expired sale end date when no price fields were submitted.' );
- $this->assertFalse( $updated_product->is_on_sale( 'edit' ), 'The expired sale should not be reactivated by a request without price fields.' );
+ public function test_quick_edit_uses_site_timezone_for_sale_dates(): void {
+ $original_timezone = get_option( 'timezone_string' );
+ update_option( 'timezone_string', 'America/New_York' );
+
+ try {
+ $product = $this->create_product( ProductType::EXTERNAL );
+ $this->quick_edit(
+ $product,
+ array(
+ '_sale_price_dates_from' => '2098-06-01',
+ '_sale_price_dates_to' => '2098-06-30',
+ )
+ );
+
+ $updated_product = wc_get_product( $product->get_id() );
+ $start_date = $updated_product->get_date_on_sale_from( 'edit' );
+ $end_date = $updated_product->get_date_on_sale_to( 'edit' );
+ } finally {
+ update_option( 'timezone_string', $original_timezone );
+ }
+
+ $this->assertInstanceOf( WC_DateTime::class, $start_date, 'Quick Edit should set the sale start date.' );
+ $this->assertInstanceOf( WC_DateTime::class, $end_date, 'Quick Edit should set the sale end date.' );
+ $this->assertSame( '2098-06-01 00:00:00', $start_date->date( 'Y-m-d H:i:s' ), 'The sale should start at the beginning of the local day.' );
+ $this->assertSame( '2098-06-30 23:59:59', $end_date->date( 'Y-m-d H:i:s' ), 'The sale should end at the end of the local day.' );
+ $this->assertSame( 'America/New_York', $start_date->getTimezone()->getName(), 'The start date should use the site timezone.' );
+ $this->assertSame( 'America/New_York', $end_date->getTimezone()->getName(), 'The end date should use the site timezone.' );
}
/**
- * Provides valid price changes for active and future sale schedules.
+ * Create a supported product type.
*
- * @return array<string, array{int, int, string, string}>
+ * @param string $product_type Product type.
+ * @return WC_Product
*/
- public static function valid_price_changes_provider(): array {
- return array(
- 'active schedule with sale price change' => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '100', '70' ),
- 'future schedule with sale price change' => array( DAY_IN_SECONDS, 2 * DAY_IN_SECONDS, '100', '70' ),
- 'active schedule with regular price change' => array( -DAY_IN_SECONDS, DAY_IN_SECONDS, '90', '80' ),
- );
+ private function create_product( string $product_type ): WC_Product {
+ return ProductType::EXTERNAL === $product_type ? WC_Helper_Product::create_external_product() : WC_Helper_Product::create_simple_product();
}
/**
- * Provides price changes for a product whose scheduled sale has already ended.
+ * Submit a Quick Edit request.
*
- * @return array<string, array{string, string}>
+ * @param WC_Product $product Product to edit.
+ * @param array<string,mixed> $request_data Request fields.
*/
- public static function expired_schedule_price_changes_provider(): array {
- return array(
- 'expired schedule with new sale price' => array( '100', '70' ),
- 'expired schedule with regular price change' => array( '90', '80' ),
+ private function quick_edit( WC_Product $product, array $request_data ): void {
+ $_REQUEST = array_merge(
+ array(
+ 'woocommerce_quick_edit' => '1',
+ 'woocommerce_quick_edit_nonce' => wp_create_nonce( 'woocommerce_quick_edit_nonce' ),
+ '_stock_status' => 'instock',
+ ),
+ $request_data
);
+
+ $this->sut->bulk_and_quick_edit_save_post( $product->get_id(), get_post( $product->get_id() ) );
}
/**
- * Provides price changes that end an active sale.
+ * Assert a stored sale date.
*
- * @return array<string, array{string, string}>
+ * @param string|null $expected Expected local date, or null.
+ * @param WC_DateTime|null $actual Stored date.
+ * @param string $boundary Boundary name for assertion messages.
*/
- public static function sale_ending_price_changes_provider(): array {
- return array(
- 'regular price no longer above sale price' => array( '70', '80' ),
- 'sale price removed' => array( '100', '' ),
- );
+ private function assert_date( ?string $expected, ?WC_DateTime $actual, string $boundary ): void {
+ if ( null === $expected ) {
+ $this->assertNull( $actual, "Quick Edit should leave the sale {$boundary} date empty." );
+ return;
+ }
+
+ $this->assertInstanceOf( WC_DateTime::class, $actual, "Quick Edit should set the sale {$boundary} date." );
+ $this->assertSame( $expected, $actual->date( 'Y-m-d H:i:s' ), "Quick Edit should set the expected sale {$boundary} boundary." );
}
}