From ed9e1884aa649f0bea5da595307ca62e4b762f96 Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 13:21:05 +0100 Subject: [PATCH 01/17] remove unrequired changes --- .../dataviews/src/dataform-controls/date.tsx | 188 ++++++++++++++++-- .../dataviews/src/stories/dataform.story.tsx | 63 ++++++ 2 files changed, 232 insertions(+), 19 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 8014c3c1706c29..4dacfe1b43203f 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -5,7 +5,6 @@ import { BaseControl, Button, privateApis as componentsPrivateApis, - __experimentalInputControl as InputControl, __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; @@ -16,6 +15,7 @@ import { getDate, getSettings } from '@wordpress/date'; /** * External dependencies */ +import deepMerge from 'deepmerge'; import { format, isValid, @@ -41,7 +41,9 @@ import { import { unlock } from '../lock-unlock'; import type { DataFormControlProps } from '../types'; -const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); +const { DateCalendar, DateRangeCalendar, ValidatedInputControl } = unlock( + componentsPrivateApis +); type DateRange = [ string, string ] | undefined; @@ -139,13 +141,15 @@ const formatDate = ( date?: Date | string ): string => { return typeof date === 'string' ? date : format( date, 'yyyy-MM-dd' ); }; -function CalendarDateControl( { +function CalendarDateControl< Item >( { id, value, onChange, label, hideLabelFromVision, className, + data, + field, }: { id: string; value: string | undefined; @@ -153,16 +157,56 @@ function CalendarDateControl( { label: string; hideLabelFromVision?: boolean; className?: string; + data: Item; + field: DataFormControlProps< Item >[ 'field' ]; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); + const [ customValidity, setCustomValidity ] = + useState< + React.ComponentProps< + typeof ValidatedInputControl + >[ 'customValidity' ] + >( undefined ); const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { const parsedDate = parseDate( value ); return parsedDate || new Date(); // Default to current month } ); + const onValidateControl = useCallback( + ( newValue: any ) => { + // If field is not required and value is empty, clear validation + if ( ! field.isValid?.required && ! newValue ) { + setCustomValidity( undefined ); + return; + } + + const message = field.isValid?.custom?.( + deepMerge( + data, + field.setValue( { + item: data, + value: newValue, + } ) as Partial< Item > + ), + field + ); + + if ( message ) { + setCustomValidity( { + type: 'invalid', + message, + } ); + return; + } + + setCustomValidity( undefined ); + }, + [ data, field ] + ); + const onSelectDate = useCallback( ( newDate: Date | undefined | null ) => { const dateValue = newDate @@ -170,8 +214,14 @@ function CalendarDateControl( { : undefined; onChange( dateValue ); setSelectedPresetId( null ); + // Clear validation error when selecting from calendar + if ( dateValue ) { + onValidateControl( dateValue ); + } else { + setCustomValidity( undefined ); + } }, - [ onChange ] + [ onChange, onValidateControl ] ); const handlePresetClick = useCallback( @@ -182,8 +232,10 @@ function CalendarDateControl( { setCalendarMonth( presetDate ); onChange( dateValue ); setSelectedPresetId( preset.id ); + // Clear validation error when selecting preset + onValidateControl( dateValue ); }, - [ onChange ] + [ onChange, onValidateControl ] ); const handleManualDateChange = useCallback( @@ -244,21 +296,23 @@ function CalendarDateControl( { { /* Manual date input */ } - { /* Calendar widget */ } ( { id, value, onChange, label, hideLabelFromVision, className, + data, + field, }: { id: string; value: DateRange; @@ -284,10 +340,24 @@ function CalendarDateRangeControl( { label: string; hideLabelFromVision?: boolean; className?: string; + data: Item; + field: DataFormControlProps< Item >[ 'field' ]; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); + const [ customValidityFrom, setCustomValidityFrom ] = + useState< + React.ComponentProps< + typeof ValidatedInputControl + >[ 'customValidity' ] + >( undefined ); + const [ customValidityTo, setCustomValidityTo ] = + useState< + React.ComponentProps< + typeof ValidatedInputControl + >[ 'customValidity' ] + >( undefined ); const selectedRange = useMemo( () => { if ( ! value ) { @@ -317,6 +387,60 @@ function CalendarDateRangeControl( { [ onChange ] ); + const validateRangeValue = useCallback( + ( + fromValue: string | undefined, + toValue: string | undefined, + setValidity: ( validity: any ) => void + ) => { + const updatedValue = [ fromValue, toValue ]; + + // If field is not required and both values are empty, clear validation + if ( ! field.isValid?.required && ! fromValue && ! toValue ) { + setValidity( undefined ); + return; + } + + const message = field.isValid?.custom?.( + deepMerge( + data, + field.setValue( { + item: data, + value: updatedValue, + } ) as Partial< Item > + ), + field + ); + + if ( message ) { + setValidity( { + type: 'invalid', + message, + } ); + return; + } + + setValidity( undefined ); + }, + [ data, field ] + ); + + const onValidateControlFrom = useCallback( + ( newValue: any ) => { + const [ , currentTo ] = value || [ undefined, undefined ]; + validateRangeValue( newValue, currentTo, setCustomValidityFrom ); + }, + [ value, validateRangeValue ] + ); + + const onValidateControlTo = useCallback( + ( newValue: any ) => { + const [ currentFrom ] = value || [ undefined, undefined ]; + validateRangeValue( currentFrom, newValue, setCustomValidityTo ); + }, + [ value, validateRangeValue ] + ); + const onSelectCalendarRange = useCallback( ( newRange: @@ -325,8 +449,17 @@ function CalendarDateRangeControl( { ) => { updateDateRange( newRange?.from, newRange?.to ); setSelectedPresetId( null ); + // Clear validation errors when selecting from calendar + if ( newRange?.from ) { + const fromValue = formatDate( newRange.from ); + onValidateControlFrom( fromValue ); + } + if ( newRange?.to ) { + const toValue = formatDate( newRange.to ); + onValidateControlTo( toValue ); + } }, - [ updateDateRange ] + [ updateDateRange, onValidateControlFrom, onValidateControlTo ] ); const handlePresetClick = useCallback( @@ -335,8 +468,13 @@ function CalendarDateRangeControl( { setCalendarMonth( startDate ); updateDateRange( startDate, endDate ); setSelectedPresetId( preset.id ); + // Clear validation errors when selecting preset + const fromValue = formatDate( startDate ); + const toValue = formatDate( endDate ); + onValidateControlFrom( fromValue ); + onValidateControlTo( toValue ); }, - [ updateDateRange ] + [ updateDateRange, onValidateControlFrom, onValidateControlTo ] ); const handleManualDateChange = useCallback( @@ -404,23 +542,31 @@ function CalendarDateRangeControl( { { /* Manual date range inputs */ } - + value={ value?.[ 0 ] ?? '' } + help={ field.description } + onChange={ ( newValue: string ) => handleManualDateChange( 'from', newValue ) } /> - + value={ value?.[ 1 ] ?? '' } + help={ field.description } + onChange={ ( newValue: string ) => handleManualDateChange( 'to', newValue ) } /> @@ -508,6 +654,8 @@ export default function DateControl< Item >( { onChange={ onChangeCalendarDateRangeControl } label={ label } hideLabelFromVision={ hideLabelFromVision } + data={ data } + field={ field } /> ); } @@ -520,6 +668,8 @@ export default function DateControl< Item >( { onChange={ onChangeCalendarDateControl } label={ label } hideLabelFromVision={ hideLabelFromVision } + data={ data } + field={ field } /> ); } diff --git a/packages/dataviews/src/stories/dataform.story.tsx b/packages/dataviews/src/stories/dataform.story.tsx index a11f4a2482e3bc..bdfdbe44196d51 100644 --- a/packages/dataviews/src/stories/dataform.story.tsx +++ b/packages/dataviews/src/stories/dataform.story.tsx @@ -28,6 +28,7 @@ import type { CardLayout, } from '../types'; import { unlock } from '../lock-unlock'; +import DateControl from '../dataform-controls/date'; const { ValidatedTextControl, Badge } = unlock( privateApis ); @@ -536,6 +537,8 @@ const ValidationComponent = ( { password: string; toggle?: boolean; toggleGroup?: string; + date?: string; + dateRange?: [ string, string ]; }; const [ post, setPost ] = useState< ValidatedItem >( { @@ -556,6 +559,8 @@ const ValidationComponent = ( { password: 'secretpassword123', toggle: undefined, toggleGroup: undefined, + date: undefined, + dateRange: undefined, } ); const customTextRule = ( value: ValidatedItem ) => { @@ -666,12 +671,49 @@ const ValidationComponent = ( { return null; }; + const customDateRule = ( value: ValidatedItem ) => { + if ( ! value.date ) { + return null; + } + const selectedDate = new Date( value.date ); + const today = new Date(); + today.setHours( 0, 0, 0, 0 ); + if ( selectedDate < today ) { + return 'Date must not be in the past.'; + } + + return null; + }; + + const customDateRangeRule = ( value: ValidatedItem ) => { + if ( ! value.dateRange ) { + return null; + } + const [ fromDate, toDate ] = value.dateRange; + if ( ! fromDate || ! toDate ) { + return null; + } + const from = new Date( fromDate ); + const to = new Date( toDate ); + const daysDiff = Math.ceil( + ( to.getTime() - from.getTime() ) / ( 1000 * 60 * 60 * 24 ) + ); + if ( daysDiff > 30 ) { + return 'Date range must not exceed 30 days.'; + } + return null; + }; + const maybeCustomRule = ( rule: ( item: ValidatedItem ) => null | string ) => { return custom ? rule : undefined; }; + const DateRangeEdit = ( props: DataFormControlProps< ValidatedItem > ) => { + return ; + }; + const _fields: Field< ValidatedItem >[] = [ { id: 'text', @@ -859,6 +901,25 @@ const ValidationComponent = ( { custom: maybeCustomRule( customToggleGroupRule ), }, }, + { + id: 'date', + type: 'date', + label: 'Date', + isValid: { + required, + custom: maybeCustomRule( customDateRule ), + }, + }, + { + id: 'dateRange', + type: 'date', + label: 'Date Range', + Edit: DateRangeEdit, + isValid: { + required, + custom: maybeCustomRule( customDateRangeRule ), + }, + }, ]; const form = { @@ -881,6 +942,8 @@ const ValidationComponent = ( { 'toggleGroup', 'password', 'customEdit', + 'date', + 'dateRange', ], }; From 2422b4c964daaa3cc277bcf1403ba087d5f79cd5 Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 18:45:37 +0100 Subject: [PATCH 02/17] working for single date --- .../dataviews/src/dataform-controls/date.tsx | 589 ++++++++++-------- .../src/dataform-controls/date.tsx.backup | 525 ++++++++++++++++ 2 files changed, 847 insertions(+), 267 deletions(-) create mode 100644 packages/dataviews/src/dataform-controls/date.tsx.backup diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 4dacfe1b43203f..cef0ada8602e2e 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -4,18 +4,21 @@ import { BaseControl, Button, + Icon, privateApis as componentsPrivateApis, + __experimentalInputControl as InputControl, __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import { useCallback, useMemo, useState } from '@wordpress/element'; +import { useCallback, useMemo, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { getDate, getSettings } from '@wordpress/date'; +import { error as errorIcon } from '@wordpress/icons'; /** * External dependencies */ -import deepMerge from 'deepmerge'; +import clsx from 'clsx'; import { format, isValid, @@ -25,6 +28,7 @@ import { startOfMonth, startOfYear, } from 'date-fns'; +import deepMerge from 'deepmerge'; /** * Internal dependencies @@ -41,9 +45,7 @@ import { import { unlock } from '../lock-unlock'; import type { DataFormControlProps } from '../types'; -const { DateCalendar, DateRangeCalendar, ValidatedInputControl } = unlock( - componentsPrivateApis -); +const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); type DateRange = [ string, string ] | undefined; @@ -150,6 +152,7 @@ function CalendarDateControl< Item >( { className, data, field, + setValue, }: { id: string; value: string | undefined; @@ -158,53 +161,61 @@ function CalendarDateControl< Item >( { hideLabelFromVision?: boolean; className?: string; data: Item; - field: DataFormControlProps< Item >[ 'field' ]; + field: any; + setValue: any; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); - const [ customValidity, setCustomValidity ] = - useState< - React.ComponentProps< - typeof ValidatedInputControl - >[ 'customValidity' ] - >( undefined ); const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { const parsedDate = parseDate( value ); return parsedDate || new Date(); // Default to current month } ); - const onValidateControl = useCallback( - ( newValue: any ) => { - // If field is not required and value is empty, clear validation - if ( ! field.isValid?.required && ! newValue ) { - setCustomValidity( undefined ); - return; - } - - const message = field.isValid?.custom?.( - deepMerge( - data, - field.setValue( { - item: data, - value: newValue, - } ) as Partial< Item > - ), - field - ); + const [ isTouched, setIsTouched ] = useState( false ); + const [ customValidity, setCustomValidity ] = useState< + { type: 'invalid'; message: string } | undefined + >( undefined ); + const validityTargetRef = useRef< HTMLInputElement >( null ); - if ( message ) { + const onValidate = useCallback( + ( newValue: string | undefined ) => { + // Check if required and empty + if ( field.isValid?.required && ! newValue ) { setCustomValidity( { type: 'invalid', - message, + message: __( 'This field is required.' ), } ); return; } + // Check custom validation (only if value exists) + if ( newValue ) { + const customMessage = field.isValid?.custom?.( + deepMerge( + data, + setValue( { + item: data, + value: newValue, + } ) as Partial< Item > + ), + field + ); + + if ( customMessage ) { + setCustomValidity( { + type: 'invalid', + message: customMessage, + } ); + return; + } + } + + // No errors setCustomValidity( undefined ); }, - [ data, field ] + [ data, field, setValue ] ); const onSelectDate = useCallback( @@ -214,14 +225,10 @@ function CalendarDateControl< Item >( { : undefined; onChange( dateValue ); setSelectedPresetId( null ); - // Clear validation error when selecting from calendar - if ( dateValue ) { - onValidateControl( dateValue ); - } else { - setCustomValidity( undefined ); - } + setIsTouched( true ); + onValidate( dateValue ); }, - [ onChange, onValidateControl ] + [ onChange, onValidate ] ); const handlePresetClick = useCallback( @@ -232,10 +239,10 @@ function CalendarDateControl< Item >( { setCalendarMonth( presetDate ); onChange( dateValue ); setSelectedPresetId( preset.id ); - // Clear validation error when selecting preset - onValidateControl( dateValue ); + setIsTouched( true ); + onValidate( dateValue ); }, - [ onChange, onValidateControl ] + [ onChange, onValidate ] ); const handleManualDateChange = useCallback( @@ -248,8 +255,10 @@ function CalendarDateControl< Item >( { } } setSelectedPresetId( null ); + setIsTouched( true ); + onValidate( newValue ); }, - [ onChange ] + [ onChange, onValidate ] ); const { @@ -257,70 +266,108 @@ function CalendarDateControl< Item >( { l10n: { startOfWeek }, } = getSettings(); + const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { + if ( isTouched ) { + return; + } + + // Only consider "blurred from the component" if focus has fully left the wrapping div. + // This prevents unnecessary blurs from components with multiple focusable elements. + if ( + ! event.relatedTarget || + ! event.currentTarget.contains( event.relatedTarget ) + ) { + setIsTouched( true ); + onValidate( value ); + } + }; + return ( - - - { /* Preset buttons */ } - - { DATE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - + ); + } ) } + + + + { /* Manual date input */ } + + + { /* Calendar widget */ } + + + +
+ { customValidity && ( +

- { __( 'Custom' ) } - - - - { /* Manual date input */ } - - - { /* Calendar widget */ } - - - + + { customValidity.message } +

+ ) } +
+ ); } @@ -333,6 +380,7 @@ function CalendarDateRangeControl< Item >( { className, data, field, + setValue, }: { id: string; value: DateRange; @@ -341,23 +389,12 @@ function CalendarDateRangeControl< Item >( { hideLabelFromVision?: boolean; className?: string; data: Item; - field: DataFormControlProps< Item >[ 'field' ]; + field: any; + setValue: any; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); - const [ customValidityFrom, setCustomValidityFrom ] = - useState< - React.ComponentProps< - typeof ValidatedInputControl - >[ 'customValidity' ] - >( undefined ); - const [ customValidityTo, setCustomValidityTo ] = - useState< - React.ComponentProps< - typeof ValidatedInputControl - >[ 'customValidity' ] - >( undefined ); const selectedRange = useMemo( () => { if ( ! value ) { @@ -375,70 +412,68 @@ function CalendarDateRangeControl< Item >( { return selectedRange.from || new Date(); } ); - const updateDateRange = useCallback( - ( fromDate?: Date | string, toDate?: Date | string ) => { - if ( fromDate && toDate ) { - onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); - } else if ( ! fromDate && ! toDate ) { - onChange( undefined ); - } - // Do nothing if only one date is set - wait for both - }, - [ onChange ] - ); - - const validateRangeValue = useCallback( - ( - fromValue: string | undefined, - toValue: string | undefined, - setValidity: ( validity: any ) => void - ) => { - const updatedValue = [ fromValue, toValue ]; - - // If field is not required and both values are empty, clear validation - if ( ! field.isValid?.required && ! fromValue && ! toValue ) { - setValidity( undefined ); - return; - } + const [ isTouched, setIsTouched ] = useState( false ); + const [ customValidity, setCustomValidity ] = useState< + { type: 'invalid'; message: string } | undefined + >( undefined ); + const validityTargetRef = useRef< HTMLInputElement >( null ); - const message = field.isValid?.custom?.( - deepMerge( - data, - field.setValue( { - item: data, - value: updatedValue, - } ) as Partial< Item > - ), - field - ); - - if ( message ) { - setValidity( { + const onValidate = useCallback( + ( newValue: DateRange ) => { + // Check if required and empty + if ( field.isValid?.required && ! newValue ) { + setCustomValidity( { type: 'invalid', - message, + message: __( 'This field is required.' ), } ); return; } - setValidity( undefined ); - }, - [ data, field ] - ); + // Check custom validation (only if value exists) + if ( newValue ) { + const customMessage = field.isValid?.custom?.( + deepMerge( + data, + setValue( { + item: data, + value: newValue, + } ) as Partial< Item > + ), + field + ); + + if ( customMessage ) { + setCustomValidity( { + type: 'invalid', + message: customMessage, + } ); + return; + } + } - const onValidateControlFrom = useCallback( - ( newValue: any ) => { - const [ , currentTo ] = value || [ undefined, undefined ]; - validateRangeValue( newValue, currentTo, setCustomValidityFrom ); + // No errors + setCustomValidity( undefined ); }, - [ value, validateRangeValue ] + [ data, field, setValue ] ); - const onValidateControlTo = useCallback( - ( newValue: any ) => { - const [ currentFrom ] = value || [ undefined, undefined ]; - validateRangeValue( currentFrom, newValue, setCustomValidityTo ); + const updateDateRange = useCallback( + ( fromDate?: Date | string, toDate?: Date | string ) => { + let newValue: DateRange; + if ( fromDate && toDate ) { + newValue = [ formatDate( fromDate ), formatDate( toDate ) ]; + onChange( newValue ); + setIsTouched( true ); + onValidate( newValue ); + } else if ( ! fromDate && ! toDate ) { + newValue = undefined; + onChange( newValue ); + setIsTouched( true ); + onValidate( newValue ); + } + // Do nothing if only one date is set - wait for both }, - [ value, validateRangeValue ] + [ onChange, onValidate ] ); const onSelectCalendarRange = useCallback( @@ -449,17 +484,8 @@ function CalendarDateRangeControl< Item >( { ) => { updateDateRange( newRange?.from, newRange?.to ); setSelectedPresetId( null ); - // Clear validation errors when selecting from calendar - if ( newRange?.from ) { - const fromValue = formatDate( newRange.from ); - onValidateControlFrom( fromValue ); - } - if ( newRange?.to ) { - const toValue = formatDate( newRange.to ); - onValidateControlTo( toValue ); - } }, - [ updateDateRange, onValidateControlFrom, onValidateControlTo ] + [ updateDateRange ] ); const handlePresetClick = useCallback( @@ -468,13 +494,8 @@ function CalendarDateRangeControl< Item >( { setCalendarMonth( startDate ); updateDateRange( startDate, endDate ); setSelectedPresetId( preset.id ); - // Clear validation errors when selecting preset - const fromValue = formatDate( startDate ); - const toValue = formatDate( endDate ); - onValidateControlFrom( fromValue ); - onValidateControlTo( toValue ); }, - [ updateDateRange, onValidateControlFrom, onValidateControlTo ] + [ updateDateRange ] ); const handleManualDateChange = useCallback( @@ -502,87 +523,119 @@ function CalendarDateRangeControl< Item >( { const { timezone, l10n } = getSettings(); - return ( - - - { /* Preset buttons */ } - - { DATE_RANGE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - - + const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { + if ( isTouched ) { + return; + } - { /* Manual date range inputs */ } - - - handleManualDateChange( 'from', newValue ) - } - /> - - handleManualDateChange( 'to', newValue ) - } + // Only consider "blurred from the component" if focus has fully left the wrapping div. + // This prevents unnecessary blurs from components with multiple focusable elements. + if ( + ! event.relatedTarget || + ! event.currentTarget.contains( event.relatedTarget ) + ) { + setIsTouched( true ); + onValidate( value ); + } + }; + + return ( +
+ + + { /* Preset buttons */ } + + { DATE_RANGE_PRESETS.map( ( preset ) => { + const isSelected = selectedPresetId === preset.id; + return ( + + ); + } ) } + + + + { /* Manual date range inputs */ } + + + handleManualDateChange( 'from', newValue ) + } + required={ !! field.isValid?.required } + /> + + handleManualDateChange( 'to', newValue ) + } + /> + + + - - - - - + + +
+ { customValidity && ( +

+ + { customValidity.message } +

+ ) } +
+
); } @@ -656,6 +709,7 @@ export default function DateControl< Item >( { hideLabelFromVision={ hideLabelFromVision } data={ data } field={ field } + setValue={ setValue } /> ); } @@ -670,6 +724,7 @@ export default function DateControl< Item >( { hideLabelFromVision={ hideLabelFromVision } data={ data } field={ field } + setValue={ setValue } /> ); } diff --git a/packages/dataviews/src/dataform-controls/date.tsx.backup b/packages/dataviews/src/dataform-controls/date.tsx.backup new file mode 100644 index 00000000000000..8014c3c1706c29 --- /dev/null +++ b/packages/dataviews/src/dataform-controls/date.tsx.backup @@ -0,0 +1,525 @@ +/** + * WordPress dependencies + */ +import { + BaseControl, + Button, + privateApis as componentsPrivateApis, + __experimentalInputControl as InputControl, + __experimentalHStack as HStack, + __experimentalVStack as VStack, +} from '@wordpress/components'; +import { useCallback, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { getDate, getSettings } from '@wordpress/date'; + +/** + * External dependencies + */ +import { + format, + isValid, + subMonths, + subDays, + subYears, + startOfMonth, + startOfYear, +} from 'date-fns'; + +/** + * Internal dependencies + */ +import RelativeDateControl, { + TIME_UNITS_OPTIONS, + type DateRelative, +} from './relative-date-control'; +import { + OPERATOR_IN_THE_PAST, + OPERATOR_OVER, + OPERATOR_BETWEEN, +} from '../constants'; +import { unlock } from '../lock-unlock'; +import type { DataFormControlProps } from '../types'; + +const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); + +type DateRange = [ string, string ] | undefined; + +const DATE_PRESETS: { + id: string; + label: string; + getValue: () => Date; +}[] = [ + { + id: 'today', + label: __( 'Today' ), + getValue: () => getDate( null ), + }, + { + id: 'yesterday', + label: __( 'Yesterday' ), + getValue: () => { + const today = getDate( null ); + return subDays( today, 1 ); + }, + }, + { + id: 'past-week', + label: __( 'Past week' ), + getValue: () => { + const today = getDate( null ); + return subDays( today, 7 ); + }, + }, + { + id: 'past-month', + label: __( 'Past month' ), + getValue: () => { + const today = getDate( null ); + return subMonths( today, 1 ); + }, + }, +]; + +const DATE_RANGE_PRESETS = [ + { + id: 'last-7-days', + label: __( 'Last 7 days' ), + getValue: () => { + const today = getDate( null ); + return [ subDays( today, 7 ), today ]; + }, + }, + { + id: 'last-30-days', + label: __( 'Last 30 days' ), + getValue: () => { + const today = getDate( null ); + return [ subDays( today, 30 ), today ]; + }, + }, + { + id: 'month-to-date', + label: __( 'Month to date' ), + getValue: () => { + const today = getDate( null ); + return [ startOfMonth( today ), today ]; + }, + }, + { + id: 'last-year', + label: __( 'Last year' ), + getValue: () => { + const today = getDate( null ); + return [ subYears( today, 1 ), today ]; + }, + }, + { + id: 'year-to-date', + label: __( 'Year to date' ), + getValue: () => { + const today = getDate( null ); + return [ startOfYear( today ), today ]; + }, + }, +]; + +const parseDate = ( dateString?: string ): Date | null => { + if ( ! dateString ) { + return null; + } + const parsed = getDate( dateString ); + return parsed && isValid( parsed ) ? parsed : null; +}; + +const formatDate = ( date?: Date | string ): string => { + if ( ! date ) { + return ''; + } + return typeof date === 'string' ? date : format( date, 'yyyy-MM-dd' ); +}; + +function CalendarDateControl( { + id, + value, + onChange, + label, + hideLabelFromVision, + className, +}: { + id: string; + value: string | undefined; + onChange: ( value: string | undefined ) => void; + label: string; + hideLabelFromVision?: boolean; + className?: string; +} ) { + const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( + null + ); + + const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { + const parsedDate = parseDate( value ); + return parsedDate || new Date(); // Default to current month + } ); + + const onSelectDate = useCallback( + ( newDate: Date | undefined | null ) => { + const dateValue = newDate + ? format( newDate, 'yyyy-MM-dd' ) + : undefined; + onChange( dateValue ); + setSelectedPresetId( null ); + }, + [ onChange ] + ); + + const handlePresetClick = useCallback( + ( preset: ( typeof DATE_PRESETS )[ 0 ] ) => { + const presetDate = preset.getValue(); + const dateValue = formatDate( presetDate ); + + setCalendarMonth( presetDate ); + onChange( dateValue ); + setSelectedPresetId( preset.id ); + }, + [ onChange ] + ); + + const handleManualDateChange = useCallback( + ( newValue?: string ) => { + onChange( newValue ); + if ( newValue ) { + const parsedDate = parseDate( newValue ); + if ( parsedDate ) { + setCalendarMonth( parsedDate ); + } + } + setSelectedPresetId( null ); + }, + [ onChange ] + ); + + const { + timezone: { string: timezoneString }, + l10n: { startOfWeek }, + } = getSettings(); + + return ( + + + { /* Preset buttons */ } + + { DATE_PRESETS.map( ( preset ) => { + const isSelected = selectedPresetId === preset.id; + return ( + + ); + } ) } + + + + { /* Manual date input */ } + + + { /* Calendar widget */ } + + + + ); +} + +function CalendarDateRangeControl( { + id, + value, + onChange, + label, + hideLabelFromVision, + className, +}: { + id: string; + value: DateRange; + onChange: ( value: DateRange ) => void; + label: string; + hideLabelFromVision?: boolean; + className?: string; +} ) { + const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( + null + ); + + const selectedRange = useMemo( () => { + if ( ! value ) { + return { from: undefined, to: undefined }; + } + + const [ from, to ] = value; + return { + from: parseDate( from ) || undefined, + to: parseDate( to ) || undefined, + }; + }, [ value ] ); + + const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { + return selectedRange.from || new Date(); + } ); + + const updateDateRange = useCallback( + ( fromDate?: Date | string, toDate?: Date | string ) => { + if ( fromDate && toDate ) { + onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); + } else if ( ! fromDate && ! toDate ) { + onChange( undefined ); + } + // Do nothing if only one date is set - wait for both + }, + [ onChange ] + ); + + const onSelectCalendarRange = useCallback( + ( + newRange: + | { from: Date | undefined; to?: Date | undefined } + | undefined + ) => { + updateDateRange( newRange?.from, newRange?.to ); + setSelectedPresetId( null ); + }, + [ updateDateRange ] + ); + + const handlePresetClick = useCallback( + ( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => { + const [ startDate, endDate ] = preset.getValue(); + setCalendarMonth( startDate ); + updateDateRange( startDate, endDate ); + setSelectedPresetId( preset.id ); + }, + [ updateDateRange ] + ); + + const handleManualDateChange = useCallback( + ( fromOrTo: 'from' | 'to', newValue?: string ) => { + const [ currentFrom, currentTo ] = value || [ + undefined, + undefined, + ]; + const updatedFrom = fromOrTo === 'from' ? newValue : currentFrom; + const updatedTo = fromOrTo === 'to' ? newValue : currentTo; + + updateDateRange( updatedFrom, updatedTo ); + + if ( newValue ) { + const parsedDate = parseDate( newValue ); + if ( parsedDate ) { + setCalendarMonth( parsedDate ); + } + } + + setSelectedPresetId( null ); + }, + [ value, updateDateRange ] + ); + + const { timezone, l10n } = getSettings(); + + return ( + + + { /* Preset buttons */ } + + { DATE_RANGE_PRESETS.map( ( preset ) => { + const isSelected = selectedPresetId === preset.id; + return ( + + ); + } ) } + + + + { /* Manual date range inputs */ } + + + handleManualDateChange( 'from', newValue ) + } + /> + + handleManualDateChange( 'to', newValue ) + } + /> + + + + + + ); +} + +export default function DateControl< Item >( { + data, + field, + onChange, + hideLabelFromVision, + operator, +}: DataFormControlProps< Item > ) { + const { id, label, getValue, setValue } = field; + const value = getValue( { item: data } ); + + const onChangeRelativeDateControl = useCallback( + ( newValue: DateRelative ) => { + onChange( setValue( { item: data, value: newValue } ) ); + }, + [ data, onChange, setValue ] + ); + + const onChangeCalendarDateRangeControl = useCallback( + ( newValue: DateRange ) => { + onChange( + setValue( { + item: data, + value: newValue, + } ) + ); + }, + [ data, onChange, setValue ] + ); + + const onChangeCalendarDateControl = useCallback( + ( newValue: string | undefined ) => + onChange( setValue( { item: data, value: newValue } ) ), + [ data, onChange, setValue ] + ); + + if ( operator === OPERATOR_IN_THE_PAST || operator === OPERATOR_OVER ) { + return ( + + ); + } + + if ( operator === OPERATOR_BETWEEN ) { + let dateRangeValue: DateRange; + if ( + Array.isArray( value ) && + value.length === 2 && + value.every( ( date ) => typeof date === 'string' ) + ) { + // Ensure the value is expected format + dateRangeValue = value as DateRange; + } + + return ( + + ); + } + + return ( + + ); +} From e51b3447f63f4e7b2dd5ba2dcd71d130789fec02 Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 19:41:13 +0100 Subject: [PATCH 03/17] in progress --- .../dataviews/src/dataform-controls/date.tsx | 90 ++++--------------- 1 file changed, 19 insertions(+), 71 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index cef0ada8602e2e..9a150be0039b6d 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -10,7 +10,13 @@ import { __experimentalHStack as HStack, __experimentalVStack as VStack, } from '@wordpress/components'; -import { useCallback, useMemo, useRef, useState } from '@wordpress/element'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { getDate, getSettings } from '@wordpress/date'; import { error as errorIcon } from '@wordpress/icons'; @@ -412,68 +418,26 @@ function CalendarDateRangeControl< Item >( { return selectedRange.from || new Date(); } ); - const [ isTouched, setIsTouched ] = useState( false ); - const [ customValidity, setCustomValidity ] = useState< - { type: 'invalid'; message: string } | undefined - >( undefined ); + // Static error for testing + const [ customValidity ] = useState< { + type: 'invalid'; + message: string; + } >( { + type: 'invalid', + message: 'There is an error', + } ); const validityTargetRef = useRef< HTMLInputElement >( null ); - const onValidate = useCallback( - ( newValue: DateRange ) => { - // Check if required and empty - if ( field.isValid?.required && ! newValue ) { - setCustomValidity( { - type: 'invalid', - message: __( 'This field is required.' ), - } ); - return; - } - - // Check custom validation (only if value exists) - if ( newValue ) { - const customMessage = field.isValid?.custom?.( - deepMerge( - data, - setValue( { - item: data, - value: newValue, - } ) as Partial< Item > - ), - field - ); - - if ( customMessage ) { - setCustomValidity( { - type: 'invalid', - message: customMessage, - } ); - return; - } - } - - // No errors - setCustomValidity( undefined ); - }, - [ data, field, setValue ] - ); - const updateDateRange = useCallback( ( fromDate?: Date | string, toDate?: Date | string ) => { - let newValue: DateRange; if ( fromDate && toDate ) { - newValue = [ formatDate( fromDate ), formatDate( toDate ) ]; - onChange( newValue ); - setIsTouched( true ); - onValidate( newValue ); + onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); } else if ( ! fromDate && ! toDate ) { - newValue = undefined; - onChange( newValue ); - setIsTouched( true ); - onValidate( newValue ); + onChange( undefined ); } // Do nothing if only one date is set - wait for both }, - [ onChange, onValidate ] + [ onChange ] ); const onSelectCalendarRange = useCallback( @@ -523,24 +487,8 @@ function CalendarDateRangeControl< Item >( { const { timezone, l10n } = getSettings(); - const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { - if ( isTouched ) { - return; - } - - // Only consider "blurred from the component" if focus has fully left the wrapping div. - // This prevents unnecessary blurs from components with multiple focusable elements. - if ( - ! event.relatedTarget || - ! event.currentTarget.contains( event.relatedTarget ) - ) { - setIsTouched( true ); - onValidate( value ); - } - }; - return ( -
+
Date: Thu, 2 Oct 2025 19:46:39 +0100 Subject: [PATCH 04/17] revert range --- .../dataviews/src/dataform-controls/date.tsx | 182 +++++++----------- 1 file changed, 71 insertions(+), 111 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 9a150be0039b6d..cb9145a07588a7 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -377,16 +377,13 @@ function CalendarDateControl< Item >( { ); } -function CalendarDateRangeControl< Item >( { +function CalendarDateRangeControl( { id, value, onChange, label, hideLabelFromVision, className, - data, - field, - setValue, }: { id: string; value: DateRange; @@ -394,9 +391,6 @@ function CalendarDateRangeControl< Item >( { label: string; hideLabelFromVision?: boolean; className?: string; - data: Item; - field: any; - setValue: any; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null @@ -418,16 +412,6 @@ function CalendarDateRangeControl< Item >( { return selectedRange.from || new Date(); } ); - // Static error for testing - const [ customValidity ] = useState< { - type: 'invalid'; - message: string; - } >( { - type: 'invalid', - message: 'There is an error', - } ); - const validityTargetRef = useRef< HTMLInputElement >( null ); - const updateDateRange = useCallback( ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { @@ -488,102 +472,78 @@ function CalendarDateRangeControl< Item >( { const { timezone, l10n } = getSettings(); return ( -
- - - { /* Preset buttons */ } - - { DATE_RANGE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - - - - { /* Manual date range inputs */ } - - - handleManualDateChange( 'from', newValue ) - } - required={ !! field.isValid?.required } - /> - - handleManualDateChange( 'to', newValue ) - } - /> - + + + { /* Preset buttons */ } + + { DATE_RANGE_PRESETS.map( ( preset ) => { + const isSelected = selectedPresetId === preset.id; + return ( + + ); + } ) } + + - + + handleManualDateChange( 'from', newValue ) + } /> - - -
- { customValidity && ( -

- - { customValidity.message } -

- ) } -
-
+ + handleManualDateChange( 'to', newValue ) + } + /> + + + + +
); } From b587f95b08f5ce282ba52d0d3fb158223d33e0e5 Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 19:48:30 +0100 Subject: [PATCH 05/17] revert --- packages/dataviews/src/dataform-controls/date.tsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index cb9145a07588a7..2b2c441fbedb6a 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -615,9 +615,6 @@ export default function DateControl< Item >( { onChange={ onChangeCalendarDateRangeControl } label={ label } hideLabelFromVision={ hideLabelFromVision } - data={ data } - field={ field } - setValue={ setValue } /> ); } From c868590c61f82b53fb916c4b8b5956ad0893f40c Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 20:16:26 +0100 Subject: [PATCH 06/17] working validation on single and range --- .../dataviews/src/dataform-controls/date.tsx | 269 +++++++++++++----- 1 file changed, 195 insertions(+), 74 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 2b2c441fbedb6a..af01f992c1c946 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -377,13 +377,16 @@ function CalendarDateControl< Item >( { ); } -function CalendarDateRangeControl( { +function CalendarDateRangeControl< Item >( { id, value, onChange, label, hideLabelFromVision, className, + data, + field, + setValue, }: { id: string; value: DateRange; @@ -391,6 +394,9 @@ function CalendarDateRangeControl( { label: string; hideLabelFromVision?: boolean; className?: string; + data: Item; + field: any; + setValue: any; } ) { const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null @@ -412,6 +418,58 @@ function CalendarDateRangeControl( { return selectedRange.from || new Date(); } ); + const [ isTouched, setIsTouched ] = useState( true ); + const [ customValidity, setCustomValidity ] = useState< + { type: 'invalid'; message: string } | undefined + >( undefined ); + + const onValidate = useCallback( + ( newValue: DateRange ) => { + // Check if required and either from or to is empty + if ( field.isValid?.required ) { + if ( ! newValue || ! newValue[ 0 ] || ! newValue[ 1 ] ) { + setCustomValidity( { + type: 'invalid', + message: __( 'This field is required.' ), + } ); + return; + } + } + + // Check custom validation (only if both dates exist) + if ( newValue && newValue[ 0 ] && newValue[ 1 ] ) { + const customMessage = field.isValid?.custom?.( + deepMerge( + data, + setValue( { + item: data, + value: newValue, + } ) as Partial< Item > + ), + field + ); + + if ( customMessage ) { + setCustomValidity( { + type: 'invalid', + message: customMessage, + } ); + return; + } + } + + // No errors + setCustomValidity( undefined ); + }, + [ data, field, setValue ] + ); + + useEffect( () => { + if ( isTouched ) { + onValidate( value ); + } + }, [ isTouched, value, onValidate ] ); + const updateDateRange = useCallback( ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { @@ -430,20 +488,35 @@ function CalendarDateRangeControl( { | { from: Date | undefined; to?: Date | undefined } | undefined ) => { + const dateRangeValue = + newRange?.from && newRange?.to + ? ( [ + formatDate( newRange.from ), + formatDate( newRange.to ), + ] as DateRange ) + : undefined; updateDateRange( newRange?.from, newRange?.to ); setSelectedPresetId( null ); + setIsTouched( true ); + onValidate( dateRangeValue ); }, - [ updateDateRange ] + [ updateDateRange, onValidate ] ); const handlePresetClick = useCallback( ( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => { const [ startDate, endDate ] = preset.getValue(); + const dateRangeValue: DateRange = [ + formatDate( startDate ), + formatDate( endDate ), + ]; setCalendarMonth( startDate ); updateDateRange( startDate, endDate ); setSelectedPresetId( preset.id ); + setIsTouched( true ); + onValidate( dateRangeValue ); }, - [ updateDateRange ] + [ updateDateRange, onValidate ] ); const handleManualDateChange = useCallback( @@ -455,6 +528,11 @@ function CalendarDateRangeControl( { const updatedFrom = fromOrTo === 'from' ? newValue : currentFrom; const updatedTo = fromOrTo === 'to' ? newValue : currentTo; + const dateRangeValue = + updatedFrom && updatedTo + ? ( [ updatedFrom, updatedTo ] as DateRange ) + : undefined; + updateDateRange( updatedFrom, updatedTo ); if ( newValue ) { @@ -465,85 +543,125 @@ function CalendarDateRangeControl( { } setSelectedPresetId( null ); + setIsTouched( true ); + onValidate( dateRangeValue ); }, - [ value, updateDateRange ] + [ value, updateDateRange, onValidate ] ); const { timezone, l10n } = getSettings(); + const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { + if ( isTouched ) { + return; + } + + // Only consider "blurred from the component" if focus has fully left the wrapping div. + // This prevents unnecessary blurs from components with multiple focusable elements. + if ( + ! event.relatedTarget || + ! event.currentTarget.contains( event.relatedTarget ) + ) { + setIsTouched( true ); + onValidate( value ); + } + }; + return ( - - - { /* Preset buttons */ } - - { DATE_RANGE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - - +
+ + + { /* Preset buttons */ } + + { DATE_RANGE_PRESETS.map( ( preset ) => { + const isSelected = selectedPresetId === preset.id; + return ( + + ); + } ) } + + - { /* Manual date range inputs */ } - - - handleManualDateChange( 'from', newValue ) - } - /> - - handleManualDateChange( 'to', newValue ) - } + { /* Manual date range inputs */ } + + + handleManualDateChange( 'from', newValue ) + } + /> + + handleManualDateChange( 'to', newValue ) + } + /> + + + - - - - - + + +
+ { customValidity && ( +

+ + { customValidity.message } +

+ ) } +
+
); } @@ -615,6 +733,9 @@ export default function DateControl< Item >( { onChange={ onChangeCalendarDateRangeControl } label={ label } hideLabelFromVision={ hideLabelFromVision } + data={ data } + field={ field } + setValue={ setValue } /> ); } From 3e46ba5667a1f022f6d10c16cf7978faad488fdb Mon Sep 17 00:00:00 2001 From: Jorge Date: Thu, 2 Oct 2025 20:58:02 +0100 Subject: [PATCH 07/17] working --- .../dataviews/src/dataform-controls/date.tsx | 2 +- .../dataviews/src/stories/dataform.story.tsx | 52 +++++++++---------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index af01f992c1c946..abea9e265efa61 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -418,7 +418,7 @@ function CalendarDateRangeControl< Item >( { return selectedRange.from || new Date(); } ); - const [ isTouched, setIsTouched ] = useState( true ); + const [ isTouched, setIsTouched ] = useState( false ); const [ customValidity, setCustomValidity ] = useState< { type: 'invalid'; message: string } | undefined >( undefined ); diff --git a/packages/dataviews/src/stories/dataform.story.tsx b/packages/dataviews/src/stories/dataform.story.tsx index bdfdbe44196d51..b2ea7b14f12711 100644 --- a/packages/dataviews/src/stories/dataform.story.tsx +++ b/packages/dataviews/src/stories/dataform.story.tsx @@ -510,6 +510,32 @@ function CustomEditControl< Item >( { ); } +type ValidatedItem = { + text: string; + select?: string; + textWithRadio?: string; + textarea: string; + email: string; + telephone: string; + url: string; + color: string; + integer: number; + number: number; + boolean: boolean; + customEdit: string; + categories: string[]; + countries: string[]; + password: string; + toggle?: boolean; + toggleGroup?: string; + date?: string; + dateRange?: [ string, string ]; +}; + +const DateRangeEdit = ( props: DataFormControlProps< ValidatedItem > ) => { + return ; +}; + const ValidationComponent = ( { required, type, @@ -519,28 +545,6 @@ const ValidationComponent = ( { custom: boolean; type: 'regular' | 'panel'; } ) => { - type ValidatedItem = { - text: string; - select?: string; - textWithRadio?: string; - textarea: string; - email: string; - telephone: string; - url: string; - color: string; - integer: number; - number: number; - boolean: boolean; - customEdit: string; - categories: string[]; - countries: string[]; - password: string; - toggle?: boolean; - toggleGroup?: string; - date?: string; - dateRange?: [ string, string ]; - }; - const [ post, setPost ] = useState< ValidatedItem >( { text: 'Can have letters and spaces', select: undefined, @@ -710,10 +714,6 @@ const ValidationComponent = ( { return custom ? rule : undefined; }; - const DateRangeEdit = ( props: DataFormControlProps< ValidatedItem > ) => { - return ; - }; - const _fields: Field< ValidatedItem >[] = [ { id: 'text', From 7bd6e607ba4f58553f33b75bbbb512d8d158884d Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 11:37:17 +0100 Subject: [PATCH 08/17] use effect on both --- .../dataviews/src/dataform-controls/date.tsx | 62 +++++++++---------- 1 file changed, 30 insertions(+), 32 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index abea9e265efa61..66f192581eb7eb 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -422,18 +422,29 @@ function CalendarDateRangeControl< Item >( { const [ customValidity, setCustomValidity ] = useState< { type: 'invalid'; message: string } | undefined >( undefined ); + const fromInputRef = useRef< HTMLInputElement >( null ); + const toInputRef = useRef< HTMLInputElement >( null ); const onValidate = useCallback( ( newValue: DateRange ) => { - // Check if required and either from or to is empty - if ( field.isValid?.required ) { - if ( ! newValue || ! newValue[ 0 ] || ! newValue[ 1 ] ) { - setCustomValidity( { - type: 'invalid', - message: __( 'This field is required.' ), - } ); - return; - } + // Check HTML5 validity for required fields + const fromInput = fromInputRef.current; + const toInput = toInputRef.current; + + if ( fromInput && ! fromInput.validity.valid ) { + setCustomValidity( { + type: 'invalid', + message: fromInput.validationMessage, + } ); + return; + } + + if ( toInput && ! toInput.validity.valid ) { + setCustomValidity( { + type: 'invalid', + message: toInput.validationMessage, + } ); + return; } // Check custom validation (only if both dates exist) @@ -474,12 +485,14 @@ function CalendarDateRangeControl< Item >( { ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); + onValidate( [ formatDate( fromDate ), formatDate( toDate ) ] ); } else if ( ! fromDate && ! toDate ) { onChange( undefined ); + onValidate( undefined ); } // Do nothing if only one date is set - wait for both }, - [ onChange ] + [ onChange, onValidate ] ); const onSelectCalendarRange = useCallback( @@ -488,35 +501,22 @@ function CalendarDateRangeControl< Item >( { | { from: Date | undefined; to?: Date | undefined } | undefined ) => { - const dateRangeValue = - newRange?.from && newRange?.to - ? ( [ - formatDate( newRange.from ), - formatDate( newRange.to ), - ] as DateRange ) - : undefined; updateDateRange( newRange?.from, newRange?.to ); setSelectedPresetId( null ); setIsTouched( true ); - onValidate( dateRangeValue ); }, - [ updateDateRange, onValidate ] + [ updateDateRange ] ); const handlePresetClick = useCallback( ( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => { const [ startDate, endDate ] = preset.getValue(); - const dateRangeValue: DateRange = [ - formatDate( startDate ), - formatDate( endDate ), - ]; setCalendarMonth( startDate ); updateDateRange( startDate, endDate ); setSelectedPresetId( preset.id ); setIsTouched( true ); - onValidate( dateRangeValue ); }, - [ updateDateRange, onValidate ] + [ updateDateRange ] ); const handleManualDateChange = useCallback( @@ -528,11 +528,6 @@ function CalendarDateRangeControl< Item >( { const updatedFrom = fromOrTo === 'from' ? newValue : currentFrom; const updatedTo = fromOrTo === 'to' ? newValue : currentTo; - const dateRangeValue = - updatedFrom && updatedTo - ? ( [ updatedFrom, updatedTo ] as DateRange ) - : undefined; - updateDateRange( updatedFrom, updatedTo ); if ( newValue ) { @@ -544,9 +539,8 @@ function CalendarDateRangeControl< Item >( { setSelectedPresetId( null ); setIsTouched( true ); - onValidate( dateRangeValue ); }, - [ value, updateDateRange, onValidate ] + [ value, updateDateRange ] ); const { timezone, l10n } = getSettings(); @@ -612,6 +606,7 @@ function CalendarDateRangeControl< Item >( { ( { onChange={ ( newValue ) => handleManualDateChange( 'from', newValue ) } + required={ !! field.isValid?.required } /> ( { onChange={ ( newValue ) => handleManualDateChange( 'to', newValue ) } + required={ !! field.isValid?.required } /> From d8aa3275c29e8e17c7f7a909984ad59e988bd9b3 Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 11:37:33 +0100 Subject: [PATCH 09/17] use effect on both --- .../dataviews/src/dataform-controls/date.tsx | 106 +++++++++++------- 1 file changed, 66 insertions(+), 40 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 66f192581eb7eb..d7a303ac534129 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -187,15 +187,6 @@ function CalendarDateControl< Item >( { const onValidate = useCallback( ( newValue: string | undefined ) => { - // Check if required and empty - if ( field.isValid?.required && ! newValue ) { - setCustomValidity( { - type: 'invalid', - message: __( 'This field is required.' ), - } ); - return; - } - // Check custom validation (only if value exists) if ( newValue ) { const customMessage = field.isValid?.custom?.( @@ -218,12 +209,31 @@ function CalendarDateControl< Item >( { } } + // Check HTML5 validity + const inputRef = validityTargetRef.current; + if ( inputRef && ! inputRef.validity.valid ) { + setCustomValidity( { + type: 'invalid', + message: inputRef.validationMessage, + } ); + return; + } + // No errors setCustomValidity( undefined ); }, [ data, field, setValue ] ); + useEffect( () => { + if ( isTouched ) { + const timeoutId = setTimeout( () => { + onValidate( value ); + }, 0 ); + return () => clearTimeout( timeoutId ); + } + }, [ isTouched, value, onValidate ] ); + const onSelectDate = useCallback( ( newDate: Date | undefined | null ) => { const dateValue = newDate @@ -232,9 +242,8 @@ function CalendarDateControl< Item >( { onChange( dateValue ); setSelectedPresetId( null ); setIsTouched( true ); - onValidate( dateValue ); }, - [ onChange, onValidate ] + [ onChange ] ); const handlePresetClick = useCallback( @@ -246,9 +255,8 @@ function CalendarDateControl< Item >( { onChange( dateValue ); setSelectedPresetId( preset.id ); setIsTouched( true ); - onValidate( dateValue ); }, - [ onChange, onValidate ] + [ onChange ] ); const handleManualDateChange = useCallback( @@ -262,9 +270,8 @@ function CalendarDateControl< Item >( { } setSelectedPresetId( null ); setIsTouched( true ); - onValidate( newValue ); }, - [ onChange, onValidate ] + [ onChange ] ); const { @@ -284,7 +291,6 @@ function CalendarDateControl< Item >( { ! event.currentTarget.contains( event.relatedTarget ) ) { setIsTouched( true ); - onValidate( value ); } }; @@ -427,27 +433,18 @@ function CalendarDateRangeControl< Item >( { const onValidate = useCallback( ( newValue: DateRange ) => { - // Check HTML5 validity for required fields - const fromInput = fromInputRef.current; - const toInput = toInputRef.current; - - if ( fromInput && ! fromInput.validity.valid ) { - setCustomValidity( { - type: 'invalid', - message: fromInput.validationMessage, - } ); - return; - } - - if ( toInput && ! toInput.validity.valid ) { - setCustomValidity( { - type: 'invalid', - message: toInput.validationMessage, - } ); - return; + // Check if required and either from or to is empty + if ( field.isValid?.required ) { + if ( ! newValue || ! newValue[ 0 ] || ! newValue[ 1 ] ) { + setCustomValidity( { + type: 'invalid', + message: __( 'This field is required.' ), + } ); + return; + } } - // Check custom validation (only if both dates exist) + // Check validation (only if both dates exist) if ( newValue && newValue[ 0 ] && newValue[ 1 ] ) { const customMessage = field.isValid?.custom?.( deepMerge( @@ -467,6 +464,35 @@ function CalendarDateRangeControl< Item >( { } ); return; } + + // Check HTML5 validity + const fromInput = fromInputRef.current; + if ( + newValue && + newValue[ 0 ] && + fromInput && + ! fromInput.validity.valid + ) { + setCustomValidity( { + type: 'invalid', + message: fromInput.validationMessage, + } ); + return; + } + + const toInput = toInputRef.current; + if ( + newValue && + newValue[ 1 ] && + toInput && + ! toInput.validity.valid + ) { + setCustomValidity( { + type: 'invalid', + message: toInput.validationMessage, + } ); + return; + } } // No errors @@ -477,7 +503,10 @@ function CalendarDateRangeControl< Item >( { useEffect( () => { if ( isTouched ) { - onValidate( value ); + const timeoutId = setTimeout( () => { + onValidate( value ); + }, 0 ); + return () => clearTimeout( timeoutId ); } }, [ isTouched, value, onValidate ] ); @@ -485,14 +514,12 @@ function CalendarDateRangeControl< Item >( { ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); - onValidate( [ formatDate( fromDate ), formatDate( toDate ) ] ); } else if ( ! fromDate && ! toDate ) { onChange( undefined ); - onValidate( undefined ); } // Do nothing if only one date is set - wait for both }, - [ onChange, onValidate ] + [ onChange ] ); const onSelectCalendarRange = useCallback( @@ -557,7 +584,6 @@ function CalendarDateRangeControl< Item >( { ! event.currentTarget.contains( event.relatedTarget ) ) { setIsTouched( true ); - onValidate( value ); } }; From f0437395b0ef4b106588e07ff48bc42761334d38 Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 11:54:23 +0100 Subject: [PATCH 10/17] simplification and duplication removal --- .../dataviews/src/dataform-controls/date.tsx | 307 +++++++----------- 1 file changed, 120 insertions(+), 187 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index d7a303ac534129..d566f2f2f99ced 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -149,44 +149,33 @@ const formatDate = ( date?: Date | string ): string => { return typeof date === 'string' ? date : format( date, 'yyyy-MM-dd' ); }; -function CalendarDateControl< Item >( { - id, +function ValidatedDateControl< Item >( { value, - onChange, - label, - hideLabelFromVision, - className, - data, field, + data, setValue, + inputRefs, + isTouched, + setIsTouched, + children, }: { - id: string; - value: string | undefined; - onChange: ( value: string | undefined ) => void; - label: string; - hideLabelFromVision?: boolean; - className?: string; - data: Item; + value: any; field: any; + data: Item; setValue: any; + inputRefs: + | React.RefObject< HTMLInputElement > + | React.RefObject< HTMLInputElement >[]; + isTouched: boolean; + setIsTouched: ( touched: boolean ) => void; + children: React.ReactNode; } ) { - const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( - null - ); - - const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { - const parsedDate = parseDate( value ); - return parsedDate || new Date(); // Default to current month - } ); - - const [ isTouched, setIsTouched ] = useState( false ); const [ customValidity, setCustomValidity ] = useState< { type: 'invalid'; message: string } | undefined >( undefined ); - const validityTargetRef = useRef< HTMLInputElement >( null ); const onValidate = useCallback( - ( newValue: string | undefined ) => { + ( newValue: any ) => { // Check custom validation (only if value exists) if ( newValue ) { const customMessage = field.isValid?.custom?.( @@ -209,20 +198,23 @@ function CalendarDateControl< Item >( { } } - // Check HTML5 validity - const inputRef = validityTargetRef.current; - if ( inputRef && ! inputRef.validity.valid ) { - setCustomValidity( { - type: 'invalid', - message: inputRef.validationMessage, - } ); - return; + // Check HTML5 validity on all refs + const refs = Array.isArray( inputRefs ) ? inputRefs : [ inputRefs ]; + for ( const ref of refs ) { + const input = ref.current; + if ( input && ! input.validity.valid ) { + setCustomValidity( { + type: 'invalid', + message: input.validationMessage, + } ); + return; + } } // No errors setCustomValidity( undefined ); }, - [ data, field, setValue ] + [ data, field, setValue, inputRefs ] ); useEffect( () => { @@ -234,6 +226,79 @@ function CalendarDateControl< Item >( { } }, [ isTouched, value, onValidate ] ); + const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { + if ( isTouched ) { + return; + } + + // Only consider "blurred from the component" if focus has fully left the wrapping div. + // This prevents unnecessary blurs from components with multiple focusable elements. + if ( + ! event.relatedTarget || + ! event.currentTarget.contains( event.relatedTarget ) + ) { + setIsTouched( true ); + } + }; + + return ( +
+ { children } +
+ { customValidity && ( +

+ + { customValidity.message } +

+ ) } +
+
+ ); +} + +function CalendarDateControl< Item >( { + id, + value, + onChange, + label, + hideLabelFromVision, + className, + data, + field, + setValue, +}: { + id: string; + value: string | undefined; + onChange: ( value: string | undefined ) => void; + label: string; + hideLabelFromVision?: boolean; + className?: string; + data: Item; + field: any; + setValue: any; +} ) { + const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( + null + ); + + const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { + const parsedDate = parseDate( value ); + return parsedDate || new Date(); // Default to current month + } ); + + const [ isTouched, setIsTouched ] = useState( false ); + const validityTargetRef = useRef< HTMLInputElement >( null ); + const onSelectDate = useCallback( ( newDate: Date | undefined | null ) => { const dateValue = newDate @@ -279,23 +344,16 @@ function CalendarDateControl< Item >( { l10n: { startOfWeek }, } = getSettings(); - const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { - if ( isTouched ) { - return; - } - - // Only consider "blurred from the component" if focus has fully left the wrapping div. - // This prevents unnecessary blurs from components with multiple focusable elements. - if ( - ! event.relatedTarget || - ! event.currentTarget.contains( event.relatedTarget ) - ) { - setIsTouched( true ); - } - }; - return ( -
+ ( { /> -
- { customValidity && ( -

- - { customValidity.message } -

- ) } -
-
+ ); } @@ -425,91 +465,9 @@ function CalendarDateRangeControl< Item >( { } ); const [ isTouched, setIsTouched ] = useState( false ); - const [ customValidity, setCustomValidity ] = useState< - { type: 'invalid'; message: string } | undefined - >( undefined ); const fromInputRef = useRef< HTMLInputElement >( null ); const toInputRef = useRef< HTMLInputElement >( null ); - const onValidate = useCallback( - ( newValue: DateRange ) => { - // Check if required and either from or to is empty - if ( field.isValid?.required ) { - if ( ! newValue || ! newValue[ 0 ] || ! newValue[ 1 ] ) { - setCustomValidity( { - type: 'invalid', - message: __( 'This field is required.' ), - } ); - return; - } - } - - // Check validation (only if both dates exist) - if ( newValue && newValue[ 0 ] && newValue[ 1 ] ) { - const customMessage = field.isValid?.custom?.( - deepMerge( - data, - setValue( { - item: data, - value: newValue, - } ) as Partial< Item > - ), - field - ); - - if ( customMessage ) { - setCustomValidity( { - type: 'invalid', - message: customMessage, - } ); - return; - } - - // Check HTML5 validity - const fromInput = fromInputRef.current; - if ( - newValue && - newValue[ 0 ] && - fromInput && - ! fromInput.validity.valid - ) { - setCustomValidity( { - type: 'invalid', - message: fromInput.validationMessage, - } ); - return; - } - - const toInput = toInputRef.current; - if ( - newValue && - newValue[ 1 ] && - toInput && - ! toInput.validity.valid - ) { - setCustomValidity( { - type: 'invalid', - message: toInput.validationMessage, - } ); - return; - } - } - - // No errors - setCustomValidity( undefined ); - }, - [ data, field, setValue ] - ); - - useEffect( () => { - if ( isTouched ) { - const timeoutId = setTimeout( () => { - onValidate( value ); - }, 0 ); - return () => clearTimeout( timeoutId ); - } - }, [ isTouched, value, onValidate ] ); - const updateDateRange = useCallback( ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { @@ -572,23 +530,16 @@ function CalendarDateRangeControl< Item >( { const { timezone, l10n } = getSettings(); - const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { - if ( isTouched ) { - return; - } - - // Only consider "blurred from the component" if focus has fully left the wrapping div. - // This prevents unnecessary blurs from components with multiple focusable elements. - if ( - ! event.relatedTarget || - ! event.currentTarget.contains( event.relatedTarget ) - ) { - setIsTouched( true ); - } - }; - return ( -
+ ( { /> -
- { customValidity && ( -

- - { customValidity.message } -

- ) } -
-
+ ); } From 45cd61d42719b2a21e7b41d38c0e341f909a1d10 Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 11:55:30 +0100 Subject: [PATCH 11/17] remove temporary file --- .../src/dataform-controls/date.tsx.backup | 525 ------------------ 1 file changed, 525 deletions(-) delete mode 100644 packages/dataviews/src/dataform-controls/date.tsx.backup diff --git a/packages/dataviews/src/dataform-controls/date.tsx.backup b/packages/dataviews/src/dataform-controls/date.tsx.backup deleted file mode 100644 index 8014c3c1706c29..00000000000000 --- a/packages/dataviews/src/dataform-controls/date.tsx.backup +++ /dev/null @@ -1,525 +0,0 @@ -/** - * WordPress dependencies - */ -import { - BaseControl, - Button, - privateApis as componentsPrivateApis, - __experimentalInputControl as InputControl, - __experimentalHStack as HStack, - __experimentalVStack as VStack, -} from '@wordpress/components'; -import { useCallback, useMemo, useState } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; -import { getDate, getSettings } from '@wordpress/date'; - -/** - * External dependencies - */ -import { - format, - isValid, - subMonths, - subDays, - subYears, - startOfMonth, - startOfYear, -} from 'date-fns'; - -/** - * Internal dependencies - */ -import RelativeDateControl, { - TIME_UNITS_OPTIONS, - type DateRelative, -} from './relative-date-control'; -import { - OPERATOR_IN_THE_PAST, - OPERATOR_OVER, - OPERATOR_BETWEEN, -} from '../constants'; -import { unlock } from '../lock-unlock'; -import type { DataFormControlProps } from '../types'; - -const { DateCalendar, DateRangeCalendar } = unlock( componentsPrivateApis ); - -type DateRange = [ string, string ] | undefined; - -const DATE_PRESETS: { - id: string; - label: string; - getValue: () => Date; -}[] = [ - { - id: 'today', - label: __( 'Today' ), - getValue: () => getDate( null ), - }, - { - id: 'yesterday', - label: __( 'Yesterday' ), - getValue: () => { - const today = getDate( null ); - return subDays( today, 1 ); - }, - }, - { - id: 'past-week', - label: __( 'Past week' ), - getValue: () => { - const today = getDate( null ); - return subDays( today, 7 ); - }, - }, - { - id: 'past-month', - label: __( 'Past month' ), - getValue: () => { - const today = getDate( null ); - return subMonths( today, 1 ); - }, - }, -]; - -const DATE_RANGE_PRESETS = [ - { - id: 'last-7-days', - label: __( 'Last 7 days' ), - getValue: () => { - const today = getDate( null ); - return [ subDays( today, 7 ), today ]; - }, - }, - { - id: 'last-30-days', - label: __( 'Last 30 days' ), - getValue: () => { - const today = getDate( null ); - return [ subDays( today, 30 ), today ]; - }, - }, - { - id: 'month-to-date', - label: __( 'Month to date' ), - getValue: () => { - const today = getDate( null ); - return [ startOfMonth( today ), today ]; - }, - }, - { - id: 'last-year', - label: __( 'Last year' ), - getValue: () => { - const today = getDate( null ); - return [ subYears( today, 1 ), today ]; - }, - }, - { - id: 'year-to-date', - label: __( 'Year to date' ), - getValue: () => { - const today = getDate( null ); - return [ startOfYear( today ), today ]; - }, - }, -]; - -const parseDate = ( dateString?: string ): Date | null => { - if ( ! dateString ) { - return null; - } - const parsed = getDate( dateString ); - return parsed && isValid( parsed ) ? parsed : null; -}; - -const formatDate = ( date?: Date | string ): string => { - if ( ! date ) { - return ''; - } - return typeof date === 'string' ? date : format( date, 'yyyy-MM-dd' ); -}; - -function CalendarDateControl( { - id, - value, - onChange, - label, - hideLabelFromVision, - className, -}: { - id: string; - value: string | undefined; - onChange: ( value: string | undefined ) => void; - label: string; - hideLabelFromVision?: boolean; - className?: string; -} ) { - const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( - null - ); - - const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { - const parsedDate = parseDate( value ); - return parsedDate || new Date(); // Default to current month - } ); - - const onSelectDate = useCallback( - ( newDate: Date | undefined | null ) => { - const dateValue = newDate - ? format( newDate, 'yyyy-MM-dd' ) - : undefined; - onChange( dateValue ); - setSelectedPresetId( null ); - }, - [ onChange ] - ); - - const handlePresetClick = useCallback( - ( preset: ( typeof DATE_PRESETS )[ 0 ] ) => { - const presetDate = preset.getValue(); - const dateValue = formatDate( presetDate ); - - setCalendarMonth( presetDate ); - onChange( dateValue ); - setSelectedPresetId( preset.id ); - }, - [ onChange ] - ); - - const handleManualDateChange = useCallback( - ( newValue?: string ) => { - onChange( newValue ); - if ( newValue ) { - const parsedDate = parseDate( newValue ); - if ( parsedDate ) { - setCalendarMonth( parsedDate ); - } - } - setSelectedPresetId( null ); - }, - [ onChange ] - ); - - const { - timezone: { string: timezoneString }, - l10n: { startOfWeek }, - } = getSettings(); - - return ( - - - { /* Preset buttons */ } - - { DATE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - - - - { /* Manual date input */ } - - - { /* Calendar widget */ } - - - - ); -} - -function CalendarDateRangeControl( { - id, - value, - onChange, - label, - hideLabelFromVision, - className, -}: { - id: string; - value: DateRange; - onChange: ( value: DateRange ) => void; - label: string; - hideLabelFromVision?: boolean; - className?: string; -} ) { - const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( - null - ); - - const selectedRange = useMemo( () => { - if ( ! value ) { - return { from: undefined, to: undefined }; - } - - const [ from, to ] = value; - return { - from: parseDate( from ) || undefined, - to: parseDate( to ) || undefined, - }; - }, [ value ] ); - - const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { - return selectedRange.from || new Date(); - } ); - - const updateDateRange = useCallback( - ( fromDate?: Date | string, toDate?: Date | string ) => { - if ( fromDate && toDate ) { - onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); - } else if ( ! fromDate && ! toDate ) { - onChange( undefined ); - } - // Do nothing if only one date is set - wait for both - }, - [ onChange ] - ); - - const onSelectCalendarRange = useCallback( - ( - newRange: - | { from: Date | undefined; to?: Date | undefined } - | undefined - ) => { - updateDateRange( newRange?.from, newRange?.to ); - setSelectedPresetId( null ); - }, - [ updateDateRange ] - ); - - const handlePresetClick = useCallback( - ( preset: ( typeof DATE_RANGE_PRESETS )[ 0 ] ) => { - const [ startDate, endDate ] = preset.getValue(); - setCalendarMonth( startDate ); - updateDateRange( startDate, endDate ); - setSelectedPresetId( preset.id ); - }, - [ updateDateRange ] - ); - - const handleManualDateChange = useCallback( - ( fromOrTo: 'from' | 'to', newValue?: string ) => { - const [ currentFrom, currentTo ] = value || [ - undefined, - undefined, - ]; - const updatedFrom = fromOrTo === 'from' ? newValue : currentFrom; - const updatedTo = fromOrTo === 'to' ? newValue : currentTo; - - updateDateRange( updatedFrom, updatedTo ); - - if ( newValue ) { - const parsedDate = parseDate( newValue ); - if ( parsedDate ) { - setCalendarMonth( parsedDate ); - } - } - - setSelectedPresetId( null ); - }, - [ value, updateDateRange ] - ); - - const { timezone, l10n } = getSettings(); - - return ( - - - { /* Preset buttons */ } - - { DATE_RANGE_PRESETS.map( ( preset ) => { - const isSelected = selectedPresetId === preset.id; - return ( - - ); - } ) } - - - - { /* Manual date range inputs */ } - - - handleManualDateChange( 'from', newValue ) - } - /> - - handleManualDateChange( 'to', newValue ) - } - /> - - - - - - ); -} - -export default function DateControl< Item >( { - data, - field, - onChange, - hideLabelFromVision, - operator, -}: DataFormControlProps< Item > ) { - const { id, label, getValue, setValue } = field; - const value = getValue( { item: data } ); - - const onChangeRelativeDateControl = useCallback( - ( newValue: DateRelative ) => { - onChange( setValue( { item: data, value: newValue } ) ); - }, - [ data, onChange, setValue ] - ); - - const onChangeCalendarDateRangeControl = useCallback( - ( newValue: DateRange ) => { - onChange( - setValue( { - item: data, - value: newValue, - } ) - ); - }, - [ data, onChange, setValue ] - ); - - const onChangeCalendarDateControl = useCallback( - ( newValue: string | undefined ) => - onChange( setValue( { item: data, value: newValue } ) ), - [ data, onChange, setValue ] - ); - - if ( operator === OPERATOR_IN_THE_PAST || operator === OPERATOR_OVER ) { - return ( - - ); - } - - if ( operator === OPERATOR_BETWEEN ) { - let dateRangeValue: DateRange; - if ( - Array.isArray( value ) && - value.length === 2 && - value.every( ( date ) => typeof date === 'string' ) - ) { - // Ensure the value is expected format - dateRangeValue = value as DateRange; - } - - return ( - - ); - } - - return ( - - ); -} From eea1e64ec41a1dd133020859d57349eda9f1bbba Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 12:11:34 +0100 Subject: [PATCH 12/17] add required indication --- packages/dataviews/src/dataform-controls/date.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index d566f2f2f99ced..4ee25932ff5441 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -344,6 +344,10 @@ function CalendarDateControl< Item >( { l10n: { startOfWeek }, } = getSettings(); + const displayLabel = field.isValid?.required + ? `${ label } (${ __( 'Required' ) })` + : label; + return ( ( { __nextHasNoMarginBottom id={ id } className={ className } - label={ label } + label={ displayLabel } hideLabelFromVision={ hideLabelFromVision } > @@ -530,6 +534,10 @@ function CalendarDateRangeControl< Item >( { const { timezone, l10n } = getSettings(); + const displayLabel = field.isValid?.required + ? `${ label } (${ __( 'Required' ) })` + : label; + return ( ( { __nextHasNoMarginBottom id={ id } className={ className } - label={ label } + label={ displayLabel } hideLabelFromVision={ hideLabelFromVision } > From 6c3d57eb27934e8058cb43b50851bdef98984cfa Mon Sep 17 00:00:00 2001 From: Jorge Date: Fri, 3 Oct 2025 16:53:40 +0100 Subject: [PATCH 13/17] fix codepath return bug --- packages/dataviews/src/dataform-controls/date.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 4ee25932ff5441..916645eaba1464 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -224,6 +224,7 @@ function ValidatedDateControl< Item >( { }, 0 ); return () => clearTimeout( timeoutId ); } + return undefined; }, [ isTouched, value, onValidate ] ); const onBlur = ( event: React.FocusEvent< HTMLDivElement > ) => { From 2eda66b0b8d65f59f623ed6278dccb556fbb2653 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:32:50 +0200 Subject: [PATCH 14/17] CalendarDateRangeControl: take DataFormControlProps as input --- .../dataviews/src/dataform-controls/date.tsx | 86 ++++++++----------- 1 file changed, 35 insertions(+), 51 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 916645eaba1464..15610dda99d9cd 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -429,26 +429,34 @@ function CalendarDateControl< Item >( { } function CalendarDateRangeControl< Item >( { - id, - value, - onChange, - label, - hideLabelFromVision, - className, data, field, - setValue, -}: { - id: string; - value: DateRange; - onChange: ( value: DateRange ) => void; - label: string; - hideLabelFromVision?: boolean; - className?: string; - data: Item; - field: any; - setValue: any; -} ) { + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label, getValue, setValue } = field; + let value: DateRange; + const fieldValue = getValue( { item: data } ); + if ( + Array.isArray( fieldValue ) && + fieldValue.length === 2 && + fieldValue.every( ( date ) => typeof date === 'string' ) + ) { + value = fieldValue as DateRange; + } + + const onChangeCallback = useCallback( + ( newValue: DateRange ) => { + onChange( + setValue( { + item: data, + value: newValue, + } ) + ); + }, + [ data, onChange, setValue ] + ); + const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); @@ -476,13 +484,16 @@ function CalendarDateRangeControl< Item >( { const updateDateRange = useCallback( ( fromDate?: Date | string, toDate?: Date | string ) => { if ( fromDate && toDate ) { - onChange( [ formatDate( fromDate ), formatDate( toDate ) ] ); + onChangeCallback( [ + formatDate( fromDate ), + formatDate( toDate ), + ] ); } else if ( ! fromDate && ! toDate ) { - onChange( undefined ); + onChangeCallback( undefined ); } // Do nothing if only one date is set - wait for both }, - [ onChange ] + [ onChangeCallback ] ); const onSelectCalendarRange = useCallback( @@ -552,7 +563,7 @@ function CalendarDateRangeControl< Item >( { @@ -648,18 +659,6 @@ export default function DateControl< Item >( { [ data, onChange, setValue ] ); - const onChangeCalendarDateRangeControl = useCallback( - ( newValue: DateRange ) => { - onChange( - setValue( { - item: data, - value: newValue, - } ) - ); - }, - [ data, onChange, setValue ] - ); - const onChangeCalendarDateControl = useCallback( ( newValue: string | undefined ) => onChange( setValue( { item: data, value: newValue } ) ), @@ -681,27 +680,12 @@ export default function DateControl< Item >( { } if ( operator === OPERATOR_BETWEEN ) { - let dateRangeValue: DateRange; - if ( - Array.isArray( value ) && - value.length === 2 && - value.every( ( date ) => typeof date === 'string' ) - ) { - // Ensure the value is expected format - dateRangeValue = value as DateRange; - } - return ( ); } From fe844f77aa76d150c6c9f0b869e0a8ee379d0554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:33:06 +0200 Subject: [PATCH 15/17] CalendarDateControl: take DataFormControlProps as input --- .../dataviews/src/dataform-controls/date.tsx | 61 +++++++------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 15610dda99d9cd..0eec541f59349d 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -268,30 +268,18 @@ function ValidatedDateControl< Item >( { } function CalendarDateControl< Item >( { - id, - value, - onChange, - label, - hideLabelFromVision, - className, data, field, - setValue, -}: { - id: string; - value: string | undefined; - onChange: ( value: string | undefined ) => void; - label: string; - hideLabelFromVision?: boolean; - className?: string; - data: Item; - field: any; - setValue: any; -} ) { + onChange, + hideLabelFromVision, +}: DataFormControlProps< Item > ) { + const { id, label, setValue, getValue } = field; const [ selectedPresetId, setSelectedPresetId ] = useState< string | null >( null ); + const fieldValue = getValue( { item: data } ); + const value = typeof fieldValue === 'string' ? fieldValue : undefined; const [ calendarMonth, setCalendarMonth ] = useState< Date >( () => { const parsedDate = parseDate( value ); return parsedDate || new Date(); // Default to current month @@ -300,16 +288,22 @@ function CalendarDateControl< Item >( { const [ isTouched, setIsTouched ] = useState( false ); const validityTargetRef = useRef< HTMLInputElement >( null ); + const onChangeCallback = useCallback( + ( newValue: string | undefined ) => + onChange( setValue( { item: data, value: newValue } ) ), + [ data, onChange, setValue ] + ); + const onSelectDate = useCallback( ( newDate: Date | undefined | null ) => { const dateValue = newDate ? format( newDate, 'yyyy-MM-dd' ) : undefined; - onChange( dateValue ); + onChangeCallback( dateValue ); setSelectedPresetId( null ); setIsTouched( true ); }, - [ onChange ] + [ onChangeCallback ] ); const handlePresetClick = useCallback( @@ -318,16 +312,16 @@ function CalendarDateControl< Item >( { const dateValue = formatDate( presetDate ); setCalendarMonth( presetDate ); - onChange( dateValue ); + onChangeCallback( dateValue ); setSelectedPresetId( preset.id ); setIsTouched( true ); }, - [ onChange ] + [ onChangeCallback ] ); const handleManualDateChange = useCallback( ( newValue?: string ) => { - onChange( newValue ); + onChangeCallback( newValue ); if ( newValue ) { const parsedDate = parseDate( newValue ); if ( parsedDate ) { @@ -337,7 +331,7 @@ function CalendarDateControl< Item >( { setSelectedPresetId( null ); setIsTouched( true ); }, - [ onChange ] + [ onChangeCallback ] ); const { @@ -362,7 +356,7 @@ function CalendarDateControl< Item >( { @@ -563,7 +557,7 @@ function CalendarDateRangeControl< Item >( { @@ -659,12 +653,6 @@ export default function DateControl< Item >( { [ data, onChange, setValue ] ); - const onChangeCalendarDateControl = useCallback( - ( newValue: string | undefined ) => - onChange( setValue( { item: data, value: newValue } ) ), - [ data, onChange, setValue ] - ); - if ( operator === OPERATOR_IN_THE_PAST || operator === OPERATOR_OVER ) { return ( ( { return ( ); } From 697be0e2b9cce0b9ab749d16bbf04ae24d677fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Wed, 8 Oct 2025 13:58:54 +0200 Subject: [PATCH 16/17] Add changelog --- packages/dataviews/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 9ab9d31e53ae0a..c7888345244d00 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -9,6 +9,7 @@ - Flip search icons depending on placement ([#72070](https://github.com/WordPress/gutenberg/pull/72070)). - DataViews: Improve renderItemLink event propagation handling. ([#72081](https://github.com/WordPress/gutenberg/pull/72081)). - Normalize search field styles ([#72072](https://github.com/WordPress/gutenberg/pull/72072)). +- DataForm control for `date` supports `required` and `custom` validation [#72048](https://github.com/WordPress/gutenberg/pull/72048). ### Breaking changes From 74cd149eaad9c42b18cd71bffe8daa1ac5c8042d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Maneiro?= <583546+oandregal@users.noreply.github.com> Date: Wed, 8 Oct 2025 14:21:36 +0200 Subject: [PATCH 17/17] Do not use curly braces --- packages/dataviews/src/dataform-controls/date.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/dataviews/src/dataform-controls/date.tsx b/packages/dataviews/src/dataform-controls/date.tsx index 0eec541f59349d..1f6018101e183c 100644 --- a/packages/dataviews/src/dataform-controls/date.tsx +++ b/packages/dataviews/src/dataform-controls/date.tsx @@ -356,7 +356,7 @@ function CalendarDateControl< Item >( {