diff --git a/src/CONST/index.ts b/src/CONST/index.ts index a3783a956abe..fc1793fe5af5 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1681,6 +1681,11 @@ const CONST = { shortcutKey: 'Backspace', modifiers: [], }, + DELETE: { + descriptionKey: null, + shortcutKey: 'Delete', + modifiers: [], + }, SPACE: { descriptionKey: null, shortcutKey: 'Space', diff --git a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx index 904011d82d30..0e30a69f7c3b 100644 --- a/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx +++ b/src/components/DatePicker/CalendarPicker/MonthPickerModal.tsx @@ -1,5 +1,6 @@ import HeaderWithBackButton from '@components/HeaderWithBackButton'; import Modal from '@components/Modal'; +import {usePopoverActions} from '@components/PopoverProvider'; import ScreenWrapper from '@components/ScreenWrapper'; import SelectionList from '@components/SelectionList'; import SingleSelectListItem from '@components/SelectionList/ListItem/SingleSelectListItem'; @@ -11,8 +12,10 @@ import DateUtils from '@libs/DateUtils'; import CONST from '@src/CONST'; +import type {View} from 'react-native'; + import {Str} from 'expensify-common'; -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {Keyboard} from 'react-native'; import type CalendarPickerListItem from './types'; @@ -41,6 +44,8 @@ type MonthPickerModalProps = { function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthChange, onClose, shouldEnableBackdropInNarrowPane = false}: MonthPickerModalProps) { const styles = useThemeStyles(); const {translate, dateFnsLocale} = useLocalize(); + const {setActivePopoverExtraAnchorRef} = usePopoverActions(); + const contentRef = useRef(null); const [searchText, setSearchText] = useState(''); // The rows are built here from numbers, so the compiler can cache them across the calendar's re-renders. const months: CalendarPickerListItem[] = DateUtils.getMonthNames(dateFnsLocale).map((month, index) => ({ @@ -51,6 +56,16 @@ function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthC isDisabled: index < minMonth || index > maxMonth, })); + useEffect(() => { + if (!isVisible) { + return; + } + + // This modal is rendered above the calendar rather than inside it, so a press on a month counts as a press + // outside the calendar popover and would dismiss it. Registering the content keeps the calendar open. + setActivePopoverExtraAnchorRef(contentRef); + }, [isVisible, setActivePopoverExtraAnchorRef]); + const filteredMonths = searchText === '' ? months : months.filter((month) => month.text?.toLowerCase().includes(searchText.toLowerCase())); const headerMessage = !filteredMonths.length ? translate('common.noResultsFound') : ''; const data = filteredMonths; @@ -82,6 +97,7 @@ function MonthPickerModal({isVisible, currentMonth, minMonth, maxMonth, onMonthC enableEdgeToEdgeBottomSafeAreaPadding > (null); const [searchText, setSearchText] = useState(''); + + useEffect(() => { + if (!isVisible) { + return; + } + + // This modal is rendered above the calendar rather than inside it, so a press on a year counts as a press + // outside the calendar popover and would dismiss it. Registering the content keeps the calendar open. + setActivePopoverExtraAnchorRef(contentRef); + }, [isVisible, setActivePopoverExtraAnchorRef]); + // Freeze the year selected when the picker opened so it stays pinned to the top for the whole open cycle, even as the live selection changes. const initialYear = useInitialSelection(currentYear, {isVisible}); // The rows are built here from numbers, so the compiler can cache them and the sort below across the calendar's re-renders. @@ -97,6 +113,7 @@ function YearPickerModal({isVisible, minYear, maxYear, currentYear, onYearChange enableEdgeToEdgeBottomSafeAreaPadding > void; + /** + * Called when a month or year is picked from its own picker, with the date that pick lands on. The selection is + * not finished, so the calendar stays open for the day press. Leave it out to have those picks only move the view. + */ + onMonthOrYearSelected?: (selectedDate: string) => void; + /** Optional style override for the header container */ headerContainerStyle?: StyleProp; @@ -55,6 +61,21 @@ type CalendarPickerProps = { /** Whether Month/Year right-docked picker modals should keep backdrop in narrow pane context */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + + /** + * Moves the calendar to this month without selecting a day, so it can follow a date being typed into the input. + * The calendar still owns its own view, so its arrows and month picker keep working between updates. + */ + viewDate?: Date; + + /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */ + viewDateVersion?: number; + + /** + * Called once the month or year picker has closed, whether a pick was made or not. Those pickers are modals, so + * they take focus with them, which a caller that owns the focused element has to take back. + */ + onMonthOrYearPickerClose?: () => void; }; /** A bound can be built from data, such as a time zone or a stored date, so an invalid one falls back to the default like a missing one, instead of making every date invalid. */ @@ -68,11 +89,15 @@ function CalendarPicker({ minDate: minDateProp, maxDate: maxDateProp, onSelected, + onMonthOrYearSelected, DayComponent = Day, isDateSelectable, headerContainerStyle, containerStyle, shouldEnableMonthYearBackdropInNarrowPane = false, + viewDate, + viewDateVersion = 0, + onMonthOrYearPickerClose, }: CalendarPickerProps) { // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {isSmallScreenWidth} = useResponsiveLayout(); @@ -83,7 +108,10 @@ function CalendarPicker({ const monthPressableRef = useRef>(null); const minDate = getBound(minDateProp, CONST.CALENDAR_PICKER.MIN_YEAR); const maxDate = getBound(maxDateProp, CONST.CALENDAR_PICKER.MAX_YEAR); - const [dateView, setDateView] = useState(value ?? defaultMonth ?? new Date()); + // The popover unmounts its content, so the calendar starts over every time it is reopened. A view the field has + // already asserted is what it starts from, since a date still being typed has no committed value to fall back to. + const [dateView, setDateView] = useState(() => viewDate ?? value ?? defaultMonth ?? new Date()); + const [appliedViewDateVersion, setAppliedViewDateVersion] = useState(viewDateVersion); // defaultMonth can be a new Date on every render, such as today, so the view follows it only when it moves to another month and nothing is selected. const validDefaultMonth = defaultMonth && isValid(defaultMonth) ? defaultMonth : undefined; const defaultMonthTime = validDefaultMonth && startOfMonth(validDefaultMonth).getTime(); @@ -94,6 +122,14 @@ function CalendarPicker({ setDateView(validDefaultMonth ?? new Date()); } } + + // Catching up with the caller here rather than in an effect keeps the view and the month matrix in step within a + // single render, so the calendar never paints the old month first. + if (viewDate && viewDateVersion !== appliedViewDateVersion) { + setAppliedViewDateVersion(viewDateVersion); + setDateView(viewDate); + } + const currentDateView = clamp(dateView, {start: minDate, end: maxDate}); const [isYearPickerVisible, setIsYearPickerVisible] = useState(false); const [isMonthPickerVisible, setIsMonthPickerVisible] = useState(false); @@ -113,13 +149,23 @@ function CalendarPicker({ const maxMonthStart = startOfMonth(maxDate); const currentMonthStart = startOfMonth(currentDateView); + /** + * The year and month on offer are each held to the bounds, but the date they land on need not be, since the day and + * whichever of the two was not picked come along unchanged. The view is pulled into range on its way to the screen, + * so the date handed over is pulled in the same way rather than disagreeing with the month being shown. + */ + const selectMonthOrYear = (newDateView: Date) => { + setDateView(newDateView); + onMonthOrYearSelected?.(format(clamp(newDateView, {start: minDate, end: maxDate}), CONST.DATE.FNS_FORMAT_STRING)); + }; + const onYearSelected = (year: number) => { - setDateView(addYears(currentDateView, year - currentYearView)); + selectMonthOrYear(addYears(currentDateView, year - currentYearView)); requestAnimationFrame(() => setIsYearPickerVisible(false)); }; const onMonthSelected = (month: number) => { - setDateView(setMonth(currentDateView, month)); + selectMonthOrYear(setMonth(currentDateView, month)); requestAnimationFrame(() => setIsMonthPickerVisible(false)); }; @@ -389,7 +435,10 @@ function CalendarPicker({ maxYear={maxYear} currentYear={currentYearView} onYearChange={onYearSelected} - onClose={() => setIsYearPickerVisible(false)} + onClose={() => { + setIsYearPickerVisible(false); + onMonthOrYearPickerClose?.(); + }} shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} /> setIsMonthPickerVisible(false)} + onClose={() => { + setIsMonthPickerVisible(false); + onMonthOrYearPickerClose?.(); + }} shouldEnableBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} /> diff --git a/src/components/DatePicker/DatePickerModal.tsx b/src/components/DatePicker/DatePickerModal.tsx index 762834252a8b..608c1eea86d9 100644 --- a/src/components/DatePicker/DatePickerModal.tsx +++ b/src/components/DatePicker/DatePickerModal.tsx @@ -52,16 +52,27 @@ function DatePickerModal({ anchorPosition, anchorAlignment = DEFAULT_ANCHOR_ORIGIN, onSelected, + onMonthOrYearSelected, shouldCloseWhenBrowserNavigationChanged = false, shouldPositionFromTop = false, forwardedFSClass, shouldEnableMonthYearBackdropInNarrowPane = false, + anchorRef: anchorRefProp, + withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, + shouldCloseOnWheel = true, + viewDate, + viewDateVersion, + onMonthOrYearPickerClose, shouldMeasureContentHeight = false, windowMargin, shouldSwitchPositionIfOverflow = true, }: DatePickerProps) { const [selectedDate, setSelectedDate] = useState(value); - const anchorRef = useRef>(null); + const fallbackAnchorRef = useRef>(null); + // PopoverProvider treats a click inside the anchor as "not outside", so the caller's own anchor has to be used + // or clicking the date input while the calendar is open would dismiss it. + const anchorRef = anchorRefProp ?? fallbackAnchorRef; const styles = useThemeStyles(); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to distinguish RHL and narrow layout @@ -77,14 +88,29 @@ function DatePickerModal({ } }, [formID, inputID, selectedDate, shouldSaveDraft, value]); - const handleDateSelection = (date: Date) => { - const newValue = format(date, CONST.DATE.FNS_FORMAT_STRING); - onSelected?.(newValue); + const applySelection = (newValue: string) => { onTouched?.(); onInputChange?.(newValue); setSelectedDate(newValue); }; + const handleDateSelection = (date: Date) => { + const newValue = format(date, CONST.DATE.FNS_FORMAT_STRING); + onSelected?.(newValue); + applySelection(newValue); + }; + + const applyMonthOrYearSelection = (newValue: string) => { + onMonthOrYearSelected?.(newValue); + + // An empty field has no day to mark as selected, so the pick only moves the calendar + if (!value) { + return; + } + + applySelection(newValue); + }; + // Pass the CalendarPicker's existing bottom padding (pb4) as the base style so the safe-area padding is // added on top of it instead of overriding it (containerStyle is applied after pb4 in CalendarPicker). // The modal doesn't render an offline indicator inside it, so disable the offline-indicator padding — @@ -110,6 +136,9 @@ function DatePickerModal({ forwardedFSClass={forwardedFSClass} shouldDisplayBelowModals enableEdgeToEdgeBottomSafeAreaPadding + withoutOverlay={withoutOverlay} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} + shouldCloseOnWheel={shouldCloseOnWheel} > ); diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index f86276f516dc..e558c0c18c35 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -1,26 +1,32 @@ +import FormContext from '@components/Form/FormContext'; import TextInput from '@components/TextInput'; import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; +import useDateSegmentInput from '@hooks/useDateSegmentInput'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; +import useRemeasureOnScroll from '@hooks/useRemeasureOnScroll'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; import ComposerFocusManager from '@libs/ComposerFocusManager'; -import {isNumeric} from '@libs/ValidationUtils'; +import {canUseTouchScreen} from '@libs/DeviceCapabilities'; +import {getDateRangeError, isNumeric} from '@libs/ValidationUtils'; import {setDraftValues} from '@userActions/FormActions'; import CONST from '@src/CONST'; import type {ComponentRef} from 'react'; -import type {TextInputKeyPressEvent} from 'react-native'; +import type {TextInput as TextInputRN, TextInputKeyPressEvent} from 'react-native'; +import {setYear} from 'date-fns'; import debounce from 'lodash/debounce'; -import React, {useCallback, useEffect, useRef, useState} from 'react'; +import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import {Keyboard, View} from 'react-native'; import type {DateInputWithPickerProps} from './types'; @@ -54,6 +60,7 @@ function DatePicker({ rightHandSideComponent, onPickerVisibilityChange, shouldHideCalendarIcon = false, + onValidationErrorChange, icon, shouldEnableMonthYearBackdropInNarrowPane = false, textInputContainerStyles, @@ -65,9 +72,16 @@ function DatePicker({ // container instead of its own margins. Otherwise the X sits in a different spot than the icon it replaces. const {paddingHorizontal: iconPaddingHorizontal} = StyleUtils.getTextInputIconContainerStyles(true); const {windowHeight, windowWidth} = useWindowDimensions(); + // The window's own width, and not the narrow layout, which a right hand pane reports even on a wide screen. Most + // date fields sit in one, and there is room to type beside them there. + // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth + const {isSmallScreenWidth} = useResponsiveLayout(); const {translate} = useLocalize(); + const {setInputValidationError} = useContext(FormContext); const [isModalVisible, setIsModalVisible] = useState(false); + // The field is drawn as focused by its own input, which is only one of the segments, so the other two report it here + const [isFieldFocused, setIsFieldFocused] = useState(false); const announcementMessage = label ? `${label}, ${translate('common.calendarOpened')}` : translate('common.calendarOpened'); useAccessibilityAnnouncement(announcementMessage, isModalVisible, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); const [selectedDate, setSelectedDate] = useState(() => value ?? defaultValue ?? ''); @@ -78,11 +92,109 @@ function DatePicker({ // Whether the user currently intends the picker to be open. Lets a deferred measurement skip opening if the // picker was dismissed before it resolved. const openIntentRef = useRef(false); + // Focus moving out of the segments while the calendar is open is the user reaching for the calendar rather than + // leaving the field, so what a blur settles waits for the calendar to close. + const isFieldFocusedRef = useRef(false); + const hasPendingFieldBlurRef = useRef(false); + + // Touch devices keep the calendar on its own, so the soft keyboard does not cover it. A narrow window has no room + // to show the calendar beside the field either, so it covers what is being typed. + const shouldAllowTyping = !canUseTouchScreen() && !isSmallScreenWidth; + const dateMask = translate('common.dateFormat'); + + // The calendar falls back to these bounds when it is given none, so validation follows the range it offers + const rangeMinDate = minDate ?? setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR); + const rangeMaxDate = maxDate ?? setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR); + + // A date the calendar could never have offered is one nothing downstream expects, and typing is what makes those + // reachable. Returns an empty string when the date is fine, which is how the form reads "no error". + const getRangeError = (date: string) => (date ? getDateRangeError(translate, date, rangeMinDate, rangeMaxDate) : ''); + + // Updates the field without ending the selection, so the calendar stays open for whatever the user does next + const commitDate = (newDate: string) => { + setSelectedDate(newDate); + const rangeError = getRangeError(newDate); + + // The form validates from inside onInputChange, which runs before this render's effects, so the error has to + // be recorded here as well for it to clear in the same keystroke that fixes the date. + setInputValidationError(inputID, rangeError); + + // A date being typed reads as empty until it is finished. Marking the field touched then would show a + // required error over a date the user is part way through. + if (newDate) { + onTouched?.(); + } + + // Only typing can reach a date the calendar would never have offered, and a consumer that saves on every + // change would have stored it before anything could object. It stays on screen under its error to be corrected. + if (rangeError) { + return; + } + + onInputChange?.(newDate); + }; + + const setPickerVisibility = useCallback( + (isVisible: boolean) => { + setIsModalVisible(isVisible); + onPickerVisibilityChange?.(isVisible); + }, + [onPickerVisibilityChange], + ); + + const closeDatePicker = useCallback(() => { + openIntentRef.current = false; + setPickerVisibility(false); + + if (!shouldDismissKeyboardBeforeShow || shouldAllowTyping) { + return; + } + + textInputRef.current?.blur(); + ComposerFocusManager.blurActiveInput(); + Keyboard.dismiss(); + }, [shouldDismissKeyboardBeforeShow, shouldAllowTyping, setPickerVisibility]); + + // The hook is the single gate on typing. When the platform does not allow it, the handlers it returns are no-ops + // and the value passes straight through, so the call sites below do not have to check again. + const segmentInput = useDateSegmentInput({ + value: selectedDate, + isEnabled: shouldAllowTyping, + minDate: rangeMinDate, + maxDate: rangeMaxDate, + onCommit: commitDate, + // Tabbing away leaves a calendar nothing has been pressed outside of, which is what otherwise dismisses it + onLeaveByKeyboard: () => { + if (!isModalVisible) { + return; + } + + closeDatePicker(); + }, + }); const {inputCallbackRef: autoFocusCallbackRef, cancelAutoFocus} = useAutoFocusInput(); const autoFocusCallbackRefRef = useRef(autoFocusCallbackRef); autoFocusCallbackRefRef.current = autoFocusCallbackRef; + // Whether focus is being placed by autofocus rather than by the user + const isAutoFocusingRef = useRef(false); + const segmentInputRef = useRef(segmentInput); + useEffect(() => { + segmentInputRef.current = segmentInput; + }); + + // The field's own ref leads to the year, since that is where typing starts in an empty field. A text field answers + // autofocus with the caret where typing would carry on instead, which is what this stands in to do. + const autoFocusTargetRef = useRef({ + focus: () => { + isAutoFocusingRef.current = true; + segmentInputRef.current.focusFirstUnfilledSegment(); + isAutoFocusingRef.current = false; + }, + blur: () => textInputRef.current?.blur(), + }); + useEffect(() => { if (shouldSaveDraft && formID) { setDraftValues(formID, {[inputID]: selectedDate}); @@ -93,9 +205,14 @@ function DatePicker({ if (value === undefined) { return; } + // A date part way through being typed reports itself as empty, so the value prop trails a render behind the + // field. Putting it back while the user is still entering a date would undo the digit they just entered. + if (segmentInput.isEditing) { + return; + } setSelectedDate(value); - }, [formID, inputID, selectedDate, shouldSaveDraft, value]); + }, [formID, inputID, selectedDate, shouldSaveDraft, value, segmentInput.isEditing]); const calculatePopoverPosition = useCallback( (onMeasured?: () => void) => { @@ -114,20 +231,27 @@ function DatePicker({ [windowHeight], ); - const setPickerVisibility = useCallback( - (isVisible: boolean) => { - setIsModalVisible(isVisible); - onPickerVisibilityChange?.(isVisible); - }, - [onPickerVisibilityChange], - ); - const showDatePickerModal = useCallback(() => { + // Re-opening would remeasure and re-announce a calendar that is already showing. Both a press and a focus can + // ask for it, and while typing both arrive for a single click. + if (isModalVisible) { + return; + } + + // Autofocus is the screen arriving rather than the user asking for a calendar + if (isAutoFocusingRef.current) { + return; + } + cancelAutoFocus(); - // Blur the date input before showing the modal, so the focus won't be returned after the modal is closed - textInputRef.current?.blur(); - if (shouldDismissKeyboardBeforeShow) { + // While typing is allowed the calendar sits under an input the user is still writing in, so the caret has to + // stay put. Otherwise blur first so focus is not returned once the modal closes. + if (!shouldAllowTyping) { + textInputRef.current?.blur(); + } + + if (shouldDismissKeyboardBeforeShow && !shouldAllowTyping) { // Blur whichever input is focused (e.g. a preceding text field) so closing the picker does not briefly restore its keyboard. ComposerFocusManager.blurActiveInput(); // Dismiss in parallel with opening — do not await the hide animation or the open feels sluggish. @@ -151,26 +275,16 @@ function DatePicker({ }; openPicker(); - }, [shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); - - const closeDatePicker = useCallback(() => { - openIntentRef.current = false; - setPickerVisibility(false); - - if (!shouldDismissKeyboardBeforeShow) { - return; - } - - textInputRef.current?.blur(); - ComposerFocusManager.blurActiveInput(); - Keyboard.dismiss(); - }, [shouldDismissKeyboardBeforeShow, setPickerVisibility]); + }, [isModalVisible, shouldDeferShowUntilPositioned, shouldDismissKeyboardBeforeShow, shouldAllowTyping, calculatePopoverPosition, cancelAutoFocus, setPickerVisibility]); const handlePress = useCallback>( (event) => { + // The field focuses its own input on any press it is not told to leave alone, which would be the year + // whichever segment was actually pressed. The segments are focused by the press itself instead. if ('preventDefault' in event) { event.preventDefault(); } + showDatePickerModal(); }, [showDatePickerModal], @@ -190,15 +304,120 @@ function DatePicker({ const handleDateSelected = (newDate: string) => { onTouched?.(); setSelectedDate(newDate); + segmentInput.onDateSelected(newDate); closeDatePicker(); // Defer until after the popover close is committed so consumers are not suppressed by modal state. requestAnimationFrame(() => onInputChange?.(newDate)); }; + // Only the typing calendar stays open while the page scrolls. Every other one is dismissed instead, so it never + // has to follow the field, and following it keeps an edit in progress from being interrupted. + useRemeasureOnScroll({isActive: shouldAllowTyping && isModalVisible, remeasure: calculatePopoverPosition}); + + // The error text renders inside the anchor, so showing or hiding it changes the height the calendar was positioned + // from. Remeasuring on the anchor's own layout covers that without having to name each thing that can resize it. + const repositionOnAnchorLayout = () => { + if (!isModalVisible) { + return; + } + + calculatePopoverPosition(); + }; + + // Digits that do not add up to a date read as no date at all, so a form with no rule about this field would accept + // the entry in silence. + const ownError = segmentInput.hasInvalidEntry ? translate('common.error.dateInvalid') : getRangeError(selectedDate); + // Nullish coalescing would keep an empty errorText, which is a form reporting no error rather than an empty one + const dateErrorText = errorText || ownError; + + // An entry part way through is never handed over, so the field still holds the date from before it. Saving that + // would discard an edit the user is in the middle of, and the error is not shown because they are still making it. + const blockingError = segmentInput.hasIncompleteEntry ? translate('common.error.dateInvalid') : ownError; + + useEffect(() => { + setInputValidationError(inputID, blockingError); + onValidationErrorChange?.(blockingError); + + // A field that is gone has nothing left to report, so its error must not outlive it and block the form + return () => { + setInputValidationError(inputID, ''); + onValidationErrorChange?.(''); + }; + }, [inputID, blockingError, setInputValidationError, onValidationErrorChange]); + + const leaveField = () => { + isFieldFocusedRef.current = false; + setIsFieldFocused(false); + + if (isModalVisible) { + hasPendingFieldBlurRef.current = true; + return; + } + + segmentInput.onFieldBlur(); + }; + + useEffect(() => { + if (isModalVisible || !hasPendingFieldBlurRef.current || isFieldFocusedRef.current) { + return; + } + + hasPendingFieldBlurRef.current = false; + segmentInput.onFieldBlur(); + // The handler is rebuilt on every render, and the refs above are what decide whether it has anything to do + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isModalVisible]); + + const commitPickedMonthOrYear = (newDate: string) => { + // Picking a month or year revises a date the field already holds. An empty field has no day to go with one, + // so it waits for the day press rather than being filled with a day nobody chose. + if (!selectedDate) { + return; + } + + commitDate(newDate); + + // A month or year pick can land on the date the field already holds, and a segment the user has taken a digit + // out of reports no date at all, so the committed value never changes and nothing else tells the segments. + segmentInput.onDateSelected(newDate); + }; + + // The pickers are modals of their own, so they take focus with them, and their focus trap holds on to it until + // they are gone. The calendar is still open at that point, which is the field still being used. + const returnFocusFromMonthOrYearPicker = () => { + if (!isModalVisible) { + return; + } + + segmentInput.restoreFocus(); + }; + + const enterField = () => { + isFieldFocusedRef.current = true; + setIsFieldFocused(true); + hasPendingFieldBlurRef.current = false; + showDatePickerModal(); + }; + + // Autofocus leaves the field focused without a calendar, so editing the date is what asks for one, as typing does + // on a field that cannot be typed into + const showCalendarOnEdit = (event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + + if (!isNumeric(key) && key !== CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey && key !== CONST.KEYBOARD_SHORTCUTS.DELETE.shortcutKey) { + return; + } + + showDatePickerModal(); + }; + const handleClear = () => { onTouched?.(); + setInputValidationError(inputID, ''); + onValidationErrorChange?.(''); onInputChange?.(''); setSelectedDate(''); + segmentInput.onClear(); }; useEffect(() => { @@ -216,19 +435,23 @@ function DatePicker({ const combinedTextInputRef = useCallback( (ref: BaseTextInputRef | null) => { textInputRef.current = ref; - if (autoFocus) { - (autoFocusCallbackRefRef.current as unknown as (ref: BaseTextInputRef | null) => void)(ref); + if (!autoFocus) { + return; } + + const autoFocusTarget = ref && shouldAllowTyping ? autoFocusTargetRef.current : ref; + autoFocusCallbackRefRef.current(autoFocusTarget as ComponentRef | null); }, // autoFocusCallbackRefRef is a stable ref — its identity never changes, so it's not a dep - [autoFocus], + [autoFocus, shouldAllowTyping], ); return ( <> showDatePickerModal()} - onSubmitEditing={() => showDatePickerModal()} - onKeyPress={handleInputKeyPress} - textInputContainerStyles={[textInputContainerStyles, isModalVisible && styles.borderColorFocus]} + hideFocusedState={shouldDismissKeyboardBeforeShow && !shouldAllowTyping} + onPress={shouldDismissKeyboardBeforeShow || shouldAllowTyping ? handlePress : () => showDatePickerModal()} + onSubmitEditing={shouldAllowTyping ? undefined : () => showDatePickerModal()} + // Reaching the field by keyboard never fires a press, so focus is what opens the calendar + onFocus={shouldAllowTyping ? enterField : undefined} + onKeyPress={shouldAllowTyping ? showCalendarOnEdit : handleInputKeyPress} + textInputContainerStyles={[textInputContainerStyles, (isModalVisible || isFieldFocused) && styles.borderColorFocus]} shouldHideClearButton={shouldHideClearButton} onClearInput={handleClear} clearButtonStyle={[styles.mh0, {paddingHorizontal: iconPaddingHorizontal}]} forwardedFSClass={forwardedFSClass} autoComplete={autoComplete} - disableKeyboard + disableKeyboard={!shouldAllowTyping} rightHandSideComponent={rightHandSideComponent} /> @@ -277,6 +520,14 @@ function DatePicker({ shouldPositionFromTop={!isInverted} forwardedFSClass={forwardedFSClass} shouldCloseWhenBrowserNavigationChanged + anchorRef={anchorRef} + withoutOverlay={shouldAllowTyping} + shouldAllowWithoutOverlayInNarrowPane={shouldAllowTyping} + shouldCloseOnWheel={!shouldAllowTyping} + viewDate={segmentInput.viewDate} + viewDateVersion={segmentInput.viewDateVersion} + onMonthOrYearSelected={shouldAllowTyping ? commitPickedMonthOrYear : undefined} + onMonthOrYearPickerClose={shouldAllowTyping ? returnFocusFromMonthOrYearPicker : undefined} shouldEnableMonthYearBackdropInNarrowPane={shouldEnableMonthYearBackdropInNarrowPane} /> diff --git a/src/components/DatePicker/types.ts b/src/components/DatePicker/types.ts index d8170c4a43af..86985c0b9fcf 100644 --- a/src/components/DatePicker/types.ts +++ b/src/components/DatePicker/types.ts @@ -5,7 +5,8 @@ import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type {OnyxFormValuesMapping} from '@src/ONYXKEYS'; -import type {StyleProp, ViewStyle} from 'react-native'; +import type {ComponentRef, RefObject} from 'react'; +import type {StyleProp, View, ViewStyle} from 'react-native'; type DatePickerBaseProps = ForwardedFSClassProps & { /** The selected date as a `yyyy-MM-dd` string, read with DateUtils.parseCalendarDate. `onInputChange` is called with the picked date in the same format. */ @@ -85,6 +86,13 @@ type DateInputWithPickerProps = DatePickerBaseProps & * @default false */ shouldHideCalendarIcon?: boolean; + + /** + * Reports an error the field works out for itself, such as a date outside the range its calendar offers or + * digits that do not make a date. A field inside a form reports these to the form, so this is for the pages + * that own their own submit. An empty string withdraws the error. + */ + onValidationErrorChange?: (error: string) => void; }; type DatePickerProps = { @@ -136,6 +144,27 @@ type DatePickerProps = { */ shouldEnableMonthYearBackdropInNarrowPane?: boolean; + /** + * The element the calendar is positioned against, normally the date input. Clicks inside it do not dismiss the + * calendar, so the user can keep editing the input while the calendar is open. Falls back to an internal ref. + */ + anchorRef?: RefObject | null>; + + /** Moves the calendar to this month without selecting a day, so it follows the date being typed into the input */ + viewDate?: Date; + + /** Changes every time `viewDate` is asserted, including when it repeats the month the calendar already shows */ + viewDateVersion?: number; + + /** + * Called when a month or year is picked from its own picker, with the date that pick lands on. The calendar stays + * open, so the user can still press a day. Leave it out to have those picks only move the view. + */ + onMonthOrYearSelected?: (selectedDate: string) => void; + + /** Called once the month or year picker has closed, which is where focus has to be taken back from it */ + onMonthOrYearPickerClose?: () => void; + /** * Whether to measure the calendar's rendered height instead of assuming `POPOVER_DATE_MIN_HEIGHT`, which only covers * a 5-row month: months that need a sixth week row reach `POPOVER_DATE_MAX_HEIGHT` instead. Needed where the popover diff --git a/src/components/DateSegmentsInput.tsx b/src/components/DateSegmentsInput.tsx new file mode 100644 index 000000000000..ed397e7b1a4b --- /dev/null +++ b/src/components/DateSegmentsInput.tsx @@ -0,0 +1,218 @@ +/** + * The inner part of the date field, rendering one input per date segment. It is registered as a `BaseTextInput` input + * type, so it sits inside the same label, border and error chrome as any other text field. + */ +import useLocalize from '@hooks/useLocalize'; +import useStyleUtils from '@hooks/useStyleUtils'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {getDateMaskParts} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; +import mergeRefs from '@libs/mergeRefs'; + +import CONST from '@src/CONST'; + +import type {ViewStyle} from 'react-native'; + +import React, {useState} from 'react'; +import {StyleSheet, View} from 'react-native'; + +import type {AnimatedTextInputRef} from './RNTextInput'; +import type {BaseTextInputProps} from './TextInput/BaseTextInput/types'; + +import {PressableWithoutFeedback} from './Pressable'; +import RNTextInput from './RNTextInput'; +import Text from './Text'; + +/** A measured width is rounded to a whole pixel, which can leave it short of the text and wrap the mask */ +const WIDTH_ROUNDING_ALLOWANCE = 1; + +/** Reads back the padding the field hands its text, which is what a selection has to leave alone to cover the line */ +function getVerticalPadding(inputStyle: BaseTextInputProps['style']): ViewStyle { + const {paddingTop, paddingBottom, paddingVertical} = StyleSheet.flatten(inputStyle) ?? {}; + + return {paddingTop, paddingBottom, paddingVertical}; +} + +function DateSegmentsInput({ + dateSegmentsConfig, + style, + placeholderTextColor, + disabled, + onPressOut, + onFocus, + onBlur, + onKeyPress, + readOnly, + forwardedFSClass, + accessibilityLabel, + 'aria-describedby': ariaDescribedBy, + 'aria-invalid': ariaInvalid, + ref, +}: BaseTextInputProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const StyleUtils = useStyleUtils(); + const {translate} = useLocalize(); + // Mask letters are wider than the digits that replace them, so each segment is measured from the text it shows + const [measuredWidths, setMeasuredWidths] = useState>>({}); + + if (!dateSegmentsConfig) { + return null; + } + + const {mask, getSegmentProps, setSegmentRef, focusFirstUnfilledSegment, selectLastSegment, selectAllSegments, isSegmentElement, isAllSelected, onFieldBlur} = dateSegmentsConfig; + + const parts = getDateMaskParts(mask).map((part) => ({...part, segmentProps: getSegmentProps(part.name)})); + + // A form hangs its validation on the field's blur, so it only runs once focus leaves the whole field + const reportFieldBlur: NonNullable = (event) => { + if ('relatedTarget' in event.nativeEvent && isSegmentElement(event.nativeEvent.relatedTarget)) { + return; + } + + onFieldBlur(); + onBlur?.(event); + }; + + // The separators belong to the mask, so they read as placeholder text while any of it is on screen + const isAnyMaskShowing = parts.some((part) => part.segmentProps.value.length < part.placeholder.length); + + // The field's input style stretches across the row, which would spread the segments apart + const sizedToContent = [styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto, styles.wAuto]; + + // The mask letters and the separators between them read as placeholder text rather than as the entered date. The + // field always hands down a theme color, though the prop's type also allows a platform color that has no string. + const maskColorStyle = StyleUtils.getColorStyle(typeof placeholderTextColor === 'string' ? placeholderTextColor : theme.placeholderText); + + return ( + + {/* Sized to the date rather than to the row, so selecting it covers the digits and not the space after them */} + + {/* Taking the field's vertical padding leaves exactly the line of text to fill */} + {isAllSelected && ( + + + + )} + {parts.map((part) => { + const segmentProps = part.segmentProps; + // The mask letters a half typed segment has not reached yet stay on screen, so the date keeps its shape + const remainder = part.placeholder.slice(segmentProps.value.length); + const measuredText = `${segmentProps.value}${remainder}`; + const width = (measuredWidths[part.name] ?? measuredText.length * CONST.CHARACTER_WIDTH) + WIDTH_ROUNDING_ALLOWANCE; + + return ( + + { + const layoutWidth = event.nativeEvent.layout.width; + if (!layoutWidth) { + return; + } + + setMeasuredWidths((previous) => (previous[part.name] === layoutWidth ? previous : {...previous, [part.name]: layoutWidth})); + }} + > + {measuredText} + + {/* A row, since the input's `flex: 1` would take a column's height away */} + + setSegmentRef(part.name, element))} + style={[style, styles.ph0, styles.w100]} + value={segmentProps.value} + onKeyPress={(event) => { + onKeyPress?.(event); + segmentProps.onKeyPress(event); + }} + onChangeText={segmentProps.onChangeText} + onFocus={(event) => { + segmentProps.onFocus(); + onFocus?.(event); + }} + onBlur={reportFieldBlur} + onPressOut={onPressOut} + // react-native-web gives a text input no press events, so the press that collapses + // a whole field selection has to be caught as a pointer one + onPointerDown={segmentProps.onPointerDown} + accessibilityLabel={translate(`common.dateSegments.${part.name}`)} + // The next digit replaces the whole selected date, so a caret would mislead + caretHidden={isAllSelected} + inputMode="numeric" + disabled={disabled} + readOnly={readOnly} + forwardedFSClass={forwardedFSClass} + aria-describedby={ariaDescribedBy} + aria-invalid={ariaInvalid} + /> + {/* The typed digits repeat invisibly so the mask letters start where the input's text ends */} + {!!remainder && ( + + {segmentProps.value} + {remainder} + + )} + + {!!part.separator && ( + + {part.separator} + + )} + + ); + })} + + { + event.preventDefault(); + + // A text field answers a double click with the word under it and a triple click with the whole + // line. The space beside the date holds no text of its own, so the nearest segment stands in for + // the word, the way clicking past the end of a line does. + if (event.detail === 2) { + selectLastSegment(); + return; + } + + if (event.detail > 2) { + selectAllSegments(); + } + }} + onPress={focusFirstUnfilledSegment} + // The field hands its own press down as `onPressOut`, which is what opens the calendar. Focus alone + // cannot stand in for it here, since a segment that already holds focus announces nothing on arrival. + onPressOut={onPressOut} + sentryLabel="DateSegmentsInput-EmptySpace" + style={[styles.flex1, styles.cursorText]} + /> + + ); +} + +export default DateSegmentsInput; diff --git a/src/components/Form/FormContext.tsx b/src/components/Form/FormContext.tsx index 9a94fd3f5a5f..19593da5072b 100644 --- a/src/components/Form/FormContext.tsx +++ b/src/components/Form/FormContext.tsx @@ -11,6 +11,13 @@ type FormContext = { registerInput: RegisterInput; getErrorAnnouncementKey: () => number; getFallbackAnnouncementMessage: () => string; + + /** + * Reports an error the input's own value is enough to decide, such as a date outside the range its calendar + * offers. A value the input refuses to commit never reaches the form, so without this the page would have no + * rule to write and would submit the entry in silence. An empty string withdraws the error. + */ + setInputValidationError: (inputID: keyof Form, error: string) => void; }; export default createContext({ @@ -19,6 +26,9 @@ export default createContext({ }, getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + + // Inputs are usable on their own outside a form, where the page owns its own submit and there is nothing to tell + setInputValidationError: () => {}, }); export type {RegisterInput}; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 0d9c163f738e..ca5b68c33ad8 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -164,6 +164,9 @@ function FormProvider({ const inputRefs = useRef({}); const formWrapperRef = useRef(null); const touchedInputs = useRef>({}); + + // A ref rather than state, so an input reporting its own error cannot drive a render of the form it sits in + const inputValidationErrors = useRef>({}); const [inputValues, setInputValues] = useState
(() => ({...draftValues})); const isLoadingDraftValues = isLoadingOnyxValue(draftValuesMetadata); const previousDraftValues = useRef(draftValues); @@ -212,6 +215,15 @@ function FormProvider({ const validateErrors: GenericFormInputErrors = validate?.(trimmedStringValues, translate) ?? {}; + for (const [inputID, inputError] of Object.entries(inputValidationErrors.current)) { + // A rule the page wrote itself is the more specific one, so it keeps the field + if (!inputError || validateErrors[inputID]) { + continue; + } + + validateErrors[inputID] = inputError; + } + if (!allowHTML) { // Validate the input for html tags. It should supersede any other error unless // shouldPreserveCustomValidationErrors is set and the field already has a custom error. @@ -585,7 +597,13 @@ function FormProvider({ const fallbackAnnouncementMessage = !isGeneralAlertVisible ? firstFieldErrorMessage : ''; const getErrorAnnouncementKey = useCallback(() => errorAnnouncementKey, [errorAnnouncementKey]); const getFallbackAnnouncementMessage = useCallback(() => fallbackAnnouncementMessage, [fallbackAnnouncementMessage]); - const value = useMemo(() => ({registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage}), [registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage]); + const setInputValidationError = useCallback((inputID: keyof Form, error: string) => { + inputValidationErrors.current[inputID] = error; + }, []); + const value = useMemo( + () => ({registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage, setInputValidationError}), + [registerInput, getErrorAnnouncementKey, getFallbackAnnouncementMessage, setInputValidationError], + ); const submitAndAnnounce = useCallback(() => { if (hasServerError) { diff --git a/src/components/MoneyRequestConfirmationFields/Provider.tsx b/src/components/MoneyRequestConfirmationFields/Provider.tsx index bbee4df05fbe..ae5080e09d71 100644 --- a/src/components/MoneyRequestConfirmationFields/Provider.tsx +++ b/src/components/MoneyRequestConfirmationFields/Provider.tsx @@ -76,6 +76,9 @@ type ProviderProps = { /** Reports whether the inline tax amount field is currently empty, so submission can be blocked when it is left empty */ onTaxAmountEmptyChange?: (isEmpty: boolean) => void; + /** Reports whether the inline date field is holding something that is not a date, so submission can be blocked while it is */ + onDateValidationErrorChange?: (hasError: boolean) => void; + /** Block components rendered inside the Provider */ children: ReactNode; }; @@ -103,6 +106,7 @@ function Provider({ scrollFocusedInputIntoView, onSubmitForm, onTaxAmountEmptyChange, + onDateValidationErrorChange, children, }: ProviderProps) { const value = { @@ -128,6 +132,7 @@ function Provider({ scrollFocusedInputIntoView, onSubmitForm, onTaxAmountEmptyChange, + onDateValidationErrorChange, }; return {children}; } diff --git a/src/components/MoneyRequestConfirmationFields/context.ts b/src/components/MoneyRequestConfirmationFields/context.ts index 2979665143fd..4a3c5397f81d 100644 --- a/src/components/MoneyRequestConfirmationFields/context.ts +++ b/src/components/MoneyRequestConfirmationFields/context.ts @@ -45,6 +45,9 @@ type ConfirmationFieldsContextValue = { /** Reports whether the inline tax amount field is currently empty, so submission can be blocked when it is left empty (new manual expense flow). */ onTaxAmountEmptyChange?: (isEmpty: boolean) => void; + + /** Reports whether the inline date field is holding something that is not a date, so submission can be blocked while it is. */ + onDateValidationErrorChange?: (hasError: boolean) => void; }; const ConfirmationFieldsContext = createContext(null); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts index a4b1e14e53e6..2f4c63e16b75 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationListData.ts @@ -146,6 +146,7 @@ function useConfirmationListDataWithPolicy({ const [didConfirm, setDidConfirm] = useState(isConfirmed); const [didConfirmSplit, setDidConfirmSplit] = useState(false); const [isTaxAmountEmpty, setIsTaxAmountEmpty] = useState(false); + const [hasDateValidationError, setHasDateValidationError] = useState(false); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- reset the tax-empty flag whenever the transaction changes @@ -184,6 +185,7 @@ function useConfirmationListDataWithPolicy({ isDistanceRequest, isReadOnly, shouldShowDate, + hasDateValidationError, }); const isCategoryRequired = !!policy?.requiresCategory && !isTypeInvoice; @@ -258,6 +260,7 @@ function useConfirmationListDataWithPolicy({ isReadOnly, shouldShowDate, isTaxAmountEmpty, + hasDateValidationError, }); // The partially filled receipt may not be the one on screen, so bring it into view to show its inline errors. @@ -344,6 +347,7 @@ function useConfirmationListDataWithPolicy({ scrollFocusedInputIntoView, onSubmitForm: confirm, onTaxAmountEmptyChange: setIsTaxAmountEmpty, + onDateValidationErrorChange: setHasDateValidationError, }, }, diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts index c3a73fad2c10..ae85f889ecf4 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts @@ -132,6 +132,9 @@ type UseConfirmationValidationParams = { /** Whether the inline tax amount field is currently left empty (new manual expense flow) */ isTaxAmountEmpty: boolean; + + /** Whether the inline date field is holding digits that do not read as a date, which never reach the transaction */ + hasDateValidationError: boolean; }; /** @@ -182,6 +185,7 @@ function useConfirmationValidation({ isReadOnly, shouldShowDate, isTaxAmountEmpty, + hasDateValidationError, }: UseConfirmationValidationParams): {validate: (paymentType?: PaymentMethodType) => ValidationResult | null} { const {getCurrencyDecimals} = useCurrencyListActions(); const selectedParticipantsCount = selectedParticipants.length; @@ -236,6 +240,11 @@ function useConfirmationValidation({ if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly, canEnterScanFieldsManually, isPolicyExpenseChat)) { return {errorKey: 'common.error.fieldRequired'}; } + // An entry the field could not read as a date is never handed over, so the transaction still holds the date + // from before it. Confirming would save that one and silently discard what the user typed. + if (shouldShowDate && !isReadOnly && hasDateValidationError) { + return {errorKey: 'common.error.dateInvalid'}; + } const merchantValue = iouMerchant ?? ''; const {isValid: isMerchantLengthValid} = isValidInputLength(merchantValue, CONST.MERCHANT_NAME_MAX_BYTES); diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts index e72a016117b1..9b3590f34664 100644 --- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts +++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts @@ -86,6 +86,9 @@ type UseFormErrorManagementParams = { /** Whether the confirmation is read-only (a read-only date is populated server-side, so it can't be missing) */ isReadOnly: boolean; + + /** Whether the date field is holding something that is not a date, so its error can be cleared once it is not */ + hasDateValidationError: boolean; }; type UseFormErrorManagementResult = { @@ -155,6 +158,7 @@ function useFormErrorManagement({ isDistanceRequest, shouldShowDate, isReadOnly, + hasDateValidationError, }: UseFormErrorManagementParams): UseFormErrorManagementResult { const isFocused = useIsFocused(); const {translate} = useLocalize(); @@ -235,6 +239,14 @@ function useFormErrorManagement({ setFormError(''); }, [isAmountRequiredMissing, isDateRequiredMissing, isMerchantRequiredMissing, partiallyManuallyFilledScanID, setFormError]); + // The field reports its own entry, so the error it raised on submit goes as soon as the entry reads as a date again + useEffect(() => { + if (formErrorRef.current !== 'common.error.dateInvalid' || hasDateValidationError) { + return; + } + setFormError(''); + }, [hasDateValidationError, setFormError]); + useEffect(() => { const currentFormError = formErrorRef.current; if (shouldDisplayFieldError && didConfirmSplit) { @@ -266,7 +278,10 @@ function useFormErrorManagement({ // rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error, // so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently. const isSuppressedInline = (error: TranslationPaths | ''): boolean => - error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'); + error === 'common.error.fieldRequired' || + error === 'common.error.dateInvalid' || + error === 'iou.error.invalidMerchant' || + (!isDistanceRequest && error === 'common.error.invalidAmount'); const computeErrorMessage = (): string | undefined => { if (routeError) { diff --git a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx index acacc39f7c6d..5769f5b8baa7 100644 --- a/src/components/MoneyRequestConfirmationList/sections/DateField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/DateField.tsx @@ -50,7 +50,7 @@ type DateFieldProps = { function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, transactionID, action, iouType, reportID, reportActionID}: DateFieldProps) { const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions(); - const {isEditingSplitBill, canEnterScanFieldsManually, isPolicyExpenseChat} = useConfirmationFields(); + const {isEditingSplitBill, canEnterScanFieldsManually, isPolicyExpenseChat, onDateValidationErrorChange} = useConfirmationFields(); const {shouldUseDropdownRows} = useExpenseFormLayout(); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -78,7 +78,10 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, // Opening the calendar blurs the input, so the open picker stands in for focus here. The hint goes once the user // takes the field over, by opening the picker or by entering any of the three fields. const [isDatePickerOpen, setIsDatePickerOpen] = useState(false); - const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasAnyManuallyEnteredScanField(dateState); + // A date the field cannot read is never handed over, so nothing about the transaction says the user has started + // filling this one in. The field reporting an error of its own is the only sign those digits are on screen. + const [hasUnusableDateEntry, setHasUnusableDateEntry] = useState(false); + const shouldShowAutomaticHint = shouldShowEmptyDate && !isDatePickerOpen && !hasUnusableDateEntry && !hasAnyManuallyEnteredScanField(dateState); const dateErrorText = shouldDisplayFieldError && createdMissing ? translate('common.error.enterDate') : ''; @@ -86,6 +89,10 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, const isDateRequiredMissing = isPartiallyEnteredScanExpense(dateState, canEnterScanFieldsManually, isPolicyExpenseChat) ? !dateState?.isCreatedSet : createdMissing; const inlineDateErrorText = formError === 'common.error.fieldRequired' && isDateRequiredMissing ? translate('common.error.fieldRequired') : ''; + // The field holds back an entry it cannot read as a date, so it is the only thing that knows one is there. Pressing + // Create is what raises it, which is why the message is shown from the form error rather than as the user types. + const invalidDateErrorText = formError === 'common.error.dateInvalid' ? translate('common.error.dateInvalid') : ''; + const handleDateChange = (newDate: string) => { if (!transactionID) { return; @@ -141,11 +148,15 @@ function DateField({shouldDisplayFieldError, didConfirm, isReadOnly, formError, maxDate={CONST.CALENDAR_PICKER.MAX_DATE} onInputChange={handleDateChange} disabled={didConfirm} - errorText={inlineDateErrorText || dateErrorText} + errorText={inlineDateErrorText || invalidDateErrorText || dateErrorText} + onValidationErrorChange={(error) => { + setHasUnusableDateEntry(!!error); + onDateValidationErrorChange?.(!!error); + }} shouldDeferShowUntilPositioned // The hint only renders while the date is empty, and `TextInput` drops its right-hand-side // component whenever the clear button can appear, which it can't without a value to clear. - shouldHideClearButton={shouldShowEmptyDate} + shouldHideClearButton={shouldShowEmptyDate && !hasUnusableDateEntry} rightHandSideComponent={shouldShowAutomaticHint ? : undefined} // The calendar icon and the hint share the right-hand side, so the field shows one or the other. // The icon comes back once the user opens the picker, the same way the amount field's buttons do. diff --git a/src/components/Popover/index.tsx b/src/components/Popover/index.tsx index a8b956a8417a..819cfd61b21b 100644 --- a/src/components/Popover/index.tsx +++ b/src/components/Popover/index.tsx @@ -34,6 +34,7 @@ function Popover(props: PopoverProps) { animationInTiming = CONST.MENU_ANIMATION_DURATION, disableAnimation = true, withoutOverlay = false, + shouldAllowWithoutOverlayInNarrowPane = false, anchorPosition = {}, anchorRef = () => {}, animationIn = 'fadeIn', @@ -133,7 +134,12 @@ function Popover(props: PopoverProps) { ); } - if (withoutOverlay && !shouldUseNarrowLayout) { + // An overlay-free popover normally needs room beside its anchor, which a narrow pane is assumed not to have. An + // opting-in caller is judged on screen size alone, so it keeps the popover in the RHP on a wide screen and still + // gets the full modal on a small one. + const canSkipOverlay = shouldAllowWithoutOverlayInNarrowPane ? !isSmallScreenWidth : !shouldUseNarrowLayout; + + if (withoutOverlay && canSkipOverlay) { return createPortal( | HTMLDivElement>; diff --git a/src/components/PopoverProvider/index.tsx b/src/components/PopoverProvider/index.tsx index 33fdde5a47b8..c64591794887 100644 --- a/src/components/PopoverProvider/index.tsx +++ b/src/components/PopoverProvider/index.tsx @@ -1,3 +1,5 @@ +import {isTopCloseModal} from '@userActions/Modal'; + import type {RefObject} from 'react'; import React, {createContext, useContext, useEffect, useRef, useState} from 'react'; @@ -94,6 +96,13 @@ function PopoverContextProvider(props: PopoverContextProps) { if (e.key !== 'Escape') { return; } + + // A modal opened above the popover answers Escape itself, and this listener runs before it does. The + // modal is rendered from the popover's own content, so closing the popover here would take both away. + if (activePopoverRef.current && !isTopCloseModal(activePopoverRef.current.close)) { + return; + } + if (closePopover()) { e.stopImmediatePropagation(); } @@ -123,6 +132,11 @@ function PopoverContextProvider(props: PopoverContextProps) { return; } + // A popover that keeps itself attached to its anchor has no reason to be dismissed by scrolling + if (activePopoverRef.current?.shouldCloseOnWheel === false) { + return; + } + closePopover(); }; document.addEventListener('wheel', listener, true); diff --git a/src/components/PopoverProvider/types.ts b/src/components/PopoverProvider/types.ts index 009d1f5cb1c1..b37c2850838a 100644 --- a/src/components/PopoverProvider/types.ts +++ b/src/components/PopoverProvider/types.ts @@ -13,6 +13,13 @@ type AnchorRef = { close: (anchorRef?: RefObject) => void; anchorRef: RefObject; extraAnchorRefs?: Array>; + + /** + * Whether scrolling the page dismisses this popover. It is the right default for one that cannot follow its + * anchor, but false suits a popover that tracks the anchor itself, or one whose anchor is still being edited. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export type {PopoverContextProps, AnchorRef, AnchorType}; diff --git a/src/components/PopoverWithoutOverlay/index.tsx b/src/components/PopoverWithoutOverlay/index.tsx index e78a0d0b1c5c..d7ceef2da6f5 100644 --- a/src/components/PopoverWithoutOverlay/index.tsx +++ b/src/components/PopoverWithoutOverlay/index.tsx @@ -32,6 +32,7 @@ function PopoverWithoutOverlay({ children, shouldDisplayBelowModals = false, enableEdgeToEdgeBottomSafeAreaPadding, + shouldCloseOnWheel = true, }: PopoverWithoutOverlayProps) { const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); @@ -62,6 +63,7 @@ function PopoverWithoutOverlay({ ref: withoutOverlayRef, close: onClose ?? NOOP, anchorRef, + shouldCloseOnWheel, }); removeOnClose = setCloseModal(onClose ?? NOOP); } else { diff --git a/src/components/PopoverWithoutOverlay/types.ts b/src/components/PopoverWithoutOverlay/types.ts index 46f44ed3dc69..773138779f2b 100644 --- a/src/components/PopoverWithoutOverlay/types.ts +++ b/src/components/PopoverWithoutOverlay/types.ts @@ -25,6 +25,12 @@ type PopoverWithoutOverlayProps = ChildrenProps & /** Whether we should display the popover below other modals (e.g. SidePanel, RHP) */ shouldDisplayBelowModals?: boolean; + + /** + * Whether scrolling the page dismisses the popover, which it should unless the popover tracks its anchor. + * @default true + */ + shouldCloseOnWheel?: boolean; }; export default PopoverWithoutOverlayProps; diff --git a/src/components/TextInput/BaseTextInput/implementation/index.tsx b/src/components/TextInput/BaseTextInput/implementation/index.tsx index 53eb2d453c4c..8e1b7991e5bb 100644 --- a/src/components/TextInput/BaseTextInput/implementation/index.tsx +++ b/src/components/TextInput/BaseTextInput/implementation/index.tsx @@ -326,6 +326,8 @@ function BaseTextInput({ const shouldAddPaddingBottom = isMultiline || (autoGrowHeight && !isAutoGrowHeightMarkdown && textInputHeight > variables.componentSizeLarge); const hasLabel = !!label?.length; + // A date part way through being entered reads as no date at all, so its digits are clearable without a value + const hasContentToClear = !!value || !!inputProps.dateSegmentsConfig?.hasTypedDigits; const isReadOnly = inputProps.readOnly ?? inputProps.disabled; // Disabling this line for safeness as nullish coalescing works only if the value is undefined or null, and errorText can be an empty string @@ -551,7 +553,7 @@ function BaseTextInput({ )} - {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && !!value && !inputProps.isLoading && ( + {((isFocused && !isReadOnly && shouldShowClearButton) || !shouldHideClearButton) && hasContentToClear && !inputProps.isLoading && ( { if (didScrollToEndRef.current || !input.current) { diff --git a/src/components/TextInput/BaseTextInput/implementations.ts b/src/components/TextInput/BaseTextInput/implementations.ts index ccb8096335b9..d90dc7229dae 100644 --- a/src/components/TextInput/BaseTextInput/implementations.ts +++ b/src/components/TextInput/BaseTextInput/implementations.ts @@ -1,3 +1,4 @@ +import DateSegmentsInput from '@components/DateSegmentsInput'; import RNMarkdownTextInput from '@components/RNMarkdownTextInput'; import RNMaskedTextInput from '@components/RNMaskedTextInput'; import RNTextInput from '@components/RNTextInput'; @@ -10,6 +11,7 @@ const InputComponentMap = new Map([ ['default', RNTextInput as InputComponentType], ['mask', RNMaskedTextInput as InputComponentType], ['markdown', RNMarkdownTextInput as InputComponentType], + ['dateSegments', DateSegmentsInput as InputComponentType], ]); export default InputComponentMap; diff --git a/src/components/TextInput/BaseTextInput/types.ts b/src/components/TextInput/BaseTextInput/types.ts index 07cf3fbce236..0c5dd67d2e06 100644 --- a/src/components/TextInput/BaseTextInput/types.ts +++ b/src/components/TextInput/BaseTextInput/types.ts @@ -1,5 +1,7 @@ import type {AnimatedTextInputRef} from '@components/RNTextInput'; +import type {UseDateSegmentInputResult} from '@hooks/useDateSegmentInput'; + import type {ForwardedFSClassProps} from '@libs/Fullstory/types'; import type IconAsset from '@src/types/utils/IconAsset'; @@ -11,7 +13,26 @@ import type {ForwardedRef} from 'react'; import type {GestureResponderEvent, StyleProp, TextInputProps, TextStyle, ViewStyle} from 'react-native'; import type {MaskedTextInputOwnProps} from 'react-native-advanced-input-mask/lib/typescript/src/types'; -type InputType = 'markdown' | 'mask' | 'default'; +type InputType = 'markdown' | 'mask' | 'default' | 'dateSegments'; + +/** Everything the segmented date input needs, which reaches it through `BaseTextInput` rather than directly */ +type DateSegmentsConfig = { + /** The localized mask, such as YYYY-MM-DD, which decides the segment order, their widths and the separators */ + mask: string; + + getSegmentProps: UseDateSegmentInputResult['getSegmentProps']; + setSegmentRef: UseDateSegmentInputResult['setSegmentRef']; + focusFirstUnfilledSegment: UseDateSegmentInputResult['focusFirstUnfilledSegment']; + selectLastSegment: UseDateSegmentInputResult['selectLastSegment']; + selectAllSegments: UseDateSegmentInputResult['selectAllSegments']; + isSegmentElement: UseDateSegmentInputResult['isSegmentElement']; + isAllSelected: UseDateSegmentInputResult['isAllSelected']; + onFieldBlur: UseDateSegmentInputResult['onFieldBlur']; + + /** Whether digits are on screen. A date part way through being entered has no value yet, but is still clearable */ + hasTypedDigits: UseDateSegmentInputResult['hasTypedDigits']; +}; + type CustomBaseTextInputProps = ForwardedFSClassProps & WithSentryLabel & { label?: string; @@ -151,9 +172,12 @@ type CustomBaseTextInputProps = ForwardedFSClassProps & clearButtonIconSize?: number; contentWidth?: number; - /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown` */ + /** The type (internal implementation) of input. Can be one of: `default`, `mask`, `markdown`, `dateSegments` */ type?: InputType; + /** Required by the `dateSegments` type, and ignored by every other one */ + dateSegmentsConfig?: DateSegmentsConfig; + mask?: MaskedTextInputOwnProps['mask']; customNotations?: MaskedTextInputOwnProps['customNotations']; diff --git a/src/hooks/useDateSegmentInput.ts b/src/hooks/useDateSegmentInput.ts new file mode 100644 index 000000000000..de355e3a4398 --- /dev/null +++ b/src/hooks/useDateSegmentInput.ts @@ -0,0 +1,645 @@ +/** + * Drives the guided date input, where the year, month and day are edited in one input each. Every keystroke is handled + * here and the raw keystroke is prevented, so a segment can only ever hold digits it is allowed to hold. + */ +import type {AnimatedTextInputRef} from '@components/RNTextInput'; + +import Clipboard from '@libs/Clipboard'; +import { + clearSegmentsUpTo, + completeZeroSegments, + DATE_SEGMENT_NAMES, + EMPTY_SEGMENTS, + getAdjacentSegmentName, + getClipboardTextFromSegments, + getFirstUnfilledSegmentName, + getISODateFromSegments, + getPastedText, + getSegmentDisplay, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName, DateSegments} from '@libs/DateInputMaskUtils'; +import {isNumeric} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; + +import type {TextInputKeyPressEvent} from 'react-native'; + +import {useRef, useState} from 'react'; + +const FIRST_SEGMENT_NAME = DATE_SEGMENT_NAMES[0]; +const LAST_SEGMENT_NAME = DATE_SEGMENT_NAMES[DATE_SEGMENT_NAMES.length - 1]; + +const SELECT_ALL_KEY = 'a'; +const COPY_KEY = 'c'; +const CUT_KEY = 'x'; +const UNDO_KEY = 'z'; +const REDO_KEY = 'y'; +const MOVE_KEYS = { + [CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey]: -1, + [CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey]: 1, +} as const; + +/** The characters a locale uses between segments, any of which means the user is finished with the one they are on */ +const SEPARATOR_KEYS = new Set(['-', '/', '.', ' ']); + +type UseDateSegmentInputParams = { + /** The committed date in the format the app stores, shown whenever the field is not being edited */ + value: string; + + /** Whether this platform lets the user type a date at all */ + isEnabled: boolean; + + /** The oldest date the calendar can show, so it is not moved somewhere with nothing to select */ + minDate: Date; + + /** The newest date the calendar can show */ + maxDate: Date; + + /** Called with a stored format date whenever the segments produce one, and with an empty string whenever they do not */ + onCommit: (isoDate: string) => void; + + /** Called as a keystroke carries focus out of the field, which no press anywhere reports */ + onLeaveByKeyboard: () => void; +}; + +/** Everything one segment's input needs. The segment itself is stateless and reports back through these */ +type DateSegmentProps = { + value: string; + onKeyPress: (event: TextInputKeyPressEvent) => void; + onChangeText: (text: string) => void; + onFocus: () => void; + + /** Puts the caret back where the user pressed, which is what collapses a whole field selection */ + onPointerDown: () => void; +}; + +type UseDateSegmentInputResult = { + /** Hands over each segment's input, so a keystroke can move focus as it is handled */ + setSegmentRef: (name: DateSegmentName, element: AnimatedTextInputRef | null) => void; + + /** Where a press on the field rather than on a segment lands */ + focusFirstUnfilledSegment: () => void; + + /** Selects the last segment's own digits, which is the word a double click on the field lands nearest to */ + selectLastSegment: () => void; + + /** Selects the whole date, which is what a triple click on the field asks for */ + selectAllSegments: () => void; + + /** Hands focus back to the segment a modal took it from, rather than to wherever the date now stops short */ + restoreFocus: () => void; + + /** Whether focus is going to another segment, which is moving within the field rather than leaving it */ + isSegmentElement: (target: unknown) => boolean; + + /** The month the calendar should show, so it follows the date being typed. Undefined leaves the calendar alone */ + viewDate: Date | undefined; + + /** Counts how many times the input has asked the calendar to follow it, so asking twice for the same month counts twice */ + viewDateVersion: number; + + /** Whether any digit has been typed, so the field is showing more than an untouched mask */ + hasTypedDigits: boolean; + + /** Whether the whole date is selected, so the field draws it as one selection and the next keystroke replaces it */ + isAllSelected: boolean; + + /** Whether a date is being entered right now, so the segments rather than the committed value are what the field shows */ + isEditing: boolean; + + /** Whether the field was left holding digits that do not add up to a date, which reads as no date at all */ + hasInvalidEntry: boolean; + + /** Whether the segments hold digits that do not add up to a date, so there is no date to hand over yet */ + hasIncompleteEntry: boolean; + + getSegmentProps: (name: DateSegmentName) => DateSegmentProps; + + /** Called once focus has left the field altogether rather than moved between segments */ + onFieldBlur: () => void; + + /** Called when a date is picked from the calendar, which the segments follow whether or not it changes the date */ + onDateSelected: (isoDate: string) => void; + + /** Called when the field's clear button empties it, so the segments start again rather than being left behind */ + onClear: () => void; +}; + +function isMoveKey(key: string): key is keyof typeof MOVE_KEYS { + return key in MOVE_KEYS; +} + +function useDateSegmentInput({value, isEnabled, minDate, maxDate, onCommit, onLeaveByKeyboard}: UseDateSegmentInputParams): UseDateSegmentInputResult { + // The segments only describe an edit in progress, so they are seeded on focus rather than synced with the value + const [segments, setSegments] = useState(EMPTY_SEGMENTS); + const [isEditing, setIsEditing] = useState(false); + const segmentRefs = useRef>>({}); + const [viewDate, setViewDate] = useState(undefined); + const [viewDateVersion, setViewDateVersion] = useState(0); + // Set on arriving at a segment, so the next digit replaces it instead of extending it + const [shouldOverwrite, setShouldOverwrite] = useState(false); + const [appliedValue, setAppliedValue] = useState(value); + const [wasEnabled, setWasEnabled] = useState(isEnabled); + // Digits that do not add up to a date stay on screen after the field is left, so the user can correct them + const [hasInvalidEntry, setHasInvalidEntry] = useState(false); + // Native selection cannot span separate inputs, so selecting the whole date is tracked here and drawn by the field + const [isAllSelected, setIsAllSelected] = useState(false); + // A repeated click selects as its last press goes down, and the press completing that click arrives after + const hasJustSelectedByClick = useRef(false); + // Set as a finished segment hands the caret on, so the separator that usually follows knows it has already happened + const isCaretHandedOverRef = useRef(false); + // Outlives the blur a modal causes, so focus can be given back to the segment the user actually left + const lastFocusedSegmentRef = useRef(undefined); + // Every keystroke is prevented, so each segment's input has an empty undo history of its own and the field keeps + // one covering all three. `pastSegments` is what undo walks back through and `undoneSegments` is what redo returns. + const pastSegments = useRef([]); + const undoneSegments = useRef([]); + + const forgetHistory = () => { + pastSegments.current = []; + undoneSegments.current = []; + }; + + // Typing is turned off by the window narrowing, and a field without segments has nowhere to show a date part way + // through. The entry goes with it, so widening the window again does not bring back an edit that already left the + // screen while the committed date was being shown in its place. + if (isEnabled !== wasEnabled) { + setWasEnabled(isEnabled); + + if (!isEnabled) { + setIsEditing(false); + setSegments(EMPTY_SEGMENTS); + setHasInvalidEntry(false); + setIsAllSelected(false); + setViewDate(undefined); + } + } + + // A date set from outside, by the calendar or a restored draft, has to reach an edit in progress too + if (value !== appliedValue) { + setAppliedValue(value); + + // A date this field has just committed already agrees with the segments, and seeding from it would replace a + // half typed segment with the padded form it is showing. The next digit would then start the segment over. + if (isEditing && value !== (getISODateFromSegments(segments) ?? '')) { + setSegments(getSegmentsFromISODate(value)); + } + + // A date picked from the calendar settles what the field holds, so there is no longer an entry to correct. An + // empty value is the field reporting its own unusable entry, which is what put those digits on screen. + if (value) { + setHasInvalidEntry(false); + } + } + + const setSegmentRef = (name: DateSegmentName, element: AnimatedTextInputRef | null) => { + segmentRefs.current[name] = element; + }; + + const isSegmentElement = (target: unknown) => !!target && Object.values(segmentRefs.current).some((element) => element === target); + + /** Rests the caret after the segment's digits, so a half typed month reads as 02 and not 0, a caret, then 2 */ + const focusSegment = (name: DateSegmentName) => { + const element = segmentRefs.current[name]; + element?.focus(); + + const caretPosition = element?.value?.length ?? 0; + element?.setSelectionRange?.(caretPosition, caretPosition); + }; + + /** + * The digits the browser has selected inside one segment's own input, or undefined while nothing there is selected. + * Only a selection spanning the whole date is tracked here, since that one cannot be made natively. + */ + const getSegmentSelection = (name: DateSegmentName) => { + const element = segmentRefs.current[name]; + const start = element?.selectionStart; + const end = element?.selectionEnd; + + if (typeof start !== 'number' || typeof end !== 'number' || start === end) { + return undefined; + } + + return {start, end}; + }; + + const enterSegment = (name: DateSegmentName) => { + setShouldOverwrite(true); + focusSegment(name); + }; + + /** + * Runs on every keystroke rather than once the date is finished. An entry part way through is held back, since a + * consumer that acts on each change would act on a date the user has not finished writing. Emptying the field is + * reported, because that is a change the user has finished making. + */ + const commitSegments = (newSegments: DateSegments) => { + const isoDate = getISODateFromSegments(newSegments); + + if (!isoDate && hasAnySegment(newSegments)) { + return; + } + + onCommit(isoDate ?? ''); + }; + + /** The count carries the request, since the user may have moved the calendar away and typed the month it was on */ + const assertViewDate = (nextViewDate: Date | undefined) => { + if (!nextViewDate) { + return; + } + + setViewDate(nextViewDate); + setViewDateVersion((version) => version + 1); + }; + + /** The one place segments are written, so the calendar cannot fall out of step with them */ + const writeSegments = (newSegments: DateSegments) => { + setSegments(newSegments); + assertViewDate(getViewDateFromSegments(newSegments, viewDate ?? new Date(), minDate, maxDate)); + commitSegments(newSegments); + }; + + /** A change the user made, which is therefore one they can take back. Anything undone is no longer ahead of them. */ + const applySegments = (newSegments: DateSegments) => { + pastSegments.current.push(segments); + undoneSegments.current = []; + writeSegments(newSegments); + }; + + /** Steps one change back or forward, moving the state being left behind onto the other side of the history */ + const moveThroughHistory = (isRedo: boolean) => { + const from = isRedo ? undoneSegments.current : pastSegments.current; + const restored = from.pop(); + + if (!restored) { + return; + } + + (isRedo ? pastSegments : undoneSegments).current.push(segments); + setIsAllSelected(false); + writeSegments(restored); + + // The caret belongs where the user would be typing in the restored date, which is the digit it stops short at + focusSegment(getFirstUnfilledSegmentName(restored) ?? LAST_SEGMENT_NAME); + setShouldOverwrite(false); + }; + + const applyKeyToSegments = (name: DateSegmentName, event: TextInputKeyPressEvent) => { + const key = event.nativeEvent.key; + const wasCaretHandedOver = isCaretHandedOverRef.current; + isCaretHandedOverRef.current = false; + + // A shortcut is the browser's to handle, apart from the ones it cannot apply across separate inputs + if (event.nativeEvent.metaKey || event.nativeEvent.ctrlKey) { + if (key === CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey || key === CONST.KEYBOARD_SHORTCUTS.DELETE.shortcutKey) { + event.preventDefault(); + setIsAllSelected(false); + applySegments(isAllSelected ? EMPTY_SEGMENTS : clearSegmentsUpTo(segments, name)); + enterSegment(FIRST_SEGMENT_NAME); + return; + } + + // Each segment's input was never written to directly, so the browser has no change of its own to take back + if (key.toLowerCase() === UNDO_KEY || key.toLowerCase() === REDO_KEY) { + event.preventDefault(); + moveThroughHistory(key.toLowerCase() === REDO_KEY || !!event.nativeEvent.shiftKey); + return; + } + + // Selecting the whole date selects nothing in the document, so the browser has neither anything of its own + // to put on the clipboard nor anything to remove from the field + if (isAllSelected && (key.toLowerCase() === COPY_KEY || key.toLowerCase() === CUT_KEY)) { + const clipboardText = getClipboardTextFromSegments(segments); + + if (!clipboardText) { + return; + } + + event.preventDefault(); + Clipboard.setString(clipboardText); + + if (key.toLowerCase() === CUT_KEY) { + setIsAllSelected(false); + applySegments(EMPTY_SEGMENTS); + enterSegment(FIRST_SEGMENT_NAME); + } + return; + } + + if (key.toLowerCase() !== SELECT_ALL_KEY || !hasAnySegment(segments)) { + return; + } + + event.preventDefault(); + setIsAllSelected(true); + // Selecting the date puts the caret at its start, so replacing it does not have to move focus first + enterSegment(FIRST_SEGMENT_NAME); + return; + } + + const wasAllSelected = isAllSelected; + setIsAllSelected(false); + + // Tab is left to the browser, which moves focus without pressing anything, so nothing else reports the field + // being left behind. Only the outermost segment hands focus out of the field rather than on to a sibling. + if (key === CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey) { + if (event.nativeEvent.shiftKey ? name === FIRST_SEGMENT_NAME : name === LAST_SEGMENT_NAME) { + onLeaveByKeyboard(); + } + return; + } + + if (isNumeric(key)) { + event.preventDefault(); + const result = typeDigitIntoSegments(wasAllSelected ? EMPTY_SEGMENTS : segments, name, key, shouldOverwrite); + setShouldOverwrite(false); + applySegments(result.segments); + + if (result.nextSegmentName) { + enterSegment(result.nextSegmentName); + // Set after the move, since arriving at the segment is what clears it + isCaretHandedOverRef.current = true; + } + return; + } + + if (isMoveKey(key)) { + event.preventDefault(); + enterSegment(getAdjacentSegmentName(name, MOVE_KEYS[key])); + return; + } + + if (key === CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey || key === CONST.KEYBOARD_SHORTCUTS.DELETE.shortcutKey) { + event.preventDefault(); + + if (wasAllSelected) { + applySegments(EMPTY_SEGMENTS); + enterSegment(FIRST_SEGMENT_NAME); + return; + } + + const selection = getSegmentSelection(name); + + // Selected digits are what the key takes out, the same as in any text field. A segment is zero padded from + // the right, so the selection is read against the text on screen rather than against the digits behind it. + if (selection) { + const shownValue = getSegmentDisplay(segments, name); + applySegments({...segments, [name]: `${shownValue.slice(0, selection.start)}${shownValue.slice(selection.end)}`}); + setShouldOverwrite(false); + return; + } + + const trimmedSegments = removeLastDigit(segments, name); + + // An empty segment has nothing to delete, so the keystroke falls back to leaving it + if (!trimmedSegments) { + enterSegment(getAdjacentSegmentName(name, -1)); + return; + } + + applySegments(trimmedSegments); + setShouldOverwrite(false); + return; + } + + if (!SEPARATOR_KEYS.has(key)) { + // Nothing else may reach the input, or the browser would write characters the mask cannot represent + if (key.length === 1) { + event.preventDefault(); + } + return; + } + + event.preventDefault(); + + // A full segment hands the caret on by itself, so the separator typed after it arrives in a segment the user + // has not written in yet. Moving on again would step over that segment, leaving whatever it held behind. + if (!segments[name] || wasCaretHandedOver) { + return; + } + + // A separator means the user is finished with this segment even if they only typed one digit into it + enterSegment(getAdjacentSegmentName(name, 1)); + }; + + // Every keystroke is prevented, so this only runs for text the user pasted or cut + const applyPastedOrCutText = (name: DateSegmentName, text: string) => { + const shownValue = getSegmentDisplay(segments, name); + + // Cutting takes the digits out of the input itself rather than through a keystroke, so text going missing is + // the only report of it. Without this the segment would be put straight back as the field redraws. + if (text.length < shownValue.length) { + setIsAllSelected(false); + setShouldOverwrite(false); + applySegments({...segments, [name]: text}); + return; + } + + // The input reports its whole value, so what the user pasted is whatever the segment was not already showing + const pastedText = getPastedText(name, shownValue, text, isAllSelected); + + // A paste over the whole date replaces it, while one into a single segment merges into what is already there + const baseSegments = isAllSelected ? EMPTY_SEGMENTS : segments; + const pastedSegments = getSegmentsFromText(pastedText, name, baseSegments); + + // Text with no digits in it leaves every segment as it was, so there is nothing to apply and nowhere to move + if (pastedSegments === baseSegments) { + return; + } + + setIsAllSelected(false); + applySegments(pastedSegments); + + const unfilledName = getFirstUnfilledSegmentName(pastedSegments); + + // A paste that fills the date leaves the last segment ready to be started over + if (!unfilledName) { + enterSegment(LAST_SEGMENT_NAME); + return; + } + + // A paste that fills only part of the date leaves the user at the digits they still have to enter. Those are + // the start of that segment, so the next digit has to extend them rather than replace them. + focusSegment(unfilledName); + setShouldOverwrite(false); + }; + + const startEditing = () => { + isCaretHandedOverRef.current = false; + setShouldOverwrite(true); + + if (isEditing) { + return; + } + + forgetHistory(); + + // Returning to an entry that was left unusable resumes it, since the date it reported was the empty one + const seededSegments = hasInvalidEntry ? segments : getSegmentsFromISODate(value); + setHasInvalidEntry(false); + setSegments(seededSegments); + assertViewDate(getViewDateFromSegments(seededSegments, new Date(), minDate, maxDate)); + setIsEditing(true); + }; + + /** + * The calendar settling the date has to reach the segments even when it repeats the date they already report. A + * segment the user has taken a digit out of reports no date at all, so the committed value never changes and + * nothing else would tell the field to catch up. + */ + const followPickedDate = (isoDate: string) => { + forgetHistory(); + setIsAllSelected(false); + setHasInvalidEntry(false); + setSegments(getSegmentsFromISODate(isoDate)); + }; + + const settleEntry = () => { + forgetHistory(); + setIsEditing(false); + setViewDate(undefined); + setShouldOverwrite(false); + setIsAllSelected(false); + + // A segment showing a zero is one the user moved on from before its second digit, so leaving the field is + // where it settles on a number a date can hold + const completedSegments = completeZeroSegments(segments); + + if (completedSegments !== segments) { + commitSegments(completedSegments); + } + + const hasUnusableEntry = hasAnySegment(completedSegments) && !getISODateFromSegments(completedSegments); + setHasInvalidEntry(hasUnusableEntry); + setSegments(hasUnusableEntry ? completedSegments : EMPTY_SEGMENTS); + }; + + // The clear button unmounts as it is pressed, so focus is placed here rather than left to whatever is underneath + const clearEntry = () => { + forgetHistory(); + setSegments(EMPTY_SEGMENTS); + setHasInvalidEntry(false); + setIsAllSelected(false); + setViewDate(undefined); + enterSegment(FIRST_SEGMENT_NAME); + }; + + if (!isEnabled) { + return { + setSegmentRef: () => {}, + focusFirstUnfilledSegment: () => {}, + selectLastSegment: () => {}, + selectAllSegments: () => {}, + restoreFocus: () => {}, + isSegmentElement: () => false, + viewDate: undefined, + viewDateVersion: 0, + hasTypedDigits: false, + isAllSelected: false, + isEditing: false, + hasInvalidEntry: false, + hasIncompleteEntry: false, + getSegmentProps: () => ({value: '', onKeyPress: () => {}, onChangeText: () => {}, onFocus: () => {}, onPointerDown: () => {}}), + onFieldBlur: () => {}, + onDateSelected: () => {}, + onClear: () => {}, + }; + } + + // Outside an edit in progress or one left unusable, the committed date is what the field shows + const shouldShowSegments = isEditing || hasInvalidEntry; + const displayedSegments = shouldShowSegments ? segments : getSegmentsFromISODate(value); + + const focusFirstUnfilledSegment = () => { + // The press that completes a repeated click would otherwise put the caret straight back where it started + if (hasJustSelectedByClick.current) { + hasJustSelectedByClick.current = false; + return; + } + + setIsAllSelected(false); + focusSegment(getFirstUnfilledSegmentName(displayedSegments) ?? LAST_SEGMENT_NAME); + }; + + /** + * A month or year pick revises the date rather than asking for a new one, so the caret belongs where the user left + * it. Where the date now stops short is no answer, since a pick that completes it leaves it stopping short nowhere. + */ + const restoreFocus = () => { + setIsAllSelected(false); + focusSegment(lastFocusedSegmentRef.current ?? getFirstUnfilledSegmentName(displayedSegments) ?? FIRST_SEGMENT_NAME); + }; + + /** + * Hands the segment a selection of its own rather than the field's, so copying and replacing it are the browser's + * to answer, exactly as they are when the segment itself is double clicked. + */ + const selectLastSegment = () => { + const shownLength = getSegmentDisplay(displayedSegments, LAST_SEGMENT_NAME).length; + + // There are no digits to select yet, so the click is left to place the caret the way a single one does + if (!shownLength) { + return; + } + + hasJustSelectedByClick.current = true; + setIsAllSelected(false); + // Focus only announces itself on arrival, and the segment may be the one the caret is already in + setShouldOverwrite(true); + + const element = segmentRefs.current[LAST_SEGMENT_NAME]; + element?.focus(); + element?.setSelectionRange?.(0, shownLength); + }; + + const selectAllSegments = () => { + // There is no date to select yet, so the click is left to place the caret the way a single one does + if (!hasAnySegment(displayedSegments)) { + return; + } + + hasJustSelectedByClick.current = true; + setIsAllSelected(true); + // Selecting the date puts the caret at its start, so replacing it does not have to move focus first + enterSegment(FIRST_SEGMENT_NAME); + }; + + return { + setSegmentRef, + focusFirstUnfilledSegment, + selectLastSegment, + selectAllSegments, + restoreFocus, + isSegmentElement, + viewDate: isEditing ? viewDate : undefined, + viewDateVersion, + hasTypedDigits: shouldShowSegments && hasAnySegment(segments), + isAllSelected, + isEditing, + hasInvalidEntry, + hasIncompleteEntry: shouldShowSegments && hasAnySegment(segments) && !getISODateFromSegments(segments), + getSegmentProps: (name: DateSegmentName) => ({ + value: getSegmentDisplay(displayedSegments, name), + onKeyPress: (event: TextInputKeyPressEvent) => applyKeyToSegments(name, event), + onChangeText: (text: string) => applyPastedOrCutText(name, text), + onFocus: () => { + lastFocusedSegmentRef.current = name; + startEditing(); + }, + onPointerDown: () => setIsAllSelected(false), + }), + onFieldBlur: settleEntry, + onDateSelected: followPickedDate, + onClear: clearEntry, + }; +} + +export default useDateSegmentInput; +export type {UseDateSegmentInputResult}; diff --git a/src/hooks/useRemeasureOnScroll/index.native.ts b/src/hooks/useRemeasureOnScroll/index.native.ts new file mode 100644 index 000000000000..8f615444b3cb --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.native.ts @@ -0,0 +1,9 @@ +/** + * Native has no document to listen to, and nothing anchored to a scrolling page to keep in place, so there is nothing + * to remeasure. + */ +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = () => {}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/index.ts b/src/hooks/useRemeasureOnScroll/index.ts new file mode 100644 index 000000000000..132fb30569db --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/index.ts @@ -0,0 +1,30 @@ +/** + * Keeps something positioned from a one-off measurement attached to what it was measured against, by remeasuring as + * the page scrolls. A capture phase listener is used because scroll events do not bubble, so this covers the inner + * scroll containers a screen is built from as well as the document itself. + */ +import CONST from '@src/CONST'; + +import throttle from 'lodash/throttle'; +import {useEffect} from 'react'; + +import type UseRemeasureOnScroll from './types'; + +const useRemeasureOnScroll: UseRemeasureOnScroll = ({isActive, remeasure}) => { + useEffect(() => { + if (!isActive) { + return; + } + + // Listening hands the scroll event to whatever it calls, and a remeasure takes an argument of its own + const handleScroll = throttle(() => remeasure(), CONST.TIMING.MIN_SMOOTH_SCROLL_EVENT_THROTTLE); + document.addEventListener('scroll', handleScroll, true); + + return () => { + document.removeEventListener('scroll', handleScroll, true); + handleScroll.cancel(); + }; + }, [isActive, remeasure]); +}; + +export default useRemeasureOnScroll; diff --git a/src/hooks/useRemeasureOnScroll/types.ts b/src/hooks/useRemeasureOnScroll/types.ts new file mode 100644 index 000000000000..10ab51f826d3 --- /dev/null +++ b/src/hooks/useRemeasureOnScroll/types.ts @@ -0,0 +1,11 @@ +type UseRemeasureOnScrollParams = { + /** Whether anything is currently anchored and so worth keeping in place */ + isActive: boolean; + + /** Takes a fresh measurement of the anchor */ + remeasure: () => void; +}; + +type UseRemeasureOnScroll = (params: UseRemeasureOnScrollParams) => void; + +export default UseRemeasureOnScroll; diff --git a/src/hooks/useTransactionStartDateValidation.ts b/src/hooks/useTransactionStartDateValidation.ts new file mode 100644 index 000000000000..7557a339051a --- /dev/null +++ b/src/hooks/useTransactionStartDateValidation.ts @@ -0,0 +1,60 @@ +import {isRequiredFulfilled} from '@libs/ValidationUtils'; + +import CONST from '@src/CONST'; + +import {useState} from 'react'; + +import useLocalize from './useLocalize'; + +type UseTransactionStartDateValidationParams = { + /** Whether the page is asking for a date at all, since the other option needs none */ + dateOption: string; + + /** The date the page is holding, in the format the app stores */ + startDate: string; +}; + +type UseTransactionStartDateValidationResult = { + /** What the date field shows, which is nothing until the page has been submitted without a date */ + requiredError: string; + + /** Handed to the date field, which reports an entry it could not read as a date */ + onValidationErrorChange: (error: string) => void; + + /** Takes the required error back down, since picking an option is the user answering the question again */ + forgetSubmitAttempt: () => void; + + /** Whether the page may be saved, recording the required error when it may not */ + validateOnSubmit: () => boolean; +}; + +/** + * The shared wiring behind a transaction start date, where a custom date is required and the field can report an entry + * it refused to hand over. Both pages asking for one answer it the same way, so they ask this rather than each other. + */ +function useTransactionStartDateValidation({dateOption, startDate}: UseTransactionStartDateValidationParams): UseTransactionStartDateValidationResult { + const {translate} = useLocalize(); + // A required error belongs after the page has been submitted, and not while the date is still being filled in + const [wasSubmittedWithoutDate, setWasSubmittedWithoutDate] = useState(false); + // The field reports a date its calendar would never have offered, which only typing can reach + const [dateError, setDateError] = useState(''); + + const isCustom = dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; + + return { + requiredError: wasSubmittedWithoutDate && !isRequiredFulfilled(startDate) ? translate('common.error.fieldRequired') : '', + onValidationErrorChange: setDateError, + forgetSubmitAttempt: () => setWasSubmittedWithoutDate(false), + validateOnSubmit: () => { + if (isCustom && !isRequiredFulfilled(startDate)) { + setWasSubmittedWithoutDate(true); + return false; + } + + // An entry the field could not read is never handed over, so saving would keep the date from before it + return !(isCustom && dateError); + }, + }; +} + +export default useTransactionStartDateValidation; diff --git a/src/languages/de.ts b/src/languages/de.ts index 9ea46507a951..2669561695b5 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jahr', + month: 'Monat', + day: 'Tag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 Tag`, other: `${count} Tage`, diff --git a/src/languages/el.ts b/src/languages/el.ts index dce3186bf105..b243d54045dd 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -33,6 +33,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Έτος', + month: 'Μήνας', + day: 'Ημέρα', + }, durationDays: ({count}: {count: number}) => ({ one: `1 ημέρα`, other: `${count} ημέρες`, diff --git a/src/languages/en.ts b/src/languages/en.ts index 443173b54dbd..d93b9dd619e5 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -597,6 +597,11 @@ const translations = { reimbursableTotal: 'Reimbursable total', nonReimbursableTotal: 'Non-reimbursable total', opensInNewTab: 'Opens in a new tab', + dateSegments: { + year: 'Year', + month: 'Month', + day: 'Day', + }, locked: 'Locked', month: 'Month', week: 'Week', diff --git a/src/languages/es.ts b/src/languages/es.ts index 8cd1e736bfe6..36ed050012c7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -497,6 +497,11 @@ const translations: TranslationDeepObject = { amountDebited: 'Importe debitado', amountReimbursed: 'Importe reembolsado', opensInNewTab: 'Se abre en una nueva pestaña', + dateSegments: { + year: 'Año', + month: 'Mes', + day: 'Día', + }, locked: 'Bloqueado', month: 'Monat', week: 'Semana', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index a5a6aae9e095..13feaa094d7d 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Année', + month: 'Mois', + day: 'Jour', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} jour`, other: `${count} jours`, diff --git a/src/languages/it.ts b/src/languages/it.ts index 72141ef69d3f..4a61ccadfde9 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Anno', + month: 'Mese', + day: 'Giorno', + }, durationDays: ({count}: {count: number}) => ({ one: `1 giorno`, other: `${count} giorni`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 7134f4be33c5..01eda9ccd2ee 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 日`, other: `${count} 日`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 6bc71f8da5c5..9ab4ff4d67e6 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Jaar', + month: 'Maand', + day: 'Dag', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dag`, other: `${count} dagen`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 04e94d2c26cf..31b7dfda37ed 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Rok', + month: 'Miesiąc', + day: 'Dzień', + }, durationDays: ({count}: {count: number}) => ({ one: `1 dzień`, other: `${count} dni`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 0b4a3a8301a4..cbd49b473e81 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: 'Ano', + month: 'Mês', + day: 'Dia', + }, durationDays: ({count}: {count: number}) => ({ one: `${count} dia`, other: `${count} dias`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index e86368063fae..b1fe7cdf410e 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -36,6 +36,11 @@ type States = Record; type AllCountries = Record; const translations: TranslationDeepObject = { common: { + dateSegments: { + year: '年', + month: '月', + day: '日', + }, durationDays: ({count}: {count: number}) => ({ one: `1 天`, other: `${count} 天`, diff --git a/src/libs/DateInputMaskUtils.ts b/src/libs/DateInputMaskUtils.ts new file mode 100644 index 000000000000..13065051b87f --- /dev/null +++ b/src/libs/DateInputMaskUtils.ts @@ -0,0 +1,487 @@ +/** + * Helpers for the guided date input, where the year, month and day are edited as separate segments. The segments hold + * the digits the user has typed rather than a parsed date, so a half finished segment is representable and the + * display text can be rebuilt from them at any point. + */ +import CONST from '@src/CONST'; + +import type {TupleToUnion} from 'type-fest'; + +import {getDaysInMonth, isValid, parse, setDate} from 'date-fns'; + +const DATE_SEGMENT_NAMES = ['year', 'month', 'day'] as const; + +const YEAR_LENGTH = 4; +const SEGMENT_LENGTH = 2; + +const FIRST_MONTH = 1; +const FIRST_DAY = 1; + +/** What a segment that cannot read as zero settles on, being the first month and the first day alike */ +const FIRST_SEGMENT_VALUE = '01'; + +/** The Date constructor takes a year below this as a shorthand for the twentieth century, so 99 would arrive as 1999 */ +const FIRST_LITERAL_YEAR = 100; + +/** + * The highest a segment may read, and the highest its leading digit may be while still allowing a second one. The day + * is held to the longest month rather than to the month that was typed, since narrowing it would answer the 31st of + * February with a different day instead of leaving a date that does not exist to be reported. + */ +const SEGMENT_LIMITS = { + month: {max: 12, maxLeadingDigit: 1}, + day: {max: 31, maxLeadingDigit: 3}, +} as const; + +/** Mask characters standing in for a digit are letters, so anything else is a separator to copy through verbatim */ +const MASK_LETTER_REGEX = /\p{L}/u; + +const NON_DIGIT_REGEX = /\D/g; + +const DIGIT_GROUP_REGEX = /\d+/g; + +const DIGITS_ONLY_REGEX = /^\d+$/; + +type DateSegmentName = TupleToUnion; + +/** The digits typed into each segment, empty when the segment has not been filled in yet */ +type DateSegments = Record; + +type DateMaskPart = { + name: DateSegmentName; + + /** The mask letters shown while the segment is empty, such as YYYY */ + placeholder: string; + + /** The characters the mask puts after this segment, such as a dash */ + separator: string; +}; + +const EMPTY_SEGMENTS: DateSegments = {year: '', month: '', day: ''}; + +function isMaskLetter(character: string): boolean { + return MASK_LETTER_REGEX.test(character); +} + +/** + * Reads the localized mask into one part per segment. Every locale writes the segments in year, month, day order, so + * the runs of letters are matched to the segments by position. + */ +function getDateMaskParts(mask: string): DateMaskPart[] { + const parts: DateMaskPart[] = []; + let index = 0; + + while (index < mask.length && parts.length < DATE_SEGMENT_NAMES.length) { + let placeholder = ''; + while (index < mask.length && isMaskLetter(mask.charAt(index))) { + placeholder += mask.charAt(index); + index++; + } + + if (!placeholder) { + break; + } + + let separator = ''; + while (index < mask.length && !isMaskLetter(mask.charAt(index))) { + separator += mask.charAt(index); + index++; + } + + parts.push({name: DATE_SEGMENT_NAMES[parts.length], placeholder, separator}); + } + + return parts; +} + +function getSegmentLength(name: DateSegmentName): number { + return name === 'year' ? YEAR_LENGTH : SEGMENT_LENGTH; +} + +/** + * Whether a segment reads as a zero padded number, which the two digit ones do and the year does not. Their digits + * therefore sit at the end of the segment, so a single typed digit shows as 02 rather than 2 followed by a mask letter. + */ +function isZeroPaddedSegment(name: DateSegmentName): name is keyof typeof SEGMENT_LIMITS { + return name in SEGMENT_LIMITS; +} + +/** + * The text one segment shows. A zero padded segment fills from the right, so a day part way through reads as 02 and + * becomes 23 on the next digit. An empty segment renders nothing and lets its own placeholder show through. + */ +function getSegmentDisplay(segments: DateSegments, name: DateSegmentName): string { + const digits = segments[name].slice(0, getSegmentLength(name)); + + if (!digits) { + return ''; + } + + return isZeroPaddedSegment(name) ? digits.padStart(getSegmentLength(name), '0') : digits; +} + +/** The segment that follows this one, or undefined for the last one, which has nowhere to hand a finished value on to */ +function getFollowingSegmentName(name: DateSegmentName): DateSegmentName | undefined { + return DATE_SEGMENT_NAMES.at(DATE_SEGMENT_NAMES.indexOf(name) + 1); +} + +/** The segment `offset` places away, clamped so moving past either end keeps the outermost segment selected */ +function getAdjacentSegmentName(name: DateSegmentName, offset: number): DateSegmentName { + const nextIndex = DATE_SEGMENT_NAMES.indexOf(name) + offset; + const clampedIndex = Math.min(Math.max(nextIndex, 0), DATE_SEGMENT_NAMES.length - 1); + + return DATE_SEGMENT_NAMES[clampedIndex]; +} + +type SegmentDigitResult = { + /** What the segment now reads */ + value: string; + + /** Whether the segment is finished with, so the caret belongs in the next one */ + shouldAdvance: boolean; + + /** A digit this segment could not take, which the next one receives instead */ + carry?: string; +}; + +/** + * Adds one typed digit to a single segment, without knowing about the others. A digit that cannot extend what is + * already there is handed on rather than dropped, so typing 1 then 3 into the month reads as January and starts the + * day off with the 3. + */ +function typeDigitIntoOneSegment(name: DateSegmentName, typedSoFar: string, digit: string): SegmentDigitResult { + const isStartingOver = typedSoFar.length >= getSegmentLength(name); + const current = isStartingOver ? '' : typedSoFar; + + if (name === 'year') { + const year = `${current}${digit}`.slice(0, YEAR_LENGTH); + + return {value: year, shouldAdvance: year.length === YEAR_LENGTH}; + } + + const limits = SEGMENT_LIMITS[name]; + + // A zero starting a segment over is the rest of a number too high to fit, such as the 0 of a 50th day, so it + // settles on the first month or day rather than waiting for a digit that was never going to come + if (isStartingOver && digit === '0') { + return {value: FIRST_SEGMENT_VALUE, shouldAdvance: false}; + } + + if (current.length === 1) { + const combined = `${current}${digit}`; + const combinedNumber = Number(combined); + + if (combinedNumber >= FIRST_MONTH && combinedNumber <= limits.max) { + return {value: combined, shouldAdvance: true}; + } + + // Finishing the segment early only works while what it already holds can stand on its own. A leading zero + // cannot, so the keystroke is dropped and the segment keeps waiting for a digit that completes it. + if (Number(current) < FIRST_MONTH) { + return {value: current, shouldAdvance: false}; + } + + return {value: current.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true, carry: digit}; + } + + // A leading digit this high cannot start a two digit number, so the segment is zero padded and finished early + if (digit !== '0' && Number(digit) > limits.maxLeadingDigit) { + return {value: digit.padStart(SEGMENT_LENGTH, '0'), shouldAdvance: true}; + } + + return {value: digit, shouldAdvance: false}; +} + +/** + * Adds one typed digit, following a carried digit into the following segments for as long as they keep handing one on. + * `nextSegmentName` is where the caret belongs afterwards, and is undefined while the segment is unfinished. + */ +function typeDigitIntoSegments( + segments: DateSegments, + name: DateSegmentName, + digit: string, + shouldOverwrite = false, +): {segments: DateSegments; nextSegmentName: DateSegmentName | undefined} { + const filled = {...segments}; + let currentName = name; + let typedSoFar = shouldOverwrite ? '' : segments[name]; + let currentDigit = digit; + let nextSegmentName: DateSegmentName | undefined; + + for (;;) { + const result = typeDigitIntoOneSegment(currentName, typedSoFar, currentDigit); + filled[currentName] = result.value; + + const followingName = getFollowingSegmentName(currentName); + if (!result.shouldAdvance || !followingName) { + break; + } + + nextSegmentName = followingName; + if (!result.carry) { + break; + } + + currentName = followingName; + typedSoFar = ''; + currentDigit = result.carry; + } + + return {segments: filled, nextSegmentName}; +} + +/** + * The date the calendar should show for the date being typed, or undefined when there is nowhere useful to go and the + * calendar should stay where it is. A month only counts once both its digits read as a real month, so the calendar + * does not lurch to January while the user is still on the first digit. + * + * A year outside the allowed range is followed too, so the calendar keeps agreeing with the field while validation is + * what reports the problem. An unfinished one is ignored instead, since 1985 passes through 1, 19 and 198 on the way, + * so a month typed before a year lands in the year already on screen. + */ +function getViewDateFromSegments(segments: DateSegments, fallbackDate: Date, minDate: Date, maxDate: Date): Date | undefined { + const monthNumber = Number(segments.month); + const hasMonth = segments.month.length === SEGMENT_LENGTH && monthNumber >= FIRST_MONTH && monthNumber <= SEGMENT_LIMITS.month.max; + const yearNumber = Number(segments.year); + // A year below the hundreds has no date that could carry it, and it sits a century before the oldest date any + // field offers, so the calendar stays where it is rather than showing the year that date would have reported. + const hasYear = segments.year.length === YEAR_LENGTH && yearNumber >= FIRST_LITERAL_YEAR; + + // Neither segment reads as somewhere to go yet, so the calendar keeps whatever it is showing + if (!hasYear && !hasMonth) { + return undefined; + } + + // The year on screen can sit outside the allowed range, since it starts at today even where only much older dates + // may be picked. Pulling it into range keeps a year the user never typed from landing somewhere unselectable. + const fallbackYear = Math.min(Math.max(fallbackDate.getFullYear(), minDate.getFullYear()), maxDate.getFullYear()); + const viewDate = new Date(hasYear ? yearNumber : fallbackYear, hasMonth ? monthNumber - 1 : fallbackDate.getMonth(), FIRST_DAY); + const dayNumber = Number(segments.day); + + // The day comes along so that a month or year picked from the calendar keeps the day the field already shows. A + // day the month it lands in is too short for would carry the view into the next month, so it is held to that month. + return dayNumber >= FIRST_DAY ? setDate(viewDate, Math.min(dayNumber, getDaysInMonth(viewDate))) : viewDate; +} + +/** Empties a segment and every segment before it, which is what deleting to the start of the field leaves behind */ +function clearSegmentsUpTo(segments: DateSegments, name: DateSegmentName): DateSegments { + const cleared = {...segments}; + + for (const clearedName of DATE_SEGMENT_NAMES.slice(0, DATE_SEGMENT_NAMES.indexOf(name) + 1)) { + cleared[clearedName] = ''; + } + + return cleared; +} + +/** Drops the last digit of a segment. Returns undefined when there was nothing left to drop */ +function removeLastDigit(segments: DateSegments, name: DateSegmentName): DateSegments | undefined { + if (!segments[name]) { + return undefined; + } + + return {...segments, [name]: segments[name].slice(0, -1)}; +} + +function getSegmentsFromISODate(value: string | undefined): DateSegments { + if (!value || !isValid(parse(value, CONST.DATE.FNS_FORMAT_STRING, new Date()))) { + return EMPTY_SEGMENTS; + } + + const digits = value.replaceAll(NON_DIGIT_REGEX, ''); + + return { + year: digits.slice(0, YEAR_LENGTH), + month: digits.slice(YEAR_LENGTH, YEAR_LENGTH + SEGMENT_LENGTH), + day: digits.slice(YEAR_LENGTH + SEGMENT_LENGTH), + }; +} + +/** The first segment still short of its digits, which is where a click on the field rather than on a segment lands */ +function getFirstUnfilledSegmentName(segments: DateSegments): DateSegmentName | undefined { + return DATE_SEGMENT_NAMES.find((name) => segments[name].length < getSegmentLength(name)); +} + +/** Splits one run of digits across the segments in turn, which is how a date written without separators is read */ +function splitDigitsBySegmentLength(digits: string, names: DateSegmentName[]): string[] { + let remaining = digits; + + return names.map((name) => { + const taken = remaining.slice(0, getSegmentLength(name)); + remaining = remaining.slice(getSegmentLength(name)); + + return taken; + }); +} + +/** + * Fills the segments from arbitrary text, so pasting a date works without going through it a keystroke at a time. + * + * The digits land in `startName` first and carry into the segments after it, so pasting a month leaves a year that is + * already there alone. A segment keeps what it held until the digits actually reach it, and is then replaced rather + * than extended. Text holding no digits at all returns `baseSegments` itself, so a caller can tell nothing was pasted. + * + * What was pasted is taken at its word rather than read a digit at a time the way typing is. Typing hands a digit + * that cannot extend a segment on to the next one, which would quietly turn a pasted 13 into January the 3rd instead + * of reporting a month that does not exist. Separators say where each segment ends, and text without them is divided + * by how much each segment holds. + */ +function getSegmentsFromText(text: string, startName: DateSegmentName, baseSegments: DateSegments): DateSegments { + const digitGroups = text.match(DIGIT_GROUP_REGEX); + + if (!digitGroups) { + return baseSegments; + } + + const names = DATE_SEGMENT_NAMES.slice(DATE_SEGMENT_NAMES.indexOf(startName)); + const digitsBySegment = digitGroups.length > 1 ? digitGroups : splitDigitsBySegmentLength(digitGroups[0], names); + const filled = {...baseSegments}; + + for (const [index, name] of names.entries()) { + const digits = digitsBySegment.at(index); + + if (!digits) { + continue; + } + + filled[name] = digits.slice(0, getSegmentLength(name)); + } + + return filled; +} + +/** + * What a change added to the text a segment was showing, and where in that text it landed. The digits the segment held + * are still there when something was inserted beside them, so the new text is split around them. Undefined means none + * of them are left, which is what replacing a selection does. + */ +function getInsertion(previousValue: string, nextValue: string): {text: string; at: number} | undefined { + if (nextValue.length <= previousValue.length) { + return undefined; + } + + for (let keptLength = 0; keptLength <= previousValue.length; keptLength++) { + if (!nextValue.startsWith(previousValue.slice(0, keptLength)) || !nextValue.endsWith(previousValue.slice(keptLength))) { + continue; + } + + return {text: nextValue.slice(keptLength, nextValue.length - (previousValue.length - keptLength)), at: keptLength}; + } + + return undefined; +} + +/** + * What a paste into one segment should be read as, given the text that segment was showing and the text its input now + * reports. + * + * A paste too short to fill the segment is read into the digits already there, at the point it landed, the way typing + * it there would be. Anything else stands on its own, since a paste that fills the segment, carries separators of its + * own, or lands over a selection is a value rather than an edit of the one that was there. + */ +function getPastedText(name: DateSegmentName, previousValue: string, nextValue: string, isReplacingWholeDate: boolean): string { + // Selecting the whole date leaves the first segment's own digits unselected, so the paste arrives beside them and + // what was pasted is whatever sits on the other side. Reading it as an edit of those digits instead can split it + // in the wrong place, since a pasted date often starts with the very digits it is replacing. + if (isReplacingWholeDate) { + if (nextValue.startsWith(previousValue)) { + return nextValue.slice(previousValue.length); + } + + return nextValue.endsWith(previousValue) ? nextValue.slice(0, nextValue.length - previousValue.length) : nextValue; + } + + const insertion = getInsertion(previousValue, nextValue); + + if (!insertion) { + return nextValue; + } + + const segmentLength = getSegmentLength(name); + + if (insertion.text.length >= segmentLength || !DIGITS_ONLY_REGEX.test(insertion.text)) { + return insertion.text; + } + + // A segment with no room left starts the segment over when it is typed into, so a paste landing after its last + // digit does the same rather than being dropped for want of somewhere to go + if (insertion.at >= segmentLength) { + return insertion.text; + } + + return `${previousValue.slice(0, insertion.at)}${insertion.text}${previousValue.slice(insertion.at)}`.slice(0, segmentLength); +} + +/** + * Returns the date in the format the rest of the app stores, or undefined while a segment is empty or reads as an + * impossible date. + * + * A zero padded segment counts from one digit, since that is already what it shows. A day of 3 therefore reads as the + * third rather than waiting to find out whether it was going to be the 30th, and typing that second digit revises it. + */ +function getISODateFromSegments(segments: DateSegments): string | undefined { + if (segments.year.length !== YEAR_LENGTH || !segments.month || !segments.day) { + return undefined; + } + + const isoDate = `${segments.year}-${getSegmentDisplay(segments, 'month')}-${getSegmentDisplay(segments, 'day')}`; + + return isValid(parse(isoDate, CONST.DATE.FNS_FORMAT_STRING, new Date())) ? isoDate : undefined; +} + +/** + * What a cut or copy puts on the clipboard, which is the date the field is showing rather than only one it could hand + * over. An entry that does not read as a date keeps its separators, so it arrives somewhere else looking the way it + * looked here. + * + * A segment left empty before a filled one has no separator that would say which segment it was, so those digits go on + * their own and are read back by position. + */ +function getClipboardTextFromSegments(segments: DateSegments): string { + const displays = DATE_SEGMENT_NAMES.map((name) => getSegmentDisplay(segments, name)); + const hasGap = displays.some((display, index) => !display && displays.slice(index + 1).some(Boolean)); + + return hasGap ? displays.join('') : displays.filter(Boolean).join('-'); +} + +/** + * Fills out a segment reading as zero, which is the start of a number between one and nine that the user moved on + * without finishing. It settles on the first month or the first day, since no date has a zero and leaving one there + * would report a date that cannot exist over an entry the user had very nearly made. + * + * Returns `segments` itself when every segment already reads as a number, so a caller can tell nothing was filled out. + */ +function completeZeroSegments(segments: DateSegments): DateSegments { + const names = DATE_SEGMENT_NAMES.filter((name) => isZeroPaddedSegment(name) && !!segments[name] && Number(segments[name]) === 0); + + if (!names.length) { + return segments; + } + + return {...segments, ...Object.fromEntries(names.map((name) => [name, FIRST_SEGMENT_VALUE]))}; +} + +function hasAnySegment(segments: DateSegments): boolean { + return DATE_SEGMENT_NAMES.some((name) => !!segments[name]); +} + +export { + DATE_SEGMENT_NAMES, + clearSegmentsUpTo, + completeZeroSegments, + EMPTY_SEGMENTS, + getAdjacentSegmentName, + getClipboardTextFromSegments, + getDateMaskParts, + getFirstUnfilledSegmentName, + getISODateFromSegments, + getPastedText, + getSegmentDisplay, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +}; +export type {DateSegmentName, DateSegments}; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index bd35e8830789..dd7c823acb44 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -320,6 +320,28 @@ function getAgeRequirementError(translate: LocalizedTranslate, date: string, min return translate('privatePersonalDetails.error.dateShouldBeAfter', format(minimalDate, CONST.DATE.FNS_FORMAT_STRING)); } +/** + * Validate that a date sits inside the range its calendar would have offered. Typing reaches dates the calendar + * cannot, so a field that once could only hold a date the calendar offered now has to say when it does not. + */ +function getDateRangeError(translate: LocalizedTranslate, date: string, minimumDate: Date, maximumDate: Date): string { + const testDate = parse(date, CONST.DATE.FNS_FORMAT_STRING, new Date()); + + if (!isValid(testDate)) { + return translate('common.error.dateInvalid'); + } + + if (isAfter(testDate, startOfDay(maximumDate))) { + return translate('privatePersonalDetails.error.dateShouldBeBefore', format(maximumDate, CONST.DATE.FNS_FORMAT_STRING)); + } + + if (isBefore(testDate, startOfDay(minimumDate))) { + return translate('privatePersonalDetails.error.dateShouldBeAfter', format(minimumDate, CONST.DATE.FNS_FORMAT_STRING)); + } + + return ''; +} + /** * Validate that given date is not in the past. */ @@ -951,6 +973,7 @@ export { isNumeric, isValidAccountRoute, getDatePassedError, + getDateRangeError, isValidRecoveryCode, validateDateTimeIsAtLeastOneMinuteInFuture, prepareValues, diff --git a/src/libs/actions/Modal.ts b/src/libs/actions/Modal.ts index 9f504108d7c6..5109b054c9f5 100644 --- a/src/libs/actions/Modal.ts +++ b/src/libs/actions/Modal.ts @@ -120,4 +120,12 @@ function areAllModalsHidden() { return closeModals.length === 0; } -export {setCloseModal, close, onModalDidClose, setModalVisibility, setModalCovering, willAlertModalBecomeVisible, setDisableDismissOnEscape, closeTop, areAllModalsHidden}; +/** + * Whether this close callback is the topmost one, which is the one Escape reaches. Anything that answers Escape from + * outside this stack uses it to leave a modal opened above it alone. + */ +function isTopCloseModal(onClose: () => void) { + return closeModals.at(-1) === onClose; +} + +export {setCloseModal, close, onModalDidClose, setModalVisibility, setModalCovering, willAlertModalBecomeVisible, setDisableDismissOnEscape, closeTop, areAllModalsHidden, isTopCloseModal}; diff --git a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx index a39985870fdc..eca4f2413647 100644 --- a/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx +++ b/src/pages/settings/Wallet/PersonalCardEditTransactionStartDatePage.tsx @@ -10,10 +10,10 @@ import useDynamicBackPath from '@hooks/useDynamicBackPath'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionStartDateValidation from '@hooks/useTransactionStartDateValidation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {isRequiredFulfilled} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; @@ -56,10 +56,10 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans return format(new Date(), CONST.DATE.FNS_FORMAT_STRING); }); - const [errorText, setErrorText] = useState(''); + const {requiredError, onValidationErrorChange, forgetSubmitAttempt, validateOnSubmit} = useTransactionStartDateValidation({dateOption: dateOptionSelected, startDate}); - const handleSelectDateOption = (dateOption: DateOption) => { - setErrorText(''); + const selectDateOption = (dateOption: DateOption) => { + forgetSubmitAttempt(); setDateOptionSelected(dateOption); if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { return; @@ -76,8 +76,7 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans }; const submit = () => { - if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); + if (!validateOnSubmit()) { return; } @@ -117,7 +116,7 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans handleSelectDateOption(value)} + onSelectRow={({value}) => selectDateOption(value)} data={dateOptions} shouldSingleExecuteRowSelect initiallyFocusedItemKey={dateOptionSelected} @@ -140,16 +139,10 @@ function PersonalCardEditTransactionStartDatePage({route}: PersonalCardEditTrans inputID="" value={startDate} label={translate('iou.startDate')} - onInputChange={(value) => { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setStartDate(value); - }} + onInputChange={setStartDate} minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} + errorText={requiredError} + onValidationErrorChange={onValidationErrorChange} /> ) : null diff --git a/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx b/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx index da6dc2cde479..bbd76320ce8b 100644 --- a/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx +++ b/src/pages/workspace/companyCards/CompanyCardTransactionStartDateForm.tsx @@ -64,6 +64,8 @@ function CompanyCardTransactionStartDateForm({ const dateOptionSelected = localDateOption ?? initialDateOption; const [startDate, setStartDate] = useState(initialStartDate); const [errorText, setErrorText] = useState(''); + // A typed date the calendar would never have offered has to block saving, and the field shows the reason itself + const [dateValidationError, setDateValidationError] = useState(''); const handleSelectDateOption = (dateOption: DateOption) => { setErrorText(''); @@ -85,6 +87,10 @@ function CompanyCardTransactionStartDateForm({ return; } + if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && dateValidationError) { + return; + } + onSubmit(dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING ? '' : startDate); }; @@ -146,6 +152,7 @@ function CompanyCardTransactionStartDateForm({ }} minDate={CONST.CALENDAR_PICKER.MIN_DATE} errorText={errorText} + onValidationErrorChange={setDateValidationError} /> ) : ( diff --git a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx index ce7050fa39ce..ddef5429f371 100644 --- a/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx +++ b/src/pages/workspace/companyCards/assignCard/TransactionStartDateStep.tsx @@ -9,10 +9,10 @@ import Text from '@components/Text'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import useTransactionStartDateValidation from '@hooks/useTransactionStartDateValidation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; -import {isRequiredFulfilled} from '@libs/ValidationUtils'; import Navigation from '@navigation/Navigation'; @@ -41,12 +41,12 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { const cardToAssign = assignCard?.cardToAssign; const [localDateOption, setLocalDateOption] = useState(); - const [errorText, setErrorText] = useState(''); const [localStartDate, setLocalStartDate] = useState(); const dateOptionSelected = localDateOption ?? cardToAssign?.dateOption ?? CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; const startDate = localStartDate ?? cardToAssign?.startDate ?? format(new Date(), CONST.DATE.FNS_FORMAT_STRING); + const {requiredError, onValidationErrorChange, forgetSubmitAttempt, validateOnSubmit} = useTransactionStartDateValidation({dateOption: dateOptionSelected, startDate}); - const handleBackButtonPress = () => { + const goBackToPreviousStep = () => { if (isEditing) { setAssignCardStepAndData({ isEditing: false, @@ -55,8 +55,8 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { Navigation.goBack(); }; - const handleSelectDateOption = (dateOption: string) => { - setErrorText(''); + const selectDateOption = (dateOption: string) => { + forgetSubmitAttempt(); setLocalDateOption(dateOption); if (dateOption === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING) { return; @@ -65,8 +65,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { }; const submit = () => { - if (dateOptionSelected === CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM && !isRequiredFulfilled(startDate)) { - setErrorText(translate('common.error.fieldRequired')); + if (!validateOnSubmit()) { return; } @@ -107,7 +106,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { > @@ -122,7 +121,7 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { handleSelectDateOption(value)} + onSelectRow={({value}) => selectDateOption(value)} data={dateOptions} shouldSingleExecuteRowSelect initiallyFocusedItemKey={dateOptionSelected} @@ -145,16 +144,10 @@ function TransactionStartDateStep({route}: TransactionStartDateStepProps) { inputID="" value={startDate} label={translate('iou.startDate')} - onInputChange={(value) => { - if (!isRequiredFulfilled(value)) { - setErrorText(translate('common.error.fieldRequired')); - } else { - setErrorText(''); - } - setLocalStartDate(value); - }} + onInputChange={setLocalStartDate} minDate={CONST.CALENDAR_PICKER.MIN_DATE} - errorText={errorText} + errorText={requiredError} + onValidationErrorChange={onValidationErrorChange} /> ) : null diff --git a/src/styles/theme/themes/dark.ts b/src/styles/theme/themes/dark.ts index 50ff2a213588..5350a64db343 100644 --- a/src/styles/theme/themes/dark.ts +++ b/src/styles/theme/themes/dark.ts @@ -67,6 +67,7 @@ const darkTheme = { textDark: colors.productDark100, textReversed: colors.productLight900, textBackground: colors.productDark200, + textSelectionBackground: colors.blue700, textMutedReversed: colors.productDark700, textError: colors.tangerine300, textSuccess: colors.green300, diff --git a/src/styles/theme/themes/light.ts b/src/styles/theme/themes/light.ts index 87f9b53d95a3..748e499f323d 100644 --- a/src/styles/theme/themes/light.ts +++ b/src/styles/theme/themes/light.ts @@ -67,6 +67,7 @@ const lightTheme = { textDark: colors.productLight900, textReversed: colors.productDark900, textBackground: colors.productLight200, + textSelectionBackground: colors.blue100, textMutedReversed: colors.productLight700, textError: colors.tangerine600, textSuccess: colors.green600, diff --git a/src/styles/theme/types.ts b/src/styles/theme/types.ts index acaffebc8360..2055aff04dfb 100644 --- a/src/styles/theme/types.ts +++ b/src/styles/theme/types.ts @@ -75,6 +75,7 @@ type ThemeColors = { textDark: Color; textReversed: Color; textBackground: Color; + textSelectionBackground: Color; textMutedReversed: Color; textError: Color; textSuccess: Color; diff --git a/src/types/modules/react-native.d.ts b/src/types/modules/react-native.d.ts index 50a6535f707d..3948838f401b 100644 --- a/src/types/modules/react-native.d.ts +++ b/src/types/modules/react-native.d.ts @@ -48,6 +48,13 @@ declare module 'react-native' { }; } + /** On web this event is raised from a key down, so it carries the modifiers the browser reports with the key */ + interface TextInputKeyPressEventData { + metaKey?: boolean; + ctrlKey?: boolean; + shiftKey?: boolean; + } + interface PressableStateCallbackType extends WebPressableStateCallbackType { readonly isScreenReaderActive: boolean; readonly isDisabled: boolean; diff --git a/tests/unit/CalendarPickerTest.tsx b/tests/unit/CalendarPickerTest.tsx index eda87000ef5c..c94f0801deff 100644 --- a/tests/unit/CalendarPickerTest.tsx +++ b/tests/unit/CalendarPickerTest.tsx @@ -144,6 +144,25 @@ describe('CalendarPicker', () => { expect(screen.getByText(currentDate.getFullYear().toString())).toBeTruthy(); }); + test('opens on the nearest date it can offer when the field holds one outside the range', () => { + // Given a field that only accepts dates up to 2008, holding a date well past that, which validation reports + const minDate = new Date(1926, 8, 27); + const maxDate = new Date(2008, 8, 27); + + // When the calendar opens, as it does again every time it is reopened + render( + , + ); + + // Then it shows the last month it offers, rather than a year with no day that could be picked + expect(screen.getByText(monthNames.at(8) ?? '')).toBeTruthy(); + expect(screen.getByText('2008')).toBeTruthy(); + }); + test('clicking next month arrow updates the displayed month', () => { // Given a picker showing the current month, with bounds a year either side of today so the test doesn't depend on the date it runs const minDate = subYears(new Date(), 1); @@ -719,6 +738,223 @@ describe('CalendarPicker', () => { expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); }); + test('selecting a year reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2027-06-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month reports the date it lands on without ending the selection', () => { + const onSelectedMock = jest.fn(); + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-8')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-09-15'); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + + test('selecting a month keeps the day inside it', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentMonthButton')); + fireEvent.press(within(screen.getByTestId('MonthPickerModal')).getByTestId('month-option-1')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('selecting a non leap year from February 29 keeps the day inside February', () => { + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2025')); + + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2025-02-28'); + }); + + test('a year selection that would overshoot the maximum reports the date the view is held to', () => { + // Given a field whose newest allowed date is part way through the year being picked + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + // When that year is picked, carrying December along with it + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2008')); + + // Then the date reported is pulled into range, so the field cannot end up showing a date the view refuses to + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2008-10-16'); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(9) ?? '')).toBeTruthy(); + }); + + test('a year selection that would undershoot the minimum reports the date the view is held to', () => { + // Given a field whose oldest allowed date is part way through the year being picked + const onMonthOrYearSelectedMock = jest.fn(); + render( + , + ); + + // When that year is picked, carrying January along with it + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2008')); + + // Then the date reported is pulled up to the bound the view settles on + expect(onMonthOrYearSelectedMock).toHaveBeenCalledWith('2008-10-16'); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(9) ?? '')).toBeTruthy(); + }); + + test('the view follows a date the caller asserts', () => { + // Given a calendar told which month to show, which is how it follows a date being typed + const {rerender} = render( + , + ); + expect(within(screen.getByTestId('currentYearText')).getByText('2025')).toBeTruthy(); + + // When another month is asserted + rerender( + , + ); + + // Then the calendar moves to it without waiting for a press + expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(within(screen.getByTestId('currentMonthText')).getByText(monthNames.at(2) ?? '')).toBeTruthy(); + }); + + test('the view stays put while the count does not move', () => { + // Given a calendar showing a month it was asked for + const {rerender} = render( + , + ); + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2029')); + expect(within(screen.getByTestId('currentYearText')).getByText('2029')).toBeTruthy(); + + // When a render arrives carrying the same request as before + rerender( + , + ); + + // Then where the user moved to is left alone, since the request was already answered + expect(within(screen.getByTestId('currentYearText')).getByText('2029')).toBeTruthy(); + }); + + test('the view answers a repeated month when the count moves', () => { + // Given a calendar the user has moved away from the month it was asked for + const {rerender} = render( + , + ); + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2029')); + expect(within(screen.getByTestId('currentYearText')).getByText('2029')).toBeTruthy(); + + // When the same month is asserted again under a new count, as retyping it does + rerender( + , + ); + + // Then the calendar goes back to it, which is why the count carries the request rather than the date alone + expect(within(screen.getByTestId('currentYearText')).getByText('2025')).toBeTruthy(); + }); + + test('a month or year selection only moves the view when the caller does not ask to be told', () => { + const onSelectedMock = jest.fn(); + render( + , + ); + + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(within(screen.getByTestId('YearPickerModal')).getByTestId('year-option-2027')); + + expect(within(screen.getByTestId('currentYearText')).getByText('2027')).toBeTruthy(); + expect(onSelectedMock).not.toHaveBeenCalled(); + }); + test('picking a year from February 29 shows February of a year without it', () => { // Given a picker showing February 29, 2024 render( diff --git a/tests/unit/DateInputMaskUtilsTest.ts b/tests/unit/DateInputMaskUtilsTest.ts new file mode 100644 index 000000000000..2049e3f1a39e --- /dev/null +++ b/tests/unit/DateInputMaskUtilsTest.ts @@ -0,0 +1,549 @@ +import { + clearSegmentsUpTo, + completeZeroSegments, + getAdjacentSegmentName, + getClipboardTextFromSegments, + getDateMaskParts, + getFirstUnfilledSegmentName, + getISODateFromSegments, + getPastedText, + getSegmentDisplay, + getSegmentsFromISODate, + getSegmentsFromText, + getViewDateFromSegments, + hasAnySegment, + removeLastDigit, + typeDigitIntoSegments, +} from '@libs/DateInputMaskUtils'; +import type {DateSegments} from '@libs/DateInputMaskUtils'; + +const MASK = 'YYYY-MM-DD'; +const EMPTY: DateSegments = {year: '', month: '', day: ''}; + +function segments(year: string, month: string, day: string): DateSegments { + return {year, month, day}; +} + +describe('DateInputMaskUtils', () => { + describe('typeDigitIntoSegments', () => { + it('completes the year on the fourth digit', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '6')).toEqual({segments: segments('2026', '', ''), nextSegmentName: 'month'}); + expect(typeDigitIntoSegments(segments('20', '', ''), 'year', '2')).toEqual({segments: segments('202', '', ''), nextSegmentName: undefined}); + }); + + it('takes a leading zero in the year, which only validation can reject', () => { + expect(typeDigitIntoSegments(EMPTY, 'year', '0')).toEqual({segments: segments('0', '', ''), nextSegmentName: undefined}); + }); + + it('starts the year over when it is already full', () => { + expect(typeDigitIntoSegments(segments('2026', '', ''), 'year', '1')).toEqual({segments: segments('1', '', ''), nextSegmentName: undefined}); + }); + + it('replaces the segment rather than extending it when told to overwrite', () => { + expect(typeDigitIntoSegments(segments('202', '', ''), 'year', '9', true)).toEqual({segments: segments('9', '', ''), nextSegmentName: undefined}); + }); + + it('zero pads a month that cannot start a two digit month', () => { + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '9')).toEqual({segments: segments('2026', '09', ''), nextSegmentName: 'day'}); + }); + + it('waits for a second digit when the month could still be a teen month', () => { + expect(typeDigitIntoSegments(segments('2026', '', ''), 'month', '1')).toEqual({segments: segments('2026', '1', ''), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '2')).toEqual({segments: segments('2026', '12', ''), nextSegmentName: 'day'}); + }); + + it('carries a digit the month cannot take into the day', () => { + expect(typeDigitIntoSegments(segments('2026', '1', ''), 'month', '3')).toEqual({segments: segments('2026', '01', '3'), nextSegmentName: 'day'}); + }); + + it('waits on a zero rather than padding it, since 0 is not a month', () => { + expect(typeDigitIntoSegments(segments('2026', '0', ''), 'month', '0')).toEqual({segments: segments('2026', '0', ''), nextSegmentName: undefined}); + }); + + it('settles a zero that starts a filled segment over on the first month or day', () => { + expect(typeDigitIntoSegments(segments('2026', '09', '05'), 'day', '0')).toEqual({segments: segments('2026', '09', '01'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '12', ''), 'month', '0')).toEqual({segments: segments('2026', '01', ''), nextSegmentName: undefined}); + }); + + it('lets the next digit replace a zero that settled that way, so 0 then 5 still reads as the fifth', () => { + expect(typeDigitIntoSegments(segments('2026', '09', '01'), 'day', '5')).toEqual({segments: segments('2026', '09', '05'), nextSegmentName: undefined}); + }); + + it('keeps a day the month that was typed does not have, rather than reading it as another day', () => { + // Given February, which has no 31st, so the entry is left to be reported instead of answered with the 3rd + expect(typeDigitIntoSegments(segments('2026', '02', '3'), 'day', '1')).toEqual({segments: segments('2026', '02', '31'), nextSegmentName: undefined}); + + // Given January, where the same keystrokes read the same way + expect(typeDigitIntoSegments(segments('2026', '01', '3'), 'day', '1')).toEqual({segments: segments('2026', '01', '31'), nextSegmentName: undefined}); + }); + + it('waits for a second digit after a 3 whichever month was typed', () => { + // Given February, where a 3 could still be meant to become the 31st the user is about to be told about + expect(typeDigitIntoSegments(segments('2026', '02', ''), 'day', '3')).toEqual({segments: segments('2026', '02', '3'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '03', ''), 'day', '3')).toEqual({segments: segments('2026', '03', '3'), nextSegmentName: undefined}); + }); + + it('keeps February 29 whether or not the year is a leap year', () => { + expect(typeDigitIntoSegments(segments('2024', '02', '2'), 'day', '9')).toEqual({segments: segments('2024', '02', '29'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '02', '2'), 'day', '9')).toEqual({segments: segments('2026', '02', '29'), nextSegmentName: undefined}); + }); + + it('caps the day at the longest month there is', () => { + expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '1')).toEqual({segments: segments('2026', '', '31'), nextSegmentName: undefined}); + expect(typeDigitIntoSegments(segments('2026', '', '3'), 'day', '2')).toEqual({segments: segments('2026', '', '03'), nextSegmentName: undefined}); + }); + + it('leaves the day alone when the month typed after it does not have that day', () => { + // Given the 31st typed before February, which the user is told about rather than moved off + expect(typeDigitIntoSegments(segments('2026', '0', '31'), 'month', '2').segments).toEqual(segments('2026', '02', '31')); + }); + + it('leaves the day alone when the year typed after it takes the leap day away', () => { + expect(typeDigitIntoSegments(segments('202', '02', '29'), 'year', '6').segments).toEqual(segments('2026', '02', '29')); + }); + + it('has nothing to carry into past the day, so a rejected pair restarts it', () => { + expect(typeDigitIntoSegments(segments('2026', '09', '3'), 'day', '9')).toEqual({segments: segments('2026', '09', '03'), nextSegmentName: undefined}); + }); + }); + + describe('getViewDateFromSegments', () => { + /** September 2026, standing in for the month and year the calendar happens to be showing */ + const FALLBACK_DATE = new Date(2026, 8, 1); + const MIN_DATE = new Date(1876, 8, 17); + const MAX_DATE = new Date(2126, 8, 17); + const viewDateFor = (dateSegments: DateSegments) => getViewDateFromSegments(dateSegments, FALLBACK_DATE, MIN_DATE, MAX_DATE); + + it('leaves the calendar alone while the year is unfinished and no month has been typed', () => { + expect(viewDateFor(segments('202', '', ''))).toBeUndefined(); + expect(viewDateFor(EMPTY)).toBeUndefined(); + }); + + it('moves the month typed before a year, keeping the year on screen', () => { + // Given a month typed into a field whose year is still empty + // When the calendar is asked where to go + // Then it follows the month rather than waiting for a year it may not be given next + expect(viewDateFor(segments('', '03', ''))).toEqual(new Date(2026, 2, 1)); + expect(viewDateFor(segments('19', '03', ''))).toEqual(new Date(2026, 2, 1)); + }); + + it('pulls the year on screen into range for a month typed before a year', () => { + // Given a field that only accepts dates up to September 2008, such as a date of birth, while the calendar + // starts from today and is therefore showing a year that can never be picked + const maxDate = new Date(2008, 8, 27); + + // When a month is typed before any year + // Then the month lands in the newest year that can be picked, rather than being refused as out of range + expect(getViewDateFromSegments(segments('', '03', ''), FALLBACK_DATE, MIN_DATE, maxDate)).toEqual(new Date(2008, 2, 1)); + }); + + it('moves the year while keeping the month on screen', () => { + expect(viewDateFor(segments('2030', '', ''))).toEqual(new Date(2030, 8, 1)); + expect(viewDateFor(segments('2030', '1', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('moves the month once both of its digits read as a real month', () => { + expect(viewDateFor(segments('2030', '02', ''))).toEqual(new Date(2030, 1, 1)); + expect(viewDateFor(segments('2030', '00', ''))).toEqual(new Date(2030, 8, 1)); + }); + + it('carries the day, so a month or year picked from the calendar keeps it', () => { + // Given a day already in the field + // When the calendar is asked where to go + // Then the day comes along, since the calendar builds a month or year pick from the date it is showing + expect(viewDateFor(segments('2030', '02', '28'))).toEqual(new Date(2030, 1, 28)); + }); + + it('holds a day to the month it lands in, which would otherwise carry the view into the next one', () => { + // Given the 31st, typed while the month still read as a longer one + // When the month that is typed next only has 30 days + // Then the view stays in that month on its last day rather than moving on to the first of the next + expect(viewDateFor(segments('2030', '04', '31'))).toEqual(new Date(2030, 3, 30)); + }); + + it('leaves the day out while it reads as nothing to go to', () => { + expect(viewDateFor(segments('2030', '02', ''))).toEqual(new Date(2030, 1, 1)); + expect(viewDateFor(segments('2030', '02', '0'))).toEqual(new Date(2030, 1, 1)); + }); + + it('leaves the calendar alone for a year below the hundreds, which no date can carry', () => { + // Given a year the Date constructor would read as a shorthand for the twentieth century + // When the calendar is asked where to go + // Then it stays where it is, rather than claiming the 1999 that year would have arrived as + expect(viewDateFor(segments('0099', '', ''))).toBeUndefined(); + + // Given a month typed alongside it, which is somewhere the calendar can go + // Then the month is followed in the year already on screen + expect(viewDateFor(segments('0099', '03', ''))).toEqual(new Date(2026, 2, 1)); + }); + + it('follows a year outside the range, which validation is what reports', () => { + // Given a year nowhere near the dates the field accepts + // When the calendar is asked where to go + // Then it follows all the same, so it does not sit on a month that disagrees with what the field shows + expect(viewDateFor(segments('1111', '01', '03'))).toEqual(new Date(1111, 0, 3)); + expect(viewDateFor(segments('9999', '01', ''))).toEqual(new Date(9999, 0, 1)); + }); + + it('follows a month at either limit, including one with no day left to select', () => { + expect(viewDateFor(segments('1876', '09', ''))).toEqual(new Date(1876, 8, 1)); + expect(viewDateFor(segments('1876', '08', ''))).toEqual(new Date(1876, 7, 1)); + }); + }); + + describe('removeLastDigit', () => { + it('drops one digit at a time', () => { + expect(removeLastDigit(segments('2026', '', ''), 'year')).toEqual(segments('202', '', '')); + expect(removeLastDigit(segments('2', '', ''), 'year')).toEqual(EMPTY); + }); + + it('reports that an empty segment had nothing to drop', () => { + expect(removeLastDigit(EMPTY, 'year')).toBeUndefined(); + }); + }); + + describe('clearSegmentsUpTo', () => { + it('empties the segment and everything before it, leaving the rest alone', () => { + // Given the day, which is the last segment, so there is nothing after it to keep + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'day')).toEqual(EMPTY); + + // Given the month, which leaves the day the user has already entered + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'month')).toEqual(segments('', '', '02')); + + // Given the year, which is the first segment, so only it is emptied + expect(clearSegmentsUpTo(segments('2026', '09', '02'), 'year')).toEqual(segments('', '09', '02')); + }); + + it('leaves an already empty date empty', () => { + expect(clearSegmentsUpTo(EMPTY, 'day')).toEqual(EMPTY); + }); + }); + + describe('getSegmentDisplay', () => { + it('shows nothing for an empty segment, leaving its own placeholder to show through', () => { + expect(getSegmentDisplay(EMPTY, 'year')).toBe(''); + expect(getSegmentDisplay(EMPTY, 'month')).toBe(''); + }); + + it('shows the year as far as it has been typed', () => { + expect(getSegmentDisplay(segments('2', '', ''), 'year')).toBe('2'); + expect(getSegmentDisplay(segments('20', '', ''), 'year')).toBe('20'); + expect(getSegmentDisplay(segments('2026', '', ''), 'year')).toBe('2026'); + }); + + it('zero pads a half typed month or day, which fill from the right', () => { + expect(getSegmentDisplay(segments('2026', '1', ''), 'month')).toBe('01'); + expect(getSegmentDisplay(segments('2026', '09', '2'), 'day')).toBe('02'); + }); + + it('shows a finished month or day as typed', () => { + expect(getSegmentDisplay(segments('2026', '09', '18'), 'month')).toBe('09'); + expect(getSegmentDisplay(segments('2026', '09', '18'), 'day')).toBe('18'); + }); + }); + + describe('getDateMaskParts', () => { + it('reads the segment order, placeholders and separators out of the mask', () => { + expect(getDateMaskParts(MASK)).toEqual([ + {name: 'year', placeholder: 'YYYY', separator: '-'}, + {name: 'month', placeholder: 'MM', separator: '-'}, + {name: 'day', placeholder: 'DD', separator: ''}, + ]); + }); + + it('uses the letters and separators of the localized mask', () => { + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.placeholder)).toEqual(['AAAA', 'MM', 'JJ']); + expect(getDateMaskParts('AAAA/MM/JJ').map((part) => part.separator)).toEqual(['/', '/', '']); + }); + }); + + describe('getAdjacentSegmentName', () => { + it('moves between segments', () => { + expect(getAdjacentSegmentName('year', 1)).toBe('month'); + expect(getAdjacentSegmentName('month', -1)).toBe('year'); + }); + + it('stays on the outermost segment rather than wrapping', () => { + expect(getAdjacentSegmentName('year', -1)).toBe('year'); + expect(getAdjacentSegmentName('day', 1)).toBe('day'); + }); + }); + + describe('getFirstUnfilledSegmentName', () => { + it('points at the year on an untouched date', () => { + expect(getFirstUnfilledSegmentName(EMPTY)).toBe('year'); + }); + + it('skips the segments already holding all of their digits', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '', ''))).toBe('month'); + expect(getFirstUnfilledSegmentName(segments('2026', '09', ''))).toBe('day'); + }); + + it('counts a half typed segment as unfilled', () => { + expect(getFirstUnfilledSegmentName(segments('202', '09', '18'))).toBe('year'); + expect(getFirstUnfilledSegmentName(segments('2026', '1', ''))).toBe('month'); + }); + + it('reports nothing once the whole date is filled in', () => { + expect(getFirstUnfilledSegmentName(segments('2026', '09', '18'))).toBeUndefined(); + }); + }); + + describe('getPastedText', () => { + it('reads a whole year pasted beside the digits the segment was showing', () => { + // Given a year reading 1990 with the caret resting between the 19 and the 90 + // When a year is pasted there + // Then only the pasted year is read back, rather than the digits it was dropped between + expect(getPastedText('year', '1990', '19199890', false)).toBe('1998'); + }); + + it('reads a whole year pasted at either end of the segment', () => { + expect(getPastedText('year', '1990', '19901998', false)).toBe('1998'); + expect(getPastedText('year', '1990', '19981990', false)).toBe('1998'); + }); + + it('reads a paste too short to fill the segment into the digits already there', () => { + // Given a year reading 1990 with the caret resting between the 19 and the 90 + // When two digits are pasted there, which say nothing about the rest of the year + // Then they are read in where they landed, the way typing them there would be + expect(getPastedText('year', '1990', '198890', false)).toBe('1988'); + expect(getPastedText('year', '1990', '881990', false)).toBe('8819'); + expect(getPastedText('month', '05', '085', false)).toBe('08'); + }); + + it('extends a segment that still has room for the paste', () => { + expect(getPastedText('year', '19', '1988', false)).toBe('1988'); + }); + + it('starts a full segment over for a paste that lands after its last digit', () => { + // Given a year with no room left, which typing into starts over rather than extending + // When two digits are pasted after it + // Then they start the year over too, rather than being dropped for want of anywhere to go + expect(getPastedText('year', '1990', '199088', false)).toBe('88'); + expect(getPastedText('month', '05', '057', false)).toBe('7'); + }); + + it('reads a paste carrying separators of its own as the date it describes', () => { + expect(getPastedText('year', '1990', '192026-09-1890', false)).toBe('2026-09-18'); + }); + + it('reads a paste over a segment that was selected', () => { + // Given a year whose digits were all selected, so none of them are left in what the input now reports + // When a year is pasted over them + // Then the whole of what the input reports is read back as the paste + expect(getPastedText('year', '1990', '1998', false)).toBe('1998'); + expect(getPastedText('year', '1990', '12', false)).toBe('12'); + }); + + it('replaces rather than merges while the whole date is selected', () => { + // Given the whole date selected, which no selection in the segment's own input stands for + // When two digits are pasted into the year + // Then they replace the date rather than being read into the year that is being replaced + expect(getPastedText('year', '1990', '881990', true)).toBe('88'); + }); + + it('replaces the whole date with a paste that starts with the digits it lands beside', () => { + // Given the whole date selected, with the caret resting after the year's own digits + // When a date beginning with that same year is pasted + // Then the year is not read as part of what was pasted, which would shift every segment along + expect(getPastedText('year', '2002', '20022002-12-02', true)).toBe('2002-12-02'); + }); + + it('reads a paste into an empty segment', () => { + expect(getPastedText('year', '', '1998', false)).toBe('1998'); + expect(getPastedText('year', '', '88', false)).toBe('88'); + }); + }); + + describe('getSegmentsFromText', () => { + it('fills the segments from a pasted date', () => { + expect(getSegmentsFromText('2026-09-18', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); + expect(getSegmentsFromText('20260918', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); + }); + + it('stops once every segment is full', () => { + expect(getSegmentsFromText('20260918123', 'year', EMPTY)).toEqual(segments('2026', '09', '18')); + }); + + it('fills what it can from a partial date', () => { + expect(getSegmentsFromText('2026-09', 'year', EMPTY)).toEqual(segments('2026', '09', '')); + expect(getSegmentsFromText('', 'year', EMPTY)).toEqual(EMPTY); + }); + + it('starts at the pasted-into segment rather than at the year', () => { + // Given a year that is already filled in + // When a month is pasted into the month segment + // Then the year is left alone, rather than being overwritten by the pasted digits + expect(getSegmentsFromText('12', 'month', segments('1990', '', ''))).toEqual(segments('1990', '12', '')); + }); + + it('keeps the segments the pasted digits never reach', () => { + // Given a date that is filled in + // When a single digit is pasted into the year + // Then only the year is started over, because the paste never reached the month or the day + expect(getSegmentsFromText('1', 'year', segments('1990', '12', '04'))).toEqual(segments('1', '12', '04')); + }); + + it('replaces a segment the digits reach rather than extending it', () => { + // Given a full date, and a whole date pasted over it starting at the year + // When the digits carry through every segment + // Then each one holds only the pasted digits, rather than the old ones with the new appended + expect(getSegmentsFromText('2026-09-18', 'year', segments('1990', '12', '04'))).toEqual(segments('2026', '09', '18')); + }); + + it('returns the segments it was given when the text holds no digits', () => { + // Given text that is not a date at all, which is how a caller tells that nothing was pasted + const baseSegments = segments('1990', '12', '04'); + + expect(getSegmentsFromText('hello', 'year', baseSegments)).toBe(baseSegments); + expect(getSegmentsFromText('', 'month', baseSegments)).toBe(baseSegments); + }); + + it('keeps a pasted day its month does not have, rather than reading it as another day', () => { + // Given the 31st of a February, which is not a date + // When it is pasted + // Then the segments hold what was pasted, so the field can report it instead of showing the 3rd + expect(getSegmentsFromText('1990-02-31', 'year', EMPTY)).toEqual(segments('1990', '02', '31')); + expect(getSegmentsFromText('19900231', 'year', EMPTY)).toEqual(segments('1990', '02', '31')); + }); + + it('keeps a pasted number no segment could hold', () => { + // Given a month that cannot read as 13 + // When it is pasted + // Then the month holds it and reads as the impossible date it is, rather than becoming January the 3rd + expect(getSegmentsFromText('13', 'month', EMPTY)).toEqual(segments('', '13', '')); + }); + + it('reads a separated date that pads neither its month nor its day', () => { + // Given a date written the short way + // When it is pasted + // Then the separators say where each segment ends, so the month and the day are not run together + expect(getSegmentsFromText('1990-2-3', 'year', EMPTY)).toEqual(segments('1990', '2', '3')); + }); + }); + + describe('getISODateFromSegments', () => { + it('returns the stored format once every segment is filled in', () => { + expect(getISODateFromSegments(segments('2026', '09', '18'))).toBe('2026-09-18'); + }); + + it('reads a zero padded segment from one digit, since that is what it already shows', () => { + expect(getISODateFromSegments(segments('2026', '9', '18'))).toBe('2026-09-18'); + expect(getISODateFromSegments(segments('2026', '09', '3'))).toBe('2026-09-03'); + }); + + it('returns undefined while a segment is empty or the year is unfinished', () => { + expect(getISODateFromSegments(segments('202', '09', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', ''))).toBeUndefined(); + expect(getISODateFromSegments(EMPTY)).toBeUndefined(); + }); + + it('refuses a segment that cannot be a month or a day, which only a zero can be', () => { + expect(getISODateFromSegments(segments('2026', '0', '18'))).toBeUndefined(); + expect(getISODateFromSegments(segments('2026', '09', '0'))).toBeUndefined(); + }); + + it('refuses a day the typed month does not have', () => { + expect(getISODateFromSegments(segments('2026', '02', '31'))).toBeUndefined(); + }); + }); + + describe('getSegmentsFromISODate', () => { + it('reads back a stored date', () => { + expect(getSegmentsFromISODate('2026-09-18')).toEqual(segments('2026', '09', '18')); + }); + + it('returns empty segments for a value it cannot parse', () => { + expect(getSegmentsFromISODate('')).toEqual(EMPTY); + expect(getSegmentsFromISODate(undefined)).toEqual(EMPTY); + expect(getSegmentsFromISODate('not a date')).toEqual(EMPTY); + }); + }); + + describe('getClipboardTextFromSegments', () => { + it('copies a finished date in the format the app stores', () => { + // Given every segment filled in + // When the date is cut or copied + // Then the clipboard carries the same text any other field would be given + expect(getClipboardTextFromSegments(segments('1990', '02', '03'))).toBe('1990-02-03'); + }); + + it('copies a date that does not exist in the shape it was written in', () => { + // Given digits the field is holding back because they do not read as a date + // When they are cut or copied + // Then they arrive elsewhere looking the way they looked here rather than as a run of digits + expect(getClipboardTextFromSegments(segments('2003', '02', '31'))).toBe('2003-02-31'); + }); + + it('copies the segments filled in so far while the date is unfinished', () => { + // Given a date the user has only started + // When it is cut or copied + // Then what was filled in is carried over, so pasting it back fills the same segments again + expect(getClipboardTextFromSegments(segments('1990', '', ''))).toBe('1990'); + expect(getClipboardTextFromSegments(segments('1990', '2', ''))).toBe('1990-02'); + }); + + it('copies digits with a segment skipped over on their own', () => { + // Given a month filled in with no year before it, so no separator says which segment the digits came from + // When it is cut or copied + // Then they are handed over to be read back by position + expect(getClipboardTextFromSegments(segments('', '09', ''))).toBe('09'); + }); + + it('has nothing to offer when no digit has been typed', () => { + // Given a field showing nothing but its mask + // When it is cut or copied + // Then the clipboard is left alone rather than being emptied + expect(getClipboardTextFromSegments(EMPTY)).toBe(''); + }); + }); + + describe('hasAnySegment', () => { + it('reports whether anything has been filled in', () => { + expect(hasAnySegment(EMPTY)).toBe(false); + expect(hasAnySegment(segments('', '09', ''))).toBe(true); + }); + }); + + describe('completeZeroSegments', () => { + it('settles a segment left reading as zero on the first month or day', () => { + // Given a month or day the user moved on from after typing only its zero + // When the field is left + // Then it settles on the first one, since no date has a zeroth month or day + expect(completeZeroSegments(segments('2026', '0', '15'))).toEqual(segments('2026', '01', '15')); + expect(completeZeroSegments(segments('2026', '09', '0'))).toEqual(segments('2026', '09', '01')); + expect(completeZeroSegments(segments('2026', '0', '0'))).toEqual(segments('2026', '01', '01')); + }); + + it('settles a segment whose digits are all zeroes', () => { + // Given both digits typed as zeroes, which reads as the same unusable number + // When the field is left + // Then it settles the same way as a single zero does + expect(completeZeroSegments(segments('2026', '00', '15'))).toEqual(segments('2026', '01', '15')); + }); + + it('returns the segments themselves when there is nothing to settle', () => { + // Given segments that already read as numbers a date can hold + // When the field is left + // Then the very same object comes back, which is how the caller tells nothing was filled out + const filled = segments('2026', '09', '15'); + expect(completeZeroSegments(filled)).toBe(filled); + expect(completeZeroSegments(EMPTY)).toBe(EMPTY); + }); + + it('leaves an empty segment empty', () => { + // Given a segment nothing was typed into, as opposed to one holding a zero + // When the field is left + // Then it stays empty, since the user never started a number there to finish + expect(completeZeroSegments(segments('2026', '', '0'))).toEqual(segments('2026', '', '01')); + }); + + it('leaves a year of zeroes alone', () => { + // Given a year reading as zero, which is four digits rather than a number cut short + // When the field is left + // Then it is left as it is, and the date it cannot make is reported instead + expect(completeZeroSegments(segments('0000', '09', '15'))).toEqual(segments('0000', '09', '15')); + }); + }); +}); diff --git a/tests/unit/DatePickerTypingTest.tsx b/tests/unit/DatePickerTypingTest.tsx new file mode 100644 index 000000000000..3a67652c8531 --- /dev/null +++ b/tests/unit/DatePickerTypingTest.tsx @@ -0,0 +1,240 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import DatePicker from '@components/DatePicker'; +import type {DateInputWithPickerProps} from '@components/DatePicker/types'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import type * as DeviceCapabilities from '@libs/DeviceCapabilities'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import type * as ReactNavigationNative from '@react-navigation/native'; +import type {PropsWithChildren} from 'react'; + +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type MockYearPickerModalProps = { + isVisible: boolean; + minYear: number; + maxYear: number; + onYearChange?: (year: number) => void; +}; + +type MockReactNativePrimitives = { + Pressable: React.ComponentType>; + Text: React.ComponentType>; + View: React.ComponentType>; +}; + +// Typing is only offered where the calendar can sit beside the field, so a touch screen turns the whole feature off +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => false, +})); + +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: jest.fn(), + addListener: jest.fn(() => jest.fn()), + }), + useIsFocused: () => true, + useFocusEffect: () => {}, +})); + +// The window's own width is what decides whether there is room to show a calendar beside the field +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +// The calendar is measured against a layout jest never performs, so it is rendered in place of its popover +jest.mock('@components/PopoverWithMeasuredContent', () => jest.fn(({isVisible, children}: PropsWithChildren<{isVisible: boolean}>) => (isVisible ? children : null))); + +// The year list is a modal of its own, so it stands in as one row per year the calendar is willing to offer +jest.mock('@components/DatePicker/CalendarPicker/YearPickerModal', () => { + const ReactNativeActual = jest.requireActual('react-native'); + const {Pressable, Text, View} = ReactNativeActual; + + function MockYearPickerModal({isVisible, minYear, maxYear, onYearChange}: MockYearPickerModalProps) { + if (!isVisible) { + return null; + } + + const years = Array.from({length: maxYear - minYear + 1}, (value, index) => minYear + index); + + return ( + + {years.map((year) => ( + onYearChange?.(year)} + > + {year.toString()} + + ))} + + ); + } + + return MockYearPickerModal; +}); + +const SEGMENT_LABELS = { + year: 'Year', + month: 'Month', + day: 'Day', +}; + +function focusSegment(label: string) { + fireEvent(screen.getByLabelText(label), 'focus', {nativeEvent: {}}); +} + +function pressKeys(label: string, keys: string[]) { + for (const key of keys) { + fireEvent(screen.getByLabelText(label), 'keyPress', { + preventDefault: jest.fn(), + nativeEvent: {key}, + }); + } +} + +async function renderDatePicker(props: Partial = {}) { + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); +} + +describe('DatePicker typing', () => { + beforeEach(() => { + jest.mocked(useResponsiveLayout).mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isExtraLargeScreenWidth: false, + isInLandscapeMode: false, + }); + }); + + // The segments are found by the accessibility labels the locale gives them + beforeAll(async () => { + await act(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + }); + + it('hands over a date once the last segment of it has been typed', async () => { + // Given a date field on a wide screen without a touch screen, which is where typing is offered + const onInputChange = jest.fn(); + await renderDatePicker({onInputChange}); + + // When a whole date is typed, the caret moving on by itself as each segment fills + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, ['2', '0', '2', '4']); + pressKeys(SEGMENT_LABELS.month, ['0', '3']); + pressKeys(SEGMENT_LABELS.day, ['1', '7']); + + // Then the field reports the date the way the calendar would have + expect(onInputChange).toHaveBeenLastCalledWith('2024-03-17'); + }); + + it('withholds a date that is still part way through being typed', async () => { + // Given the same field + const onInputChange = jest.fn(); + await renderDatePicker({onInputChange}); + + // When only the year and the month have been entered + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, ['2', '0', '2', '4']); + pressKeys(SEGMENT_LABELS.month, ['0', '3']); + + // Then nothing is handed over, since a half written date would replace the one already saved + expect(onInputChange).not.toHaveBeenCalled(); + }); + + it('shows the digits that have been typed so far', async () => { + // Given a field being filled in + await renderDatePicker(); + + // When the year is typed + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, ['1', '9', '9', '0']); + + // Then the year segment holds those digits and the caret has moved on to the month + expect(screen.getByLabelText(SEGMENT_LABELS.year).props.value).toBe('1990'); + expect(screen.getByLabelText(SEGMENT_LABELS.month).props.value).toBe(''); + }); + + it('opens the calendar as soon as the field is typed in, so the two show the same date', async () => { + // Given a field whose calendar is closed + const onPickerVisibilityChange = jest.fn(); + await renderDatePicker({onPickerVisibilityChange}); + + // When the first digit is typed + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, ['2']); + + // Then the calendar opens alongside, rather than waiting for the field to be pressed + expect(onPickerVisibilityChange).toHaveBeenCalledWith(true); + }); + + it('reports a typed date the calendar would never have offered, and keeps it to itself', async () => { + // Given a field bounded to a range, which the calendar enforces by only drawing the days inside it + const onValidationErrorChange = jest.fn(); + const onInputChange = jest.fn(); + await renderDatePicker({ + onValidationErrorChange, + onInputChange, + minDate: new Date('2024-01-01'), + maxDate: new Date('2024-12-31'), + }); + + // When a date outside those bounds is typed, which only typing can reach + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, ['2', '0', '3', '0']); + pressKeys(SEGMENT_LABELS.month, ['0', '5']); + pressKeys(SEGMENT_LABELS.day, ['0', '9']); + + // Then the field reports an error naming the range, which is what the page's submit guard reads + expect(onValidationErrorChange).toHaveBeenLastCalledWith(expect.stringContaining('2024')); + + // And it never hands the date over, since a field that saves on every change would already have stored it + expect(onInputChange).not.toHaveBeenCalledWith('2030-05-09'); + }); + + it('restores a year taken apart by hand when the calendar is asked for the same year back', async () => { + // Given a field holding a date whose year the user has taken the last digit out of, which is held back as no + // date at all, so the field still reports the date it had before + await renderDatePicker({value: '1990-05-15', minDate: new Date('1900-01-01'), maxDate: new Date('2100-12-31')}); + focusSegment(SEGMENT_LABELS.year); + pressKeys(SEGMENT_LABELS.year, [CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey]); + expect(screen.getByLabelText(SEGMENT_LABELS.year).props.value).toBe('199'); + + // When the same year is picked from the calendar, which hands over the date the field already holds + fireEvent.press(screen.getByTestId('currentYearButton')); + fireEvent.press(screen.getByTestId('year-option-1990')); + await waitForBatchedUpdatesWithAct(); + + // Then the year is whole again, rather than being left behind because the date never changed + expect(screen.getByLabelText(SEGMENT_LABELS.year).props.value).toBe('1990'); + }); +}); diff --git a/tests/unit/DateSegmentsInputTest.tsx b/tests/unit/DateSegmentsInputTest.tsx new file mode 100644 index 000000000000..2e2f2e7483f5 --- /dev/null +++ b/tests/unit/DateSegmentsInputTest.tsx @@ -0,0 +1,153 @@ +import {act, fireEvent, render, screen} from '@testing-library/react-native'; + +import ComposeProviders from '@components/ComposeProviders'; +import DateSegmentsInput from '@components/DateSegmentsInput'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import type {BaseTextInputProps} from '@components/TextInput/BaseTextInput/types'; + +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; + +import CONST from '@src/CONST'; +import IntlStore from '@src/languages/IntlStore'; + +import React from 'react'; + +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +type DateSegmentsConfig = NonNullable; + +const SEGMENT_LABELS: Record = { + year: 'Year', + month: 'Month', + day: 'Day', +}; + +async function renderField(overrides: Partial = {}) { + const config: DateSegmentsConfig = { + mask: 'YYYY-MM-DD', + getSegmentProps: () => ({ + value: '', + onKeyPress: jest.fn(), + onChangeText: jest.fn(), + onFocus: jest.fn(), + onPointerDown: jest.fn(), + }), + setSegmentRef: jest.fn(), + focusFirstUnfilledSegment: jest.fn(), + selectLastSegment: jest.fn(), + selectAllSegments: jest.fn(), + isSegmentElement: jest.fn(() => false), + isAllSelected: false, + onFieldBlur: jest.fn(), + hasTypedDigits: false, + ...overrides, + }; + + render( + + + , + ); + await waitForBatchedUpdatesWithAct(); + + return config; +} + +describe('DateSegmentsInput', () => { + // The accessibility labels the segments are found by come from the locale, which loads into Onyx + beforeAll(async () => { + await act(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + }); + + it('keeps the field from reporting a blur while focus moves between its own segments', async () => { + // Given a field whose segments are each their own input, so moving along the date blurs one of them + const config = await renderField({isSegmentElement: jest.fn(() => true)}); + + // When focus leaves the year for another segment + fireEvent(screen.getByLabelText(SEGMENT_LABELS.year), 'blur', { + nativeEvent: {relatedTarget: {}}, + }); + + // Then the field has not been left, so nothing hanging on its blur runs + expect(config.onFieldBlur).not.toHaveBeenCalled(); + }); + + it('reports a blur once focus lands outside every segment', async () => { + // Given a field that recognizes its own segments and is handed something else + const config = await renderField({ + isSegmentElement: jest.fn(() => false), + }); + + // When focus leaves the day for an element the field does not own + fireEvent(screen.getByLabelText(SEGMENT_LABELS.day), 'blur', { + nativeEvent: {relatedTarget: {}}, + }); + + // Then the field reports the blur, which is what a form hangs its validation on + expect(config.onFieldBlur).toHaveBeenCalledTimes(1); + }); + + it('reports a blur when focus leaves for nothing at all', async () => { + // Given a field being left by a click on the page background, which names no related target + const config = await renderField(); + + // When the year blurs with no element taking over + fireEvent(screen.getByLabelText(SEGMENT_LABELS.year), 'blur', { + nativeEvent: {relatedTarget: null}, + }); + + // Then the blur is reported, since focus is no longer anywhere in the date + expect(config.onFieldBlur).toHaveBeenCalledTimes(1); + }); + + it('answers a double click on the space beside the date with the nearest segment', async () => { + // Given the empty space that fills the rest of the row, which holds no text of its own + const config = await renderField(); + + // When it takes the second click of a double click + fireEvent(screen.getByLabelText('Date'), 'mouseDown', { + preventDefault: jest.fn(), + detail: 2, + }); + + // Then the last segment stands in for the word a text field would have selected + expect(config.selectLastSegment).toHaveBeenCalledTimes(1); + expect(config.selectAllSegments).not.toHaveBeenCalled(); + }); + + it('answers a triple click on that space with the whole date', async () => { + // Given the same space, clicked a third time + const config = await renderField(); + + // When the click count passes two + fireEvent(screen.getByLabelText('Date'), 'mouseDown', { + preventDefault: jest.fn(), + detail: 3, + }); + + // Then the whole date is selected, the way a triple click takes a whole line + expect(config.selectAllSegments).toHaveBeenCalledTimes(1); + expect(config.selectLastSegment).not.toHaveBeenCalled(); + }); + + it('leaves a single click on that space to move focus rather than select', async () => { + // Given a first click, which carries no selection meaning + const config = await renderField(); + + // When the space is pressed once + fireEvent(screen.getByLabelText('Date'), 'mouseDown', { + preventDefault: jest.fn(), + detail: 1, + }); + + // Then neither selection runs, leaving the press itself to pick a segment to type in + expect(config.selectLastSegment).not.toHaveBeenCalled(); + expect(config.selectAllSegments).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/InputWrapperTest.tsx b/tests/unit/InputWrapperTest.tsx index d08d0e9e286e..619e7ffe3ed7 100644 --- a/tests/unit/InputWrapperTest.tsx +++ b/tests/unit/InputWrapperTest.tsx @@ -56,6 +56,7 @@ function renderAndCaptureRegistration({autoGrowSingleLine, autoGrowHeight, multi registerInput, getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + setInputValidationError: () => {}, }; render( diff --git a/tests/unit/PopoverProviderGuardsTest.tsx b/tests/unit/PopoverProviderGuardsTest.tsx new file mode 100644 index 000000000000..5ce7ac7cc241 --- /dev/null +++ b/tests/unit/PopoverProviderGuardsTest.tsx @@ -0,0 +1,132 @@ +import {act, render} from '@testing-library/react-native'; + +import PopoverContextProvider, {usePopoverActions} from '@components/PopoverProvider'; +import type * as PopoverProviderWeb from '@components/PopoverProvider/index'; +import type {AnchorRef, AnchorType} from '@components/PopoverProvider/types'; + +import {setCloseModal} from '@userActions/Modal'; + +import React, {useEffect, useRef} from 'react'; + +// These guards are document listeners, which only the web provider has. Jest resolves the native variant by default. +jest.mock('@components/PopoverProvider', () => jest.requireActual('@components/PopoverProvider/index.tsx')); + +type OpenPopoverProps = { + close: () => void; + shouldCloseOnWheel?: boolean; +}; + +/** Opens a popover through the provider the way a real one does, so the document listeners have something to act on */ +function OpenPopover({close, shouldCloseOnWheel}: OpenPopoverProps) { + const {onOpen} = usePopoverActions(); + const anchorRef = useRef(null); + const ref = useRef(null); + + useEffect(() => { + const popover: AnchorRef = {ref, anchorRef, close}; + onOpen(shouldCloseOnWheel === undefined ? popover : {...popover, shouldCloseOnWheel}); + }, [close, onOpen, shouldCloseOnWheel]); + + return null; +} + +function openPopover(props: OpenPopoverProps) { + return render( + + + , + ); +} + +function pressEscape() { + act(() => { + document.dispatchEvent(new KeyboardEvent('keyup', {key: 'Escape'})); + }); +} + +function scroll() { + act(() => { + document.dispatchEvent(new Event('wheel')); + }); +} + +describe('PopoverProvider guards', () => { + describe('escape', () => { + it('closes a popover that is the topmost thing on screen', () => { + // Given an open popover whose close is the one escape is meant to reach + const close = jest.fn(); + openPopover({close}); + const unregister = setCloseModal(close); + + // When escape is pressed + pressEscape(); + + // Then the popover closes, which is what the key is for + expect(close).toHaveBeenCalledTimes(1); + unregister(); + }); + + it('leaves the popover alone while a modal is open above it', () => { + // Given a popover with a modal opened from its own content sitting above it + const close = jest.fn(); + const closeModalAbove = jest.fn(); + openPopover({close}); + const unregisterPopover = setCloseModal(close); + const unregisterModal = setCloseModal(closeModalAbove); + + // When escape is pressed, which this listener sees before the modal does + pressEscape(); + + // Then the popover stays, since closing it would take the modal rendered inside it away too + expect(close).not.toHaveBeenCalled(); + unregisterModal(); + unregisterPopover(); + }); + + it('closes the popover again once the modal above it has gone', () => { + // Given a modal that was opened above the popover and then closed + const close = jest.fn(); + const closeModalAbove = jest.fn(); + openPopover({close}); + const unregisterPopover = setCloseModal(close); + const unregisterModal = setCloseModal(closeModalAbove); + unregisterModal(); + + // When escape is pressed + pressEscape(); + + // Then it reaches the popover, which is topmost again + expect(close).toHaveBeenCalledTimes(1); + unregisterPopover(); + }); + }); + + describe('scrolling', () => { + it('dismisses a popover that cannot follow its anchor', () => { + // Given an open popover that has not asked to stay put + const close = jest.fn(); + openPopover({close}); + + // When the page is scrolled + scroll(); + + // Then it is dismissed, since it would otherwise be left behind by its anchor + expect(close).toHaveBeenCalledTimes(1); + }); + + it('keeps a popover that follows its anchor', () => { + // Given a popover that keeps itself attached to its anchor, such as the calendar beside a date being typed + const close = jest.fn(); + openPopover({close, shouldCloseOnWheel: false}); + + // When the page is scrolled + scroll(); + + // Then it stays, so scrolling does not interrupt an edit in progress + expect(close).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/unit/PopoverWithoutOverlayChoiceTest.tsx b/tests/unit/PopoverWithoutOverlayChoiceTest.tsx new file mode 100644 index 000000000000..c8cf245af580 --- /dev/null +++ b/tests/unit/PopoverWithoutOverlayChoiceTest.tsx @@ -0,0 +1,108 @@ +import {render, screen} from '@testing-library/react-native'; + +import Popover from '@components/Popover'; +import type * as PopoverWeb from '@components/Popover/index'; + +import useResponsiveLayout from '@hooks/useResponsiveLayout'; + +import type {ComponentRef} from 'react'; +import type * as ReactDOM from 'react-dom'; +import type {View as ViewComponent} from 'react-native'; + +import React, {useRef} from 'react'; +import {View} from 'react-native'; + +// Only the web popover chooses between the two, and jest resolves the native variant by default +jest.mock('@components/Popover', () => jest.requireActual('@components/Popover/index.tsx')); + +// The overlay-free popover is portalled to the document body, which is outside the tree the queries search +jest.mock('react-dom', () => ({...jest.requireActual('react-dom'), createPortal: (children: React.ReactNode) => children})); + +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); +jest.mock('@hooks/useSidePanelState', () => jest.fn(() => ({isSidePanelTransitionEnded: true}))); + +// Each stands in for one of the two the popover picks between, so which one it picked can be read off the tree +jest.mock('@components/Modal', () => { + const {View: MockedView} = jest.requireActual<{View: typeof ViewComponent}>('react-native'); + return (props: {children?: React.ReactNode}) => {props.children}; +}); + +jest.mock('@components/PopoverWithoutOverlay', () => { + const {View: MockedView} = jest.requireActual<{View: typeof ViewComponent}>('react-native'); + return (props: {children?: React.ReactNode}) => {props.children}; +}); + +const mockedUseResponsiveLayout = jest.mocked(useResponsiveLayout); + +type LayoutOptions = {isSmallScreenWidth: boolean; shouldUseNarrowLayout: boolean}; + +function PopoverUnderTest({shouldAllowWithoutOverlayInNarrowPane}: {shouldAllowWithoutOverlayInNarrowPane: boolean}) { + const anchorRef = useRef>(null); + + return ( + {}} + withoutOverlay + shouldAllowWithoutOverlayInNarrowPane={shouldAllowWithoutOverlayInNarrowPane} + anchorPosition={{left: 0, top: 0}} + anchorRef={anchorRef} + > + + + ); +} + +function renderPopover(layout: LayoutOptions, shouldAllowWithoutOverlayInNarrowPane: boolean) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + mockedUseResponsiveLayout.mockReturnValue({...layout, isInNarrowPaneModal: false} as ReturnType); + + return render(); +} + +/** A right hand pane on a wide screen, which reports a narrow layout while the window itself is wide */ +const RIGHT_HAND_PANE = {isSmallScreenWidth: false, shouldUseNarrowLayout: true}; +const WIDE_SCREEN = {isSmallScreenWidth: false, shouldUseNarrowLayout: false}; +const SMALL_SCREEN = {isSmallScreenWidth: true, shouldUseNarrowLayout: true}; + +describe('Popover overlay choice', () => { + afterEach(() => { + mockedUseResponsiveLayout.mockReset(); + }); + + it('never reaches the choice on a wide screen', () => { + // Given a wide screen, where a popover is already shown beside its anchor + renderPopover(WIDE_SCREEN, true); + + // When it renders + // Then it takes the path it always did, so opting in changes nothing outside a narrow pane + expect(screen.getByTestId('with-overlay')).toBeTruthy(); + }); + + it('takes the overlay in a narrow pane by default', () => { + // Given the same popover in a right hand pane, which is assumed to have no room beside the anchor + renderPopover(RIGHT_HAND_PANE, false); + + // When it renders + // Then it falls back to the full modal + expect(screen.getByTestId('with-overlay')).toBeTruthy(); + }); + + it('skips the overlay in a narrow pane for a caller that opts in', () => { + // Given a popover that knows it fits beside its anchor even in a pane, such as the calendar beside a date field + renderPopover(RIGHT_HAND_PANE, true); + + // When it renders + // Then it keeps the overlay-free popover, so what is being typed stays visible behind it + expect(screen.getByTestId('without-overlay')).toBeTruthy(); + }); + + it('takes the overlay on a small screen even for a caller that opts in', () => { + // Given that same caller on a window with no room beside the anchor at all + renderPopover(SMALL_SCREEN, true); + + // When it renders + // Then the full modal comes back, since opting in is judged on screen size rather than on the pane + expect(screen.getByTestId('with-overlay')).toBeTruthy(); + }); +}); diff --git a/tests/unit/ValidationUtilsTest.ts b/tests/unit/ValidationUtilsTest.ts index 82fd9b7829cd..c49890126782 100644 --- a/tests/unit/ValidationUtilsTest.ts +++ b/tests/unit/ValidationUtilsTest.ts @@ -5,6 +5,7 @@ import type {Country} from '@src/CONST'; import { containsHtmlTag, getCountryZipRegexDetails, + getDateRangeError, getFieldRequiredErrors, isValidZipCodeForCountry, isValidNameOnCard, @@ -354,6 +355,59 @@ describe('ValidationUtils', () => { }); }); + describe('getDateRangeError', () => { + const minimumDate = new Date(2008, 0, 10); + const maximumDate = new Date(2008, 9, 16); + + test('Should return an empty string for a date inside the range', () => { + // Given a date the calendar would have offered + // When it is validated against that calendar's bounds + // Then nothing is reported, since there is no problem to tell the user about + expect(getDateRangeError(translateLocal, '2008-05-20', minimumDate, maximumDate)).toBe(''); + }); + + test('Should accept the bounds themselves', () => { + // Given a date on either bound + // When it is validated + // Then it passes, because a calendar offers the first and last dates it shows + expect(getDateRangeError(translateLocal, '2008-01-10', minimumDate, maximumDate)).toBe(''); + expect(getDateRangeError(translateLocal, '2008-10-16', minimumDate, maximumDate)).toBe(''); + }); + + test('Should return an error message for a date after the maximum', () => { + // Given a date past the newest the calendar offers, which only typing can reach + // When it is validated + // Then the error names the bound, so the user knows what to correct it to + expect(getDateRangeError(translateLocal, '2008-12-25', minimumDate, maximumDate)).toBe( + translateLocal('privatePersonalDetails.error.dateShouldBeBefore', format(maximumDate, CONST.DATE.FNS_FORMAT_STRING)), + ); + }); + + test('Should return an error message for a date before the minimum', () => { + // Given a date before the oldest the calendar offers + // When it is validated + // Then the error names the bound it fell short of + expect(getDateRangeError(translateLocal, '2007-12-31', minimumDate, maximumDate)).toBe( + translateLocal('privatePersonalDetails.error.dateShouldBeAfter', format(minimumDate, CONST.DATE.FNS_FORMAT_STRING)), + ); + }); + + test('Should ignore the time of day on the bounds', () => { + // Given bounds carrying a time, which a date field never shows + // When a date on the same day as the maximum is validated + // Then it passes, since the comparison is by day and not by the hour the bound happens to hold + expect(getDateRangeError(translateLocal, '2008-10-16', new Date(2008, 0, 10, 23, 59), new Date(2008, 9, 16, 1, 0))).toBe(''); + }); + + test('Should return an error message for a date that does not exist', () => { + // Given digits that read as a date but name a day the month does not have + // When they are validated + // Then they are reported as invalid rather than as out of range, since there is no range to place them in + expect(getDateRangeError(translateLocal, '2008-02-31', minimumDate, maximumDate)).toBe(translateLocal('common.error.dateInvalid')); + expect(getDateRangeError(translateLocal, '', minimumDate, maximumDate)).toBe(translateLocal('common.error.dateInvalid')); + }); + }); + describe('isValidTwoFactorCode', () => { test('numeric two-factor code', () => { expect(isValidTwoFactorCode('123456')).toBe(true); diff --git a/tests/unit/components/Form/FormProviderValidation.test.tsx b/tests/unit/components/Form/FormProviderValidation.test.tsx index 56f7d3afdaa1..db37f0315ace 100644 --- a/tests/unit/components/Form/FormProviderValidation.test.tsx +++ b/tests/unit/components/Form/FormProviderValidation.test.tsx @@ -113,6 +113,20 @@ async function touchAndChange(handlers: {current: CapturedHandlers | undefined}, }); } +const INPUT_REPORTED_ERROR = 'That is not a date this field can use'; + +/** + * Stands in for an input that refuses to commit what the user entered, such as the date field while its segments do + * not add up to a date. It reports the error to the form rather than through a value the form could validate. + */ +function ReportValidationError({error}: {error: string}) { + const {setInputValidationError} = useContext(FormContext); + useEffect(() => { + setInputValidationError(STREET, error); + }, [error, setInputValidationError]); + return null; +} + describe('FormProvider validation', () => { afterEach(async () => { await act(async () => { @@ -196,4 +210,77 @@ describe('FormProvider validation', () => { expect(screen.getByTestId(`${STREET}-error`)).toHaveTextContent('Invalid character'); }); + + describe('errors reported by an input rather than by the form', () => { + function renderFormWithReportedError(handlers: {current: CapturedHandlers | undefined}, error: string) { + return render( + + + { + const errors: FormInputErrors = {}; + const street = values[STREET]; + if (typeof street === 'string' && street.toLowerCase().includes('po box')) { + errors[STREET] = PO_BOX_ERROR; + } + return errors; + }} + > + + + { + // eslint-disable-next-line no-param-reassign + handlers.current = captured; + }} + /> + + + + , + ); + } + + it('surfaces an error the input reported about a value it never handed over', async () => { + // Given an input refusing to commit what the user entered, so the form has a valid old value to validate + const handlers: {current: CapturedHandlers | undefined} = {current: undefined}; + renderFormWithReportedError(handlers, INPUT_REPORTED_ERROR); + await waitForBatchedUpdatesWithAct(); + + // When the form validates that value, which it finds nothing wrong with + await touchAndChange(handlers, VALID_STREET); + + // Then the input's own error still reaches the field, which is what stops the form being submitted + expect(screen.getByTestId(`${STREET}-error`)).toHaveTextContent(INPUT_REPORTED_ERROR); + }); + + it('keeps the page rule for a field both of them object to', async () => { + // Given an input reporting an error on a field the form also has a rule about + const handlers: {current: CapturedHandlers | undefined} = {current: undefined}; + renderFormWithReportedError(handlers, INPUT_REPORTED_ERROR); + await waitForBatchedUpdatesWithAct(); + + // When a value arrives that breaks that rule + await touchAndChange(handlers, PO_BOX_VALUE); + + // Then the page's rule wins, being the more specific thing to say about this particular value + expect(screen.getByTestId(`${STREET}-error`)).toHaveTextContent(PO_BOX_ERROR); + }); + + it('leaves the field alone once the input stops reporting', async () => { + // Given an input that has nothing to report, which is how it says its entry is usable again + const handlers: {current: CapturedHandlers | undefined} = {current: undefined}; + renderFormWithReportedError(handlers, ''); + await waitForBatchedUpdatesWithAct(); + + // When the form validates a value it finds nothing wrong with + await touchAndChange(handlers, VALID_STREET); + + // Then no error is shown, so an empty report does not read as an error of its own + expect(screen.getByTestId(`${STREET}-error`)).toHaveTextContent(''); + }); + }); }); diff --git a/tests/unit/components/Form/FormWrapper.test.tsx b/tests/unit/components/Form/FormWrapper.test.tsx index 3ade8eed7cbc..bc9b10bb93a7 100644 --- a/tests/unit/components/Form/FormWrapper.test.tsx +++ b/tests/unit/components/Form/FormWrapper.test.tsx @@ -50,6 +50,7 @@ function renderFormWrapper({errors = {}, serverErrorFields, inputRefs}: {errors? registerInput: jest.fn(), getErrorAnnouncementKey: () => 0, getFallbackAnnouncementMessage: () => '', + setInputValidationError: () => {}, }} > ({navigate: jest.fn()})); + +// Typing is only offered where the calendar can sit beside the field, so a touch screen turns the whole feature off +jest.mock('@libs/DeviceCapabilities', () => ({ + ...jest.requireActual('@libs/DeviceCapabilities'), + canUseTouchScreen: () => false, +})); + +// The window's own width is what decides whether there is room to show a calendar beside the field +jest.mock('@hooks/useResponsiveLayout', () => jest.fn()); + +// The field reads the navigator to decide when autofocus is still wanted, and the test renders it on its own +jest.mock('@hooks/useAutoFocusInput', () => jest.fn(() => ({inputCallbackRef: jest.fn(), cancelAutoFocus: jest.fn()}))); + +const TRANSACTION_ID = '1'; +const CHAT_REPORT_ID = 'chat1'; +const POLICY_ID = 'policy1'; + +const YEAR_LABEL = 'Year'; +const DAY_LABEL = 'Day'; +const AUTOMATIC_HINT = 'Automatic'; + +function renderDateField() { + render( + + + + + + + , + ); +} + +function typeYear(digits: string[]) { + fireEvent(screen.getByLabelText(YEAR_LABEL), 'focus', {nativeEvent: {}}); + + for (const key of digits) { + fireEvent(screen.getByLabelText(YEAR_LABEL), 'keyPress', {preventDefault: jest.fn(), nativeEvent: {key}}); + } +} + +// Tabbing out of the last segment is what tells the field the keyboard has carried focus away, which closes the +// calendar the first digit opened. The blur that follows is focus landing outside every segment. +function leaveFieldByTabbing() { + fireEvent(screen.getByLabelText(DAY_LABEL), 'keyPress', {preventDefault: jest.fn(), nativeEvent: {key: CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey}}); + fireEvent(screen.getByLabelText(YEAR_LABEL), 'blur', {nativeEvent: {relatedTarget: undefined}}); +} + +describe('Scan date field', () => { + // The segments and the hint are found by the strings the locale gives them + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await act(async () => { + await IntlStore.load(CONST.LOCALES.EN); + }); + }); + + beforeEach(async () => { + jest.mocked(useResponsiveLayout).mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + isInNarrowPaneModal: false, + isExtraSmallScreenHeight: false, + isMediumScreenWidth: false, + isLargeScreenWidth: true, + isExtraSmallScreenWidth: false, + isSmallScreen: false, + onboardingIsMediumOrLargerScreenWidth: true, + isExtraLargeScreenWidth: false, + isInLandscapeMode: false, + }); + + // A scanned receipt whose date SmartScan has not filled in yet, which is what the hint promises to do + await Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION_DRAFT}${TRANSACTION_ID}`, { + transactionID: TRANSACTION_ID, + reportID: '', + iouRequestType: CONST.IOU.REQUEST_TYPE.SCAN, + receipt: {source: 'receipt.jpg'}, + isCreatedSet: false, + participants: [{policyID: POLICY_ID, reportID: CHAT_REPORT_ID, isPolicyExpenseChat: true, selected: true}], + }); + await waitForBatchedUpdates(); + }); + + it('promises to fill the date in while the field is untouched', async () => { + // Given a scanned receipt whose date has not come back yet + // When the form renders the date field + renderDateField(); + await waitForBatchedUpdates(); + + // Then the field says SmartScan is the one filling it in + expect(screen.getByText(AUTOMATIC_HINT)).toBeOnTheScreen(); + }); + + it('drops the promise once digits that are not yet a date have been left in the field', async () => { + // Given the same field + renderDateField(); + await waitForBatchedUpdates(); + + // When a year is started but never finished, and the keyboard carries focus away + typeYear(['2', '0', '2']); + leaveFieldByTabbing(); + await waitForBatchedUpdates(); + + // Then those digits are the user's, so the field stops promising to fill itself in over them + expect(screen.getByLabelText(YEAR_LABEL).props.value).toBe('202'); + expect(screen.queryByText(AUTOMATIC_HINT)).toBeNull(); + }); +}); diff --git a/tests/unit/hooks/useConfirmationValidation.test.ts b/tests/unit/hooks/useConfirmationValidation.test.ts index 4967c8d3892d..36b9ea94e3ea 100644 --- a/tests/unit/hooks/useConfirmationValidation.test.ts +++ b/tests/unit/hooks/useConfirmationValidation.test.ts @@ -106,6 +106,7 @@ const baseParams = { isReadOnly: false, shouldShowDate: true, isTaxAmountEmpty: false, + hasDateValidationError: false, } satisfies UseConfirmationValidationParams; function createValidationParamsForParticipant( @@ -311,6 +312,46 @@ describe('useConfirmationValidation', () => { expect(result.current.validate()).toEqual({errorKey: null}); }); + describe('date validation error', () => { + it('returns dateInvalid when the field reports an entry it could not read', () => { + // Given a date field holding digits that do not add up to a date, which it therefore never handed over + // When confirmation is validated + // Then it is blocked, since confirming would save the date from before the edit and discard what was typed + const {result} = renderHook(() => useConfirmationValidation({...baseParams, hasDateValidationError: true})); + expect(result.current.validate()).toEqual({errorKey: 'common.error.dateInvalid'}); + }); + + it('ignores the error while the date is not shown', () => { + // Given a flow with no date field on screen + // When confirmation is validated + // Then a stale error from a field the user cannot see does not block them + const {result} = renderHook(() => useConfirmationValidation({...baseParams, hasDateValidationError: true, shouldShowDate: false})); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('ignores the error while the date is read only', () => { + // Given a date the user is not allowed to edit + // When confirmation is validated + // Then nothing blocks them, since they have no way to correct what is being reported + const {result} = renderHook(() => useConfirmationValidation({...baseParams, hasDateValidationError: true, isReadOnly: true})); + expect(result.current.validate()).toEqual({errorKey: null}); + }); + + it('reports a missing date ahead of an unreadable one', () => { + // Given a date that was cleared outright while the field also reports an unreadable entry + // When confirmation is validated + // Then the required error wins, since the more specific thing to tell the user is that nothing is there + const {result} = renderHook(() => + useConfirmationValidation({ + ...baseParams, + hasDateValidationError: true, + transaction: createManualTransaction([P2P_PARTICIPANT], {created: '', modifiedCreated: ''}), + }), + ); + expect(result.current.validate()).toEqual({errorKey: 'common.error.fieldRequired'}); + }); + }); + it('returns fieldRequired for manual expense when amount is not set with a policy expense chat participant', () => { const {result} = renderHook(() => useConfirmationValidation( diff --git a/tests/unit/hooks/useDateSegmentInput.test.ts b/tests/unit/hooks/useDateSegmentInput.test.ts new file mode 100644 index 000000000000..ec80b69b65f7 --- /dev/null +++ b/tests/unit/hooks/useDateSegmentInput.test.ts @@ -0,0 +1,555 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import type {AnimatedTextInputRef} from '@components/RNTextInput'; + +import useDateSegmentInput from '@hooks/useDateSegmentInput'; + +import Clipboard from '@libs/Clipboard'; +import {DATE_SEGMENT_NAMES} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; + +import CONST from '@src/CONST'; + +import type {TextInputKeyPressEvent} from 'react-native'; + +// Copying the whole date is the field's own job, since the browser cannot select across separate inputs +jest.mock('@libs/Clipboard', () => ({__esModule: true, default: {setString: jest.fn()}})); + +const setClipboardString = jest.mocked(Clipboard.setString); + +const MIN_DATE = new Date(2000, 0, 1); +const MAX_DATE = new Date(2030, 11, 31); + +/** + * Stands in for one segment's input. The hook moves focus and reads selections through these, so giving it real + * objects is what makes both observable from a test that renders no component. + */ +type SegmentElement = { + focus: jest.Mock; + setSelectionRange: jest.Mock; + value: string; + selectionStart: number | null; + selectionEnd: number | null; +}; + +function createElement(): SegmentElement { + return {focus: jest.fn(), setSelectionRange: jest.fn(), value: '', selectionStart: null, selectionEnd: null}; +} + +function createKeyEvent(key: string, modifiers: {metaKey?: boolean; shiftKey?: boolean} = {}) { + const event = { + preventDefault: jest.fn(), + nativeEvent: {key, metaKey: modifiers.metaKey ?? false, ctrlKey: false, shiftKey: modifiers.shiftKey ?? false}, + }; + + // The hook reads a handful of fields off the event and prevents it, which is all this has to carry + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return event as unknown as TextInputKeyPressEvent; +} + +type SetupOptions = {value?: string; isEnabled?: boolean}; + +function setup({value = '', isEnabled = true}: SetupOptions = {}) { + const onCommit = jest.fn(); + const onLeaveByKeyboard = jest.fn(); + const elements: Record = {year: createElement(), month: createElement(), day: createElement()}; + + const {result, rerender} = renderHook( + (props: SetupOptions) => useDateSegmentInput({value: props.value ?? '', isEnabled: props.isEnabled ?? true, minDate: MIN_DATE, maxDate: MAX_DATE, onCommit, onLeaveByKeyboard}), + { + initialProps: {value, isEnabled}, + }, + ); + + act(() => { + for (const name of DATE_SEGMENT_NAMES) { + // The hook only focuses these and reads their selection back, which the stand-in covers + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + result.current.setSegmentRef(name, elements[name] as unknown as AnimatedTextInputRef); + } + }); + + /** Mirrors what the field draws, so the displayed date can be read back as one string */ + const read = () => DATE_SEGMENT_NAMES.map((name) => result.current.getSegmentProps(name).value).join('-'); + + const focusSegment = (name: DateSegmentName) => act(() => result.current.getSegmentProps(name).onFocus()); + + const press = (name: DateSegmentName, key: string, modifiers?: {metaKey?: boolean; shiftKey?: boolean}) => { + const event = createKeyEvent(key, modifiers); + act(() => result.current.getSegmentProps(name).onKeyPress(event)); + return event; + }; + + /** The segment the hook last moved the caret to, which is how focus movement is asserted */ + const focusedSegment = () => DATE_SEGMENT_NAMES.findLast((name) => elements[name].focus.mock.calls.length > 0); + + const clearFocusCalls = () => { + for (const name of DATE_SEGMENT_NAMES) { + elements[name].focus.mockClear(); + } + }; + + const type = (name: DateSegmentName, digits: string) => { + for (const digit of digits) { + press(name, digit); + } + }; + + return {result, rerender, onCommit, onLeaveByKeyboard, elements, read, focusSegment, press, focusedSegment, clearFocusCalls, type}; +} + +describe('useDateSegmentInput', () => { + beforeEach(() => { + setClipboardString.mockClear(); + }); + + describe('entering a date', () => { + it('fills the segments in turn and hands the caret on as each one finishes', () => { + // Given an empty field the user has clicked into + const {focusSegment, type, read, focusedSegment, clearFocusCalls, press} = setup(); + focusSegment('year'); + + // When a full year is typed + clearFocusCalls(); + type('year', '2026'); + + // Then the year is complete and the caret has moved on by itself + expect(read()).toBe('2026--'); + expect(focusedSegment()).toBe('month'); + + // When the month and day follow + press('month', '0'); + press('month', '9'); + press('day', '1'); + press('day', '5'); + + // Then the field reads the whole date + expect(read()).toBe('2026-09-15'); + }); + + it('hands a digit a segment cannot take on to the next one', () => { + // Given a month holding a 1, which only a 0, 1 or 2 can extend + const {focusSegment, type, read, focusedSegment, clearFocusCalls} = setup(); + focusSegment('month'); + type('month', '1'); + + // When a 3 is typed, which would make a month that does not exist + clearFocusCalls(); + type('month', '3'); + + // Then the month settles on January and the 3 starts the day off + expect(read()).toBe('-01-03'); + expect(focusedSegment()).toBe('day'); + }); + + it('replaces a segment arrived at rather than extending it', () => { + // Given a date already in the field, with the caret placed on the month + const {focusSegment, type, read} = setup({value: '2026-09-15'}); + focusSegment('year'); + focusSegment('month'); + + // When a digit is typed + type('month', '4'); + + // Then it starts the month over instead of being added to what was there + expect(read()).toBe('2026-04-15'); + }); + }); + + describe('moving between segments', () => { + it('moves one segment at a time with the arrows and stops at either end', () => { + // Given the caret on the month + const {focusSegment, press, focusedSegment, clearFocusCalls} = setup({value: '2026-09-15'}); + focusSegment('month'); + + // When the right arrow is pressed + clearFocusCalls(); + press('month', CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey); + expect(focusedSegment()).toBe('day'); + + // Then pressing it again at the last segment stays there rather than leaving the field + clearFocusCalls(); + press('day', CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT.shortcutKey); + expect(focusedSegment()).toBe('day'); + + // And the left arrow walks back the same way + clearFocusCalls(); + press('day', CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT.shortcutKey); + expect(focusedSegment()).toBe('month'); + }); + + it('reports only the keystroke that carries focus out of the field', () => { + // Given a field being typed in, which nothing else tells when Tab takes focus away + const {focusSegment, press, onLeaveByKeyboard} = setup({value: '2026-09-15'}); + focusSegment('year'); + + // When Tab moves between segments + press('month', CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey); + expect(onLeaveByKeyboard).not.toHaveBeenCalled(); + + // Then only the outermost segments hand focus out + press('day', CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey); + expect(onLeaveByKeyboard).toHaveBeenCalledTimes(1); + press('year', CONST.KEYBOARD_SHORTCUTS.TAB.shortcutKey, {shiftKey: true}); + expect(onLeaveByKeyboard).toHaveBeenCalledTimes(2); + }); + }); + + describe('separators', () => { + it('finishes a segment early', () => { + // Given a month holding a single digit that could still be extended into a two digit month + const {focusSegment, type, press, focusedSegment, clearFocusCalls} = setup(); + focusSegment('month'); + type('month', '1'); + + // When a separator is typed + clearFocusCalls(); + press('month', '-'); + + // Then the caret moves on, since the separator says the user is done with that segment + expect(focusedSegment()).toBe('day'); + }); + + it('does nothing in a segment the caret was just handed to', () => { + // Given a field that already holds a date, with the year retyped so the caret lands on the month + const {focusSegment, type, press, read, focusedSegment, clearFocusCalls} = setup({value: '2026-09-15'}); + focusSegment('year'); + type('year', '1990'); + expect(focusedSegment()).toBe('month'); + + // When the separator the user habitually types next arrives + clearFocusCalls(); + press('month', '-'); + + // Then it is ignored, rather than stepping over a month holding digits the user is about to replace + expect(focusedSegment()).toBeUndefined(); + expect(read()).toBe('1990-09-15'); + }); + + it('does nothing in a segment nothing has been typed into', () => { + // Given an empty month the caret was placed on + const {focusSegment, press, focusedSegment, clearFocusCalls} = setup(); + focusSegment('month'); + + // When a separator is typed + clearFocusCalls(); + press('month', '-'); + + // Then the caret stays, since moving on would leave the month empty + expect(focusedSegment()).toBeUndefined(); + }); + }); + + describe('deleting', () => { + it('takes off the last digit', () => { + // Given a year that has been typed in full + const {focusSegment, type, read, press} = setup(); + focusSegment('year'); + type('year', '2026'); + + // When backspace is pressed on it + press('year', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + + // Then one digit goes, leaving the rest to be corrected + expect(read()).toBe('202--'); + }); + + it('clears the digits selected inside a segment', () => { + // Given a month whose digits the user has selected, which only the input itself knows about + const {focusSegment, read, press, elements} = setup({value: '2026-12-15'}); + focusSegment('month'); + elements.month.selectionStart = 0; + elements.month.selectionEnd = 2; + + // When backspace is pressed + press('month', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + + // Then the whole selection goes, rather than one digit leaving the other behind + expect(read()).toBe('2026--15'); + }); + + it('takes out only the part of a segment that is selected', () => { + // Given the second of a month's two digits selected + const {focusSegment, read, press, elements} = setup({value: '2026-12-15'}); + focusSegment('month'); + elements.month.selectionStart = 1; + elements.month.selectionEnd = 2; + + // When backspace is pressed + press('month', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + + // Then the digit that was not selected stays + expect(read()).toBe('2026-01-15'); + }); + + it('leaves an empty segment by moving back to the one before it', () => { + // Given a day with nothing in it + const {focusSegment, press, focusedSegment, clearFocusCalls} = setup(); + focusSegment('day'); + + // When backspace is pressed + clearFocusCalls(); + press('day', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + + // Then there is nothing to delete, so the keystroke moves back instead + expect(focusedSegment()).toBe('month'); + }); + + it('empties the field and everything before the caret on the shortcut', () => { + // Given a full date with the caret on the month + const {focusSegment, read, press, focusedSegment, clearFocusCalls} = setup({value: '2026-09-15'}); + focusSegment('month'); + + // When delete to the start of the line is pressed + clearFocusCalls(); + press('month', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey, {metaKey: true}); + + // Then the month and everything before it goes, and the caret returns to the start + expect(read()).toBe('--15'); + expect(focusedSegment()).toBe('year'); + }); + }); + + describe('selecting the whole date', () => { + it('replaces everything with the next digit typed', () => { + // Given a date the user has selected in full + const {focusSegment, press, read, result} = setup({value: '2026-09-15'}); + focusSegment('year'); + press('year', 'a', {metaKey: true}); + expect(result.current.isAllSelected).toBe(true); + + // When a digit is typed + press('year', '1'); + + // Then it starts a new date rather than joining the one that was there + expect(read()).toBe('1--'); + expect(result.current.isAllSelected).toBe(false); + }); + + it('copies the date without taking it out of the field', () => { + // Given the whole date selected, which spans separate inputs the browser cannot copy from + const {focusSegment, press, read} = setup({value: '2026-09-15'}); + focusSegment('year'); + press('year', 'a', {metaKey: true}); + + // When it is copied + press('year', 'c', {metaKey: true}); + + // Then the clipboard carries the date and the field keeps it + expect(setClipboardString).toHaveBeenCalledWith('2026-09-15'); + expect(read()).toBe('2026-09-15'); + }); + + it('cuts the date out of the field', () => { + // Given the whole date selected + const {focusSegment, press, read, onCommit} = setup({value: '2026-09-15'}); + focusSegment('year'); + press('year', 'a', {metaKey: true}); + + // When it is cut + press('year', 'x', {metaKey: true}); + + // Then the clipboard carries it and the field is emptied, which is a change the form hears about + expect(setClipboardString).toHaveBeenCalledWith('2026-09-15'); + expect(read()).toBe('--'); + expect(onCommit).toHaveBeenCalledWith(''); + }); + }); + + describe('undo and redo', () => { + it('walks back through the digits typed and forward again', () => { + // Given a few digits typed into a field whose inputs each have an empty history of their own + const {focusSegment, type, read, press} = setup(); + focusSegment('year'); + type('year', '202'); + expect(read()).toBe('202--'); + + // When undo is pressed twice + press('year', 'z', {metaKey: true}); + press('year', 'z', {metaKey: true}); + expect(read()).toBe('2--'); + + // Then redo returns what was undone + press('year', 'y', {metaKey: true}); + expect(read()).toBe('20--'); + }); + + it('drops what was undone once something else is typed', () => { + // Given a digit that has been undone, leaving it ahead of the user + const {focusSegment, type, read, press} = setup(); + focusSegment('year'); + type('year', '20'); + press('year', 'z', {metaKey: true}); + expect(read()).toBe('2--'); + + // When a different digit is typed instead + type('year', '5'); + expect(read()).toBe('25--'); + + // Then there is nothing to redo, since that branch was abandoned + press('year', 'y', {metaKey: true}); + expect(read()).toBe('25--'); + }); + }); + + describe('handing the date over', () => { + it('holds back an entry part way through', () => { + // Given a date the user is still typing + const {focusSegment, type, onCommit} = setup(); + focusSegment('year'); + type('year', '2026'); + + // When each digit arrives + // Then nothing is handed over, since a consumer would act on a date that is not finished + expect(onCommit).not.toHaveBeenCalled(); + }); + + it('hands over a date as soon as it is complete', () => { + // Given a full date being typed + const {focusSegment, type, press, onCommit} = setup(); + focusSegment('year'); + type('year', '2026'); + press('month', '0'); + press('month', '9'); + press('day', '1'); + + // When the day reads as a number a date can hold + // Then the date is handed over without waiting for its second digit + expect(onCommit).toHaveBeenLastCalledWith('2026-09-01'); + }); + + it('reports a date that does not exist as no date at all', () => { + // Given the 31st typed into February + const {focusSegment, type, press, onCommit, result} = setup(); + focusSegment('year'); + type('year', '2026'); + press('month', '0'); + press('month', '2'); + press('day', '3'); + press('day', '1'); + + // When the entry is complete but names a day the month does not have + // Then it is held back, and the field says it has digits that do not add up to a date + expect(onCommit).not.toHaveBeenCalledWith('2026-02-31'); + expect(result.current.hasIncompleteEntry).toBe(true); + }); + }); + + describe('leaving the field', () => { + it('settles a segment left reading as zero', () => { + // Given a month the user moved on from after typing only its zero + const {focusSegment, type, press, onCommit, result} = setup(); + focusSegment('year'); + type('year', '2026'); + press('month', '0'); + press('day', '1'); + press('day', '5'); + + // When focus leaves the field + act(() => result.current.onFieldBlur()); + + // Then the zero settles on the first month, which is the date the user had very nearly entered + expect(onCommit).toHaveBeenLastCalledWith('2026-01-15'); + }); + + it('keeps digits that do not add up to a date on screen to be corrected', () => { + // Given a year left unfinished + const {focusSegment, type, result, read} = setup(); + focusSegment('year'); + type('year', '202'); + + // When focus leaves the field + act(() => result.current.onFieldBlur()); + + // Then the digits stay, with the field reporting that they cannot be used + expect(result.current.hasInvalidEntry).toBe(true); + expect(read()).toBe('202--'); + }); + + it('shows the committed date again once a usable entry is left', () => { + // Given a date that was entered in full + const {focusSegment, type, press, result} = setup(); + focusSegment('year'); + type('year', '2026'); + press('month', '0'); + press('month', '9'); + press('day', '1'); + press('day', '5'); + + // When focus leaves the field + act(() => result.current.onFieldBlur()); + + // Then the segments step aside and the committed value is what the field shows + expect(result.current.hasInvalidEntry).toBe(false); + expect(result.current.isEditing).toBe(false); + }); + }); + + describe('following the calendar', () => { + it('asks the calendar to show the month being typed', () => { + // Given a field being typed into + const {focusSegment, type, press, result} = setup(); + focusSegment('year'); + const startingVersion = result.current.viewDateVersion; + type('year', '2026'); + press('month', '0'); + press('month', '9'); + + // When a month and year that read as somewhere to go are entered + // Then the calendar is asked for that month, and the count says the request is new + expect(result.current.viewDate).toEqual(new Date(2026, 8, 1)); + expect(result.current.viewDateVersion).toBeGreaterThan(startingVersion); + }); + + it('counts a repeated month as a request of its own', () => { + // Given a calendar already showing the month the field asked for + const {focusSegment, type, press, result} = setup(); + focusSegment('year'); + type('year', '2026'); + press('month', '0'); + press('month', '9'); + const versionAfterFirstRequest = result.current.viewDateVersion; + + // When the same month is typed again, which the user may have moved the calendar away from + press('month', '0'); + press('month', '9'); + + // Then the count moves, since the date alone would not say the calendar has to come back + expect(result.current.viewDate).toEqual(new Date(2026, 8, 1)); + expect(result.current.viewDateVersion).toBeGreaterThan(versionAfterFirstRequest); + }); + + it('follows a date picked from the calendar even when it repeats the one already entered', () => { + // Given a date whose year has had a digit taken out, so nothing was handed over and the value never changed + const {focusSegment, press, result, read} = setup({value: '1990-02-15'}); + focusSegment('year'); + press('year', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + expect(read()).toBe('199-02-15'); + + // When that same date is picked from the calendar + act(() => result.current.onDateSelected('1990-02-15')); + + // Then the segments catch up, which no change of value would have told them to do + expect(read()).toBe('1990-02-15'); + }); + }); + + describe('when typing is turned off', () => { + it('drops an entry part way through rather than keeping it behind the plain field', () => { + // Given a date being typed when the window narrows past where the segments are shown + const {focusSegment, press, result, rerender} = setup({value: '1990-02-15'}); + focusSegment('year'); + press('year', CONST.KEYBOARD_SHORTCUTS.BACKSPACE.shortcutKey); + expect(result.current.isEditing).toBe(true); + + // When typing is turned off + rerender({value: '1990-02-15', isEnabled: false}); + expect(result.current.isEditing).toBe(false); + + // Then widening the window again shows the committed date rather than the edit that left the screen + rerender({value: '1990-02-15', isEnabled: true}); + expect(result.current.isEditing).toBe(false); + expect(DATE_SEGMENT_NAMES.map((name) => result.current.getSegmentProps(name).value).join('-')).toBe('1990-02-15'); + }); + }); +}); diff --git a/tests/unit/hooks/useFormErrorManagement.test.tsx b/tests/unit/hooks/useFormErrorManagement.test.tsx index d21b7d7ff64f..773abee10b07 100644 --- a/tests/unit/hooks/useFormErrorManagement.test.tsx +++ b/tests/unit/hooks/useFormErrorManagement.test.tsx @@ -49,6 +49,7 @@ const baseParams: Params = { isDistanceRequest: false, shouldShowDate: false, isReadOnly: false, + hasDateValidationError: false, }; // A manual draft the user never typed a merchant into: `initMoneyRequest` seeds it with the "Expense" placeholder. @@ -120,6 +121,41 @@ describe('useFormErrorManagement', () => { expect(result.current.formError).toBe(''); }); + describe('clearing the date error once the entry reads as a date again', () => { + it('clears the error the field raised on submit', async () => { + // Given a date field reporting an entry it could not read, and a submit that raised the error for it + const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { + wrapper: Wrapper, + initialProps: {...baseParams, shouldShowDate: true, hasDateValidationError: true}, + }); + act(() => result.current.setFormError('common.error.dateInvalid')); + expect(result.current.formError).toBe('common.error.dateInvalid'); + + // When the user corrects the date and the field stops reporting + rerender({...baseParams, shouldShowDate: true, hasDateValidationError: false}); + await waitForBatchedUpdatesWithAct(); + + // Then the error goes on its own, without waiting for another submit + expect(result.current.formError).toBe(''); + }); + + it('leaves an error it did not raise alone', async () => { + // Given some other error on screen while the date reads fine + const {result, rerender} = renderHook((props: Params) => useFormErrorManagement(props), { + wrapper: Wrapper, + initialProps: {...baseParams, shouldShowDate: true, hasDateValidationError: true}, + }); + act(() => result.current.setFormError('iou.error.invalidMerchant')); + + // When the date stops reporting + rerender({...baseParams, shouldShowDate: true, hasDateValidationError: false}); + await waitForBatchedUpdatesWithAct(); + + // Then the merchant error stays, since the date was never what it was about + expect(result.current.formError).toBe('iou.error.invalidMerchant'); + }); + }); + it('errorMessage returns routeError when present', () => { const {result} = renderHook(() => useFormErrorManagement({...baseParams, routeError: 'route.error.unreachable'}), {wrapper: Wrapper}); expect(result.current.errorMessage).toBe('route.error.unreachable'); diff --git a/tests/unit/hooks/useTransactionStartDateValidation.test.tsx b/tests/unit/hooks/useTransactionStartDateValidation.test.tsx new file mode 100644 index 000000000000..08247110f15d --- /dev/null +++ b/tests/unit/hooks/useTransactionStartDateValidation.test.tsx @@ -0,0 +1,149 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import {LocaleContextProvider} from '@components/LocaleContextProvider'; + +import useTransactionStartDateValidation from '@hooks/useTransactionStartDateValidation'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import React from 'react'; +import Onyx from 'react-native-onyx'; + +import {translateLocal} from '../../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct'; + +const CUSTOM = CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.CUSTOM; +const FROM_BEGINNING = CONST.COMPANY_CARD.TRANSACTION_START_DATE_OPTIONS.FROM_BEGINNING; +const A_DATE = '2026-09-15'; + +function Wrapper({children}: {children: React.ReactNode}) { + return {children}; +} + +type Params = Parameters[0]; + +function setup(initialProps: Params) { + return renderHook((props: Params) => useTransactionStartDateValidation(props), {wrapper: Wrapper, initialProps}); +} + +describe('useTransactionStartDateValidation', () => { + beforeAll(async () => { + Onyx.init({keys: ONYXKEYS}); + await Onyx.set(ONYXKEYS.NVP_PREFERRED_LOCALE, CONST.LOCALES.DEFAULT); + return waitForBatchedUpdatesWithAct(); + }); + + it('says nothing before the page has been submitted', () => { + // Given a custom date the user has cleared but not tried to save yet + const {result} = setup({dateOption: CUSTOM, startDate: ''}); + + // When the field is drawn + // Then no error is shown, since the user is still filling the date in + expect(result.current.requiredError).toBe(''); + }); + + it('reports the missing date once saving is attempted', () => { + // Given a custom date that was cleared + const {result} = setup({dateOption: CUSTOM, startDate: ''}); + + // When the page is submitted + let canSubmit = true; + act(() => { + canSubmit = result.current.validateOnSubmit(); + }); + + // Then saving is refused and the field says what is missing + expect(canSubmit).toBe(false); + expect(result.current.requiredError).toBe(translateLocal('common.error.fieldRequired')); + }); + + it('takes the error back down once an option is picked again', () => { + // Given a page that was submitted without a date + const {result} = setup({dateOption: CUSTOM, startDate: ''}); + act(() => { + result.current.validateOnSubmit(); + }); + expect(result.current.requiredError).not.toBe(''); + + // When the user answers the question again + act(() => { + result.current.forgetSubmitAttempt(); + }); + + // Then the error goes, rather than sitting there while they decide + expect(result.current.requiredError).toBe(''); + }); + + it('needs no date for the option that does not ask for one', () => { + // Given the option to take everything from the beginning, which carries no date of its own + const {result} = setup({dateOption: FROM_BEGINNING, startDate: ''}); + + // When the page is submitted + let canSubmit = false; + act(() => { + canSubmit = result.current.validateOnSubmit(); + }); + + // Then it saves, since there is nothing to be missing + expect(canSubmit).toBe(true); + expect(result.current.requiredError).toBe(''); + }); + + it('refuses to save an entry the field could not read', () => { + // Given a date field reporting digits that do not add up to a date, so it handed nothing over + const {result} = setup({dateOption: CUSTOM, startDate: A_DATE}); + act(() => { + result.current.onValidationErrorChange('That is not a date this field can use'); + }); + + // When the page is submitted + let canSubmit = true; + act(() => { + canSubmit = result.current.validateOnSubmit(); + }); + + // Then saving is refused, since it would keep the date from before the edit and discard what was typed + expect(canSubmit).toBe(false); + // And no required error is raised, because a date is there and the field is already saying what is wrong with it + expect(result.current.requiredError).toBe(''); + }); + + it('saves once the field stops reporting', () => { + // Given a field that was reporting an unreadable entry and then had it corrected + const {result} = setup({dateOption: CUSTOM, startDate: A_DATE}); + act(() => { + result.current.onValidationErrorChange('That is not a date this field can use'); + }); + act(() => { + result.current.onValidationErrorChange(''); + }); + + // When the page is submitted + let canSubmit = false; + act(() => { + canSubmit = result.current.validateOnSubmit(); + }); + + // Then it saves + expect(canSubmit).toBe(true); + }); + + it('ignores an error left behind by a date the page is no longer asking for', () => { + // Given a field that reported an unreadable entry before the user switched option + const {result, rerender} = setup({dateOption: CUSTOM, startDate: ''}); + act(() => { + result.current.onValidationErrorChange('That is not a date this field can use'); + }); + + // When the option that needs no date is picked + rerender({dateOption: FROM_BEGINNING, startDate: ''}); + let canSubmit = false; + act(() => { + canSubmit = result.current.validateOnSubmit(); + }); + + // Then saving goes ahead, since the date that error was about is not being asked for any more + expect(canSubmit).toBe(true); + }); +}); diff --git a/tests/unit/useDateSegmentInputTest.tsx b/tests/unit/useDateSegmentInputTest.tsx new file mode 100644 index 000000000000..05391f4b13dd --- /dev/null +++ b/tests/unit/useDateSegmentInputTest.tsx @@ -0,0 +1,88 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import type {AnimatedTextInputRef} from '@components/RNTextInput'; + +import useDateSegmentInput from '@hooks/useDateSegmentInput'; + +import {DATE_SEGMENT_NAMES, getSegmentsFromISODate} from '@libs/DateInputMaskUtils'; +import type {DateSegmentName} from '@libs/DateInputMaskUtils'; + +type StubSegment = { + focus: jest.Mock; + setSelectionRange: jest.Mock; + value: string; +}; + +/** + * Stands in for the three inputs the field renders, since focus is handed to them through their refs and a hook on its + * own has none to hand it to. + */ +function renderDateSegmentInput(value = '') { + const shownSegments = getSegmentsFromISODate(value); + const makeStub = (segmentValue: string): StubSegment => ({focus: jest.fn(), setSelectionRange: jest.fn(), value: segmentValue}); + const stubs: Record = { + year: makeStub(shownSegments.year), + month: makeStub(shownSegments.month), + day: makeStub(shownSegments.day), + }; + + const {result} = renderHook(() => + useDateSegmentInput({ + value, + isEnabled: true, + minDate: new Date('1900-01-01'), + maxDate: new Date('2100-12-31'), + onCommit: jest.fn(), + onLeaveByKeyboard: jest.fn(), + }), + ); + + act(() => { + for (const name of DATE_SEGMENT_NAMES) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test-only input stub; focus is handed over through the two methods it carries + result.current.setSegmentRef(name, stubs[name] as unknown as AnimatedTextInputRef); + } + }); + + return {result, stubs}; +} + +describe('useDateSegmentInput', () => { + describe('restoreFocus', () => { + it('gives focus back to the segment the user was last in', () => { + // Given a field holding a whole date, which the user has entered at the month + const {result, stubs} = renderDateSegmentInput('1990-05-15'); + act(() => result.current.getSegmentProps('month').onFocus()); + + // When a modal has taken focus away and hands it back, as the month and year pickers do + act(() => result.current.restoreFocus()); + + // Then the caret returns to the month rather than being moved somewhere the user did not ask about + expect(stubs.month.focus).toHaveBeenCalled(); + expect(stubs.day.focus).not.toHaveBeenCalled(); + }); + + it('starts at the beginning of the date when the user has not been in it yet', () => { + // Given a field holding a whole date that nothing has been focused in, so there is no caret to return + const {result, stubs} = renderDateSegmentInput('1990-05-15'); + + // When focus is handed back anyway + act(() => result.current.restoreFocus()); + + // Then it lands where an untouched field starts, since a whole date stops short nowhere + expect(stubs.year.focus).toHaveBeenCalled(); + expect(stubs.day.focus).not.toHaveBeenCalled(); + }); + + it('stops at the digits the date is still missing when the user has not been in it yet', () => { + // Given an empty field, which the user has to fill in from the first segment on + const {result, stubs} = renderDateSegmentInput(''); + + // When focus is handed back + act(() => result.current.restoreFocus()); + + // Then it lands on the segment typing would start at + expect(stubs.year.focus).toHaveBeenCalled(); + }); + }); +});