diff --git a/src/CONST.ts b/src/CONST.ts index 78b01d67b78e..5ee00b882161 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -2805,6 +2805,7 @@ const CONST = { MARK_AS_INCOMPLETE: 'markAsIncomplete', CANCEL_PAYMENT: 'cancelPayment', UNAPPROVE: 'unapprove', + DEBUG: 'debug', }, EDIT_REQUEST_FIELD: { AMOUNT: 'amount', @@ -4140,6 +4141,7 @@ const CONST = { CARD_AUTHENTICATION_REQUIRED: 'authentication_required', }, TAB: { + DEBUG_TAB_ID: 'DebugTab', NEW_CHAT_TAB_ID: 'NewChatTab', NEW_CHAT: 'chat', NEW_ROOM: 'room', @@ -5754,6 +5756,13 @@ const CONST = { CATEGORIES_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-categories#import-custom-categories', TAGS_ARTICLE_LINK: 'https://help.expensify.com/articles/expensify-classic/workspaces/Create-tags#import-a-spreadsheet-1', }, + + DEBUG: { + DETAILS: 'details', + JSON: 'json', + REPORT_ACTIONS: 'actions', + REPORT_ACTION_PREVIEW: 'preview', + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 38affd97c637..68a9ca2f8502 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -692,6 +692,12 @@ const ONYXKEYS = { RULES_MAX_EXPENSE_AMOUNT_FORM_DRAFT: 'rulesMaxExpenseAmountFormDraft', RULES_MAX_EXPENSE_AGE_FORM: 'rulesMaxExpenseAgeForm', RULES_MAX_EXPENSE_AGE_FORM_DRAFT: 'rulesMaxExpenseAgeFormDraft', + DEBUG_REPORT_PAGE_FORM: 'debugReportPageForm', + DEBUG_REPORT_PAGE_FORM_DRAFT: 'debugReportPageFormDraft', + DEBUG_REPORT_ACTION_PAGE_FORM: 'debugReportActionPageForm', + DEBUG_REPORT_ACTION_PAGE_FORM_DRAFT: 'debugReportActionPageFormDraft', + DEBUG_DETAILS_FORM: 'debugDetailsForm', + DEBUG_DETAILS_FORM_DRAFT: 'debugDetailsFormDraft', }, } as const; @@ -786,6 +792,9 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AMOUNT_FORM]: FormTypes.RulesMaxExpenseAmountForm; [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; + [ONYXKEYS.FORMS.DEBUG_REPORT_PAGE_FORM]: FormTypes.DebugReportForm; + [ONYXKEYS.FORMS.DEBUG_REPORT_ACTION_PAGE_FORM]: FormTypes.DebugReportActionForm; + [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm; }; type OnyxFormDraftValuesMapping = { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 04cc8a125fbb..3238683379ea 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1483,6 +1483,50 @@ const ROUTES = { route: 'settings/workspaces/:policyID/accounting/sage-intacct/advanced/payment-account', getRoute: (policyID: string) => `settings/workspaces/${policyID}/accounting/sage-intacct/advanced/payment-account` as const, }, + DEBUG_REPORT: { + route: 'debug/report/:reportID', + getRoute: (reportID: string) => `debug/report/${reportID}` as const, + }, + DEBUG_REPORT_TAB_DETAILS: { + route: 'debug/report/:reportID/details', + getRoute: (reportID: string) => `debug/report/${reportID}/details` as const, + }, + DEBUG_REPORT_TAB_JSON: { + route: 'debug/report/:reportID/json', + getRoute: (reportID: string) => `debug/report/${reportID}/json` as const, + }, + DEBUG_REPORT_TAB_ACTIONS: { + route: 'debug/report/:reportID/actions', + getRoute: (reportID: string) => `debug/report/${reportID}/actions` as const, + }, + DEBUG_REPORT_ACTION: { + route: 'debug/report/:reportID/actions/:reportActionID', + getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}` as const, + }, + DEBUG_REPORT_ACTION_CREATE: { + route: 'debug/report/:reportID/actions/create', + getRoute: (reportID: string) => `debug/report/${reportID}/actions/create` as const, + }, + DEBUG_REPORT_ACTION_TAB_DETAILS: { + route: 'debug/report/:reportID/actions/:reportActionID/details', + getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}/details` as const, + }, + DEBUG_REPORT_ACTION_TAB_JSON: { + route: 'debug/report/:reportID/actions/:reportActionID/json', + getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}/json` as const, + }, + DEBUG_REPORT_ACTION_TAB_PREVIEW: { + route: 'debug/report/:reportID/actions/:reportActionID/preview', + getRoute: (reportID: string, reportActionID: string) => `debug/report/${reportID}/actions/${reportActionID}/preview` as const, + }, + DETAILS_CONSTANT_PICKER_PAGE: { + route: 'debug/details/constant/:fieldName', + getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/constant/${fieldName}?fieldValue=${fieldValue}`, backTo), + }, + DETAILS_DATE_TIME_PICKER_PAGE: { + route: 'debug/details/datetime/:fieldName', + getRoute: (fieldName: string, fieldValue?: string, backTo?: string) => getUrlWithBackToParam(`debug/details/datetime/${fieldName}?fieldValue=${fieldValue}`, backTo), + }, } as const; /** diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 2369e231f519..67719cc44816 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -178,6 +178,7 @@ const SCREENS = { RESTRICTED_ACTION: 'RestrictedAction', REPORT_EXPORT: 'Report_Export', MISSING_PERSONAL_DETAILS: 'MissingPersonalDetails', + DEBUG: 'Debug', }, ONBOARDING_MODAL: { ONBOARDING: 'Onboarding', @@ -551,6 +552,13 @@ const SCREENS = { FEATURE_TRAINING_ROOT: 'FeatureTraining_Root', RESTRICTED_ACTION_ROOT: 'RestrictedAction_Root', MISSING_PERSONAL_DETAILS_ROOT: 'MissingPersonalDetails_Root', + DEBUG: { + REPORT: 'Debug_Report', + REPORT_ACTION: 'Debug_Report_Action', + REPORT_ACTION_CREATE: 'Debug_Report_Action_Create', + DETAILS_CONSTANT_PICKER_PAGE: 'Debug_Details_Constant_Picker_Page', + DETAILS_DATE_TIME_PICKER_PAGE: 'Debug_Details_Date_Time_Picker_Page', + }, } as const; type Screen = DeepValueOf; diff --git a/src/components/AccountSwitcher.tsx b/src/components/AccountSwitcher.tsx index a9e223e56632..d536f115e694 100644 --- a/src/components/AccountSwitcher.tsx +++ b/src/components/AccountSwitcher.tsx @@ -38,6 +38,8 @@ function AccountSwitcher() { const {canUseNewDotCopilot} = usePermissions(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const [account] = useOnyx(ONYXKEYS.ACCOUNT); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [user] = useOnyx(ONYXKEYS.USER); const buttonRef = useRef(null); const [shouldShowDelegatorMenu, setShouldShowDelegatorMenu] = useState(false); @@ -166,6 +168,14 @@ function AccountSwitcher() { > {Str.removeSMSDomain(currentUserPersonalDetails?.login ?? '')} + {!!user?.isDebugModeEnabled && ( + + AccountID: {session?.accountID} + + )} diff --git a/src/components/DatePicker/index.tsx b/src/components/DatePicker/index.tsx index eba54a58ae69..7e5774fd5594 100644 --- a/src/components/DatePicker/index.tsx +++ b/src/components/DatePicker/index.tsx @@ -35,7 +35,7 @@ type DatePickerProps = { maxDate?: Date; /** A function that is passed by FormWrapper */ - onInputChange?: (value: Date) => void; + onInputChange?: (value: string) => void; /** A function that is passed by FormWrapper */ onTouched?: () => void; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 48fea78da0dc..db52c45751b7 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -77,6 +77,9 @@ type FormProviderProps = FormProvider /** Whether button is disabled */ isSubmitDisabled?: boolean; + + /** Whether HTML is allowed in form inputs */ + allowHTML?: boolean; }; function FormProvider( @@ -92,6 +95,7 @@ function FormProvider( draftValues, onSubmit, shouldTrimValues = true, + allowHTML = false, ...rest }: FormProviderProps, forwardedRef: ForwardedRef, @@ -114,40 +118,42 @@ function FormProvider( const validateErrors: GenericFormInputErrors = validate?.(trimmedStringValues) ?? {}; - // Validate the input for html tags. It should supersede any other error - Object.entries(trimmedStringValues).forEach(([inputID, inputValue]) => { - // If the input value is empty OR is non-string, we don't need to validate it for HTML tags - if (!inputValue || typeof inputValue !== 'string') { - return; - } - const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX); - - // Return early if there are no HTML characters - if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) { - return; - } - - const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX); - let isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(inputValue)); - // Check for any matches that the original regex (foundHtmlTagIndex) matched - if (matchedHtmlTags) { - // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed. - for (const htmlTag of matchedHtmlTags) { - isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(htmlTag)); - if (!isMatch) { - break; + if (!allowHTML) { + // Validate the input for html tags. It should supersede any other error + Object.entries(trimmedStringValues).forEach(([inputID, inputValue]) => { + // If the input value is empty OR is non-string, we don't need to validate it for HTML tags + if (!inputValue || typeof inputValue !== 'string') { + return; + } + const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX); + const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX); + + // Return early if there are no HTML characters + if (leadingSpaceIndex === -1 && foundHtmlTagIndex === -1) { + return; + } + + const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX); + let isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(inputValue)); + // Check for any matches that the original regex (foundHtmlTagIndex) matched + if (matchedHtmlTags) { + // Check if any matched inputs does not match in WHITELISTED_TAGS list and return early if needed. + for (const htmlTag of matchedHtmlTags) { + isMatch = CONST.WHITELISTED_TAGS.some((regex) => regex.test(htmlTag)); + if (!isMatch) { + break; + } } } - } - if (isMatch && leadingSpaceIndex === -1) { - return; - } + if (isMatch && leadingSpaceIndex === -1) { + return; + } - // Add a validation error here because it is a string value that contains HTML characters - validateErrors[inputID] = translate('common.error.invalidCharacter'); - }); + // Add a validation error here because it is a string value that contains HTML characters + validateErrors[inputID] = translate('common.error.invalidCharacter'); + }); + } if (typeof validateErrors !== 'object') { throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}'); @@ -161,7 +167,7 @@ function FormProvider( return touchedInputErrors; }, - [shouldTrimValues, formID, validate, errors, translate], + [shouldTrimValues, formID, validate, errors, translate, allowHTML], ); // When locales change from another session of the same account, diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 720706e048e5..45899065e1ba 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -22,6 +22,7 @@ import type StateSelector from '@components/StateSelector'; import type TextInput from '@components/TextInput'; import type TextPicker from '@components/TextPicker'; import type ValuePicker from '@components/ValuePicker'; +import type ConstantSelector from '@pages/Debug/ConstantSelector'; import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker'; import type DimensionTypeSelector from '@pages/workspace/accounting/intacct/import/DimensionTypeSelector'; import type NetSuiteCustomFieldMappingPicker from '@pages/workspace/accounting/netsuite/import/NetSuiteImportCustomFieldNew/NetSuiteCustomFieldMappingPicker'; @@ -61,7 +62,8 @@ type ValidInputs = | typeof NetSuiteCustomFieldMappingPicker | typeof NetSuiteMenuWithTopDescriptionForm | typeof CountryPicker - | typeof StatePicker; + | typeof StatePicker + | typeof ConstantSelector; type ValueTypeKey = 'string' | 'boolean' | 'date' | 'country' | 'reportFields' | 'disabledListValues'; type ValueTypeMap = { diff --git a/src/components/TabSelector/TabSelector.tsx b/src/components/TabSelector/TabSelector.tsx index 7fca66b5f8c7..1bf753cd4aa4 100644 --- a/src/components/TabSelector/TabSelector.tsx +++ b/src/components/TabSelector/TabSelector.tsx @@ -27,6 +27,14 @@ type IconAndTitle = { function getIconAndTitle(route: string, translate: LocaleContextProps['translate']): IconAndTitle { switch (route) { + case CONST.DEBUG.DETAILS: + return {icon: Expensicons.Info, title: translate('debug.details')}; + case CONST.DEBUG.JSON: + return {icon: Expensicons.Eye, title: translate('debug.JSON')}; + case CONST.DEBUG.REPORT_ACTIONS: + return {icon: Expensicons.Document, title: translate('debug.reportActions')}; + case CONST.DEBUG.REPORT_ACTION_PREVIEW: + return {icon: Expensicons.Document, title: translate('debug.reportActionPreview')}; case CONST.TAB_REQUEST.MANUAL: return {icon: Expensicons.Pencil, title: translate('tabSelector.manual')}; case CONST.TAB_REQUEST.SCAN: diff --git a/src/components/TestToolMenu.tsx b/src/components/TestToolMenu.tsx index 2c553386aff0..115ce3bfc152 100644 --- a/src/components/TestToolMenu.tsx +++ b/src/components/TestToolMenu.tsx @@ -1,6 +1,6 @@ import React from 'react'; import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; +import {useOnyx} from 'react-native-onyx'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import * as ApiUtils from '@libs/ApiUtils'; @@ -17,19 +17,23 @@ import TestCrash from './TestCrash'; import TestToolRow from './TestToolRow'; import Text from './Text'; -type TestToolMenuOnyxProps = { - /** User object in Onyx */ - user: OnyxEntry; -}; - -type TestToolMenuProps = TestToolMenuOnyxProps & { +type TestToolMenuProps = { /** Network object in Onyx */ network: OnyxEntry; }; -const USER_DEFAULT: UserOnyx = {shouldUseStagingServer: undefined, isSubscribedToNewsletter: false, validated: false, isFromPublicDomain: false, isUsingExpensifyCard: false}; +const USER_DEFAULT: UserOnyx = { + shouldUseStagingServer: undefined, + isSubscribedToNewsletter: false, + validated: false, + isFromPublicDomain: false, + isUsingExpensifyCard: false, + isDebugModeEnabled: false, +}; -function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { +function TestToolMenu({network}: TestToolMenuProps) { + const [user = USER_DEFAULT] = useOnyx(ONYXKEYS.USER); const shouldUseStagingServer = user?.shouldUseStagingServer ?? ApiUtils.isUsingStagingApi(); + const isDebugModeEnabled = !!user?.isDebugModeEnabled; const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -41,6 +45,15 @@ function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { > {translate('initialSettingsPage.troubleshoot.testingPreferences')} + {/* When toggled the app will be put into debug mode. */} + + User.setIsDebugModeEnabled(!isDebugModeEnabled)} + /> + + {/* Option to switch between staging and default api endpoints. This enables QA, internal testers and external devs to take advantage of sandbox environments for 3rd party services like Plaid and Onfido. This toggle is not rendered for internal devs as they make environment changes directly to the .env file. */} @@ -97,10 +110,4 @@ function TestToolMenu({user = USER_DEFAULT, network}: TestToolMenuProps) { TestToolMenu.displayName = 'TestToolMenu'; -export default withNetwork()( - withOnyx({ - user: { - key: ONYXKEYS.USER, - }, - })(TestToolMenu), -); +export default withNetwork()(TestToolMenu); diff --git a/src/components/TimePicker/TimePicker.tsx b/src/components/TimePicker/TimePicker.tsx index cc1b6a161404..f65546295ceb 100644 --- a/src/components/TimePicker/TimePicker.tsx +++ b/src/components/TimePicker/TimePicker.tsx @@ -8,6 +8,7 @@ import BigNumberPad from '@components/BigNumberPad'; import Button from '@components/Button'; import FormHelpMessage from '@components/FormHelpMessage'; import Text from '@components/Text'; +import type {BaseTextInputRef} from '@components/TextInput/BaseTextInput/types'; import useAutoFocusInput from '@hooks/useAutoFocusInput'; import useKeyboardShortcut from '@hooks/useKeyboardShortcut'; import useLocalize from '@hooks/useLocalize'; @@ -19,7 +20,9 @@ import * as DeviceCapabilities from '@libs/DeviceCapabilities'; import CONST from '@src/CONST'; import setCursorPosition from './setCursorPosition'; -type MinuteHourRefs = {hourRef: TextInput | null; minuteRef: TextInput | null}; +type TimePickerRefName = 'hourRef' | 'minuteRef' | 'secondRef' | 'milisecondRef'; + +type TimePickerRef = Record; type TimePickerProps = { /** Default value for the inputs */ @@ -30,6 +33,12 @@ type TimePickerProps = { /** Callback to call when the input changes */ onInputChange?: (timeString: string) => void; + + /** Whether the time value should be validated */ + shouldValidate?: boolean; + + /** Whether the picker shows hours, minutes, seconds and miliseconds */ + showFullFormat?: boolean; }; const AMOUNT_VIEW_ID = 'amountView'; @@ -71,31 +80,37 @@ function insertAtPosition(originalString: string, newSubstring: string, from: nu * * @returns - the modified string with the range (from, to) replaced with zeros */ -function replaceRangeWithZeros(originalString: string, from: number, to: number): string { +function replaceRangeWithZeros(originalString: string, from: number, to: number, numOfDigits = 2): string { const normalizedFrom = Math.max(from, 0); - const normalizedTo = Math.min(to, 2); + const normalizedTo = Math.min(to, numOfDigits); const replacement = '0'.repeat(normalizedTo - normalizedFrom); return `${originalString.slice(0, normalizedFrom)}${replacement}${originalString.slice(normalizedTo)}`; } /** - * Clear the value under selection of an input (either hours or minutes) by replacing it with zeros + * Clear the value under selection of an input (either hours, minutes, seconds or miliseconds) by replacing it with zeros * * @param value - current value of the input * @param selection - current selection of the input * @param setValue - the function that modifies the value of the input * @param setSelection - the function that modifies the selection of the input */ -function clearSelectedValue(value: string, selection: {start: number; end: number}, setValue: (value: string) => void, setSelection: (value: {start: number; end: number}) => void) { +function clearSelectedValue( + value: string, + selection: {start: number; end: number}, + setValue: (value: string) => void, + setSelection: (value: {start: number; end: number}) => void, + numOfDigits?: number, +) { let newValue; let newCursorPosition; if (selection.start !== selection.end) { - newValue = replaceRangeWithZeros(value, selection.start, selection.end); + newValue = replaceRangeWithZeros(value, selection.start, selection.end, numOfDigits); newCursorPosition = selection.start; } else { const positionBeforeSelection = Math.max(selection.start - 1, 0); - newValue = replaceRangeWithZeros(value, positionBeforeSelection, selection.start); + newValue = replaceRangeWithZeros(value, positionBeforeSelection, selection.start, numOfDigits); newCursorPosition = positionBeforeSelection; } @@ -103,33 +118,46 @@ function clearSelectedValue(value: string, selection: {start: number; end: numbe setSelection({start: newCursorPosition, end: newCursorPosition}); } -function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: TimePickerProps, ref: ForwardedRef) { +function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}, shouldValidate = true, showFullFormat = false}: TimePickerProps, ref: ForwardedRef) { const {numberFormat, translate} = useLocalize(); const {isExtraSmallScreenHeight} = useResponsiveLayout(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const value = DateUtils.extractTime12Hour(defaultValue); + const value = DateUtils.extractTime12Hour(defaultValue, showFullFormat); const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen(); const [isError, setError] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [selectionHour, setSelectionHour] = useState({start: 0, end: 0}); - const [selectionMinute, setSelectionMinute] = useState({start: 2, end: 2}); // we focus it by default so need to have selection on the end - const [hours, setHours] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).hour); - const [minutes, setMinutes] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).minute); - const [amPmValue, setAmPmValue] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).period); + const [selectionMinute, setSelectionMinute] = useState(showFullFormat ? {start: 0, end: 0} : {start: 2, end: 2}); // we focus it by default so need to have selection on the end + const [selectionSecond, setSelectionSecond] = useState({start: 0, end: 0}); + const [selectionMilisecond, setSelectionMilisecond] = useState(showFullFormat ? {start: 6, end: 6} : {start: 0, end: 0}); + const [hours, setHours] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).hour); + const [minutes, setMinutes] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).minute); + const [seconds, setSeconds] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).seconds); + const [miliseconds, setMiliseconds] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).miliseconds); + const [amPmValue, setAmPmValue] = useState(() => DateUtils.get12HourTimeObjectFromDate(value, showFullFormat).period); const lastPressedKey = useRef(''); const hourInputRef = useRef(null); const minuteInputRef = useRef(null); + const secondInputRef = useRef(null); + const milisecondInputRef = useRef(null); const {inputCallbackRef} = useAutoFocusInput(); + const focusMilisecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, milisecondInputRef, setSelectionMilisecond), []); + const focusSecondInputOnLastCharacter = useCallback(() => setCursorPosition(2, secondInputRef, setSelectionSecond), []); + const focusSecondInputOnFirstCharacter = useCallback(() => setCursorPosition(0, secondInputRef, setSelectionSecond), []); + const focusMinuteInputOnLastCharacter = useCallback(() => setCursorPosition(2, minuteInputRef, setSelectionMinute), []); const focusMinuteInputOnFirstCharacter = useCallback(() => setCursorPosition(0, minuteInputRef, setSelectionMinute), []); const focusHourInputOnLastCharacter = useCallback(() => setCursorPosition(2, hourInputRef, setSelectionHour), []); const validate = useCallback( (time: string) => { + if (!shouldValidate) { + return true; + } const timeString = time || `${hours}:${minutes} ${amPmValue}`; const [hourStr] = timeString.split(/[:\s]+/); const hour = parseInt(hourStr, 10); @@ -143,7 +171,7 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim setErrorMessage(translate('common.error.invalidTimeShouldBeFuture')); return isValid; }, - [hours, minutes, amPmValue, defaultValue, translate], + [shouldValidate, hours, minutes, amPmValue, defaultValue, translate], ); const resetHours = () => { @@ -156,6 +184,16 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim setSelectionMinute({start: 0, end: 0}); }; + const resetSeconds = () => { + setSeconds('00'); + setSelectionSecond({start: 0, end: 0}); + }; + + const resetMiliseconds = () => { + setMinutes('000'); + setSelectionMilisecond({start: 0, end: 0}); + }; + // This function receive value from hour input and validate it // The valid format is HH(from 00 to 12). If the user input 9, it will be 09. If user try to change 09 to 19 it would skip the first character const handleHourChange = (text: string) => { @@ -317,6 +355,170 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim setMinutes(newMinute); setSelectionMinute({start: newSelection, end: newSelection}); + if (showFullFormat && newSelection === 2) { + focusSecondInputOnFirstCharacter(); + } + }; + + /* + This function receives value from the seconds input and validates it. + The valid format is SS(from 00 to 59). If the user enters 9, it will be prepended to 09. If the user tries to change 09 to 99, it would skip the character + */ + const handleSecondsChange = (text: string) => { + // Replace spaces with 0 to implement the following digit removal by pressing space + const trimmedText = text.replace(/ /g, '0'); + if (!trimmedText) { + resetSeconds(); + return; + } + + const isOnlyNumericValue = /^\d+$/.test(trimmedText); + if (!isOnlyNumericValue) { + return; + } + + let newSecond; + let newSelection; + + if (selectionSecond.start === 0 && selectionSecond.end === 0) { + // The cursor is at the start of seconds + const firstDigit = trimmedText[0]; + if (trimmedText.length === 1) { + // To support the forward-removal using Delete key + newSecond = `0${firstDigit}`; + newSelection = 1; + } else if (Number(firstDigit) <= 5) { + // The first entered digit is 0-5, we can safely append the second digit. + newSecond = `${firstDigit}${trimmedText[2] || 0}`; + newSelection = 1; + } else { + // The first entered digit is 6-9. We should replace the whole value by prepending 0 to the entered digit. + newSecond = `0${firstDigit}`; + newSelection = 2; + } + } else if (selectionSecond.start === 1 && selectionSecond.end === 1) { + // The cursor is in-between the digits + if (trimmedText.length === 1 && lastPressedKey.current === 'Backspace') { + // We have removed the first digit. Replace it with 0 and move the cursor to the start. + newSecond = `0${trimmedText}`; + newSelection = 0; + } else { + newSecond = `${trimmedText[0]}${trimmedText[1] || 0}`; + newSelection = 2; + } + } else if (selectionSecond.start === 0 && selectionSecond.end === 1) { + // There is an active selection of the first digit + newSecond = trimmedText.substring(0, 2).padStart(2, '0'); + newSelection = trimmedText.length === 1 ? 0 : 1; + } else if (selectionSecond.start === 1 && selectionSecond.end === 2) { + // There is an active selection of the second digit + newSecond = trimmedText.substring(0, 2).padEnd(2, '0'); + newSelection = trimmedText.length === 1 ? 1 : 2; + } else if (trimmedText.length === 1 && Number(trimmedText) <= 5) { + /* + The trimmed text is from 0 to 5. + We are either replacing seconds with a single digit, or removing the last digit. + In both cases, we should append 0 to the remaining value. + Note: we must check the length of the filtered text to avoid incorrectly handling e.g. "01" as "1" + */ + newSecond = `${trimmedText}0`; + newSelection = 1; + } else { + newSecond = trimmedText.substring(0, 2).padStart(2, '0'); + newSelection = 2; + } + + if (Number(newSecond) > 59) { + newSecond = seconds; + } + + setSeconds(newSecond); + setSelectionSecond({start: newSelection, end: newSelection}); + if (newSelection === 2) { + focusMilisecondInputOnFirstCharacter(); + } + }; + + /* + This function receives value from the miliseconds input and validates it. + The valid format is SSS(from 000 to 999). If the user enters 9, it will be prepended to 009. If the user tries to change 999 to 9999, it would skip the character + */ + const handleMilisecondsChange = (text: string) => { + // Replace spaces with 0 to implement the following digit removal by pressing space + const trimmedText = text.replace(/ /g, '0'); + if (!trimmedText) { + resetMiliseconds(); + return; + } + + const isOnlyNumericValue = /^\d+$/.test(trimmedText); + if (!isOnlyNumericValue) { + return; + } + + let newMilisecond; + let newSelection; + + if (selectionMilisecond.start === 0 && selectionMilisecond.end === 0) { + // The cursor is at the start of miliseconds + const firstDigit = trimmedText[0]; + const secondDigit = trimmedText[2] || '0'; + const thirdDigit = trimmedText[3] || '0'; + newMilisecond = `${firstDigit}${secondDigit}${thirdDigit}`; + newSelection = 1; + } else if (selectionMilisecond.start === 1 && selectionMilisecond.end === 1) { + // The cursor is in-between the digits + if (lastPressedKey.current === 'Backspace') { + // We have removed the first digit. Replace it with 0 and move the cursor to the start. + const secondDigit = trimmedText[0]; + const thirdDigit = trimmedText[1] || '0'; + newMilisecond = `0${secondDigit}${thirdDigit}`; + newSelection = 0; + } else { + const firstDigit = trimmedText[0]; + const secondDigit = trimmedText[1] || '0'; + const thirdDigit = trimmedText[3] || '0'; + newMilisecond = `${firstDigit}${secondDigit}${thirdDigit}`; + newSelection = 2; + } + } else if (selectionMilisecond.start === 2 && selectionMilisecond.end === 2) { + // The cursor is in-between the digits + if (lastPressedKey.current === 'Backspace') { + // We have removed the second digit. Replace it with 0 and move the cursor back. + const firstDigit = trimmedText[0]; + const thirdDigit = trimmedText[1] || '0'; + newMilisecond = `${firstDigit}0${thirdDigit}`; + newSelection = 1; + } else { + const firstDigit = trimmedText[0]; + const secondDigit = trimmedText[1] || '0'; + const thirdDigit = trimmedText[2] || '0'; + newMilisecond = `${firstDigit}${secondDigit}${thirdDigit}`; + newSelection = 3; + } + } else if (selectionMilisecond.start === 0 && selectionMilisecond.end === 1) { + // There is an active selection of the first digit + newMilisecond = trimmedText.substring(0, 3).padStart(3, '0'); + newSelection = trimmedText.length === 1 ? 0 : 1; + } else if (selectionMilisecond.start === 1 && selectionMilisecond.end === 2) { + // There is an active selection of the second digit + newMilisecond = trimmedText.substring(0, 3).padStart(3, '0'); + newSelection = trimmedText.length === 1 ? 1 : 2; + } else if (selectionMilisecond.start === 2 && selectionMilisecond.end === 3) { + // There is an active selection of the third digit + newMilisecond = trimmedText.substring(0, 3).padEnd(3, '0'); + newSelection = trimmedText.length === 2 ? 2 : 3; + } else { + newMilisecond = trimmedText.substring(0, 3).padEnd(3, '0'); + newSelection = trimmedText.length; + } + + if (Number(newMilisecond) > 999) { + newMilisecond = miliseconds; + } + + setMiliseconds(newMilisecond); + setSelectionMilisecond({start: newSelection, end: newSelection}); }; /** @@ -327,7 +529,11 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim (key: string) => { const isHourFocused = hourInputRef.current?.isFocused(); const isMinuteFocused = minuteInputRef.current?.isFocused(); - if (!isHourFocused && !isMinuteFocused) { + const isSecondFocused = secondInputRef.current?.isFocused(); + const isMilisecondFocused = milisecondInputRef.current?.isFocused(); + if (showFullFormat && !isHourFocused && !isMinuteFocused && !isSecondFocused && !isMilisecondFocused) { + milisecondInputRef.current?.focus(); + } else if (!showFullFormat && !isHourFocused && !isMinuteFocused) { minuteInputRef.current?.focus(); } @@ -344,6 +550,20 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim } clearSelectedValue(minutes, selectionMinute, setMinutes, setSelectionMinute); + } else if (isSecondFocused) { + if (selectionSecond.start === 0 && selectionSecond.end === 0) { + focusMinuteInputOnLastCharacter(); + return; + } + + clearSelectedValue(seconds, selectionSecond, setSeconds, setSelectionSecond); + } else if (isMilisecondFocused) { + if (selectionMilisecond.start === 0 && selectionMilisecond.end === 0) { + focusSecondInputOnLastCharacter(); + return; + } + + clearSelectedValue(miliseconds, selectionMilisecond, setMiliseconds, setSelectionMilisecond, 3); } return; } @@ -353,10 +573,14 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end)); } else if (isMinuteFocused) { handleMinutesChange(insertAtPosition(minutes, trimmedKey, selectionMinute.start, selectionMinute.end)); + } else if (isSecondFocused) { + handleSecondsChange(insertAtPosition(seconds, trimmedKey, selectionSecond.start, selectionSecond.end)); + } else if (isMilisecondFocused) { + handleMilisecondsChange(insertAtPosition(miliseconds, trimmedKey, selectionMilisecond.start, selectionMilisecond.end)); } }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [minutes, hours, selectionMinute, selectionHour], + [minutes, hours, seconds, miliseconds, selectionMinute, selectionHour, selectionSecond, selectionMilisecond], ); useEffect(() => { @@ -374,28 +598,45 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim const arrowLeftCallback = useCallback( (e?: GestureResponderEvent | KeyboardEvent) => { - const isMinuteFocused = minuteInputRef.current?.isFocused(); - if (isMinuteFocused && selectionMinute.start === 0) { + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0) { // Check e to be truthy to avoid crashing on Android (e is undefined there) e?.preventDefault(); focusHourInputOnLastCharacter(); } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (milisecondInputRef.current?.isFocused() && selectionMilisecond.start === 0) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnLastCharacter(); + } }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps [selectionHour, selectionMinute], ); const arrowRightCallback = useCallback( (e?: GestureResponderEvent | KeyboardEvent) => { - const isHourFocused = hourInputRef.current?.isFocused(); - - if (isHourFocused && selectionHour.start === 2) { + if (hourInputRef.current?.isFocused() && selectionHour.start === 2) { // Check e to be truthy to avoid crashing on Android (e is undefined there) e?.preventDefault(); focusMinuteInputOnFirstCharacter(); } + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusSecondInputOnFirstCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 2) { + // Check e to be truthy to avoid crashing on Android (e is undefined there) + e?.preventDefault(); + focusMilisecondInputOnFirstCharacter(); + } }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [selectionHour, selectionMinute], + [selectionHour, selectionMinute, selectionSecond, selectionMilisecond], ); useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig); @@ -403,14 +644,34 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim const handleFocusOnBackspace = useCallback( (e: NativeSyntheticEvent) => { - if (selectionMinute.start !== 0 || selectionMinute.end !== 0 || e.nativeEvent.key !== 'Backspace') { + if (e.nativeEvent.key !== 'Backspace') { return; } - e.preventDefault(); - focusHourInputOnLastCharacter(); + if (minuteInputRef.current?.isFocused() && selectionMinute.start === 0 && selectionMinute.end === 0) { + e.preventDefault(); + focusHourInputOnLastCharacter(); + } + if (secondInputRef.current?.isFocused() && selectionSecond.start === 0 && selectionSecond.end === 0) { + e.preventDefault(); + focusMinuteInputOnLastCharacter(); + } + if (milisecondInputRef.current?.isFocused() && selectionMilisecond.start === 0 && selectionMilisecond.end === 0) { + e.preventDefault(); + focusSecondInputOnLastCharacter(); + } }, // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [selectionMinute.start, selectionMinute.end, focusHourInputOnLastCharacter], + [ + selectionMinute.start, + selectionMinute.end, + selectionSecond.start, + selectionSecond.end, + selectionMilisecond.start, + selectionMilisecond.end, + focusHourInputOnLastCharacter, + focusMinuteInputOnLastCharacter, + focusSecondInputOnLastCharacter, + ], ); const {styleForAM, styleForPM} = StyleUtils.getStatusAMandPMButtonStyle(amPmValue); @@ -429,12 +690,12 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim }, [canUseTouchScreen, updateAmountNumberPad]); useEffect(() => { - onInputChange(`${hours}:${minutes} ${amPmValue}`); + onInputChange(showFullFormat ? `${hours}:${minutes}:${seconds}.${miliseconds} ${amPmValue}` : `${hours}:${minutes} ${amPmValue}`); // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [hours, minutes, amPmValue]); const handleSubmit = () => { - const time = `${hours}:${minutes} ${amPmValue}`; + const time = showFullFormat ? `${hours}:${minutes}:${seconds}.${miliseconds}` : `${hours}:${minutes} ${amPmValue}`; const isValid = validate(time); if (isValid) { @@ -442,6 +703,22 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim } }; + const updateRefs = (refName: TimePickerRefName, updatedRef: BaseTextInputRef | null) => { + const updatedRefs = { + hourRef: hourInputRef.current, + minuteRef: minuteInputRef.current, + secondRef: secondInputRef.current, + milisecondRef: milisecondInputRef.current, + [refName]: updatedRef, + }; + if (typeof ref === 'function') { + ref(updatedRefs); + } else if (ref && 'current' in ref) { + // eslint-disable-next-line no-param-reassign + ref.current = updatedRefs; + } + }; + return ( @@ -457,24 +734,19 @@ function TimePicker({defaultValue = '', onSubmit, onInputChange = () => {}}: Tim }} onChangeAmount={handleHourChange} ref={(textInputRef) => { - if (typeof ref === 'function') { - ref({hourRef: textInputRef as TextInput | null, minuteRef: minuteInputRef.current}); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = {hourRef: textInputRef as TextInput | null, minuteRef: minuteInputRef.current}; - } + updateRefs('hourRef', textInputRef); // eslint-disable-next-line react-compiler/react-compiler hourInputRef.current = textInputRef as TextInput | null; }} onSelectionChange={(e) => { setSelectionHour(e.nativeEvent.selection); }} - style={[styles.iouAmountTextInput, styles.timePickerInput]} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} containerStyle={[styles.iouAmountTextInputContainer]} - touchableInputWrapperStyle={styles.timePickerHeight100} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionHour} /> - {CONST.COLON} + {CONST.COLON} {}}: Tim }} onChangeAmount={handleMinutesChange} ref={(textInputRef) => { - if (typeof ref === 'function') { - ref({hourRef: hourInputRef.current, minuteRef: textInputRef as TextInput | null}); - } else if (ref && 'current' in ref) { - // eslint-disable-next-line no-param-reassign - ref.current = {hourRef: hourInputRef.current, minuteRef: textInputRef as TextInput | null}; - } + updateRefs('minuteRef', textInputRef); minuteInputRef.current = textInputRef as TextInput | null; - inputCallbackRef(textInputRef as TextInput | null); + if (!showFullFormat) { + inputCallbackRef(textInputRef as TextInput | null); + } }} onSelectionChange={(e) => { setSelectionMinute(e.nativeEvent.selection); }} - style={[styles.iouAmountTextInput, styles.timePickerInput]} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} containerStyle={[styles.iouAmountTextInputContainer]} - touchableInputWrapperStyle={styles.timePickerHeight100} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} selection={selectionMinute} /> + {showFullFormat && ( + <> + {CONST.COLON} + { + lastPressedKey.current = e.nativeEvent.key; + handleFocusOnBackspace(e); + }} + onChangeAmount={handleSecondsChange} + ref={(textInputRef) => { + updateRefs('secondRef', textInputRef); + secondInputRef.current = textInputRef as TextInput | null; + }} + onSelectionChange={(e) => { + setSelectionSecond(e.nativeEvent.selection); + }} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} + containerStyle={[styles.iouAmountTextInputContainer]} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} + selection={selectionSecond} + /> + {CONST.COLON} + { + lastPressedKey.current = e.nativeEvent.key; + handleFocusOnBackspace(e); + }} + onChangeAmount={handleMilisecondsChange} + ref={(textInputRef) => { + updateRefs('milisecondRef', textInputRef); + milisecondInputRef.current = textInputRef as TextInput | null; + if (showFullFormat) { + inputCallbackRef(textInputRef as TextInput | null); + } + }} + onSelectionChange={(e) => { + setSelectionMilisecond(e.nativeEvent.selection); + }} + style={[styles.iouAmountTextInput, styles.timePickerInput, showFullFormat && [styles.textXXLarge, styles.mnw0]]} + containerStyle={[styles.iouAmountTextInputContainer]} + touchableInputWrapperStyle={!showFullFormat && styles.timePickerHeight100} + selection={selectionMilisecond} + /> + + )}