Commit 23a4a918769 for woocommerce

commit 23a4a918769d9e512f336a1f19823e1c829c7d29
Author: Luigi Teschio <gigitux@gmail.com>
Date:   Wed May 20 16:16:01 2026 +0200

    Update Products App schedule sale control (#65195)

diff --git a/packages/js/experimental-products-app/changelog/update-schedule-sale-control b/packages/js/experimental-products-app/changelog/update-schedule-sale-control
new file mode 100644
index 00000000000..f1472aac8f8
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/update-schedule-sale-control
@@ -0,0 +1,4 @@
+Significance: patch
+Type: update
+
+Move the sale schedule date pickers into the schedule sale field.
diff --git a/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx b/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx
index 11829d57a0e..8cb68928296 100644
--- a/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx
+++ b/packages/js/experimental-products-app/src/fields/schedule_sale/field.tsx
@@ -1,16 +1,9 @@
 /**
  * External dependencies
  */
-import {
-	BaseControl,
-	FlexBlock,
-	FormToggle,
-	__experimentalHStack as HStack,
-} from '@wordpress/components';
-
-import { useInstanceId } from '@wordpress/compose';
+import { CheckboxControl } from '@wordpress/components';

-import { useState } from '@wordpress/element';
+import { useCallback, useMemo, useState } from '@wordpress/element';

 import { __ } from '@wordpress/i18n';

@@ -22,6 +15,11 @@ import type { Field } from '@wordpress/dataviews';
 import type { ProductEntityRecord } from '../types';

 import { getLocalDefaultSaleStart } from '../price/utils';
+import {
+	DatePicker,
+	formatDateTimeLocal,
+	parseDateTimeLocal,
+} from '../components/date-picker';

 const fieldDefinition = {
 	type: 'boolean',
@@ -34,7 +32,6 @@ const fieldDefinition = {
 export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 	...fieldDefinition,
 	Edit: ( { data, onChange, field } ) => {
-		const toggleId = useInstanceId( FormToggle, 'schedule-sale-toggle' );
 		const [ tempDateOnSaleFrom, setTempDateOnSaleFrom ] = useState(
 			data.date_on_sale_from || ''
 		);
@@ -42,51 +39,152 @@ export const fieldExtensions: Partial< Field< ProductEntityRecord > > = {
 			data.date_on_sale_to || ''
 		);
 		const checked = !! data.date_on_sale_to || !! data.date_on_sale_from;
+		const today = useMemo( () => {
+			const d = new Date();
+			d.setHours( 0, 0, 0, 0 );
+			return d;
+		}, [] );
+		const dateOnSaleFrom = useMemo(
+			() =>
+				typeof data.date_on_sale_from === 'string' &&
+				data.date_on_sale_from
+					? parseDateTimeLocal( data.date_on_sale_from )
+					: null,
+			[ data.date_on_sale_from ]
+		);
+		const minDateOnSaleTo = useMemo( () => {
+			if ( dateOnSaleFrom ) {
+				const min = new Date( dateOnSaleFrom );
+				min.setMinutes( min.getMinutes() + 1 );
+				return min;
+			}
+
+			return today;
+		}, [ dateOnSaleFrom, today ] );
+		const handleScheduleChange = useCallback(
+			( value: boolean ) => {
+				if ( ! value ) {
+					setTempDateOnSaleFrom( data.date_on_sale_from || '' );
+					setTempDateOnSaleTo( data.date_on_sale_to || '' );
+					onChange( {
+						date_on_sale_from: '',
+						date_on_sale_to: '',
+					} );
+					return;
+				}
+
+				let nextDateOnSaleFrom =
+					data.date_on_sale_from || tempDateOnSaleFrom;
+				const nextDateOnSaleTo =
+					data.date_on_sale_to || tempDateOnSaleTo;
+
+				if ( ! nextDateOnSaleFrom && ! nextDateOnSaleTo ) {
+					nextDateOnSaleFrom = getLocalDefaultSaleStart();
+				}
+
+				onChange( {
+					date_on_sale_from: nextDateOnSaleFrom,
+					date_on_sale_to: nextDateOnSaleTo,
+				} );
+			},
+			[
+				data.date_on_sale_from,
+				data.date_on_sale_to,
+				onChange,
+				tempDateOnSaleFrom,
+				tempDateOnSaleTo,
+			]
+		);
+		const handleDateOnSaleFromChange = useCallback(
+			( value: { date_on_sale_from?: string | null } ) => {
+				const newStart = value.date_on_sale_from;
+				const currentEnd = data.date_on_sale_to;
+
+				if (
+					typeof newStart !== 'string' ||
+					! newStart ||
+					typeof currentEnd !== 'string' ||
+					! currentEnd
+				) {
+					onChange( value );
+					return;
+				}
+
+				const startDate = parseDateTimeLocal( newStart );
+				const endDate = parseDateTimeLocal( currentEnd );
+
+				if (
+					startDate &&
+					endDate &&
+					startDate.getTime() >= endDate.getTime()
+				) {
+					const newEndDate = new Date( startDate );
+					newEndDate.setDate( newEndDate.getDate() + 1 );
+
+					onChange( {
+						...value,
+						date_on_sale_to: formatDateTimeLocal( newEndDate ),
+					} );
+					return;
+				}
+
+				onChange( value );
+			},
+			[ data.date_on_sale_to, onChange ]
+		);
+
 		return (
-			<BaseControl className="components-toggle-control">
-				<HStack justify="flex-start" spacing={ 2 }>
-					<FormToggle
-						id={ toggleId }
-						checked={ checked }
-						onChange={ () => {
-							if ( checked ) {
-								setTempDateOnSaleFrom(
-									data.date_on_sale_from || ''
-								);
-								setTempDateOnSaleTo(
-									data.date_on_sale_to || ''
-								);
-								onChange( {
-									date_on_sale_from: '',
-									date_on_sale_to: '',
-								} );
-							} else {
-								let dateOnSaleFrom =
-									data.date_on_sale_from ||
-									tempDateOnSaleFrom;
-								const dateOnSaleTo =
-									data.date_on_sale_to || tempDateOnSaleTo;
-
-								if ( ! dateOnSaleFrom && ! dateOnSaleTo ) {
-									dateOnSaleFrom = getLocalDefaultSaleStart();
-								}
-
-								onChange( {
-									date_on_sale_from: dateOnSaleFrom,
-									date_on_sale_to: dateOnSaleTo,
-								} );
-							}
-						} }
-					/>
-					<FlexBlock
-						as="label"
-						htmlFor={ toggleId }
-						className="components-toggle-control__label"
-					>
-						{ field.label }
-					</FlexBlock>
-				</HStack>
-			</BaseControl>
+			<div className="woocommerce-schedule-sale-control">
+				<CheckboxControl
+					label={ field.label }
+					checked={ checked }
+					onChange={ handleScheduleChange }
+				/>
+				{ checked && (
+					<div className="woocommerce-schedule-sale-control__dates">
+						<DatePicker
+							data={ data }
+							onChange={ handleDateOnSaleFromChange }
+							field={ {
+								label: __( 'Start sale on', 'woocommerce' ),
+							} }
+							fieldKey="date_on_sale_from"
+							min={ today }
+						/>
+						<DatePicker
+							data={ data }
+							onChange={ onChange }
+							field={ {
+								label: __( 'End sale on', 'woocommerce' ),
+							} }
+							fieldKey="date_on_sale_to"
+							min={ minDateOnSaleTo }
+						/>
+					</div>
+				) }
+			</div>
 		);
 	},
+	getValue: ( { item } ) =>
+		!! item.date_on_sale_to || !! item.date_on_sale_from,
+	setValue: ( { item, value } ) => {
+		if ( ! value ) {
+			return {
+				date_on_sale_from: '',
+				date_on_sale_to: '',
+			};
+		}
+
+		let dateOnSaleFrom = item.date_on_sale_from || '';
+		const dateOnSaleTo = item.date_on_sale_to || '';
+
+		if ( ! dateOnSaleFrom && ! dateOnSaleTo ) {
+			dateOnSaleFrom = getLocalDefaultSaleStart();
+		}
+
+		return {
+			date_on_sale_from: dateOnSaleFrom,
+			date_on_sale_to: dateOnSaleTo,
+		};
+	},
 };
diff --git a/packages/js/experimental-products-app/src/fields/schedule_sale/style.scss b/packages/js/experimental-products-app/src/fields/schedule_sale/style.scss
new file mode 100644
index 00000000000..4c5d879e56f
--- /dev/null
+++ b/packages/js/experimental-products-app/src/fields/schedule_sale/style.scss
@@ -0,0 +1,11 @@
+.woocommerce-schedule-sale-control {
+	display: flex;
+	flex-direction: column;
+	gap: 12px;
+}
+
+.woocommerce-schedule-sale-control__dates {
+	display: grid;
+	grid-template-columns: repeat(2, minmax(0, 1fr));
+	gap: 16px;
+}
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index f206be358bd..cd70129e0ab 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -592,8 +592,6 @@ describe( 'product edit utils', () => {
 			'regular_price',
 			'sale_price',
 			'schedule_sale',
-			'date_on_sale_from',
-			'date_on_sale_to',
 			'cost_of_goods_sold',
 		];
 		const basePriceFieldIds = [ 'regular_price', 'sale_price' ];
@@ -635,8 +633,6 @@ describe( 'product edit utils', () => {
 				'regular_price',
 				'sale_price',
 				'schedule_sale',
-				'date_on_sale_from',
-				'date_on_sale_to',
 				'cost_of_goods_sold',
 				'images',
 				'sku',
@@ -690,8 +686,6 @@ describe( 'product edit utils', () => {
 				'regular_price',
 				'sale_price',
 				'schedule_sale',
-				'date_on_sale_from',
-				'date_on_sale_to',
 				'cost_of_goods_sold',
 			] );
 		} );
@@ -982,8 +976,6 @@ describe( 'product edit utils', () => {
 					'regular_price',
 					'sale_price',
 					'schedule_sale',
-					'date_on_sale_from',
-					'date_on_sale_to',
 					'cost_of_goods_sold',
 					'images',
 					'sku',
@@ -1025,8 +1017,6 @@ describe( 'product edit utils', () => {
 					'regular_price',
 					'sale_price',
 					'schedule_sale',
-					'date_on_sale_from',
-					'date_on_sale_to',
 					'cost_of_goods_sold',
 					'stock',
 					'manage_stock',
@@ -1106,8 +1096,6 @@ describe( 'product edit utils', () => {
 					'regular_price',
 					'sale_price',
 					'schedule_sale',
-					'date_on_sale_from',
-					'date_on_sale_to',
 					'cost_of_goods_sold',
 					...bulkSellableInstanceFieldIds,
 				] )
@@ -1377,7 +1365,7 @@ describe( 'product edit utils', () => {
 			[ 'simple product', 'simple' ],
 			[ 'variation product', 'variation' ],
 		] as const )(
-			'groups sale schedule date fields on the same row for %s',
+			'uses schedule sale as a compound price field for %s',
 			( _label, productType ) => {
 				const product = buildProduct( {
 					type: productType,
@@ -1398,14 +1386,6 @@ describe( 'product edit utils', () => {
 						'regular_price',
 						'sale_price',
 						'schedule_sale',
-						{
-							id: 'sale-schedule-dates',
-							layout: { type: 'row' },
-							children: [
-								'date_on_sale_from',
-								'date_on_sale_to',
-							],
-						},
 						'cost_of_goods_sold',
 					],
 				} );
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index b0ec4f27f82..404db201bc4 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -120,11 +120,6 @@ const SIMPLE_PRODUCT_EDIT_FORM_FIELDS = [
 		'regular_price',
 		'sale_price',
 		'schedule_sale',
-		{
-			id: 'sale-schedule-dates',
-			layout: { type: 'row' as const },
-			children: [ 'date_on_sale_from', 'date_on_sale_to' ],
-		},
 		'cost_of_goods_sold',
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
@@ -158,11 +153,6 @@ const VARIATION_PRODUCT_EDIT_FORM_FIELDS = [
 		'regular_price',
 		'sale_price',
 		'schedule_sale',
-		{
-			id: 'sale-schedule-dates',
-			layout: { type: 'row' as const },
-			children: [ 'date_on_sale_from', 'date_on_sale_to' ],
-		},
 		'cost_of_goods_sold',
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
@@ -217,11 +207,6 @@ const EXTERNAL_PRODUCT_EDIT_FORM_FIELDS = [
 		'regular_price',
 		'sale_price',
 		'schedule_sale',
-		{
-			id: 'sale-schedule-dates',
-			layout: { type: 'row' as const },
-			children: [ 'date_on_sale_from', 'date_on_sale_to' ],
-		},
 	] ),
 	createProductEditFormGroup( 'image-fields', __( 'Images', 'woocommerce' ), [
 		'images',
diff --git a/packages/js/experimental-products-app/src/style.scss b/packages/js/experimental-products-app/src/style.scss
index 10fd41274e4..5623223a094 100644
--- a/packages/js/experimental-products-app/src/style.scss
+++ b/packages/js/experimental-products-app/src/style.scss
@@ -29,6 +29,7 @@
 	@import "./fields/components/taxonomy-edit/style.scss";
 	@import "./fields/downloadable/style.scss";
 	@import "./fields/images/style.scss";
+	@import "./fields/schedule_sale/style.scss";
 	@import "./fields/stock/style.scss";
 	@import "./fields/visibility_summary/style.scss";
 	--wp-ui-drawer-z-index: 100000;