Commit b2836b83fc7 for woocommerce
commit b2836b83fc7241f700ccaf03059ce85b274e0faa
Author: Seghir Nadir <nadir.seghir@gmail.com>
Date: Fri Sep 11 15:43:41 2026 +0200
Add min/max constraints to date checkout fields (#68083)
* Fix conditional field rules matching when a field has no value yet
* Add changelog entry for conditional checkout field rules fix
* Remove leftover experimental-blocks gate from field persistence
* Update feature flag docs after ungating conditional field persistence
* update changelog
* remove duplicate changelog (thanks AI!)
* Add conditional rule tests for contact and address location fields
* Remove redundant AI comment
* update comment
* Provide schema and order properties for CheckoutTrait in DocumentObject tests
* Fix saved checkout field values during validation
* Update changelog for conditional checkout field fixes
* Fix nullable order handling in checkout payment route
* Add min/max constraints to date checkout fields
Adds optional min and max options to date additional checkout fields,
accepting an absolute YYYY-MM-DD date, an ISO 8601-2 duration relative to
today such as P1D or -P18Y, or a DateInterval. Durations are resolved on
every read on both the client and the server so they stay correct behind a
page cache.
* Simplify date field min/max constraint handling
Drop DateInterval support for date field min/max constraints, keeping
absolute YYYY-MM-DD dates and signed ISO 8601-2 durations. Constraint
validation and normalization move inline into process_options, and
resolve_constraint / get_constraints become private.
Restructure DateFieldTypeTest around registration, prepare_form_field
and validation, and trim the CheckoutFields tests to the delegation
they still cover. Update the docs and changelog to match.
* Apply date field min/max constraints on the order edit screen
Route admin meta box fields through CheckoutFields::prepare_form_field()
so the order edit screen picks up the same type-specific arguments the
My Account forms get, including the resolved date min/max attributes.
This replaces the select and checkbox handling that format_field_for_meta_box
duplicated inline.
DateFieldType::prepare_form_field() now merges into custom_attributes
rather than replacing them, so it does not clobber attributes another
consumer set.
* Drop the admin constraint docs and test additions
Reverts the doc paragraph and docblock added alongside the meta box
change, and removes CheckoutFieldsAdminTest. The prepare_form_field()
routing in CheckoutFieldsAdmin is unchanged, so the order edit screen
still renders the resolved date bounds.
* Fix date checkout field limits after review
* Update date field constraint guidance
* Update docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
diff --git a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
index e014c20a459..8cae00c94ab 100644
--- a/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
+++ b/docs/block-development/extensible-blocks/cart-and-checkout-blocks/additional-checkout-fields.md
@@ -252,7 +252,48 @@ Text fields don't have any additional options beyond the general options listed
#### Options for `date` fields
-Date fields don't have any additional options beyond the general options listed above.
+As well as the options above, date fields support `min` and `max` options to limit the range of dates a shopper can pick.
+
+| Option name | Description | Required? | Example | Default value |
+| --- | --- | --- | --- | --- |
+| `min` | The earliest date the shopper can select. | No | `2026-01-01`, `P0D`, `P1D` | No minimum. |
+| `max` | The latest date the shopper can select. | No | `2026-12-31`, `P30D`, `-P18Y` | No maximum. |
+
+Each one takes either:
+
+- An **absolute** date in `YYYY-MM-DD` format, such as `2026-01-01`. This is the same format the HTML `min` and `max` attributes use.
+- A **duration relative to today**, written in the [ISO 8601-2 duration format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration#iso_8601_duration_format), optionally signed: `P1D` (tomorrow), `-P5D` (five days ago), `P2W`, `P3M`, `-P18Y`. `P0D` means today.
+
+```php
+woocommerce_register_additional_checkout_field(
+ array(
+ 'id' => 'my-plugin/delivery-date',
+ 'label' => 'Preferred delivery date',
+ 'location' => 'order',
+ 'type' => 'date',
+ 'required' => true,
+ 'min' => 'P1D', // From tomorrow,
+ 'max' => 'P30D', // up to 30 days out.
+ )
+);
+```
+
+##### Dates only, not times
+
+A date field holds a calendar date with no time component, so only the `Y`, `M`, `W` and `D` parts of a duration are meaningful. A duration carrying a time component, such as `PT1H` or `P1DT12H`, is rejected at registration.
+
+##### Pass the duration, don't resolve it yourself
+
+```php
+// Don't do this.
+'min' => date( 'Y-m-d', strtotime( '+1 day' ) ),
+```
+
+This ends up resolving to a date that may not always be up to date between registration, field rendering, and value submission. Instead, pass P1D, which will be evaluated at input time and submission time.
+
+Registration fails with a `_doing_it_wrong` notice if a constraint can't be parsed. Express both bounds in the same unit, i.e. avoid `'min' => 'P30D'`, `'max' => 'P1M'` as it would form an invalid range in February for example. Avoid mixing absolute and durations unless you're sure they won't overlap at some point in the future.
+
+If mixed dates (absolute and durations) resolve to an invalid range, where min is later than max, WooCommerce will ignore them and the field will be boundless and will emit a log warning.
The input value will follow the browser's locale settings, the DB value will be in YYYY-MM-DD, and the final rendered value (in pages and emails) will follow the site's date format, set in **Settings -> General**.
diff --git a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
index 8d0a5d6365a..2616b6597dd 100644
--- a/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
+++ b/docs/block-development/tutorials/how-to-additional-checkout-fields-guide.md
@@ -182,6 +182,24 @@ woocommerce_register_additional_checkout_field(
The field input value will follows the shopper's browser and OS locale. The value is always stored as `YYYY-MM-DD`, and it is displayed using the site's date format (**Settings → General**) in emails, order screens, and other places the value is rendered.
+Use `min` and `max` to limit the range of dates a shopper can pick. Both accept an absolute date in `Y-m-d` format, or a duration relative to today in the [ISO 8601-2 duration format](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/Duration#iso_8601_duration_format), such as `P1D`, `-P5D` or `-P18Y`:
+
+```php
+woocommerce_register_additional_checkout_field(
+ array(
+ 'id' => 'my-plugin/delivery-date',
+ 'label' => __('Preferred delivery date', 'your-text-domain'),
+ 'location' => 'order',
+ 'type' => 'date',
+ 'required' => true,
+ 'min' => 'P1D',
+ 'max' => 'P30D',
+ )
+);
+```
+
+Pass the duration rather than resolving it yourself with `strtotime()` or `date()`. WooCommerce resolves it against the current date each time the field is rendered or validated, so it stays correct behind a page cache and across midnight.
+
## Adding Field Attributes
You can enhance your fields with HTML attributes for better user experience:
diff --git a/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max b/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max
new file mode 100644
index 00000000000..7c71a2897f5
--- /dev/null
+++ b/plugins/woocommerce/changelog/wooplug-6456-date-field-min-max
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Add `min` and `max` options to `date` additional checkout fields, accepting an absolute date or an ISO 8601-2 duration relative to today such as `P1D` or `-P18Y`.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
new file mode 100644
index 00000000000..4fa6d5b853d
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/date-constraints.ts
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import { date as formatDate } from '@wordpress/date';
+import * as DurationFns from 'temporal-polyfill/fns/Duration';
+import * as PlainDateFns from 'temporal-polyfill/fns/PlainDate';
+
+// Remove once TypeScript's lib ships Temporal types.
+/* eslint-disable @typescript-eslint/naming-convention -- Temporal's own class names. */
+declare const Temporal:
+ | {
+ PlainDate: {
+ from: ( date: string ) => {
+ add: ( duration: unknown ) => { toString: () => string };
+ };
+ };
+ Duration: { from: ( duration: string ) => unknown };
+ }
+ | undefined;
+/* eslint-enable @typescript-eslint/naming-convention */
+
+/**
+ * Resolves an ISO 8601-2 duration to a date relative to today.
+ * We use the store timezone (from wp.date.date) so dates match between server and client.
+ *
+ * Uses native `Temporal` where available, falling back to the polyfill.
+ */
+const resolveDuration = ( duration: string ): string => {
+ const today = formatDate( 'Y-m-d', new Date() );
+
+ if ( typeof Temporal !== 'undefined' ) {
+ return Temporal.PlainDate.from( today )
+ .add( Temporal.Duration.from( duration ) )
+ .toString();
+ }
+
+ const [ year, month, day ] = today.split( '-' ).map( Number );
+
+ return PlainDateFns.toString(
+ PlainDateFns.add(
+ PlainDateFns.create( year, month, day ),
+ DurationFns.fromString( duration )
+ )
+ );
+};
+
+/**
+ * Resolves a date field's min/max constraint to a YYYY-MM-DD value for a date input.
+ *
+ * A constraint is either an absolute YYYY-MM-DD date or an ISO 8601-2 duration relative to today,
+ * such as `P1D` or `-P18Y`. WooCommerce resolves the same expression in PHP when the submitted value is validated.
+ *
+ * @param constraint The constraint as registered, or undefined when the field is unconstrained.
+ * @return The resolved date, or undefined if there is no constraint or it could not be parsed.
+ */
+export const resolveDateConstraint = (
+ constraint: string | undefined
+): string | undefined => {
+ if ( ! constraint ) {
+ return undefined;
+ }
+
+ const value = constraint.trim();
+
+ // If it's already a date, return it.
+ if ( /^\d{4}-\d{2}-\d{2}$/.test( value ) ) {
+ return value;
+ }
+
+ try {
+ return resolveDuration( value );
+ } catch {
+ return undefined;
+ }
+};
+
+/**
+ * Resolves both date limits and drops them when the range has no valid date.
+ *
+ * @param field The date field's registered constraints.
+ * @param field.min The minimum date or duration.
+ * @param field.max The maximum date or duration.
+ */
+export const resolveDateConstraints = ( field: {
+ min?: string;
+ max?: string;
+} ): { min: string | undefined; max: string | undefined } => {
+ const min = resolveDateConstraint( field.min );
+ const max = resolveDateConstraint( field.max );
+
+ if ( min && max && min > max ) {
+ return { min: undefined, max: undefined };
+ }
+
+ return { min, max };
+};
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
index ea81f6a549d..ca5bdcd591b 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/form.tsx
@@ -38,6 +38,7 @@ import fastDeepEqual from 'fast-deep-equal/es6';
*/
import { Select } from '../../select';
import AddressLineFields from './address-line-fields';
+import { resolveDateConstraints } from './date-constraints';
import { FormProps } from './types';
import { useFormFields } from './use-form-fields';
import { useFormValidation } from './use-form-validation';
@@ -422,16 +423,22 @@ const Form = <
}
{ ...fieldProps }
type={ field.type }
- icon={
- field.type === 'date' ? (
- <span
- className="wc-block-components-text-input__date-icon"
- aria-hidden="true"
- >
- <Icon icon={ calendar } size={ 24 } />
- </span>
- ) : null
- }
+ { ...( field.type === 'date'
+ ? {
+ ...resolveDateConstraints( field ),
+ icon: (
+ <span
+ className="wc-block-components-text-input__date-icon"
+ aria-hidden="true"
+ >
+ <Icon
+ icon={ calendar }
+ size={ 24 }
+ />
+ </span>
+ ),
+ }
+ : {} ) }
ariaDescribedBy={ ariaDescribedBy }
value={
decodeEntities(
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts
new file mode 100644
index 00000000000..abe7c00ee89
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/base/components/cart-checkout/form/test/date-constraints.ts
@@ -0,0 +1,122 @@
+/**
+ * Internal dependencies
+ */
+import {
+ resolveDateConstraint,
+ resolveDateConstraints,
+} from '../date-constraints';
+
+describe( 'resolveDateConstraint', () => {
+ afterEach( () => {
+ jest.useRealTimers();
+ } );
+
+ const onDate = ( today: string ) =>
+ jest
+ .useFakeTimers()
+ .setSystemTime( new Date( `${ today }T12:00:00Z` ) );
+
+ it( 'returns undefined when there is no constraint', () => {
+ expect( resolveDateConstraint( undefined ) ).toBeUndefined();
+ } );
+
+ it.each( [ [ 'not-a-date' ], [ '' ], [ 'today' ], [ '+1 day' ], [ 'P' ] ] )(
+ 'returns undefined for the unparseable constraint %s',
+ ( value ) => {
+ expect( resolveDateConstraint( value ) ).toBeUndefined();
+ }
+ );
+
+ it( 'passes an absolute date through', () => {
+ expect( resolveDateConstraint( '2026-01-01' ) ).toBe( '2026-01-01' );
+ } );
+
+ it.each( [
+ [ 'P0D', '2026-08-26' ],
+ [ 'P1D', '2026-08-27' ],
+ [ '-P5D', '2026-08-21' ],
+ [ 'P2W', '2026-09-09' ],
+ [ 'P1W2D', '2026-09-04' ],
+ [ '-P1W2D', '2026-08-17' ],
+ [ 'P1M2W', '2026-10-10' ],
+ [ 'P3M', '2026-11-26' ],
+ [ '-P18Y', '2008-08-26' ],
+ [ 'P1Y2M3D', '2027-10-29' ],
+ ] )( 'resolves %s to %s', ( constraint, expected ) => {
+ onDate( '2026-08-26' );
+
+ expect( resolveDateConstraint( constraint ) ).toBe( expected );
+ } );
+
+ // Edge cases in which adding a period to a date should resolve based on the period, not number of dates.
+ // For example, adding 1 month to Jan 31 should resolve to Feb 28, not Mar 3. This is a bug in PHP that
+ // we had to fix, and we test for here regardless.
+ it.each( [
+ [ '2026-01-31', 'P1M', '2026-02-28' ],
+ [ '2026-03-31', '-P1M', '2026-02-28' ],
+ [ '2024-02-29', 'P1Y', '2025-02-28' ],
+ [ '2026-01-31', 'P1M15D', '2026-03-15' ],
+ [ '2026-01-31', 'P1M2W3D', '2026-03-17' ],
+ ] )(
+ 'clamps %s + %s to the end of the target month, giving %s',
+ ( today, constraint, expected ) => {
+ onDate( today );
+
+ expect( resolveDateConstraint( constraint ) ).toBe( expected );
+ }
+ );
+
+ it.each( [
+ [ '2026-12-31', '2026-01-01' ],
+ [ 'P2M', 'P1M' ],
+ [ 'P0D', '2026-08-25' ],
+ [ '2026-08-27', 'P0D' ],
+ ] )( 'drops both limits for an inverted range %s to %s', ( min, max ) => {
+ onDate( '2026-08-26' );
+
+ expect( resolveDateConstraints( { min, max } ) ).toEqual( {
+ min: undefined,
+ max: undefined,
+ } );
+ } );
+
+ it.each( [
+ [ 'P0D', 'P0D', '2026-08-26', '2026-08-26' ],
+ [ 'P0D', 'P1D', '2026-08-26', '2026-08-27' ],
+ [ undefined, 'P0D', undefined, '2026-08-26' ],
+ [ 'P0D', undefined, '2026-08-26', undefined ],
+ ] )(
+ 'keeps valid limits %s to %s',
+ ( min, max, expectedMin, expectedMax ) => {
+ onDate( '2026-08-26' );
+
+ expect( resolveDateConstraints( { min, max } ) ).toEqual( {
+ min: expectedMin,
+ max: expectedMax,
+ } );
+ }
+ );
+
+ it( 'drops a mixed range when the minimum moves past the maximum', () => {
+ const field = { min: 'P0D', max: '2026-08-26' };
+ onDate( '2026-08-26' );
+ expect( resolveDateConstraints( field ) ).toEqual( {
+ min: '2026-08-26',
+ max: '2026-08-26',
+ } );
+
+ onDate( '2026-08-27' );
+ expect( resolveDateConstraints( field ) ).toEqual( {
+ min: undefined,
+ max: undefined,
+ } );
+ } );
+
+ it( 'follows the clock rather than the moment the page was rendered', () => {
+ onDate( '2026-08-26' );
+ expect( resolveDateConstraint( 'P0D' ) ).toBe( '2026-08-26' );
+
+ jest.setSystemTime( new Date( '2026-08-27T12:00:00Z' ) );
+ expect( resolveDateConstraint( 'P0D' ) ).toBe( '2026-08-27' );
+ } );
+} );
diff --git a/plugins/woocommerce/client/blocks/package.json b/plugins/woocommerce/client/blocks/package.json
index 06ea3c5fe45..2b4f3140b77 100644
--- a/plugins/woocommerce/client/blocks/package.json
+++ b/plugins/woocommerce/client/blocks/package.json
@@ -263,6 +263,7 @@
"prop-types": "^15.8.1",
"react-number-format": "5.4.5",
"react-transition-group": "^4.4.5",
+ "temporal-polyfill": "1.0.4",
"trim-html": "0.1.9",
"use-debounce": "9.0.4",
"usehooks-ts": "^2.9.1",
diff --git a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
index 3c848574733..61d9f387ead 100644
--- a/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
+++ b/plugins/woocommerce/client/blocks/packages/public-api/settings/default-fields.ts
@@ -50,6 +50,10 @@ export interface Field {
options?: SelectOption[];
// The placeholder for the field, only applicable for select fields.
placeholder?: string;
+ // The earliest date a date field accepts, as YYYY-MM-DD or an ISO 8601-2 duration such as `P1D`.
+ min?: string;
+ // The latest date a date field accepts, as YYYY-MM-DD or an ISO 8601-2 duration such as `-P18Y`.
+ max?: string;
// Additional attributes added when registering a field. String in key is required for data attributes.
attributes?: Record< keyof CustomFieldAttributes, string >;
}
diff --git a/plugins/woocommerce/client/blocks/tests/js/jest.config.js b/plugins/woocommerce/client/blocks/tests/js/jest.config.js
index f714f3b5649..e52facc6b52 100644
--- a/plugins/woocommerce/client/blocks/tests/js/jest.config.js
+++ b/plugins/woocommerce/client/blocks/tests/js/jest.config.js
@@ -138,7 +138,8 @@ module.exports = {
'^.+\\.(js|ts|tsx)$': '<rootDir>/tests/js/scripts/babel-transformer.js',
},
transformIgnorePatterns: [
- '/node_modules/(?!\\.pnpm/dinero\\.js|dinero\\.js)',
+ // temporal-polyfill and its deps are ESM-only, so they need transforming too.
+ '/node_modules/(?!\\.pnpm/dinero\\.js|dinero\\.js|\\.pnpm/temporal-|temporal-)',
],
verbose: true,
cacheDirectory: '<rootDir>/../../node_modules/.cache/jest',
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
index e8c96fb140e..3d6f8b5c53a 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldType.php
@@ -9,10 +9,114 @@ use WP_Error;
/**
* The "date" additional checkout field type.
*
- * Values are calendar dates in YYYY-MM-DD format with no time or timezone component.
+ * Values are calendar dates in YYYY-MM-DD format with no time or timezone component. Field
+ * also accept min/max constraints as absolute dates (i.e. YYYY-MM-DD) or relative
+ * ISO 8601-2 periods (e.g. -P2Y, P1M).
*/
class DateFieldType extends AbstractFieldType {
+ /**
+ * Matches a date in YYYY-MM-DD format.
+ */
+ private const ABSOLUTE_DATE = '/^\d{4}-\d{2}-\d{2}$/';
+
+ /**
+ * Processes the options for a date field and returns the new field_options array.
+ *
+ * @param array $field_data The field data array to be updated.
+ * @param array $options The options supplied during field registration.
+ * @return array|false The updated $field_data array, or false if an error was encountered.
+ */
+ protected function process_type_options( array $field_data, array $options ) {
+ $id = $options['id'];
+
+ foreach ( array( 'min', 'max' ) as $constraint ) {
+ // Both are optional. Drop the key entirely rather than carrying a null through to the client.
+ if ( ! isset( $options[ $constraint ] ) ) {
+ unset( $field_data[ $constraint ] );
+ continue;
+ }
+
+ $value = $options[ $constraint ];
+
+ if ( ! is_string( $value ) ) {
+ return $this->registration_error(
+ $id,
+ sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y".', $constraint ),
+ '11.2.0'
+ );
+ }
+
+ $value = trim( $value );
+
+ if ( $this->is_absolute_date( $value ) ) {
+ if ( null === $this->parse_date( $value ) ) {
+ return $this->registration_error(
+ $id,
+ sprintf( 'The "%s" property of a "date" field must be a real calendar date, and "%s" is not.', $constraint, $value ),
+ '11.2.0'
+ );
+ }
+
+ $field_data[ $constraint ] = $value;
+ continue;
+ }
+
+ // DateInterval implements ISO 8601-1, which has no sign, so the ISO 8601-2 sign is peeled off first.
+ $sign = 1;
+ $body = $value;
+
+ if ( '' !== $body && in_array( $body[0], array( '+', '-' ), true ) ) {
+ $sign = '-' === $body[0] ? -1 : 1;
+ $body = substr( $body, 1 );
+ }
+
+ try {
+ $interval = self::parse_duration( $body );
+ } catch ( \Exception $e ) {
+ return $this->registration_error(
+ $id,
+ sprintf( 'The "%s" property of a "date" field must be a date in YYYY-MM-DD format or an ISO 8601-2 duration such as "P1D" or "-P18Y", and "%s" is neither.', $constraint, $value ),
+ '11.2.0'
+ );
+ }
+
+ // A date field has no sub-day precision, so a time component means the caller meant something else.
+ if ( $interval->h || $interval->i || $interval->s || $interval->f ) {
+ return $this->registration_error(
+ $id,
+ sprintf( 'The "%s" property of a "date" field must be a duration in whole days, but "%s" includes a time component.', $constraint, $value ),
+ '11.2.0'
+ );
+ }
+
+ // Keep the duration unresolved and drop an explicit plus sign.
+ $field_data[ $constraint ] = ( -1 === $sign ? '-' : '' ) . $body;
+ }
+
+ return $field_data;
+ }
+
+ /**
+ * Parses a duration without losing weeks on PHP 7.
+ *
+ * @param string $duration The unsigned ISO duration.
+ * @return \DateInterval The parsed duration.
+ */
+ private static function parse_duration( string $duration ): \DateInterval {
+ if ( version_compare( PHP_VERSION, '8.0', '<' ) ) {
+ $duration = preg_replace_callback(
+ '/(\d+)W(?:(\d+)D)?/',
+ static function ( $matches ) {
+ return ( (int) $matches[1] * 7 + (int) ( $matches[2] ?? 0 ) ) . 'D';
+ },
+ $duration
+ ) ?? '';
+ }
+
+ return new \DateInterval( $duration );
+ }
+
/**
* Trims whitespace from submitted date values before they are validated and stored.
*
@@ -25,7 +129,7 @@ class DateFieldType extends AbstractFieldType {
}
/**
- * Validates that a submitted value is a real calendar date.
+ * Validates that a submitted value is a real calendar date within the field's min/max constraints.
*
* @param mixed $value The submitted value.
* @param array $field The field.
@@ -48,6 +152,33 @@ class DateFieldType extends AbstractFieldType {
);
}
+ [ 'min' => $min, 'max' => $max ] = $this->get_constraints( $field, true );
+
+ // Both sides are YYYY-MM-DD, so a string comparison orders them correctly.
+ if ( $min && $value < $min ) {
+ return new WP_Error(
+ 'woocommerce_invalid_checkout_field',
+ sprintf(
+ /* translators: 1: is the field label, 2: is the earliest date allowed */
+ __( 'Please provide a %1$s on or after %2$s.', 'woocommerce' ),
+ $field['label'],
+ $this->format_value( $min, $field )
+ )
+ );
+ }
+
+ if ( $max && $value > $max ) {
+ return new WP_Error(
+ 'woocommerce_invalid_checkout_field',
+ sprintf(
+ /* translators: 1: is the field label, 2: is the latest date allowed */
+ __( 'Please provide a %1$s on or before %2$s.', 'woocommerce' ),
+ $field['label'],
+ $this->format_value( $max, $field )
+ )
+ );
+ }
+
return null;
}
@@ -70,6 +201,97 @@ class DateFieldType extends AbstractFieldType {
return false === $formatted ? $value : $formatted;
}
+ /**
+ * Adds the resolved min/max constraints as input attributes for woocommerce_form_field().
+ *
+ * These forms are rendered server side, so the constraints are resolved here rather than by the client.
+ *
+ * @param array $form_field The woocommerce_form_field() arguments built from the field.
+ * @return array The updated arguments.
+ */
+ public function prepare_form_field( array $form_field ): array {
+ $form_field['custom_attributes'] = array_merge(
+ isset( $form_field['custom_attributes'] ) && is_array( $form_field['custom_attributes'] ) ? $form_field['custom_attributes'] : array(),
+ array_filter( $this->get_constraints( $form_field ) )
+ );
+
+ return $form_field;
+ }
+
+ /**
+ * Returns the resolved min and max dates for a date field.
+ *
+ * @param array $field The field.
+ * @param bool $log_invalid_range Whether to log an invalid range during value validation.
+ * @return array Array with "min" and "max" keys, each a YYYY-MM-DD date or null when unconstrained.
+ */
+ private function get_constraints( array $field, bool $log_invalid_range = false ): array {
+ $constraints = array(
+ 'min' => isset( $field['min'] ) ? $this->resolve_constraint( $field['min'] ) : null,
+ 'max' => isset( $field['max'] ) ? $this->resolve_constraint( $field['max'] ) : null,
+ );
+
+ if ( isset( $constraints['min'], $constraints['max'] ) && $constraints['min'] > $constraints['max'] ) {
+ if ( $log_invalid_range ) {
+ wc_get_logger()->warning(
+ sprintf( 'Date limits for checkout field "%s" were ignored because min (%s) is after max (%s).', $field['id'], $constraints['min'], $constraints['max'] ),
+ array( 'source' => 'checkout-fields' )
+ );
+ }
+
+ return array(
+ 'min' => null,
+ 'max' => null,
+ );
+ }
+
+ return $constraints;
+ }
+
+ /**
+ * Resolves a date field min/max constraint to a YYYY-MM-DD date in the store's timezone.
+ *
+ * Durations are resolved at read time, so they follow the current date rather
+ * than freezing into whatever markup a page cache stored.
+ *
+ * We use DateInterval to resolve durations but it has 2 issues that we must solve here:
+ * 1. Negative constraints are not supported with this version, so we must account for them ourselves.
+ * 2. DateInterval has an inconsistent behavior compared to ISO 8601-2 in which adding
+ * a month to a date equals adding 31 days instead of a calendar month,
+ * resulting in P1M after Jan 31 giving you Mar 3 instead of Feb 28.
+ *
+ * @param string $value The constraint, as validated and normalized at registration.
+ * @param \DateTimeInterface|null $reference Date a duration is relative to. Defaults to today in the store timezone.
+ * @return string The resolved date.
+ */
+ private function resolve_constraint( string $value, $reference = null ): string {
+ if ( $this->is_absolute_date( $value ) ) {
+ return $value;
+ }
+
+ $sign = '-' === $value[0] ? -1 : 1;
+ $interval = self::parse_duration( ltrim( $value, '-' ) );
+ $today = new \DateTimeImmutable( $reference ? $reference->format( 'Y-m-d' ) : 'today', wp_timezone() );
+
+ // Handles the DateInterval bug in which months days overflow to the next month instead of staying in the current month.
+ // P1M to Jan 31 gives you Mar 3 instead of Feb 28.
+ // So we deconstruct the period into years, months, and days, and then add them back separately.
+ $months = $sign * ( $interval->y * 12 + $interval->m );
+ $days = $sign * $interval->d;
+ // Adding x months to today and asking for the last day of that month, then clamping the day to today if it exceeds it.
+ $end_of_month = $today->modify( sprintf( 'last day of %+d months', $months ) );
+ $resolved = $end_of_month->setDate(
+ (int) $end_of_month->format( 'Y' ),
+ (int) $end_of_month->format( 'n' ),
+ // Asking for min will get us the correct end of month if we're about to overflow, or the actual date.
+ // Because adding P1M to Jan 17 should give you Feb 17 but adding P1M to Jan 28-31 should give you Feb 28 (or Feb 29 on leap years).
+ min( (int) $today->format( 'j' ), (int) $end_of_month->format( 'j' ) )
+ );
+
+ // Add the days back from the original interval.
+ return $resolved->modify( sprintf( '%+d days', $days ) )->format( 'Y-m-d' );
+ }
+
/**
* Parses a YYYY-MM-DD date in the store's timezone.
*
@@ -85,4 +307,14 @@ class DateFieldType extends AbstractFieldType {
return $date ? $date : null;
}
+
+ /**
+ * Returns true if the given value is a date in YYYY-MM-DD format.
+ *
+ * @param mixed $value The value to check.
+ * @return bool
+ */
+ private function is_absolute_date( $value ): bool {
+ return is_string( $value ) && 1 === preg_match( self::ABSOLUTE_DATE, $value );
+ }
}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
index 0b2221db759..c7b75ab7520 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Services/CheckoutFields.php
@@ -985,7 +985,8 @@ class CheckoutFields {
/**
* Applies type-specific arguments to a field before it is rendered with woocommerce_form_field().
*
- * Used by the server-rendered My Account forms: maps select options and sets checkbox submit values.
+ * Used by the server-rendered My Account forms: maps select options, sets checkbox submit values, and
+ * resolves date min/max constraints into input attributes.
*
* @param array $form_field The woocommerce_form_field() arguments built from the field.
* @return array The updated arguments.
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php
new file mode 100644
index 00000000000..7857413185c
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldTypes/DateFieldTypeTest.php
@@ -0,0 +1,467 @@
+<?php
+declare( strict_types=1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services\CheckoutFieldTypes;
+
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldTypes\DateFieldType;
+use WP_Error;
+use WP_UnitTestCase;
+
+/**
+ * Tests for the DateFieldType class.
+ */
+class DateFieldTypeTest extends WP_UnitTestCase {
+ /**
+ * The system under test.
+ *
+ * @var DateFieldType
+ */
+ private $field_type;
+
+ /**
+ * Setup test case.
+ */
+ protected function setUp(): void {
+ parent::setUp();
+ $this->field_type = new DateFieldType();
+ }
+
+ /**
+ * @testdox Absolute dates and ISO 8601-2 durations resolve to the expected dates.
+ *
+ * @testWith ["2025-08-26", "2025-08-26"]
+ * ["P0D", "today"]
+ * ["P1D", "+1 day"]
+ * ["-P5D", "-5 days"]
+ * ["P2W", "+14 days"]
+ * ["-P18Y", "-18 years"]
+ * ["P1Y2M3D", "+1 year +2 months +3 days"]
+ *
+ * @param string $constraint The constraint to resolve.
+ * @param string $equivalent A date expression resolving to the same day.
+ */
+ public function test_supported_date_constraint_vocabulary( string $constraint, string $equivalent ) {
+ $form_field = $this->field_type->prepare_form_field( $this->date_field( array( 'min' => $constraint ) ) );
+
+ $this->assertSame( $this->date_relative_to_today( $equivalent ), $form_field['custom_attributes']['min'] );
+ }
+
+ /**
+ * @testdox Invalid constraints fail registration with an error saying why they were rejected.
+ *
+ * @testWith ["min", "2026-02-31", "real calendar date"]
+ * ["max", "2026-02-31", "real calendar date"]
+ * ["min", "2026-8-6", "ISO 8601-2 duration"]
+ * ["min", "PT1H", "time component"]
+ * ["min", "P1DT2H", "time component"]
+ * ["max", "garbage", "ISO 8601-2 duration"]
+ * ["min", "--P1D", "ISO 8601-2 duration"]
+ * ["min", "today", "ISO 8601-2 duration"]
+ * ["min", "+1 day", "ISO 8601-2 duration"]
+ * ["min", "P", "ISO 8601-2 duration"]
+ * ["max", "", "ISO 8601-2 duration"]
+ *
+ * @param string $key The constraint being set.
+ * @param string $constraint The invalid value.
+ * @param string $expected A phrase expected in the registration error.
+ */
+ public function test_invalid_constraints_are_registration_errors( string $key, string $constraint, string $expected ) {
+ $this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
+
+ $message = null;
+ add_action(
+ 'doing_it_wrong_run',
+ function ( $function_name, $function_message ) use ( &$message ) {
+ // Avoid parameter-not-used PHPCS errors.
+ unset( $function_name );
+ $message = $function_message;
+ },
+ 10,
+ 2
+ );
+
+ $this->assertFalse( $this->register( array( $key => $constraint ) ) );
+ $this->assertStringContainsString( $expected, (string) $message );
+ }
+
+ /**
+ * @testdox A constraint that is not a string fails registration.
+ */
+ public function test_registration_rejects_non_string_constraints() {
+ $this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
+
+ $this->assertFalse( $this->register( array( 'min' => 20260826 ) ) );
+ $this->assertFalse( $this->register( array( 'max' => new \DateInterval( 'P1D' ) ) ) );
+ }
+
+ /**
+ * @testdox Registration trims a duration and drops an explicit plus sign.
+ */
+ public function test_registration_normalizes_constraints() {
+ $this->assertSame( 'P2W', $this->register( array( 'min' => ' +P2W ' ) )['min'] );
+ }
+
+ /**
+ * @testdox Durations are stored on the field as registered, not resolved.
+ */
+ public function test_constraints_are_stored_unresolved() {
+ // A resolved value here would freeze into any page cache holding the rendered form.
+ $field_data = $this->register(
+ array(
+ 'min' => 'P0D',
+ 'max' => 'P30D',
+ )
+ );
+
+ $this->assertSame( 'P0D', $field_data['min'] );
+ $this->assertSame( 'P30D', $field_data['max'] );
+ }
+
+ /**
+ * @testdox A field registered without constraints carries no min or max key.
+ */
+ public function test_missing_constraints_are_dropped() {
+ $field_data = $this->register( array() );
+
+ $this->assertArrayNotHasKey( 'min', $field_data );
+ $this->assertArrayNotHasKey( 'max', $field_data );
+ }
+
+ /**
+ * @testdox An inverted range keeps the field, drops both limits, and logs only during value validation.
+ *
+ * @testWith ["2025-12-31", "2025-01-01"]
+ * ["P2M", "P1M"]
+ * ["-P13Y", "-P18Y"]
+ * ["P0D", "1900-01-01"]
+ * ["2999-12-31", "P0D"]
+ *
+ * @param string $min The min constraint.
+ * @param string $max The max constraint.
+ */
+ public function test_inverted_constraints_are_ignored( string $min, string $max ): void {
+ $warnings = 0;
+ $logger = $this->createMock( \WC_Logger_Interface::class );
+ $logger->expects( $this->exactly( 2 ) )->method( 'warning' )->with(
+ $this->stringContains( 'Date limits for checkout field "test/date-of-birth" were ignored' ),
+ $this->arrayHasKey( 'source' )
+ )->willReturnCallback(
+ static function () use ( &$warnings ) {
+ ++$warnings;
+ }
+ );
+ add_filter(
+ 'woocommerce_logging_class',
+ static function () use ( $logger ) {
+ return $logger;
+ }
+ );
+
+ $field_data = $this->register(
+ array(
+ 'min' => $min,
+ 'max' => $max,
+ )
+ );
+ $this->assertIsArray( $field_data );
+ $this->assertSame( $min, $field_data['min'] );
+ $this->assertSame( $max, $field_data['max'] );
+ $field = $this->date_field( $field_data );
+ $form_field = $this->field_type->prepare_form_field( $field );
+
+ $this->assertArrayNotHasKey( 'min', $form_field['custom_attributes'] );
+ $this->assertArrayNotHasKey( 'max', $form_field['custom_attributes'] );
+ $this->assertSame( 0, $warnings, 'Rendering the field should not log a warning.' );
+ $this->assertNull( $this->field_type->validate( '1800-01-01', $field ) );
+ $this->assertSame( 1, $warnings, 'Validating a submitted date should log one warning.' );
+ $this->assertNull( $this->field_type->validate( '3000-01-01', $field ) );
+ $this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-02-31', $field ) );
+ }
+
+ /**
+ * @testdox Equal limits allow only that date.
+ */
+ public function test_equal_constraints_allow_one_date(): void {
+ $field = $this->date_field(
+ array(
+ 'min' => '2026-08-26',
+ 'max' => '2026-08-26',
+ )
+ );
+
+ $this->assertNull( $this->field_type->validate( '2026-08-26', $field ) );
+ $this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-08-25', $field ) );
+ $this->assertInstanceOf( WP_Error::class, $this->field_type->validate( '2026-08-27', $field ) );
+ }
+
+ /**
+ * @testdox An ordered min/max pair is accepted, as is a mixed absolute/duration pair.
+ *
+ * @testWith ["P1D", "P1M"]
+ * ["P28D", "P1M"]
+ * ["P12M", "P370D"]
+ * ["-P18Y", "-P13Y"]
+ * ["P0D", "2999-12-31"]
+ * ["2020-01-01", "P0D"]
+ *
+ * @param string $min The min constraint.
+ * @param string $max The max constraint.
+ */
+ public function test_ordered_constraints_are_accepted( string $min, string $max ) {
+ $this->assertIsArray(
+ $this->register(
+ array(
+ 'min' => $min,
+ 'max' => $max,
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox Month arithmetic clamps to the end of the target month, matching Temporal on the client.
+ *
+ * @testWith ["2026-01-31", "P1M", "2026-02-28"]
+ * ["2026-03-31", "-P1M", "2026-02-28"]
+ * ["2024-02-29", "P1Y", "2025-02-28"]
+ * ["2026-01-31", "P1M15D", "2026-03-15"]
+ * ["2026-08-26", "P2W", "2026-09-09"]
+ * ["2026-08-26", "P1W2D", "2026-09-04"]
+ * ["2026-08-26", "-P1W2D", "2026-08-17"]
+ * ["2026-08-26", "P1M2W", "2026-10-10"]
+ * ["2026-01-31", "P1M2W3D", "2026-03-17"]
+ *
+ * @param string $today The current date in the store timezone.
+ * @param string $constraint The constraint to resolve.
+ * @param string $expected The expected resolved date.
+ */
+ public function test_month_arithmetic_clamps_like_temporal( string $today, string $constraint, string $expected ) {
+ // PHP's own DateInterval arithmetic would roll 31 January + P1M forward to 3 March and disagree
+ // with the date the browser offers in the picker. Resolution is relative to today, so the
+ // private resolver is called directly to pin the day the arithmetic starts from.
+ $resolve = new \ReflectionMethod( $this->field_type, 'resolve_constraint' );
+ $resolve->setAccessible( true );
+ $constraint = $this->register( array( 'min' => $constraint ) )['min'];
+
+ $this->assertSame( $expected, $resolve->invoke( $this->field_type, $constraint, new \DateTimeImmutable( $today, wp_timezone() ) ) );
+ }
+
+ /**
+ * @testdox prepare_form_field exposes both resolved constraints as input attributes.
+ */
+ public function test_prepare_form_field() {
+ $form_field = $this->field_type->prepare_form_field(
+ $this->date_field(
+ array(
+ 'min' => 'P0D',
+ 'max' => '2999-12-31',
+ )
+ )
+ );
+
+ $this->assertSame(
+ array(
+ 'min' => $this->date_relative_to_today( 'today' ),
+ 'max' => '2999-12-31',
+ ),
+ $form_field['custom_attributes']
+ );
+ }
+
+ /**
+ * @testdox An unconstrained field gets no min or max input attribute.
+ */
+ public function test_prepare_form_field_without_constraints() {
+ $form_field = $this->field_type->prepare_form_field( $this->date_field() );
+
+ $this->assertSame( array(), $form_field['custom_attributes'] );
+ }
+
+ /**
+ * @testdox Only a real calendar date in Y-m-d format is accepted.
+ *
+ * @testWith ["2025-08-26", false]
+ * ["2024-02-29", false]
+ * ["2026-02-29", true]
+ * ["", false]
+ * ["2025-02-31", true]
+ * ["2025-8-6", true]
+ * ["26-08-2025", true]
+ * ["not-a-date", true]
+ *
+ * @param string $value The submitted value.
+ * @param bool $has_errors Whether the value should be rejected.
+ */
+ public function test_only_real_calendar_dates_are_valid( string $value, bool $has_errors ) {
+ $this->assert_rejects( $has_errors, $value, $this->date_field() );
+ }
+
+ /**
+ * @testdox Absolute date constraints are enforced, inclusive of both bounds.
+ *
+ * @testWith ["2025-01-01", false]
+ * ["2025-06-15", false]
+ * ["2025-12-31", false]
+ * ["2024-12-31", true]
+ * ["2026-01-01", true]
+ *
+ * @param string $value The submitted value.
+ * @param bool $has_errors Whether the value should be rejected.
+ */
+ public function test_absolute_date_constraints_are_enforced( string $value, bool $has_errors ) {
+ $this->assert_rejects(
+ $has_errors,
+ $value,
+ $this->date_field(
+ array(
+ 'min' => '2025-01-01',
+ 'max' => '2025-12-31',
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox Relative date constraints are enforced against the current date.
+ *
+ * @testWith ["today", false]
+ * ["+1 day", false]
+ * ["+30 days", false]
+ * ["-1 day", true]
+ * ["+31 days", true]
+ *
+ * @param string $offset The submitted value, relative to today.
+ * @param bool $has_errors Whether the value should be rejected.
+ */
+ public function test_relative_date_constraints_are_enforced( string $offset, bool $has_errors ) {
+ $this->assert_rejects(
+ $has_errors,
+ $this->date_relative_to_today( $offset ),
+ $this->date_field(
+ array(
+ 'min' => 'P0D',
+ 'max' => 'P30D',
+ )
+ )
+ );
+ }
+
+ /**
+ * @testdox A field without constraints accepts any real calendar date.
+ */
+ public function test_unconstrained_fields_have_no_bounds() {
+ $this->assert_rejects( false, '1901-01-01', $this->date_field() );
+ $this->assert_rejects( false, '2222-12-31', $this->date_field() );
+ }
+
+ /**
+ * @testdox An out of range date is rejected with a message naming the boundary in the site date format.
+ */
+ public function test_out_of_range_date_error_message() {
+ update_option( 'date_format', 'F j, Y' );
+
+ $field = $this->date_field(
+ array(
+ 'min' => '2025-01-01',
+ 'max' => '2025-12-31',
+ )
+ );
+
+ $this->assertSame(
+ 'Please provide a Promotion date on or after January 1, 2025.',
+ $this->field_type->validate( '2024-12-31', $field )->get_error_message()
+ );
+ $this->assertSame(
+ 'Please provide a Promotion date on or before December 31, 2025.',
+ $this->field_type->validate( '2026-01-01', $field )->get_error_message()
+ );
+ }
+
+ /**
+ * @testdox Values are displayed using the site date format, in the site timezone.
+ *
+ * @testWith ["UTC", "F j, Y", "August 26, 2025"]
+ * ["America/New_York", "Y-m-d", "2025-08-26"]
+ * ["Pacific/Auckland", "Y-m-d", "2025-08-26"]
+ *
+ * @param string $timezone The site timezone.
+ * @param string $date_format The site date format.
+ * @param string $expected The expected formatted value.
+ */
+ public function test_value_formatting( string $timezone, string $date_format, string $expected ) {
+ update_option( 'timezone_string', $timezone );
+ update_option( 'date_format', $date_format );
+
+ $this->assertSame( $expected, $this->field_type->format_value( '2025-08-26', $this->date_field() ), 'The stored calendar date should never shift when it is formatted.' );
+ }
+
+ /**
+ * @testdox Values that are not a real calendar date are displayed as stored.
+ *
+ * @testWith ["2025-02-31"]
+ * ["2025-13-01"]
+ * ["not-a-date"]
+ * [""]
+ *
+ * @param string $value The stored value.
+ */
+ public function test_invalid_value_is_not_reformatted( string $value ) {
+ update_option( 'date_format', 'F j, Y' );
+
+ $this->assertSame( $value, $this->field_type->format_value( $value, $this->date_field() ), 'A value that is not a real date should be shown as stored rather than rolled forward.' );
+ }
+
+ /**
+ * Asserts whether validating a value against a field produces an error.
+ *
+ * @param bool $has_errors Whether the value should be rejected.
+ * @param string $value The submitted value.
+ * @param array $field The field to validate against.
+ */
+ private function assert_rejects( bool $has_errors, string $value, array $field ) {
+ $error = $this->field_type->validate( $value, $field );
+
+ $this->assertSame( $has_errors, $error instanceof WP_Error, sprintf( 'Unexpected validation result for "%s".', $value ) );
+ }
+
+ /**
+ * Returns a registered date field, with the given constraints applied.
+ *
+ * @param array $constraints The min and/or max to apply.
+ * @return array
+ */
+ private function date_field( array $constraints = array() ): array {
+ return array_merge(
+ array(
+ 'type' => 'date',
+ 'label' => 'Promotion date',
+ ),
+ $constraints
+ );
+ }
+
+ /**
+ * Processes registration options through the field type, as field registration does.
+ *
+ * @param array $options The options supplied during field registration.
+ * @return array|false The processed field data, or false if an error should prevent registration.
+ */
+ private function register( array $options ) {
+ $field_data = array(
+ 'id' => 'test/date-of-birth',
+ 'attributes' => array(),
+ );
+
+ return $this->field_type->process_options( $field_data, array_merge( array( 'id' => $field_data['id'] ), $options ) );
+ }
+
+ /**
+ * Returns a Y-m-d date, resolved independently of the code under test.
+ *
+ * @param string $expression An absolute Y-m-d date, or an expression relative to today such as "+1 day".
+ * @return string
+ */
+ private function date_relative_to_today( string $expression ): string {
+ return ( new \DateTime( $expression, wp_timezone() ) )->format( 'Y-m-d' );
+ }
+}
diff --git a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
index e04e80cdc94..4c8e90bb850 100644
--- a/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
+++ b/plugins/woocommerce/tests/php/src/Blocks/Domain/Services/CheckoutFieldsTest.php
@@ -5,6 +5,7 @@ namespace Automattic\WooCommerce\Tests\Blocks\Domain\Services;
use Automattic\WooCommerce\Blocks\Package;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFields;
+use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsAdmin;
use Automattic\WooCommerce\Blocks\Domain\Services\CheckoutFieldsSchema\DocumentObject;
use WP_UnitTestCase;
@@ -97,6 +98,14 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
'location' => 'order',
'type' => 'date',
),
+ array(
+ 'id' => 'plugin-namespace/appointment-date',
+ 'label' => 'Appointment date',
+ 'location' => 'order',
+ 'type' => 'date',
+ 'min' => 'P0D',
+ 'max' => 'P30D',
+ ),
array(
'id' => 'namespace/vat-number',
'label' => 'VAT Number',
@@ -196,35 +205,38 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
}
/**
- * @testdox Date fields can be registered.
+ * @testdox Date fields can be registered, with their constraints stored as registered.
*/
public function test_date_fields_can_be_registered() {
$fields = $this->controller->get_additional_fields();
$this->assertArrayHasKey( 'plugin-namespace/delivery-date', $fields, 'Date fields should be a supported field type.' );
$this->assertSame( 'date', $fields['plugin-namespace/delivery-date']['type'] );
+
+ // The constraint rules themselves are covered by DateFieldTypeTest; this only checks registration carries them through unresolved.
+ $this->assertSame( 'P0D', $fields['plugin-namespace/appointment-date']['min'] );
+ $this->assertSame( 'P30D', $fields['plugin-namespace/appointment-date']['max'] );
}
/**
- * @testdox Date fields only accept a real calendar date in Y-m-d format.
- *
- * @testWith ["2026-08-26", false]
- * ["2024-02-29", false]
- * ["", false]
- * ["2026-02-31", true]
- * ["2026-02-29", true]
- * ["2026-8-6", true]
- * ["26-08-2026", true]
- * ["not-a-date", true]
- *
- * @param string $value The submitted value.
- * @param bool $has_errors Whether the value should be rejected.
+ * @testdox Old order dates can be edited without the current checkout limits.
*/
- public function test_date_field_validation( string $value, bool $has_errors ) {
- $fields = $this->controller->get_additional_fields();
- $errors = $this->controller->validate_field( $fields['plugin-namespace/delivery-date'], $value );
-
- $this->assertSame( $has_errors, $errors->has_errors(), sprintf( 'Unexpected validation result for "%s".', $value ) );
+ public function test_order_editor_omits_date_constraints(): void {
+ $sut = Package::container()->get( CheckoutFieldsAdmin::class );
+ $order = new \WC_Order();
+ $key = '_wc_other/plugin-namespace/appointment-date';
+ $order->set_created_via( 'store-api' );
+ $order->update_meta_data( $key, '1900-01-01' );
+
+ $fields = $sut->admin_order_fields( array(), $order );
+ $field = $fields['plugin-namespace/appointment-date'];
+
+ $this->assertSame( '1900-01-01', $field['value'] );
+ $this->assertArrayNotHasKey( 'min', $field['custom_attributes'] ?? array() );
+ $this->assertArrayNotHasKey( 'max', $field['custom_attributes'] ?? array() );
+
+ $sut->update_callback( $key, '1900-01-02', $order );
+ $this->assertSame( '1900-01-02', $order->get_meta( $key ) );
}
/**
@@ -271,46 +283,32 @@ class CheckoutFieldsTest extends WP_UnitTestCase {
}
/**
- * @testdox Date field values are displayed using the site date format, in the site timezone.
- *
- * @testWith ["UTC", "F j, Y", "August 26, 2026"]
- * ["America/New_York", "Y-m-d", "2026-08-26"]
- * ["Pacific/Auckland", "Y-m-d", "2026-08-26"]
- *
- * @param string $timezone The site timezone.
- * @param string $date_format The site date format.
- * @param string $expected The expected formatted value.
+ * @testdox Date field validation is delegated to the date field type.
*/
- public function test_date_field_value_formatting( string $timezone, string $date_format, string $expected ) {
- update_option( 'timezone_string', $timezone );
- update_option( 'date_format', $date_format );
-
- $fields = $this->controller->get_additional_fields();
- $value = $this->controller->format_additional_field_value( '2026-08-26', $fields['plugin-namespace/delivery-date'] );
+ public function test_date_field_validation_is_delegated() {
+ $field = $this->controller->get_additional_fields()['plugin-namespace/delivery-date'];
- $this->assertSame( $expected, $value, 'The stored calendar date should never shift when it is formatted.' );
+ $this->assertFalse( $this->controller->validate_field( $field, '2025-08-26' )->has_errors() );
+ $this->assertTrue( $this->controller->validate_field( $field, '2025-02-31' )->has_errors() );
}
/**
- * @testdox Date values that are not a real calendar date are displayed as stored.
- *
- * @testWith ["2026-02-31"]
- * ["2026-13-01"]
- * ["not-a-date"]
- * [""]
- *
- * @param string $value The stored value.
+ * @testdox A date field whose constraints the date field type rejects is not registered.
*/
- public function test_invalid_date_field_value_is_not_reformatted( string $value ) {
- update_option( 'date_format', 'F j, Y' );
-
- $fields = $this->controller->get_additional_fields();
+ public function test_date_field_with_invalid_constraint_is_not_registered() {
+ $this->setExpectedIncorrectUsage( 'woocommerce_register_additional_checkout_field' );
- $this->assertSame(
- $value,
- $this->controller->format_additional_field_value( $value, $fields['plugin-namespace/delivery-date'] ),
- 'A value that is not a real date should be shown as stored rather than rolled forward.'
+ woocommerce_register_additional_checkout_field(
+ array(
+ 'id' => 'plugin-namespace/invalid-constraint',
+ 'label' => 'Invalid constraint',
+ 'location' => 'order',
+ 'type' => 'date',
+ 'min' => 'not-a-date',
+ )
);
+
+ $this->assertArrayNotHasKey( 'plugin-namespace/invalid-constraint', $this->controller->get_additional_fields() );
}
/**
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 608655b220f..4122aee3bbc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -3367,6 +3367,9 @@ importers:
react-transition-group:
specifier: ^4.4.5
version: 4.4.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ temporal-polyfill:
+ specifier: 1.0.4
+ version: 1.0.4
trim-html:
specifier: 0.1.9
version: 0.1.9
@@ -18424,6 +18427,15 @@ packages:
resolution: {integrity: sha512-s0ZZzd0BzYv5tLSptZooSjK8oj6C+c19p7Vqta9+6NPOf7r+fxq0cJe6/oN4LTC79sy5NY8ucOJNgwsKCSbfqg==}
engines: {node: '>=6.0.0'}
+ temporal-polyfill@1.0.4:
+ resolution: {integrity: sha512-MLEU0qOD2uXlz24oINNtdLZQl8RgmMxSnRtKEGZGGetTJjlRwQJjk+VsJ4EREaUoiaTb8NJOcUiKtoAiWn9EBg==, tarball: https://registry.npmjs.org/temporal-polyfill/-/temporal-polyfill-1.0.4.tgz}
+
+ temporal-spec@1.0.1:
+ resolution: {integrity: sha512-wxVoanmDeavXie1vu2JaQ3WIc3JZnWAOYFBsJyATaVsXsycKYUflGsyBmrRSnoCpZJpwPyr38VpgSUlQ8CbFxg==, tarball: https://registry.npmjs.org/temporal-spec/-/temporal-spec-1.0.1.tgz}
+
+ temporal-utils@1.0.2:
+ resolution: {integrity: sha512-1B8Dl4KzrOvsNUlpoWGno2VLQlxroLjDgc5NBjVC9ax9ymdo+ezfyvhyjEMx+IVfhINr6CIG2gcnlP95iRrybQ==, tarball: https://registry.npmjs.org/temporal-utils/-/temporal-utils-1.0.2.tgz}
+
term-size@1.2.0:
resolution: {integrity: sha512-7dPUZQGy/+m3/wjVz3ZW5dobSoD/02NxJpoXUX0WIyjfVS3l0c+b/+9phIDFA7FHzkYtwtMFgeGZ/Y8jVTeqQQ==}
engines: {node: '>=4'}
@@ -41666,6 +41678,15 @@ snapshots:
dependencies:
rimraf: 2.6.3
+ temporal-polyfill@1.0.4:
+ dependencies:
+ temporal-spec: 1.0.1
+ temporal-utils: 1.0.2
+
+ temporal-spec@1.0.1: {}
+
+ temporal-utils@1.0.2: {}
+
term-size@1.2.0:
dependencies:
execa: 0.7.0