diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 394a29c3fe06ca..405e84c478fb65 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -5,6 +5,7 @@ ### Bug Fixes - `TimePicker`: Fix date parsing for years below 1000 by implementing ISO 8601 compliant year formatting with zero-padding ([#75343](https://github.com/WordPress/gutenberg/pull/75343)). +- `TimePicker`: Prevent Invalid Date from propagating via `onChange` when date fields are cleared ([#75285](https://github.com/WordPress/gutenberg/pull/75285)). - `Text`: Remove `text-wrap: balance` fallback. Only `text-wrap: pretty` is now used ([#75089](https://github.com/WordPress/gutenberg/pull/75089)). - `RangeControl`: support forced-colors mode ([#75165](https://github.com/WordPress/gutenberg/pull/75165)). - `ToggleControl`: Prevent `__nextHasNoMarginBottom` from logging a console warning ([#75296](https://github.com/WordPress/gutenberg/pull/75296)). @@ -12,6 +13,7 @@ ### Enhancements +- `TimePicker`, `DateTimePicker`: Add `minYear` prop to control minimum selectable year. Defaults to 1, enabling support for historical dates. Post scheduling continues to restrict dates to the current year and future via the `minYear` prop ([#75285](https://github.com/WordPress/gutenberg/pull/75285)). - Improve visual emphasis of the selected item in the ToggleGroupControl component ([#75138](https://github.com/WordPress/gutenberg/pull/75138)). ### Internal diff --git a/packages/components/src/date-time/README.md b/packages/components/src/date-time/README.md index b57a3f859b0e11..4df884ad0c274b 100644 --- a/packages/components/src/date-time/README.md +++ b/packages/components/src/date-time/README.md @@ -89,3 +89,27 @@ The day that the week should start on. 0 for Sunday, 1 for Monday, etc. - Required: No - Default: 0 + +### `minYear`: `number` + +The minimum year that can be selected in the year input field. + +- Required: No +- Default: `1` + +**Usage examples:** + +```jsx +// Default behavior - allows years from 1 onwards + + +// For post scheduling - restrict to current year and future only + +``` diff --git a/packages/components/src/date-time/date-time/index.tsx b/packages/components/src/date-time/date-time/index.tsx index 2be59694253700..2770e6d6dcfa40 100644 --- a/packages/components/src/date-time/date-time/index.tsx +++ b/packages/components/src/date-time/date-time/index.tsx @@ -30,6 +30,7 @@ function UnforwardedDateTimePicker( onChange, events, startOfWeek, + minYear = 1, }: DateTimePickerProps, ref: ForwardedRef< any > ) { @@ -41,6 +42,7 @@ function UnforwardedDateTimePicker( onChange={ onChange } is12Hour={ is12Hour } dateOrder={ dateOrder } + minYear={ minYear } /> // Truncate the date at the minutes, see: #15495. @@ -106,8 +107,23 @@ export function TimePicker( { [ date ] ); + const updateDate = ( newDate: Date ) => { + // Guard against Invalid Date propagation. + if ( + newDate instanceof Date && + ! Number.isFinite( newDate.getTime() ) + ) { + return; + } + + setDate( newDate ); + onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) ); + }; + const buildNumberControlChangeCallback = ( method: 'date' | 'year' ) => { const callback: InputChangeCallback = ( value, { event } ) => { + // validateInputElementTarget checks event.target.validity.valid, + // which catches empty values via the 'required' attribute if ( ! validateInputElementTarget( event ) ) { return; } @@ -120,8 +136,7 @@ export function TimePicker( { const newDate = setInConfiguredTimezone( date, { [ method ]: numberValue, } ); - setDate( newDate ); - onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) ); + updateDate( newDate ); }; return callback; }; @@ -136,8 +151,7 @@ export function TimePicker( { hours: newHours, minutes: newMinutes, } ); - setDate( newDate ); - onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) ); + updateDate( newDate ); }; const dayField = ( @@ -175,8 +189,7 @@ export function TimePicker( { const newDate = setInConfiguredTimezone( date, { month: Number( value ) - 1, } ); - setDate( newDate ); - onChange?.( formatDate( TIMEZONELESS_FORMAT, newDate ) ); + updateDate( newDate ); } } /> @@ -191,7 +204,7 @@ export function TimePicker( { __next40pxDefaultSize value={ year } step={ 1 } - min={ 1 } + min={ minYear } max={ 9999 } required spinControls="none" diff --git a/packages/components/src/date-time/time/test/index.tsx b/packages/components/src/date-time/time/test/index.tsx index cb0ff6331771db..b6fdc9f7aa58d2 100644 --- a/packages/components/src/date-time/time/test/index.tsx +++ b/packages/components/src/date-time/time/test/index.tsx @@ -633,4 +633,236 @@ describe( 'TimePicker', () => { expect( outputDate ).not.toContain( 'Invalid' ); } ); } ); + + describe( 'Invalid date handling', () => { + it( 'should not emit invalid dates when year is cleared and blurred', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Clear the year field and blur + await user.clear( yearInput ); + await user.keyboard( '{Tab}' ); + + // Verify the input field value after browser coercion + expect( yearInput ).toHaveValue( 1 ); + + // When the year is cleared, the browser may coerce to minYear. + // If onChange is called, it should only emit valid date strings. + onChangeSpy.mock.calls.forEach( ( call ) => { + const dateString = call[ 0 ]; + expect( dateString ).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ + ); + expect( dateString ).not.toContain( 'NaN' ); + expect( dateString ).not.toContain( 'Invalid' ); + } ); + } ); + + it( 'should not call onChange with an invalid date when day is cleared and month is changed', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const dayInput = screen.getByLabelText( 'Day' ); + const monthInput = screen.getByLabelText( 'Month' ); + + // Clear the day field + await user.clear( dayInput ); + // Change the month (this would create an invalid date if day is 0) + await user.selectOptions( monthInput, '12' ); + + // Verify the input field value after browser coercion + expect( dayInput ).toHaveValue( 1 ); + + // Verify that if onChange was called, it only received valid date strings. + // Our guard should prevent invalid dates from being emitted, so any calls + // should have properly formatted dates without "NaN" or "Invalid". + onChangeSpy.mock.calls.forEach( ( call ) => { + const dateString = call[ 0 ]; + expect( dateString ).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ + ); + expect( dateString ).not.toContain( 'NaN' ); + expect( dateString ).not.toContain( 'Invalid' ); + } ); + } ); + + it( 'should handle years below 1000 when minYear allows them', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Clear the year field and blur - browser will coerce to minYear (1) + await user.clear( yearInput ); + await user.keyboard( '{Tab}' ); + + // Verify the input field value after browser coercion to minYear + expect( yearInput ).toHaveValue( 1 ); + + // Verify that if onChange was called, it only received valid date strings + onChangeSpy.mock.calls.forEach( ( call ) => { + const dateString = call[ 0 ]; + expect( dateString ).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/ + ); + expect( dateString ).not.toContain( 'NaN' ); + expect( dateString ).not.toContain( 'Invalid' ); + } ); + } ); + } ); + + describe( 'Years below 1000 support', () => { + it( 'should handle year 999 with zero-padded output', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Verify input displays human-readable year (not zero-padded) + expect( yearInput ).toHaveValue( 999 ); + + // Change to year 500 + await user.clear( yearInput ); + await user.type( yearInput, '500' ); + await user.keyboard( '{Tab}' ); + + // Verify input displays human-readable year + expect( yearInput ).toHaveValue( 500 ); + + // Verify onChange emits zero-padded ISO 8601 format + const lastCall = + onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ]; + expect( lastCall[ 0 ] ).toMatch( /^0500-10-18T/ ); + expect( lastCall[ 0 ] ).not.toContain( 'NaN' ); + } ); + + it( 'should handle year 1 with zero-padded output', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Verify input displays human-readable year (not zero-padded) + expect( yearInput ).toHaveValue( 1 ); + + // Change to year 99 + await user.clear( yearInput ); + await user.type( yearInput, '99' ); + await user.keyboard( '{Tab}' ); + + // Verify input displays human-readable year + expect( yearInput ).toHaveValue( 99 ); + + // Verify onChange emits zero-padded ISO 8601 format + const lastCall = + onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ]; + expect( lastCall[ 0 ] ).toMatch( /^0099-01-01T/ ); + } ); + + it( 'should allow years below 1000 when minYear permits', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Verify initial input displays correctly + expect( yearInput ).toHaveValue( 1986 ); + + // Type year 500 - should be allowed + await user.clear( yearInput ); + await user.type( yearInput, '500' ); + await user.keyboard( '{Tab}' ); + + // Verify input displays human-readable year (not zero-padded) + expect( yearInput ).toHaveValue( 500 ); + + // Verify onChange was called with zero-padded ISO 8601 format + expect( onChangeSpy ).toHaveBeenCalled(); + const lastCall = + onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ]; + expect( lastCall[ 0 ] ).toMatch( /^0500-/ ); + } ); + + it( 'should handle year 500 correctly', async () => { + const user = userEvent.setup(); + const onChangeSpy = jest.fn(); + + render( + + ); + + const yearInput = screen.getByLabelText( 'Year' ); + + // Verify input displays human-readable year (not zero-padded) + expect( yearInput ).toHaveValue( 500 ); + + // Change to year 750 + await user.clear( yearInput ); + await user.type( yearInput, '750' ); + await user.keyboard( '{Tab}' ); + + // Verify input displays human-readable year + expect( yearInput ).toHaveValue( 750 ); + + // Verify onChange emits zero-padded ISO 8601 format + const lastCall = + onChangeSpy.mock.calls[ onChangeSpy.mock.calls.length - 1 ]; + expect( lastCall[ 0 ] ).toMatch( /^0750-06-15T/ ); + } ); + } ); } ); diff --git a/packages/components/src/date-time/types.ts b/packages/components/src/date-time/types.ts index 1f2298d9d450b5..68c39d7b4d4718 100644 --- a/packages/components/src/date-time/types.ts +++ b/packages/components/src/date-time/types.ts @@ -36,6 +36,13 @@ export type TimePickerProps = { * @default false */ hideLabelFromVision?: boolean; + + /** + * The minimum year that can be selected in the year input field. + * + * @default 1 + */ + minYear?: number; }; export type TimeInputValue = { diff --git a/packages/editor/src/components/post-schedule/index.js b/packages/editor/src/components/post-schedule/index.js index e324e40896d379..c0ad5541181b57 100644 --- a/packages/editor/src/components/post-schedule/index.js +++ b/packages/editor/src/components/post-schedule/index.js @@ -6,7 +6,7 @@ import { parseISO, endOfMonth, startOfMonth } from 'date-fns'; /** * WordPress dependencies */ -import { getSettings } from '@wordpress/date'; +import { getDate, getSettings } from '@wordpress/date'; import { _x } from '@wordpress/i18n'; import { useDispatch, useSelect } from '@wordpress/data'; import { privateApis as blockEditorPrivateApis } from '@wordpress/block-editor'; @@ -94,6 +94,11 @@ export function PrivatePostSchedule( { .join( '' ) // Reverse the string and test for "a" not followed by a slash. ); + // Get the current year in the site's timezone to use as the minimum schedulable year. + // We use getDate() instead of new Date() to ensure we respect the WordPress site's + // configured timezone rather than the browser/system timezone. + const currentSiteYear = getDate().getFullYear(); + return ( setPreviewedMonth( parseISO( date ) ) diff --git a/test/e2e/specs/editor/various/datepicker.spec.js b/test/e2e/specs/editor/various/datepicker.spec.js index f6337016c18ed1..5f961897a18a01 100644 --- a/test/e2e/specs/editor/various/datepicker.spec.js +++ b/test/e2e/specs/editor/various/datepicker.spec.js @@ -40,7 +40,7 @@ TIMEZONES.forEach( ( timezone ) => { ).toHaveText( 'Immediately' ); } ); - test( 'should show the publishing date if the date is in the past', async ( { + test( 'should not allow scheduling to a past year', async ( { page, } ) => { const datepicker = page.getByRole( 'button', { @@ -48,18 +48,26 @@ TIMEZONES.forEach( ( timezone ) => { } ); await datepicker.click(); - // Change the publishing date to a year in the future. - await page + const yearInput = page .getByRole( 'group', { name: 'Date' } ) - .getByRole( 'spinbutton', { name: 'Year' } ) - .click(); + .getByRole( 'spinbutton', { name: 'Year' } ); + + // Get the current year value + const currentYear = await yearInput.inputValue(); + + // Try to decrease the year + await yearInput.click(); await page.keyboard.press( 'ArrowDown' ); + + // The year should remain at the current year (cannot go to past) + await expect( yearInput ).toHaveValue( currentYear ); + await page.keyboard.press( 'Escape' ); - // The expected date format will be "Sep 26, 2018 11:52 pm". + // Since the date hasn't changed from current, it should still show "Immediately" await expect( page.getByRole( 'button', { name: 'Change date' } ) - ).toContainText( /^[A-Za-z]+\s\d{1,2},\s\d{1,4}/ ); + ).toHaveText( 'Immediately' ); } ); test( 'should show the publishing date if the date is in the future', async ( {