From 2c8e17ab7109d688f8f94ab2f7af2408339a2028 Mon Sep 17 00:00:00 2001 From: xMort Date: Wed, 2 Apr 2025 15:20:05 +0200 Subject: [PATCH 1/3] feat(sdk-ui-filters): make date filter static period form accessible JIRA: LX-975 risk: low --- .../DateRangePicker/DateRangePicker.tsx | 55 ++++++- .../DateRangePicker/DateTimePicker.tsx | 137 +++++++++++++----- .../scss/components/DateRangePicker.scss | 65 ++++++--- .../src/base/localization/bundles/en-US.json | 30 ++++ 4 files changed, 227 insertions(+), 60 deletions(-) diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx index 6a7b59a0de8..0835c19ef74 100644 --- a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx @@ -19,6 +19,7 @@ import { IExtendedDateFilterErrors } from "../interfaces/index.js"; import { DateTimePickerWithInt } from "./DateTimePicker.js"; import { DAY_END_TIME } from "../constants/Platform.js"; +import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; import enUS from "date-fns/locale/en-US/index.js"; import de from "date-fns/locale/de/index.js"; @@ -58,6 +59,8 @@ const convertedLocales: Record = { }; const ALIGN_POINTS = [{ align: "bl tl", offset: { x: 0, y: 1 } }]; +const DATE_INPUT_HINT_ID = "date-range-picker-date-input-hint"; +const TIME_INPUT_HINT_ID = "date-range-picker-time-input-hint"; function convertLocale(locale: string): Locale { return convertedLocales[locale]; @@ -194,10 +197,14 @@ class DateRangePickerComponent extends React.Component ); @@ -214,10 +231,14 @@ class DateRangePickerComponent extends React.Component ); @@ -239,24 +270,21 @@ class DateRangePickerComponent extends React.Component {isTimeEnabled ? (
- {FromField} {isFromInputDatePickerOpen ? DatePickerComponent : null} - {ToField} {isToInputDatePickerOpen ? DatePickerComponent : null}
) : ( <> -
+
{FromField} - {ToField}
{this.state.isOpen ? DatePickerComponent : null} )} - {errorFrom || errorTo ? ( + {isMobile && (errorFrom || errorTo) ? ( ) : null} +
+
+ {intl.formatMessage( + { id: "filters.staticPeriod.dateFormatHint" }, + { format: dateFormat || getLocalizedDateFormat(intl.locale) }, + )} +
+ {isTimeEnabled ? ( +
+ {intl.formatMessage({ id: "filters.staticPeriod.timeFormatHint" })} +
+ ) : null} +
); } diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx index c2909d54b7d..bbcba503b84 100644 --- a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx @@ -6,6 +6,7 @@ import moment from "moment"; import isValid from "date-fns/isValid/index.js"; import parse from "date-fns/parse/index.js"; import format from "date-fns/format/index.js"; +import { useId } from "@gooddata/sdk-ui-kit"; import { DateRangePickerInputFieldBody } from "./DateRangePickerInputFieldBody.js"; @@ -13,6 +14,27 @@ import { convertPlatformDateStringToDate } from "../utils/DateConversions.js"; import { TIME_FORMAT } from "../constants/Platform.js"; import { getPlatformStringFromDate, getTimeStringFromDate } from "./utils.js"; +const InputDescription: React.FC<{ descriptionId: string; warning?: string; error?: string }> = ({ + descriptionId, + warning, + error, +}) => { + if (!error && !warning) { + return null; + } + return ( +
+ {error ? error : warning} +
+ ); +}; + function formatDate(date: Date, dateFormat: string): string { return format(date, dateFormat); } @@ -45,6 +67,15 @@ function parseDate(str: string, dateFormat: string): Date | undefined { interface IDateTimePickerAccessibilityConfig { dateAriaLabel?: React.AriaAttributes["aria-label"]; timeAriaLabel?: React.AriaAttributes["aria-label"]; + dateInputHintId?: string; + timeInputHintId?: string; +} + +interface IDateTimePickerErrors { + dateError?: string; + timeError?: string; + dateWarning?: string; + timeWarning?: string; } interface IDateTimePickerOwnProps { @@ -58,8 +89,10 @@ interface IDateTimePickerOwnProps { className: string; onKeyDown: (e: React.KeyboardEvent) => void; defaultTime?: string; - error?: boolean; + errors?: IDateTimePickerErrors; accessibilityConfig?: IDateTimePickerAccessibilityConfig; + dateInputLabel?: string; + timeInputLabel?: string; } type DateTimePickerComponentProps = IDateTimePickerOwnProps & WrappedComponentProps; @@ -67,6 +100,8 @@ type DateTimePickerComponentProps = IDateTimePickerOwnProps & WrappedComponentPr const DateTimePickerComponent = React.forwardRef( (props: DateTimePickerComponentProps, ref) => { const { + dateInputLabel, + timeInputLabel, placeholderDate, value, onChange, @@ -77,10 +112,15 @@ const DateTimePickerComponent = React.forwardRef(getTimeStringFromDate(value)); @@ -127,8 +167,13 @@ const DateTimePickerComponent = React.forwardRef +
{isMobile ? ( ) : ( -
- +
+ {dateInputLabel ? : null} +
- +
+
)} {isTimeEnabled ? ( - - - onTimeChange(event.target.value)} - value={pickerTime} +
+ {timeInputLabel ? : null} + + + onTimeChange(event.target.value)} + value={pickerTime} + aria-labelledby={timeInputLabel ? timeInputLabelId : undefined} + aria-describedby={ + !!timeWarning || !!timeError ? timeInputErrorId : timeInputHintId + } + /> + + - +
) : null}
); diff --git a/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss b/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss index 7ec5cda2a32..ab9750b7b69 100644 --- a/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss +++ b/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss @@ -14,14 +14,26 @@ $gd-color-warning-text: #888; $gd-day-picker-width: 267px; -.gd-flex-row { +$gd-day-picker-input-width: 126px; + +.gd-date-range-row { display: flex; flex-direction: row; justify-content: space-between; - align-items: center; + align-items: flex-start; + gap: 10px; +} + +.gd-date-range-column { + display: flex; + flex-direction: column; } .gd-date-range-picker { + display: flex; + flex-direction: column; + gap: 10px; + & .gd-input-field, & .input-text { font-size: 13px; @@ -32,19 +44,9 @@ $gd-day-picker-width: 267px; position: relative; } - &-dash { - margin: 0 5px; - color: kit-variables.$gd-color-state-blank; - } - - &-dash + &-to .gd-date-range-picker-picker { - left: -143px; - } - label { - font-size: 9pt; + font-size: 13px; display: block; - text-transform: capitalize; &:nth-of-type(2) { padding-top: 10px; @@ -54,7 +56,7 @@ $gd-day-picker-width: 267px; &.datetime { input { @media #{kit-variables.$medium-up} { - width: 130px; + width: $gd-day-picker-input-width; } } } @@ -68,17 +70,14 @@ $gd-day-picker-width: 267px; } &-input { - &-time { - position: relative; - margin-left: 10px; - } + position: relative; & input { height: 30px; padding-left: 34px; @media #{kit-variables.$medium-up} { - width: 126px; + width: $gd-day-picker-input-width; } } @@ -124,6 +123,24 @@ $gd-day-picker-width: 267px; border-color: kit-variables.$gd-input-text-border-warning; } } + + &__description { + white-space: normal; + font-size: 12px; + line-height: 17px; + padding-top: 5px; + + @media #{kit-variables.$medium-up} { + width: $gd-day-picker-input-width; + } + + &--warning { + color: kit-variables.$gd-palette-warning-base-text; + } + &--error { + color: kit-variables.$gd-palette-error-base; + } + } } //CSS for old RANGE PICKER due to RAIL-4460 @@ -131,7 +148,7 @@ $gd-day-picker-width: 267px; display: flex; flex-direction: row; justify-content: space-between; - align-items: center; + align-items: flex-start; } &-input-wrapper { position: relative; @@ -449,6 +466,14 @@ $gd-day-picker-width: 267px; } } +.gd-date-range__hint { + display: flex; + flex-direction: column; + color: kit-variables.$gd-medium-gray; + font-size: 12px; + padding-top: 10px; +} + .gd-date-range-picker-wrapper { position: relative; } diff --git a/libs/sdk-ui/src/base/localization/bundles/en-US.json b/libs/sdk-ui/src/base/localization/bundles/en-US.json index 1044b5a8ec3..c3ad60f52ba 100644 --- a/libs/sdk-ui/src/base/localization/bundles/en-US.json +++ b/libs/sdk-ui/src/base/localization/bundles/en-US.json @@ -1082,6 +1082,36 @@ "comment": "Displayed as placeholder in static period inputs where user enters date", "limit": 0 }, + "filters.staticPeriod.dateFrom": { + "value": "Start date", + "comment": "Displayed as label of input where user enters a date that marks the start of date range.", + "limit": 0 + }, + "filters.staticPeriod.timeFrom": { + "value": "Start time", + "comment": "Displayed as label of input where user enters a time that marks the start of date range.", + "limit": 0 + }, + "filters.staticPeriod.dateTo": { + "value": "End date", + "comment": "Displayed as label of input where user enters a date that marks the end of date range.", + "limit": 0 + }, + "filters.staticPeriod.timeTo": { + "value": "End time", + "comment": "Displayed as label of input where user enters a time that marks the end of date range.", + "limit": 0 + }, + "filters.staticPeriod.dateFormatHint": { + "value": "Date format: {format}.", + "comment": "Displayed as a hint below input in which user enters a date. Do not translate {format} placeholder. It will be replaced in runtime with date format pattern.", + "limit": 0 + }, + "filters.staticPeriod.timeFormatHint": { + "value": "Time format: HH:MM, max. value 23:59.", + "comment": "Displayed as a hint below input in which user enters a time.", + "limit": 0 + }, "filters.date.accessibility.label.from": { "value": "Select date from", "comment": "", From 63a8dffd18428d9b75721f189feb2030259470fc Mon Sep 17 00:00:00 2001 From: xMort Date: Fri, 4 Apr 2025 13:40:23 +0200 Subject: [PATCH 2/3] fixup! rework ranger picker as functional component --- .../DateRangePicker/DateRangePicker.tsx | 523 ++++++++++-------- 1 file changed, 292 insertions(+), 231 deletions(-) diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx index 0835c19ef74..bbff6e23be8 100644 --- a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx @@ -1,26 +1,25 @@ // (C) 2007-2025 GoodData Corporation -import React from "react"; +import React, { useState, useRef, useEffect, useCallback, forwardRef } from "react"; import cx from "classnames"; import { DayPicker, DayPickerRangeProps, DateRange, SelectRangeEventHandler, - ClassNames, DayPickerProps, } from "react-day-picker"; -import { injectIntl, WrappedComponentProps } from "react-intl"; +import { injectIntl, WrappedComponentProps, IntlShape } from "react-intl"; import { WeekStart } from "@gooddata/sdk-model"; import { Overlay } from "@gooddata/sdk-ui-kit"; +import { DAY_END_TIME } from "../constants/Platform.js"; +import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; + import { mergeDayPickerProps } from "./utils.js"; import { DateRangePickerError } from "./DateRangePickerError.js"; import { IExtendedDateFilterErrors } from "../interfaces/index.js"; import { DateTimePickerWithInt } from "./DateTimePicker.js"; -import { DAY_END_TIME } from "../constants/Platform.js"; -import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; - import enUS from "date-fns/locale/en-US/index.js"; import de from "date-fns/locale/de/index.js"; import es from "date-fns/locale/es/index.js"; @@ -62,23 +61,13 @@ const ALIGN_POINTS = [{ align: "bl tl", offset: { x: 0, y: 1 } }]; const DATE_INPUT_HINT_ID = "date-range-picker-date-input-hint"; const TIME_INPUT_HINT_ID = "date-range-picker-time-input-hint"; -function convertLocale(locale: string): Locale { - return convertedLocales[locale]; -} +const convertLocale = (locale: string): Locale => convertedLocales[locale]; + export interface IDateRange { from: Date; to: Date; } -interface IDateRangePickerState { - isOpen: boolean; - inputFromValue: Date; - inputToValue: Date; - selectedRange: DateRange; - monthDate: Date; - selectedInput: string; -} - export interface IDateRangePickerProps { range: IDateRange; onRangeChange: (newRange: IDateRange) => void; @@ -104,78 +93,77 @@ function convertWeekStart(weekStart: WeekStart): DayPickerProps["weekStartsOn"] } } -class DateRangePickerComponent extends React.Component { - private dateRangePickerInputFrom = React.createRef(); - private dateRangePickerInputTo = React.createRef(); - private dateRangePickerContainer = React.createRef(); - - constructor(props: DateRangePickerProps) { - super(props); - - this.state = { - isOpen: false, - inputFromValue: this.props.range.from, - inputToValue: this.props.range.to, - selectedRange: { from: this.props.range.from, to: this.props.range.to }, - monthDate: null, - selectedInput: null, - }; - - this.handleMonthChanged = this.handleMonthChanged.bind(this); - this.handleClickOutside = this.handleClickOutside.bind(this); - this.handleMonthChanged = this.handleMonthChanged.bind(this); - this.handleRangeSelect = this.handleRangeSelect.bind(this); - this.handleFromDayClick = this.handleFromDayClick.bind(this); - this.handleToDayClick = this.handleToDayClick.bind(this); - this.handleFromChange = this.handleFromChange.bind(this); - this.handleToChange = this.handleToChange.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); - } - - public componentDidMount(): void { - document.addEventListener("mousedown", this.handleClickOutside); - } +const isClickOutsideOfCalendar = ( + event: MouseEvent, + container: HTMLElement, + inputFrom: HTMLElement, + inputTo: HTMLElement, +): boolean => { + return ( + container && + !container.contains(event.target as Node) && + inputFrom && + !inputFrom.contains(event.target as Node) && + inputTo && + !inputTo.contains(event.target as Node) + ); +}; - public componentWillUnmount(): void { - document.removeEventListener("mousedown", this.handleClickOutside); +const DayPickerComponent = forwardRef< + HTMLDivElement, + { + mode: "from" | "to"; + originalDateRange: IDateRange; + selectedDateRange: IDateRange; + alignTo: string; + calendarClassNames: string; + onDateRangeSelect: SelectRangeEventHandler; + dayPickerProps?: DayPickerRangeProps; + weekStart?: WeekStart; + renderAsOverlay?: boolean; + intl: IntlShape; } - - public render() { - const { - dateFormat, - range: { from, to }, +>( + ( + { + mode, + originalDateRange, + selectedDateRange, + onDateRangeSelect, dayPickerProps, + alignTo, + weekStart, + renderAsOverlay, + calendarClassNames, intl, - isMobile, - errors: { from: errorFrom, to: errorTo } = { from: undefined, to: undefined }, - isTimeEnabled, - weekStart = "Sunday", - shouldOverlayDatePicker = false, - } = this.props; + }, + ref, + ) => { + const [currentMonthDate, setCurrentMonthDate] = useState( + mode === "from" ? selectedDateRange.from : selectedDateRange.to, + ); const defaultDayPickerProps: DayPickerRangeProps = { mode: "range", showOutsideDays: true, - modifiers: { start: from, end: to }, - selected: { from, to }, + modifiers: { start: originalDateRange.from, end: originalDateRange.to }, + selected: { from: originalDateRange.from, to: originalDateRange.to }, locale: convertLocale(intl.locale), }; const dayPickerPropsWithDefaults = mergeDayPickerProps(defaultDayPickerProps, dayPickerProps); - const classNameProps: ClassNames = { - root: `gd-date-range-picker-picker s-date-range-calendar-${this.state.selectedInput}`, - }; - const DatePicker = ( -
+
@@ -183,7 +171,7 @@ class DateRangePickerComponent extends React.Component ); + if (renderAsOverlay) { + return OverlayDatePicker; + } + return DatePicker; + }, +); - const FromField = ( +DayPickerComponent.displayName = "DayPickerComponent"; + +const DateRangeHint: React.FC<{ + dateFormat: string; + isTimeEnabled: boolean; + intl: IntlShape; +}> = ({ dateFormat, isTimeEnabled, intl }) => ( +
+
+ {intl.formatMessage( + { id: "filters.staticPeriod.dateFormatHint" }, + { format: dateFormat || getLocalizedDateFormat(intl.locale) }, + )} +
+ {isTimeEnabled ? ( +
+ {intl.formatMessage({ id: "filters.staticPeriod.timeFormatHint" })} +
+ ) : null} +
+); + +interface IInputFieldProps { + value: Date; + onKeyDown: (e: React.KeyboardEvent) => void; + onChange: (date: Date) => void; + onInputClick: () => void; + errors?: IExtendedDateFilterErrors["absoluteForm"]; + dateFormat: string; + isMobile: boolean; + isTimeEnabled: boolean; + intl: IntlShape; +} + +const FromInputField = forwardRef( + ( + { value, onKeyDown, onChange, onInputClick, errors, dateFormat, isMobile, isTimeEnabled, intl }, + ref, + ) => { + return ( ); - - const ToField = ( + }, +); +FromInputField.displayName = "FromInputField"; + +const ToInputField = forwardRef( + ( + { value, onKeyDown, onChange, onInputClick, errors, dateFormat, isMobile, isTimeEnabled, intl }, + ref, + ) => { + return ( ); - - const DatePickerComponent = shouldOverlayDatePicker ? OverlayDatePicker : DatePicker; - - const isFromInputDatePickerOpen = this.state.selectedInput === "from" && this.state.isOpen; - const isToInputDatePickerOpen = this.state.selectedInput === "to" && this.state.isOpen; - return ( - <> - {isTimeEnabled ? ( -
- {FromField} - {isFromInputDatePickerOpen ? DatePickerComponent : null} - {ToField} - {isToInputDatePickerOpen ? DatePickerComponent : null} -
- ) : ( - <> -
- {FromField} - {ToField} -
- {this.state.isOpen ? DatePickerComponent : null} - - )} - {isMobile && (errorFrom || errorTo) ? ( - - ) : null} -
-
- {intl.formatMessage( - { id: "filters.staticPeriod.dateFormatHint" }, - { format: dateFormat || getLocalizedDateFormat(intl.locale) }, - )} -
- {isTimeEnabled ? ( -
- {intl.formatMessage({ id: "filters.staticPeriod.timeFormatHint" })} -
- ) : null} -
- - ); - } - - private onKeyDown(e: React.KeyboardEvent) { - if (e.key === "Escape" || e.key === "Tab") { - this.setState({ isOpen: false }); - } - } - - private handleMonthChanged(month: Date) { - this.setState({ monthDate: month }); - } - - // get new date object composed from the date of the first argument - // and the time of the date provided as the second argument - private setTimeForDate(date: Date, time: Date): Date { - const result = new Date(date); - result.setHours(time.getHours()); - result.setMinutes(time.getMinutes()); - return result; - } - - private handleRangeSelect: SelectRangeEventHandler = ( + }, +); +ToInputField.displayName = "ToInputField"; + +const DateRangePickerComponent: React.FC = ({ + range, + onRangeChange, + errors, + dateFormat, + dayPickerProps, + intl, + isMobile, + isTimeEnabled, + weekStart = "Sunday", + shouldOverlayDatePicker = false, +}) => { + const [isOpen, setIsOpen] = useState(false); + const [inputFromValue, setInputFromValue] = useState(range.from); + const [inputToValue, setInputToValue] = useState(range.to); + const [selectedRange, setSelectedRange] = useState({ from: range.from, to: range.to }); + const [selectedInput, setSelectedInput] = useState<"from" | "to" | null>(null); + + const dateRangePickerInputFrom = useRef(null); + const dateRangePickerInputTo = useRef(null); + const dateRangePickerContainer = useRef(null); + + const handleRangeSelect: SelectRangeEventHandler = ( _range: DateRange | undefined, selectedDate: Date, ) => { let calculatedFrom: Date; let calculatedTo: Date; - // it is better to use selectedDate property as _range is not working correctly in corner cases - if (this.state.selectedInput == "from") { - calculatedFrom = this.setTimeForDate(selectedDate, this.state.inputFromValue); - calculatedTo = this.state.inputToValue; + if (selectedInput === "from") { + calculatedFrom = setTimeForDate(selectedDate, inputFromValue); + calculatedTo = inputToValue; } else { - calculatedFrom = this.state.inputFromValue; - calculatedTo = this.setTimeForDate(selectedDate, this.state.inputToValue); + calculatedFrom = inputFromValue; + calculatedTo = setTimeForDate(selectedDate, inputToValue); } - this.setState( - { - inputFromValue: calculatedFrom, - inputToValue: calculatedTo, - selectedRange: { from: calculatedFrom, to: calculatedTo }, - isOpen: false, - }, - () => { - this.updateRange(calculatedFrom, calculatedTo); - }, - ); + setInputFromValue(calculatedFrom); + setInputToValue(calculatedTo); + setSelectedRange({ from: calculatedFrom, to: calculatedTo }); + setIsOpen(false); + + onRangeChange({ from: calculatedFrom, to: calculatedTo }); }; - private handleClickOutside(event: MouseEvent) { - if ( - this.dateRangePickerContainer.current && - !this.dateRangePickerContainer.current.contains(event.target as Node) && - this.dateRangePickerInputFrom && - !this.dateRangePickerInputFrom.current.contains(event.target as Node) && - this.dateRangePickerInputTo && - !this.dateRangePickerInputTo.current.contains(event.target as Node) - ) { - this.setState({ isOpen: false }); + const handleFromChange = (date: Date) => { + if (date) { + setInputFromValue(date); } - } + setSelectedRange((prevRange) => ({ from: date, to: prevRange.to })); + onRangeChange({ from: date, to: selectedRange.to }); + }; - private updateRange = (from: Date, to: Date) => { - this.props.onRangeChange({ from, to }); + const handleToChange = (date: Date) => { + if (date) { + setInputToValue(date); + } + setSelectedRange((prevRange) => ({ from: prevRange.from, to: date })); + onRangeChange({ from: selectedRange.from, to: date }); }; - private handleFromDayClick = () => { - this.setState({ - selectedInput: "from", - isOpen: true, - monthDate: this.props.range.from, - }); + const handleFromDayClick = () => { + setSelectedInput("from"); + setIsOpen(true); }; - private handleToDayClick = () => { - this.setState({ - selectedInput: "to", - isOpen: true, - monthDate: this.props.range.to, - }); + const handleToDayClick = () => { + setSelectedInput("to"); + setIsOpen(true); }; - private handleFromChange = (date: Date) => { - if (date) { - this.setState({ inputFromValue: date }); + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Escape" || e.key === "Tab") { + setIsOpen(false); } - - this.setState( - { - selectedRange: { from: date, to: this.state.selectedRange.to }, - monthDate: date, - }, - () => { - this.updateRange(date, this.state.selectedRange.to); - }, - ); }; - private handleToChange = (date: Date) => { - if (date) { - this.setState({ inputToValue: date }); + const handleClickOutside = useCallback((event: MouseEvent) => { + if ( + isClickOutsideOfCalendar( + event, + dateRangePickerContainer.current, + dateRangePickerInputFrom.current, + dateRangePickerInputTo.current, + ) + ) { + setIsOpen(false); } + }, []); - this.setState( - { - selectedRange: { from: this.state.selectedRange.from, to: date }, - monthDate: date, - }, - () => { - this.updateRange(this.state.selectedRange.from, date); - }, - ); + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [handleClickOutside]); + + const setTimeForDate = (date: Date, time: Date): Date => { + const result = new Date(date); + result.setHours(time.getHours()); + result.setMinutes(time.getMinutes()); + return result; }; -} + + const FromField = ( + + ); + + const ToField = ( + + ); + + const isFromInputDatePickerOpen = selectedInput === "from" && isOpen; + const isToInputDatePickerOpen = selectedInput === "to" && isOpen; + + const DatePicker = ( + + ); + + return ( + <> + {isTimeEnabled ? ( +
+ {FromField} + {isFromInputDatePickerOpen ? DatePicker : null} + {ToField} + {isToInputDatePickerOpen ? DatePicker : null} +
+ ) : ( + <> +
+ {FromField} + {ToField} +
+ {isOpen ? DatePicker : null} + + )} + {isMobile ? ( + errors?.from || errors?.to ? ( + + ) : null + ) : ( + + )} + + ); +}; + export const DateRangePicker = injectIntl(DateRangePickerComponent); From c6d386c9648c8e3d9d3369faecfc9e00c750d094 Mon Sep 17 00:00:00 2001 From: xMort Date: Mon, 7 Apr 2025 18:49:17 +0200 Subject: [PATCH 3/3] fixup! split component to multiple files, somehow working error validation --- libs/sdk-ui-filters/api/sdk-ui-filters.api.md | 2 + .../DateFilter/DateRangePicker/DatePicker.tsx | 146 ++++++++ .../DateRangePicker/DateRangeHint.tsx | 28 ++ .../DateRangePicker/DateRangePicker.tsx | 330 +++--------------- .../DateRangePicker/DateTimePicker.tsx | 122 ++++--- .../DateRangePicker/EndDateInputField.tsx | 55 +++ .../DateRangePicker/StartDateInputField.tsx | 52 +++ .../DateRangePicker/errorValidation.ts | 190 ++++++++++ .../src/DateFilter/DateRangePicker/types.ts | 25 ++ .../src/DateFilter/interfaces/index.ts | 21 +- .../DateFilter/validation/OptionValidation.ts | 23 +- libs/sdk-ui-filters/src/locales.ts | 10 +- .../scss/components/DateRangePicker.scss | 3 - .../src/base/localization/bundles/en-US.json | 50 +++ 14 files changed, 721 insertions(+), 336 deletions(-) create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DatePicker.tsx create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangeHint.tsx create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/EndDateInputField.tsx create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/StartDateInputField.tsx create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/errorValidation.ts create mode 100644 libs/sdk-ui-filters/src/DateFilter/DateRangePicker/types.ts diff --git a/libs/sdk-ui-filters/api/sdk-ui-filters.api.md b/libs/sdk-ui-filters/api/sdk-ui-filters.api.md index 426d51394a7..5b6c56c1340 100644 --- a/libs/sdk-ui-filters/api/sdk-ui-filters.api.md +++ b/libs/sdk-ui-filters/api/sdk-ui-filters.api.md @@ -804,6 +804,8 @@ export interface IDateTranslator { // @public export interface IExtendedDateFilterErrors { + // (undocumented) + absoluteDateTimeForm?: IDateFilterAbsoluteDateTimeFormErrors; absoluteForm?: IDateFilterAbsoluteFormErrors; relativeForm?: IDateFilterRelativeFormErrors; } diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DatePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DatePicker.tsx new file mode 100644 index 00000000000..382255115a2 --- /dev/null +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DatePicker.tsx @@ -0,0 +1,146 @@ +// (C) 2025 GoodData Corporation + +import React, { forwardRef, useState } from "react"; +import { IntlShape } from "react-intl"; +import { WeekStart } from "@gooddata/sdk-model"; +import { Overlay } from "@gooddata/sdk-ui-kit"; +import { + DayPicker as DayPickerComponent, + DayPickerRangeProps, + SelectRangeEventHandler, + DayPickerProps, +} from "react-day-picker"; +import enUS from "date-fns/locale/en-US/index.js"; +import de from "date-fns/locale/de/index.js"; +import es from "date-fns/locale/es/index.js"; +import fr from "date-fns/locale/fr/index.js"; +import ja from "date-fns/locale/ja/index.js"; +import nl from "date-fns/locale/nl/index.js"; +import pt from "date-fns/locale/pt/index.js"; +import ptBR from "date-fns/locale/pt-BR/index.js"; +import zhCN from "date-fns/locale/zh-CN/index.js"; +import ru from "date-fns/locale/ru/index.js"; +import it from "date-fns/locale/it/index.js"; +import enGB from "date-fns/locale/en-GB/index.js"; +import frCA from "date-fns/locale/fr-CA/index.js"; +import enAU from "date-fns/locale/en-AU/index.js"; +import fi from "date-fns/locale/fi/index.js"; + +import { mergeDayPickerProps } from "./utils.js"; +import { IDateRange } from "./DateRangePicker.js"; + +const convertedLocales: Record = { + "en-US": enUS, + "de-DE": de, + "es-ES": es, + "fr-FR": fr, + "ja-JP": ja, + "nl-NL": nl, + "pt-BR": ptBR, + "pt-PT": pt, + "zh-Hans": zhCN, + "ru-RU": ru, + "it-IT": it, + "es-419": es, + "en-GB": enGB, + "fr-CA": frCA, + "zh-Hant": zhCN, + "en-AU": enAU, + "fi-FI": fi, + "zh-HK": zhCN, +}; + +const ALIGN_POINTS = [{ align: "bl tl", offset: { x: 0, y: 1 } }]; + +const convertLocale = (locale: string): Locale => convertedLocales[locale]; + +function convertWeekStart(weekStart: WeekStart): DayPickerProps["weekStartsOn"] { + switch (weekStart) { + case "Sunday": + return 0; + case "Monday": + return 1; + default: + throw new Error(`Unknown week start ${weekStart}`); + } +} + +export const DayPicker = forwardRef< + HTMLDivElement, + { + mode: "from" | "to"; + originalDateRange: IDateRange; + selectedDateRange: IDateRange; + alignTo: string; + calendarClassNames: string; + onDateRangeSelect: SelectRangeEventHandler; + dayPickerProps?: DayPickerRangeProps; + weekStart?: WeekStart; + renderAsOverlay?: boolean; + intl: IntlShape; + } +>( + ( + { + mode, + originalDateRange, + selectedDateRange, + onDateRangeSelect, + dayPickerProps, + alignTo, + weekStart, + renderAsOverlay, + calendarClassNames, + intl, + }, + ref, + ) => { + const [currentMonthDate, setCurrentMonthDate] = useState( + mode === "from" ? selectedDateRange.from : selectedDateRange.to, + ); + + const defaultDayPickerProps: DayPickerRangeProps = { + mode: "range", + showOutsideDays: true, + modifiers: { start: originalDateRange.from, end: originalDateRange.to }, + selected: { from: originalDateRange.from, to: originalDateRange.to }, + locale: convertLocale(intl.locale), + }; + + const dayPickerPropsWithDefaults = mergeDayPickerProps(defaultDayPickerProps, dayPickerProps); + + const DatePicker = ( +
+ +
+ ); + + const OverlayDatePicker = ( + + {DatePicker} + + ); + if (renderAsOverlay) { + return OverlayDatePicker; + } + return DatePicker; + }, +); + +DayPicker.displayName = "DayPicker"; diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangeHint.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangeHint.tsx new file mode 100644 index 00000000000..e9f24acbd46 --- /dev/null +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangeHint.tsx @@ -0,0 +1,28 @@ +// (C) 2025 GoodData Corporation + +import React from "react"; +import { IntlShape } from "react-intl"; + +import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; + +import { DATE_INPUT_HINT_ID, TIME_INPUT_HINT_ID } from "./types.js"; + +export const DateRangeHint: React.FC<{ + dateFormat: string; + isTimeEnabled: boolean; + intl: IntlShape; +}> = ({ dateFormat, isTimeEnabled, intl }) => ( +
+
+ {intl.formatMessage( + { id: "filters.staticPeriod.dateFormatHint" }, + { format: dateFormat || getLocalizedDateFormat(intl.locale) }, + )} +
+ {isTimeEnabled ? ( +
+ {intl.formatMessage({ id: "filters.staticPeriod.timeFormatHint" })} +
+ ) : null} +
+); diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx index bbff6e23be8..84d080c6911 100644 --- a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateRangePicker.tsx @@ -1,67 +1,18 @@ // (C) 2007-2025 GoodData Corporation -import React, { useState, useRef, useEffect, useCallback, forwardRef } from "react"; -import cx from "classnames"; -import { - DayPicker, - DayPickerRangeProps, - DateRange, - SelectRangeEventHandler, - DayPickerProps, -} from "react-day-picker"; -import { injectIntl, WrappedComponentProps, IntlShape } from "react-intl"; +import React, { useState, useRef, useEffect, useCallback } from "react"; +import { DayPickerRangeProps, DateRange, SelectRangeEventHandler } from "react-day-picker"; +import { injectIntl, WrappedComponentProps } from "react-intl"; import { WeekStart } from "@gooddata/sdk-model"; -import { Overlay } from "@gooddata/sdk-ui-kit"; -import { DAY_END_TIME } from "../constants/Platform.js"; -import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; - -import { mergeDayPickerProps } from "./utils.js"; -import { DateRangePickerError } from "./DateRangePickerError.js"; import { IExtendedDateFilterErrors } from "../interfaces/index.js"; -import { DateTimePickerWithInt } from "./DateTimePicker.js"; - -import enUS from "date-fns/locale/en-US/index.js"; -import de from "date-fns/locale/de/index.js"; -import es from "date-fns/locale/es/index.js"; -import fr from "date-fns/locale/fr/index.js"; -import ja from "date-fns/locale/ja/index.js"; -import nl from "date-fns/locale/nl/index.js"; -import pt from "date-fns/locale/pt/index.js"; -import ptBR from "date-fns/locale/pt-BR/index.js"; -import zhCN from "date-fns/locale/zh-CN/index.js"; -import ru from "date-fns/locale/ru/index.js"; -import it from "date-fns/locale/it/index.js"; -import enGB from "date-fns/locale/en-GB/index.js"; -import frCA from "date-fns/locale/fr-CA/index.js"; -import enAU from "date-fns/locale/en-AU/index.js"; -import fi from "date-fns/locale/fi/index.js"; - -const convertedLocales: Record = { - "en-US": enUS, - "de-DE": de, - "es-ES": es, - "fr-FR": fr, - "ja-JP": ja, - "nl-NL": nl, - "pt-BR": ptBR, - "pt-PT": pt, - "zh-Hans": zhCN, - "ru-RU": ru, - "it-IT": it, - "es-419": es, - "en-GB": enGB, - "fr-CA": frCA, - "zh-Hant": zhCN, - "en-AU": enAU, - "fi-FI": fi, - "zh-HK": zhCN, -}; -const ALIGN_POINTS = [{ align: "bl tl", offset: { x: 0, y: 1 } }]; -const DATE_INPUT_HINT_ID = "date-range-picker-date-input-hint"; -const TIME_INPUT_HINT_ID = "date-range-picker-time-input-hint"; - -const convertLocale = (locale: string): Locale => convertedLocales[locale]; +import { DateRangePickerError } from "./DateRangePickerError.js"; +import { StartDateInputField } from "./StartDateInputField.js"; +import { EndDateInputField } from "./EndDateInputField.js"; +import { DateRangeHint } from "./DateRangeHint.js"; +import { DayPicker } from "./DatePicker.js"; +import { useErrorValidation } from "./errorValidation.js"; +import { ChangeSource, DateParseError } from "./types.js"; export interface IDateRange { from: Date; @@ -82,17 +33,6 @@ export interface IDateRangePickerProps { type DateRangePickerProps = IDateRangePickerProps & WrappedComponentProps; -function convertWeekStart(weekStart: WeekStart): DayPickerProps["weekStartsOn"] { - switch (weekStart) { - case "Sunday": - return 0; - case "Monday": - return 1; - default: - throw new Error(`Unknown week start ${weekStart}`); - } -} - const isClickOutsideOfCalendar = ( event: MouseEvent, container: HTMLElement, @@ -109,205 +49,10 @@ const isClickOutsideOfCalendar = ( ); }; -const DayPickerComponent = forwardRef< - HTMLDivElement, - { - mode: "from" | "to"; - originalDateRange: IDateRange; - selectedDateRange: IDateRange; - alignTo: string; - calendarClassNames: string; - onDateRangeSelect: SelectRangeEventHandler; - dayPickerProps?: DayPickerRangeProps; - weekStart?: WeekStart; - renderAsOverlay?: boolean; - intl: IntlShape; - } ->( - ( - { - mode, - originalDateRange, - selectedDateRange, - onDateRangeSelect, - dayPickerProps, - alignTo, - weekStart, - renderAsOverlay, - calendarClassNames, - intl, - }, - ref, - ) => { - const [currentMonthDate, setCurrentMonthDate] = useState( - mode === "from" ? selectedDateRange.from : selectedDateRange.to, - ); - - const defaultDayPickerProps: DayPickerRangeProps = { - mode: "range", - showOutsideDays: true, - modifiers: { start: originalDateRange.from, end: originalDateRange.to }, - selected: { from: originalDateRange.from, to: originalDateRange.to }, - locale: convertLocale(intl.locale), - }; - - const dayPickerPropsWithDefaults = mergeDayPickerProps(defaultDayPickerProps, dayPickerProps); - - const DatePicker = ( -
- -
- ); - - const OverlayDatePicker = ( - - {DatePicker} - - ); - if (renderAsOverlay) { - return OverlayDatePicker; - } - return DatePicker; - }, -); - -DayPickerComponent.displayName = "DayPickerComponent"; - -const DateRangeHint: React.FC<{ - dateFormat: string; - isTimeEnabled: boolean; - intl: IntlShape; -}> = ({ dateFormat, isTimeEnabled, intl }) => ( -
-
- {intl.formatMessage( - { id: "filters.staticPeriod.dateFormatHint" }, - { format: dateFormat || getLocalizedDateFormat(intl.locale) }, - )} -
- {isTimeEnabled ? ( -
- {intl.formatMessage({ id: "filters.staticPeriod.timeFormatHint" })} -
- ) : null} -
-); - -interface IInputFieldProps { - value: Date; - onKeyDown: (e: React.KeyboardEvent) => void; - onChange: (date: Date) => void; - onInputClick: () => void; - errors?: IExtendedDateFilterErrors["absoluteForm"]; - dateFormat: string; - isMobile: boolean; - isTimeEnabled: boolean; - intl: IntlShape; -} - -const FromInputField = forwardRef( - ( - { value, onKeyDown, onChange, onInputClick, errors, dateFormat, isMobile, isTimeEnabled, intl }, - ref, - ) => { - return ( - - ); - }, -); -FromInputField.displayName = "FromInputField"; - -const ToInputField = forwardRef( - ( - { value, onKeyDown, onChange, onInputClick, errors, dateFormat, isMobile, isTimeEnabled, intl }, - ref, - ) => { - return ( - - ); - }, -); -ToInputField.displayName = "ToInputField"; - const DateRangePickerComponent: React.FC = ({ range, onRangeChange, - errors, + // errors, // TODO what to do with this? Use onError instead to propagate local errors up? dateFormat, dayPickerProps, intl, @@ -321,6 +66,14 @@ const DateRangePickerComponent: React.FC = ({ const [inputToValue, setInputToValue] = useState(range.to); const [selectedRange, setSelectedRange] = useState({ from: range.from, to: range.to }); const [selectedInput, setSelectedInput] = useState<"from" | "to" | null>(null); + const { + errors, + validateFromField, + validateToField, + clearError, + onFromInputMarkedValid, + onToInputMarkedValid, + } = useErrorValidation(); const dateRangePickerInputFrom = useRef(null); const dateRangePickerInputTo = useRef(null); @@ -333,14 +86,18 @@ const DateRangePickerComponent: React.FC = ({ let calculatedFrom: Date; let calculatedTo: Date; + // clear dateError as this function always sets a valid date (later it will be validated for before/after) + clearError(selectedInput, "dateError"); + if (selectedInput === "from") { calculatedFrom = setTimeForDate(selectedDate, inputFromValue); calculatedTo = inputToValue; + validateFromField(calculatedFrom, calculatedTo, "date"); } else { calculatedFrom = inputFromValue; calculatedTo = setTimeForDate(selectedDate, inputToValue); + validateToField(calculatedFrom, calculatedTo, "date"); } - setInputFromValue(calculatedFrom); setInputToValue(calculatedTo); setSelectedRange({ from: calculatedFrom, to: calculatedTo }); @@ -349,20 +106,24 @@ const DateRangePickerComponent: React.FC = ({ onRangeChange({ from: calculatedFrom, to: calculatedTo }); }; - const handleFromChange = (date: Date) => { + const handleFromChange = (date: Date, source: ChangeSource, parseError?: DateParseError) => { if (date) { setInputFromValue(date); } setSelectedRange((prevRange) => ({ from: date, to: prevRange.to })); onRangeChange({ from: date, to: selectedRange.to }); + + validateFromField(date, selectedRange.to, source, parseError); }; - const handleToChange = (date: Date) => { + const handleToChange = (date: Date, source: ChangeSource, parseError?: DateParseError) => { if (date) { setInputToValue(date); } setSelectedRange((prevRange) => ({ from: prevRange.from, to: date })); onRangeChange({ from: selectedRange.from, to: date }); + + validateToField(selectedRange.from, date, source, parseError); }; const handleFromDayClick = () => { @@ -406,14 +167,15 @@ const DateRangePickerComponent: React.FC = ({ return result; }; - const FromField = ( - onFromInputMarkedValid(date, selectedRange.to, source)} onInputClick={handleFromDayClick} - errors={errors} + errors={errors.from} dateFormat={dateFormat} isMobile={isMobile} isTimeEnabled={isTimeEnabled} @@ -421,14 +183,15 @@ const DateRangePickerComponent: React.FC = ({ /> ); - const ToField = ( - onToInputMarkedValid(selectedRange.from, date, source)} onInputClick={handleToDayClick} - errors={errors} + errors={errors.to} dateFormat={dateFormat} isMobile={isMobile} isTimeEnabled={isTimeEnabled} @@ -440,7 +203,7 @@ const DateRangePickerComponent: React.FC = ({ const isToInputDatePickerOpen = selectedInput === "to" && isOpen; const DatePicker = ( - = ({ /> ); + const mobileErrorId = + errors?.from.dateError || errors?.from.timeError || errors?.to.dateError || errors?.to.timeError; + return ( <> {isTimeEnabled ? (
- {FromField} + {StartDateField} {isFromInputDatePickerOpen ? DatePicker : null} - {ToField} + {EndDateField} {isToInputDatePickerOpen ? DatePicker : null}
) : ( <>
- {FromField} - {ToField} + {StartDateField} + {EndDateField}
{isOpen ? DatePicker : null} )} {isMobile ? ( - errors?.from || errors?.to ? ( - + mobileErrorId ? ( + ) : null ) : ( diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx index bbcba503b84..7030685721c 100644 --- a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/DateTimePicker.tsx @@ -1,7 +1,7 @@ // (C) 2022-2025 GoodData Corporation import React, { useState, useEffect } from "react"; import cx from "classnames"; -import { injectIntl, WrappedComponentProps } from "react-intl"; +import { injectIntl, WrappedComponentProps, IntlShape } from "react-intl"; import moment from "moment"; import isValid from "date-fns/isValid/index.js"; import parse from "date-fns/parse/index.js"; @@ -13,24 +13,28 @@ import { DateRangePickerInputFieldBody } from "./DateRangePickerInputFieldBody.j import { convertPlatformDateStringToDate } from "../utils/DateConversions.js"; import { TIME_FORMAT } from "../constants/Platform.js"; import { getPlatformStringFromDate, getTimeStringFromDate } from "./utils.js"; +import { IDateTimePickerErrors } from "../interfaces/index.js"; +import isEmpty from "lodash/isEmpty.js"; +import { getLocalizedDateFormat } from "../utils/FormattingUtils.js"; +import { ChangeSource, DateParseError } from "./types.js"; -const InputDescription: React.FC<{ descriptionId: string; warning?: string; error?: string }> = ({ - descriptionId, - warning, - error, -}) => { - if (!error && !warning) { +const InputDescription: React.FC<{ + descriptionId: string; + error?: string; + dateFormat: string; + intl: IntlShape; +}> = ({ descriptionId, error, dateFormat, intl }) => { + if (!error) { return null; } return (
- {error ? error : warning} + {intl.formatMessage({ id: error }, { format: dateFormat || getLocalizedDateFormat(intl.locale) })}
); }; @@ -71,17 +75,11 @@ interface IDateTimePickerAccessibilityConfig { timeInputHintId?: string; } -interface IDateTimePickerErrors { - dateError?: string; - timeError?: string; - dateWarning?: string; - timeWarning?: string; -} - interface IDateTimePickerOwnProps { placeholderDate: string; dateFormat: string; - onChange: (value: Date) => void; + onChange: (value: Date, source: ChangeSource, error?: DateParseError) => void; + onInputMarkedValid: (date: Date, source: ChangeSource) => void; value: Date; handleDayClick: () => void; isMobile: boolean; @@ -93,10 +91,13 @@ interface IDateTimePickerOwnProps { accessibilityConfig?: IDateTimePickerAccessibilityConfig; dateInputLabel?: string; timeInputLabel?: string; + intl: IntlShape; } type DateTimePickerComponentProps = IDateTimePickerOwnProps & WrappedComponentProps; +const buildAriaDescribedByValue = (values: string[]) => values.filter((value) => !!value).join(" "); + const DateTimePickerComponent = React.forwardRef( (props: DateTimePickerComponentProps, ref) => { const { @@ -105,6 +106,7 @@ const DateTimePickerComponent = React.forwardRef { - onChange(adjustDate(selectedDate)); + const onMobileDateChange = (value: string) => { + const selectedDate = convertPlatformDateStringToDate(value); + onChange(adjustDate(selectedDate), "date"); }; - const handleInputChange = (value: string) => { + const onDateInputChange = (value: string) => { setInputValue(value); + const date = parseDate(value, dateFormat); + if (date) { + onInputMarkedValid(adjustDate(date), "date"); + } + }; - const parsedDate = parseDate(value, dateFormat); - - onDateChange(parsedDate); + const onDateInputBlur = () => { + if (isEmpty(inputValue)) { + onChange(undefined, "date", "empty"); + return; + } + const date = parseDate(inputValue, dateFormat); + if (date === undefined) { + onChange(undefined, "date", "invalid"); + return; + } + onChange(adjustDate(date), "date"); }; - const onTimeChange = (input: string) => { + const onTimeInputChange = (input: string) => { const date = value ?? new Date(); // set today in case of invalid date const time = moment(input, TIME_FORMAT); if (time.isValid()) { date.setHours(time.hours()); date.setMinutes(time.minutes()); setPickerTime(input); + onInputMarkedValid(date, "time"); } + }; - onChange(date); + const onTimeInputBlur = () => { + const date = value ?? new Date(); // set today in case of invalid date + const time = moment(pickerTime, TIME_FORMAT); + if (time.isValid()) { + date.setHours(time.hours()); + date.setMinutes(time.minutes()); + } + onChange(date, "time"); }; - const { dateError, dateWarning, timeError, timeWarning } = errors ?? {}; + const { dateError, timeError } = errors ?? {}; // mobile view still renders errors below inputs, unlike accessible version that has errors split // below the input that triggered the error - const hasSomeError = !!dateError || !!dateWarning || !!timeError || !!timeWarning; + const hasSomeError = !!dateError || !!timeError; return (
{isMobile ? ( @@ -187,9 +213,7 @@ const DateTimePickerComponent = React.forwardRef - onDateChange(convertPlatformDateStringToDate(event.target.value)) - } + onChange={(event) => onMobileDateChange(event.target.value)} value={getPlatformStringFromDate(value)} /> ) : ( @@ -198,9 +222,7 @@ const DateTimePickerComponent = React.forwardRef @@ -208,22 +230,25 @@ const DateTimePickerComponent = React.forwardRef handleInputChange(event.target.value)} + placeholder={dateFormat} + onChange={(event) => onDateInputChange(event.target.value)} onClick={handleDayClick} onFocus={handleDayClick} + onBlur={onDateInputBlur} value={inputValue} className="input-text s-date-range-picker-input-field" aria-labelledby={dateInputLabel ? dateInputLabelId : undefined} - aria-describedby={ - !!dateWarning || !!dateError ? dateInputErrorId : dateInputHintId - } + aria-describedby={buildAriaDescribedByValue([ + dateInputHintId, + dateError ? dateInputErrorId : undefined, + ])} />
)} @@ -237,9 +262,7 @@ const DateTimePickerComponent = React.forwardRef @@ -248,18 +271,21 @@ const DateTimePickerComponent = React.forwardRef onTimeChange(event.target.value)} + onChange={(event) => onTimeInputChange(event.target.value)} + onBlur={onTimeInputBlur} value={pickerTime} aria-labelledby={timeInputLabel ? timeInputLabelId : undefined} - aria-describedby={ - !!timeWarning || !!timeError ? timeInputErrorId : timeInputHintId - } + aria-describedby={buildAriaDescribedByValue([ + timeInputHintId, + timeError ? timeInputErrorId : undefined, + ])} />
) : null} @@ -270,9 +296,9 @@ const DateTimePickerComponent = React.forwardRef( + ( + { + value, + onKeyDown, + onChange, + onInputMarkedValid, + onInputClick, + errors, + dateFormat, + isMobile, + isTimeEnabled, + intl, + }, + ref, + ) => { + return ( + + ); + }, +); + +EndDateInputField.displayName = "ToInputField"; diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/StartDateInputField.tsx b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/StartDateInputField.tsx new file mode 100644 index 00000000000..155e2492f31 --- /dev/null +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/StartDateInputField.tsx @@ -0,0 +1,52 @@ +// (C) 2025 GoodData Corporation + +import React, { forwardRef } from "react"; +import cx from "classnames"; + +import { DateTimePickerWithIntl } from "./DateTimePicker.js"; +import { IDateRangeInputFieldProps, DATE_INPUT_HINT_ID, TIME_INPUT_HINT_ID } from "./types.js"; + +export const StartDateInputField = forwardRef( + ( + { + value, + onKeyDown, + onChange, + onInputMarkedValid, + onInputClick, + errors, + dateFormat, + isMobile, + isTimeEnabled, + intl, + }, + ref, + ) => { + return ( + + ); + }, +); + +StartDateInputField.displayName = "FromInputField"; diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/errorValidation.ts b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/errorValidation.ts new file mode 100644 index 00000000000..6f5f1c724ce --- /dev/null +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/errorValidation.ts @@ -0,0 +1,190 @@ +// (C) 2025 GoodData Corporation + +import { useState } from "react"; + +import { + IDateTimePickerErrors, + IDateFilterAbsoluteDateTimeFormErrors, + IExtendedDateFilterErrors, +} from "../interfaces/index.js"; +import { messages } from "../../locales.js"; + +import { DateParseError, ChangeSource } from "./types.js"; + +type DateRangePosition = "from" | "to"; + +type FieldError = "date_empty" | "date_invalid" | "date_before" | "time_before"; + +const getFieldKey = (fieldError: FieldError): keyof IDateTimePickerErrors => { + if (["date_empty", "date_invalid", "date_before"].includes(fieldError)) { + return "dateError"; + } + if (fieldError === "time_before") { + return "timeError"; + } + return undefined; +}; + +const getErrorMessageId = (fieldError: FieldError, position: DateRangePosition): string => { + switch (fieldError) { + case "date_empty": + return position === "from" ? messages.errorEmptyStartDate.id : messages.errorEmptyEndDate.id; + case "date_invalid": + return position === "from" ? messages.errorInvalidStartDate.id : messages.errorInvalidEndDate.id; + case "date_before": + return position === "from" + ? messages.errorStartDateAfterEndDate.id + : messages.errorEndDateBeforeStartDate.id; + case "time_before": + return position === "from" + ? messages.errorStartTimeAfterEndTime.id + : messages.errorEndTimeBeforeStartTime.id; + default: + return undefined; + } +}; + +const setFieldError = ( + errors: IDateTimePickerErrors, + fieldKey: keyof IDateTimePickerErrors, + errorMessageId: string, +) => ({ + ...errors, + [fieldKey]: errorMessageId, +}); + +const setError = ( + errors: IDateFilterAbsoluteDateTimeFormErrors, + position: DateRangePosition, + error: FieldError, +): IDateFilterAbsoluteDateTimeFormErrors => { + const fieldKey = getFieldKey(error); + const errorMessageId = getErrorMessageId(error, position); + return { + from: position === "from" ? setFieldError(errors.from, fieldKey, errorMessageId) : errors.from, + to: position === "to" ? setFieldError(errors.to, fieldKey, errorMessageId) : errors.to, + }; +}; + +const unsetFieldError = (errors: IDateTimePickerErrors, fieldKeys: Array) => + fieldKeys.reduce((acc, key) => ({ ...acc, [key]: undefined }), { ...errors }); + +const unsetError = ( + errors: IDateFilterAbsoluteDateTimeFormErrors, + position: DateRangePosition, + fieldKeys: Array | keyof IDateTimePickerErrors, +): IDateFilterAbsoluteDateTimeFormErrors => { + const keys = Array.isArray(fieldKeys) ? fieldKeys : [fieldKeys]; + return { + from: position === "from" ? unsetFieldError(errors.from, keys) : errors.from, + to: position === "to" ? unsetFieldError(errors.to, keys) : errors.to, + }; +}; + +const isSameDay = (d1: Date, d2: Date): boolean => { + return ( + !!d1 && + !!d2 && + d1.getFullYear() === d2.getFullYear() && + d1.getMonth() === d2.getMonth() && + d1.getDate() === d2.getDate() + ); +}; + +export const useErrorValidation = () => { + const [errors, setErrors] = useState({ + from: {}, + to: {}, + }); + + const validateDateField = ( + startDate: Date, + endDate: Date, + position: DateRangePosition, + _source: ChangeSource, + parseError?: DateParseError, + ) => { + const isRangeFromSameDay = isSameDay(startDate, endDate); + if (parseError) { + setErrors(setError(errors, position, parseError === "empty" ? "date_empty" : "date_invalid")); + return; + } + if (startDate < endDate) { + const modifiedDate = position === "from" ? startDate : endDate; + const otherDate = position === "from" ? endDate : startDate; + + const sanitizedPositionErrors = modifiedDate + ? unsetError(errors, position, ["dateError", "timeError"]) + : errors; + const reversedPosition = position === "from" ? "to" : "from"; + const sanitizedReversedPositionErrors = otherDate + ? unsetError(sanitizedPositionErrors, reversedPosition, ["dateError", "timeError"]) + : sanitizedPositionErrors; + setErrors(sanitizedReversedPositionErrors); + } else { + const sanitizedErrors = unsetError(errors, position === "from" ? "to" : "from", [ + "dateError", + "timeError", + ]); + setErrors( + setError(sanitizedErrors, position, isRangeFromSameDay ? "time_before" : "date_before"), + ); + } + }; + + const validateFromField = ( + newStartDate: Date, + previousEndDate: Date, + source: ChangeSource, + parseError?: DateParseError, + ) => { + validateDateField(newStartDate, previousEndDate, "from", source, parseError); + }; + + const validateToField = ( + previousStartDate: Date, + newEndDate: Date, + source: ChangeSource, + parseError?: DateParseError, + ) => { + validateDateField(previousStartDate, newEndDate, "to", source, parseError); + }; + + const clearError = ( + position: DateRangePosition, + fieldKeys: Array | keyof IDateTimePickerErrors, + ) => { + setErrors((errors) => unsetError(errors, position, fieldKeys)); + }; + + const onInputMarkedValid = ( + startDate: Date, + endDate: Date, + source: ChangeSource, + editedField: DateRangePosition, + otherField: DateRangePosition, + ) => { + clearError(editedField, source === "date" ? ["dateError", "timeError"] : ["timeError"]); + + if (startDate < endDate) { + clearError(otherField, ["dateError", "timeError"]); + } + }; + + const onFromInputMarkedValid = (newStartDate: Date, previousEndDate: Date, source: ChangeSource) => { + onInputMarkedValid(newStartDate, previousEndDate, source, "from", "to"); + }; + + const onToInputMarkedValid = (previousStartDate: Date, newEndDate: Date, source: ChangeSource) => { + onInputMarkedValid(previousStartDate, newEndDate, source, "to", "from"); + }; + + return { + errors, + validateFromField, + validateToField, + clearError, + onFromInputMarkedValid, + onToInputMarkedValid, + }; +}; diff --git a/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/types.ts b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/types.ts new file mode 100644 index 00000000000..676f6be5061 --- /dev/null +++ b/libs/sdk-ui-filters/src/DateFilter/DateRangePicker/types.ts @@ -0,0 +1,25 @@ +// (C) 2025 GoodData Corporation + +import React from "react"; +import { IntlShape } from "react-intl"; + +import { IDateTimePickerErrors } from "../interfaces/index.js"; + +export type DateParseError = "invalid" | "empty"; +export type ChangeSource = "date" | "time"; + +export const DATE_INPUT_HINT_ID = "date-range-picker-date-input-hint"; +export const TIME_INPUT_HINT_ID = "date-range-picker-time-input-hint"; + +export interface IDateRangeInputFieldProps { + value: Date; + onKeyDown: (e: React.KeyboardEvent) => void; + onChange: (date: Date, source: ChangeSource) => void; + onInputMarkedValid: (date: Date, source: ChangeSource) => void; + onInputClick: () => void; + errors?: IDateTimePickerErrors; + dateFormat: string; + isMobile: boolean; + isTimeEnabled: boolean; + intl: IntlShape; +} diff --git a/libs/sdk-ui-filters/src/DateFilter/interfaces/index.ts b/libs/sdk-ui-filters/src/DateFilter/interfaces/index.ts index 94dbcf9eb74..2187b8cfa29 100644 --- a/libs/sdk-ui-filters/src/DateFilter/interfaces/index.ts +++ b/libs/sdk-ui-filters/src/DateFilter/interfaces/index.ts @@ -1,4 +1,4 @@ -// (C) 2007-2022 GoodData Corporation +// (C) 2007-2025 GoodData Corporation import { DateString, DateFilterGranularity, @@ -125,6 +125,11 @@ export interface IDateFilterOptionsByType { relativePreset?: DateFilterRelativeOptionGroup; } +export interface IDateTimePickerErrors { + dateError?: string; + timeError?: string; +} + /** * Absolute form date filter errors. * @@ -135,6 +140,16 @@ export interface IDateFilterAbsoluteFormErrors { to?: string; } +/** + * Absolute form date filter errors. + * + * @public + */ +export interface IDateFilterAbsoluteDateTimeFormErrors { + from?: IDateTimePickerErrors; + to?: IDateTimePickerErrors; +} + /** * Relative form date filter errors. * @@ -154,6 +169,10 @@ export interface IExtendedDateFilterErrors { * Global absolute date filter errors */ absoluteForm?: IDateFilterAbsoluteFormErrors; + /** + * + */ + absoluteDateTimeForm?: IDateFilterAbsoluteDateTimeFormErrors; /** * Global relative date filter errors */ diff --git a/libs/sdk-ui-filters/src/DateFilter/validation/OptionValidation.ts b/libs/sdk-ui-filters/src/DateFilter/validation/OptionValidation.ts index 3040bcaea06..e210084c869 100644 --- a/libs/sdk-ui-filters/src/DateFilter/validation/OptionValidation.ts +++ b/libs/sdk-ui-filters/src/DateFilter/validation/OptionValidation.ts @@ -1,4 +1,4 @@ -// (C) 2007-2023 GoodData Corporation +// (C) 2007-2025 GoodData Corporation import { IExtendedDateFilterErrors, DateFilterOption, @@ -19,8 +19,29 @@ const validateVisibility = (filterOption: DateFilterOption): IExtendedDateFilter return errors; }; +// TODO somehow make work with local validation const validateAbsoluteForm = (filterOption: IUiAbsoluteDateFilterForm): IExtendedDateFilterErrors => { const errors = validateVisibility(filterOption); + + if (filterOption.from === undefined) { + errors.absoluteDateTimeForm = { + ...(errors.absoluteDateTimeForm ?? {}), + from: { + ...(errors.absoluteDateTimeForm?.from ?? {}), + dateError: messages.errorEmptyStartDate.id, + }, + }; + } + if (filterOption.to === undefined) { + errors.absoluteDateTimeForm = { + ...(errors.absoluteDateTimeForm ?? {}), + to: { + ...(errors.absoluteDateTimeForm?.to ?? {}), + dateError: messages.errorEmptyEndDate.id, + }, + }; + } + const absoluteFormKeys: Array = ["from", "to"]; absoluteFormKeys.forEach((field) => { if (!filterOption[field]) { diff --git a/libs/sdk-ui-filters/src/locales.ts b/libs/sdk-ui-filters/src/locales.ts index 26a906ed078..9ef85d18d50 100644 --- a/libs/sdk-ui-filters/src/locales.ts +++ b/libs/sdk-ui-filters/src/locales.ts @@ -1,4 +1,4 @@ -// (C) 2019-2024 GoodData Corporation +// (C) 2019-2025 GoodData Corporation import { MessageDescriptor, defineMessages } from "react-intl"; //NOTE: Follow up ticket for move all messages: https://gooddata.atlassian.net/browse/FET-1050 @@ -63,6 +63,14 @@ export const messages: Record = defineMessages({ NOT_BETWEEN: { id: "mvf.operator.notBetween" }, incorrectFormat: { id: "filters.staticPeriod.incorrectFormat" }, endDateBeforeStartDate: { id: "filters.staticPeriod.endDateBeforeStartDate" }, + errorEmptyStartDate: { id: "filters.staticPeriod.errors.emptyStartDate" }, + errorInvalidStartDate: { id: "filters.staticPeriod.errors.invalidStartDate" }, + errorStartDateAfterEndDate: { id: "filters.staticPeriod.errors.startDateAfterEndDate" }, + errorStartTimeAfterEndTime: { id: "filters.staticPeriod.errors.startTimeAfterEndTime" }, + errorEmptyEndDate: { id: "filters.staticPeriod.errors.emptyEndDate" }, + errorInvalidEndDate: { id: "filters.staticPeriod.errors.invalidEndDate" }, + errorEndDateBeforeStartDate: { id: "filters.staticPeriod.errors.endDateBeforeStartDate" }, + errorEndTimeBeforeStartTime: { id: "filters.staticPeriod.errors.endTimeBeforeStartTime" }, thisMinute: { id: "filters.thisMinute.title" }, lastMinute: { id: "filters.lastMinute.title" }, nextMinute: { id: "filters.nextMinute.title" }, diff --git a/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss b/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss index ab9750b7b69..25b7c57ca32 100644 --- a/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss +++ b/libs/sdk-ui-filters/styles/scss/components/DateRangePicker.scss @@ -134,9 +134,6 @@ $gd-day-picker-input-width: 126px; width: $gd-day-picker-input-width; } - &--warning { - color: kit-variables.$gd-palette-warning-base-text; - } &--error { color: kit-variables.$gd-palette-error-base; } diff --git a/libs/sdk-ui/src/base/localization/bundles/en-US.json b/libs/sdk-ui/src/base/localization/bundles/en-US.json index c3ad60f52ba..36a75724c2e 100644 --- a/libs/sdk-ui/src/base/localization/bundles/en-US.json +++ b/libs/sdk-ui/src/base/localization/bundles/en-US.json @@ -1367,6 +1367,56 @@ "comment": "This error is displayed once there is a datetime range and End time is before To time", "limit": 0 }, + "filters.staticPeriod.errors.emptyStartDate": { + "value": "Error: Start date cannot be empty.", + "comment": "The error is displayed when user did not entered value that specifies start of the daterange.", + "limit": 0 + }, + "filters.staticPeriod.errors.invalidStartDate": { + "value": "Error: Start date is not in {format} format.", + "comment": "Placeholder '{format}' is used for showing date format. Example 'Error: Start date is not in MM/dd/yyyy format'. Don't translate placeholder '{format}'.", + "limit": 0 + }, + "filters.staticPeriod.errors.startDateAfterEndDate": { + "value": "Error: Start date cannot be after End date.", + "comment": "This error is displayed once there is a datetime range and Start date is after End date.", + "limit": 0 + }, + "filters.staticPeriod.errors.startTimeAfterEndTime": { + "value": "Error: Start time cannot be after End time.", + "comment": "This error is displayed once there is a datetime range and Start time is after End time.", + "limit": 0 + }, + "filters.staticPeriod.errors.startTimeAdjusted": { + "value": "Warning: Start time minutes were out of range and has been adjusted to the nearest valid value.", + "comment": "This warning is displayed when user entered invalid minutes value that is not in range 0-59 and the value has been automatically adjusted.", + "limit": 0 + }, + "filters.staticPeriod.errors.emptyEndDate": { + "value": "Error: End date cannot be empty.", + "comment": "The error is displayed when user did not entered value that specifies end of the daterange.", + "limit": 0 + }, + "filters.staticPeriod.errors.invalidEndDate": { + "value": "Error: End date is not in {format} format.", + "comment": "Placeholder '{format}' is used for showing date format. Example 'Error: End date is not in MM/dd/yyyy format'. Don't translate placeholder '{format}'.", + "limit": 0 + }, + "filters.staticPeriod.errors.endDateBeforeStartDate": { + "value": "Error: End date cannot be before Start date.", + "comment": "This error is displayed once there is a datetime range and End date is before Start date.", + "limit": 0 + }, + "filters.staticPeriod.errors.endTimeBeforeStartTime": { + "value": "Error: End time cannot be before Start time.", + "comment": "This error is displayed once there is a datetime range and End time is before Start time.", + "limit": 0 + }, + "filters.staticPeriod.errors.endTimeAdjusted": { + "value": "Warning: End time minutes were out of range and has been adjusted to the nearest valid value.", + "comment": "This warning is displayed when user entered invalid minutes value that is not in range 0-59 and the value has been automatically adjusted.", + "limit": 0 + }, "mvf.operator.all": { "value": "all", "comment": "Covers all the operators like 'Greater than', 'Less than', etc.",