diff --git a/src/CONST.ts b/src/CONST.ts
index 072f780b54ae..d3f24b3fbacd 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -2902,6 +2902,10 @@ const CONST = {
NAVIGATE: 'NAVIGATE',
},
},
+ TIME_PERIOD: {
+ AM: 'AM',
+ PM: 'PM',
+ },
INDENTS: ' ',
PARENT_CHILD_SEPARATOR: ': ',
CATEGORY_LIST_THRESHOLD: 8,
@@ -2911,7 +2915,7 @@ const CONST = {
SBE: 'SbeDemoSetup',
MONEY2020: 'Money2020DemoSetup',
},
-
+ COLON: ':',
MAPBOX: {
PADDING: 50,
DEFAULT_ZOOM: 10,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 933ae678da23..0cc7934ad007 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -334,10 +334,10 @@ const ONYXKEYS = {
WAYPOINT_FORM_DRAFT: 'waypointFormDraft',
SETTINGS_STATUS_SET_FORM: 'settingsStatusSetForm',
SETTINGS_STATUS_SET_FORM_DRAFT: 'settingsStatusSetFormDraft',
- SETTINGS_STATUS_CLEAR_AFTER_FORM: 'settingsStatusClearAfterForm',
- SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusClearAfterFormDraft',
SETTINGS_STATUS_SET_CLEAR_AFTER_FORM: 'settingsStatusSetClearAfterForm',
SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT: 'settingsStatusSetClearAfterFormDraft',
+ SETTINGS_STATUS_CLEAR_DATE_FORM: 'settingsStatusClearDateForm',
+ SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT: 'settingsStatusClearDateFormDraft',
PRIVATE_NOTES_FORM: 'privateNotesForm',
PRIVATE_NOTES_FORM_DRAFT: 'privateNotesFormDraft',
I_KNOW_A_TEACHER_FORM: 'iKnowTeacherForm',
@@ -508,8 +508,8 @@ type OnyxValues = {
[ONYXKEYS.FORMS.WAYPOINT_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_FORM_DRAFT]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM]: OnyxTypes.Form;
- [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM]: OnyxTypes.Form;
+ [ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM]: OnyxTypes.Form;
[ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM_DRAFT]: OnyxTypes.Form;
[ONYXKEYS.FORMS.PRIVATE_NOTES_FORM]: OnyxTypes.Form;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 425ff73af56b..ca1fe9f0e81a 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -140,7 +140,9 @@ const ROUTES = {
getRoute: (backTo?: string) => getUrlWithBackToParam('settings/security/two-factor-auth', backTo),
},
SETTINGS_STATUS: 'settings/profile/status',
- SETTINGS_STATUS_SET: 'settings/profile/status/set',
+ SETTINGS_STATUS_CLEAR_AFTER: 'settings/profile/status/clear-after',
+ SETTINGS_STATUS_CLEAR_AFTER_DATE: 'settings/profile/status/clear-after/date',
+ SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time',
KEYBOARD_SHORTCUTS: 'keyboard-shortcuts',
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 921f57953482..2cd263237866 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -37,8 +37,10 @@ const SCREENS = {
CONTACT_METHODS: 'Settings_ContactMethods',
CONTACT_METHOD_DETAILS: 'Settings_ContactMethodDetails',
NEW_CONTACT_METHOD: 'Settings_NewContactMethod',
+ STATUS_CLEAR_AFTER: 'Settings_Status_Clear_After',
+ STATUS_CLEAR_AFTER_DATE: 'Settings_Status_Clear_After_Date',
+ STATUS_CLEAR_AFTER_TIME: 'Settings_Status_Clear_After_Time',
STATUS: 'Settings_Status',
- STATUS_SET: 'Settings_Status_Set',
PRONOUNS: 'Settings_Pronouns',
TIMEZONE: 'Settings_Timezone',
TIMEZONE_SELECT: 'Settings_Timezone_Select',
diff --git a/src/components/AmountTextInput.js b/src/components/AmountTextInput.js
index 5efcc003d853..63e59d7eef4a 100644
--- a/src/components/AmountTextInput.js
+++ b/src/components/AmountTextInput.js
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import React from 'react';
+import useStyleUtils from '@styles/useStyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import refPropTypes from './refPropTypes';
@@ -27,6 +28,12 @@ const propTypes = {
/** Function to call when selection in text input is changed */
onSelectionChange: PropTypes.func,
+ /** Style for the input */
+ style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+
+ /** Style for the container */
+ containerStyles: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+
/** Function to call to handle key presses in the text input */
onKeyPress: PropTypes.func,
};
@@ -36,16 +43,19 @@ const defaultProps = {
selection: undefined,
onSelectionChange: () => {},
onKeyPress: () => {},
+ style: {},
+ containerStyles: {},
};
function AmountTextInput(props) {
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
return (
);
}
diff --git a/src/components/BigNumberPad.tsx b/src/components/BigNumberPad.tsx
index 812e9e78635b..3204b39cb19c 100644
--- a/src/components/BigNumberPad.tsx
+++ b/src/components/BigNumberPad.tsx
@@ -15,6 +15,9 @@ type BigNumberPadProps = {
/** Used to locate this view from native classes. */
id?: string;
+
+ /** Whether long press is disabled */
+ isLongPressDisabled: boolean;
};
const padNumbers = [
@@ -24,7 +27,7 @@ const padNumbers = [
['.', '0', '<'],
] as const;
-function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, id = 'numPadView'}: BigNumberPadProps) {
+function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, id = 'numPadView', isLongPressDisabled = false}: BigNumberPadProps) {
const {toLocaleDigit} = useLocalize();
const styles = useThemeStyles();
@@ -85,6 +88,7 @@ function BigNumberPad({numberPressed, longPressHandlerStateChanged = () => {}, i
onMouseDown={(e) => {
e.preventDefault();
}}
+ isLongPressDisabled={isLongPressDisabled}
/>
);
})}
diff --git a/src/components/Button/index.tsx b/src/components/Button/index.tsx
index eb99d4b09396..06577c3ac813 100644
--- a/src/components/Button/index.tsx
+++ b/src/components/Button/index.tsx
@@ -106,6 +106,9 @@ type ButtonProps = (ButtonWithText | ChildrenProps) & {
/** Should enable the haptic feedback? */
shouldEnableHapticFeedback?: boolean;
+ /** Should disable the long press? */
+ isLongPressDisabled?: boolean;
+
/** Id to use for this button */
id?: string;
@@ -149,6 +152,7 @@ function Button(
shouldRemoveRightBorderRadius = false,
shouldRemoveLeftBorderRadius = false,
shouldEnableHapticFeedback = false,
+ isLongPressDisabled = false,
id = '',
accessibilityLabel = '',
@@ -255,6 +259,9 @@ function Button(
return onPress(event);
}}
onLongPress={(event) => {
+ if (isLongPressDisabled) {
+ return;
+ }
if (shouldEnableHapticFeedback) {
HapticFeedback.longPress();
}
diff --git a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
index 8135fa38e992..5dc43ef47699 100644
--- a/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
+++ b/src/components/EmojiPicker/EmojiPickerButtonDropdown.js
@@ -5,7 +5,7 @@ import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
-import Tooltip from '@components/Tooltip';
+import Tooltip from '@components/Tooltip/PopoverAnchorTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import getButtonState from '@libs/getButtonState';
import useStyleUtils from '@styles/useStyleUtils';
@@ -47,7 +47,7 @@ function EmojiPickerButtonDropdown(props) {
({}),
+ submitButtonText: '',
};
-function Form(props) {
+const Form = forwardRef((props, forwardedRef) => {
const styles = useThemeStyles();
const [errors, setErrors] = useState({});
const [inputValues, setInputValues] = useState(() => ({...props.draftValues}));
@@ -245,6 +250,30 @@ function Form(props) {
onSubmit(trimmedStringValues);
}, [props.formState.isLoading, props.network.isOffline, props.enabledWhenOffline, inputValues, onValidate, onSubmit]);
+ /**
+ * Resets the form
+ */
+ const resetForm = useCallback(
+ (optionalValue) => {
+ _.each(inputValues, (inputRef, inputID) => {
+ setInputValues((prevState) => {
+ const copyPrevState = _.clone(prevState);
+
+ touchedInputs.current[inputID] = false;
+ copyPrevState[inputID] = optionalValue[inputID] || '';
+
+ return copyPrevState;
+ });
+ });
+ setErrors({});
+ },
+ [inputValues],
+ );
+
+ useImperativeHandle(forwardedRef, () => ({
+ resetForm,
+ }));
+
/**
* Loops over Form's children and automatically supplies Form props to them
*
@@ -464,7 +493,9 @@ function Form(props) {
containerStyles={[styles.mh0, styles.mt5, styles.flex1, ...props.submitButtonStyles]}
enabledWhenOffline={props.enabledWhenOffline}
isSubmitActionDangerous={props.isSubmitActionDangerous}
+ useSmallerSubmitButtonSize={props.useSmallerSubmitButtonSize}
disablePressOnEnter
+ errorMessageStyle={props.errorMessageStyle}
/>
)}
@@ -474,6 +505,8 @@ function Form(props) {
props.style,
props.isSubmitButtonVisible,
props.submitButtonText,
+ props.useSmallerSubmitButtonSize,
+ props.errorMessageStyle,
props.formState.errorFields,
props.formState.isLoading,
props.footerContent,
@@ -539,7 +572,7 @@ function Form(props) {
}
);
-}
+});
Form.displayName = 'Form';
Form.propTypes = propTypes;
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 63953d8303db..0d6dcb001091 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -1,6 +1,6 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
-import React, {createRef, useCallback, useMemo, useRef, useState} from 'react';
+import React, {createRef, forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import networkPropTypes from '@components/networkPropTypes';
@@ -109,250 +109,278 @@ function getInitialValueByType(valueType) {
}
}
-function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}) {
- const inputRefs = useRef({});
- const touchedInputs = useRef({});
- const [inputValues, setInputValues] = useState(() => ({...draftValues}));
- const [errors, setErrors] = useState({});
- const hasServerError = useMemo(() => Boolean(formState) && !_.isEmpty(formState.errors), [formState]);
+const FormProvider = forwardRef(
+ ({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}, forwardedRef) => {
+ const inputRefs = useRef({});
+ const touchedInputs = useRef({});
+ const [inputValues, setInputValues] = useState(() => ({...draftValues}));
+ const [errors, setErrors] = useState({});
+ const hasServerError = useMemo(() => Boolean(formState) && !_.isEmpty(formState.errors), [formState]);
- const onValidate = useCallback(
- (values, shouldClearServerError = true) => {
- const trimmedStringValues = ValidationUtils.prepareValues(values);
+ const onValidate = useCallback(
+ (values, shouldClearServerError = true) => {
+ const trimmedStringValues = ValidationUtils.prepareValues(values);
- if (shouldClearServerError) {
- FormActions.setErrors(formID, null);
- }
- FormActions.setErrorFields(formID, null);
+ if (shouldClearServerError) {
+ FormActions.setErrors(formID, null);
+ }
+ FormActions.setErrorFields(formID, null);
- const validateErrors = validate(values) || {};
+ const validateErrors = validate(values) || {};
- // Validate the input for html tags. It should supercede any other error
- _.each(trimmedStringValues, (inputValue, inputID) => {
- // If the input value is empty OR is non-string, we don't need to validate it for HTML tags
- if (!inputValue || !_.isString(inputValue)) {
- return;
- }
- const foundHtmlTagIndex = inputValue.search(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
- const leadingSpaceIndex = inputValue.search(CONST.VALIDATE_FOR_LEADINGSPACES_HTML_TAG_REGEX);
+ // Validate the input for html tags. It should supercede any other error
+ _.each(trimmedStringValues, (inputValue, inputID) => {
+ // If the input value is empty OR is non-string, we don't need to validate it for HTML tags
+ if (!inputValue || !_.isString(inputValue)) {
+ 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;
- }
+ // 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 = _.some(CONST.WHITELISTED_TAGS, (r) => r.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 (let i = 0; i < matchedHtmlTags.length; i++) {
- const htmlTag = matchedHtmlTags[i];
- isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag));
- if (!isMatch) {
- break;
+ const matchedHtmlTags = inputValue.match(CONST.VALIDATE_FOR_HTML_TAG_REGEX);
+ let isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.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 (let i = 0; i < matchedHtmlTags.length; i++) {
+ const htmlTag = matchedHtmlTags[i];
+ isMatch = _.some(CONST.WHITELISTED_TAGS, (r) => r.test(htmlTag));
+ if (!isMatch) {
+ break;
+ }
}
}
+ // Add a validation error here because it is a string value that contains HTML characters
+ validateErrors[inputID] = 'common.error.invalidCharacter';
+ });
+
+ if (!_.isObject(validateErrors)) {
+ throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}');
}
- // Add a validation error here because it is a string value that contains HTML characters
- validateErrors[inputID] = 'common.error.invalidCharacter';
- });
- if (!_.isObject(validateErrors)) {
- throw new Error('Validate callback must return an empty object or an object with shape {inputID: error}');
- }
+ const touchedInputErrors = _.pick(validateErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID]));
- const touchedInputErrors = _.pick(validateErrors, (inputValue, inputID) => Boolean(touchedInputs.current[inputID]));
+ if (!_.isEqual(errors, touchedInputErrors)) {
+ setErrors(touchedInputErrors);
+ }
- if (!_.isEqual(errors, touchedInputErrors)) {
- setErrors(touchedInputErrors);
+ return touchedInputErrors;
+ },
+ [errors, formID, validate],
+ );
+
+ /**
+ * @param {String} inputID - The inputID of the input being touched
+ */
+ const setTouchedInput = useCallback(
+ (inputID) => {
+ touchedInputs.current[inputID] = true;
+ },
+ [touchedInputs],
+ );
+
+ const submit = useCallback(() => {
+ // Return early if the form is already submitting to avoid duplicate submission
+ if (formState.isLoading) {
+ return;
}
- return touchedInputErrors;
- },
- [errors, formID, validate],
- );
-
- /**
- * @param {String} inputID - The inputID of the input being touched
- */
- const setTouchedInput = useCallback(
- (inputID) => {
- touchedInputs.current[inputID] = true;
- },
- [touchedInputs],
- );
-
- const submit = useCallback(() => {
- // Return early if the form is already submitting to avoid duplicate submission
- if (formState.isLoading) {
- return;
- }
-
- // Prepare values before submitting
- const trimmedStringValues = ValidationUtils.prepareValues(inputValues);
-
- // Touches all form inputs so we can validate the entire form
- _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true));
-
- // Validate form and return early if any errors are found
- if (!_.isEmpty(onValidate(trimmedStringValues))) {
- return;
- }
-
- // Do not submit form if network is offline and the form is not enabled when offline
- if (network.isOffline && !enabledWhenOffline) {
- return;
- }
-
- onSubmit(trimmedStringValues);
- }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]);
-
- const registerInput = useCallback(
- (inputID, propsToParse = {}) => {
- const newRef = inputRefs.current[inputID] || propsToParse.ref || createRef();
- if (inputRefs.current[inputID] !== newRef) {
- inputRefs.current[inputID] = newRef;
+ // Prepare values before submitting
+ const trimmedStringValues = ValidationUtils.prepareValues(inputValues);
+
+ // Touches all form inputs so we can validate the entire form
+ _.each(inputRefs.current, (inputRef, inputID) => (touchedInputs.current[inputID] = true));
+
+ // Validate form and return early if any errors are found
+ if (!_.isEmpty(onValidate(trimmedStringValues))) {
+ return;
}
- if (!_.isUndefined(propsToParse.value)) {
- inputValues[inputID] = propsToParse.value;
- } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) {
- inputValues[inputID] = draftValues[inputID];
- } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) {
- // We force the form to set the input value from the defaultValue props if there is a saved valid value
- inputValues[inputID] = propsToParse.defaultValue;
- } else if (_.isUndefined(inputValues[inputID])) {
- // We want to initialize the input value if it's undefined
- inputValues[inputID] = _.isUndefined(propsToParse.defaultValue) ? getInitialValueByType(propsToParse.valueType) : propsToParse.defaultValue;
+ // Do not submit form if network is offline and the form is not enabled when offline
+ if (network.isOffline && !enabledWhenOffline) {
+ return;
}
- const errorFields = lodashGet(formState, 'errorFields', {});
- const fieldErrorMessage =
- _.chain(errorFields[inputID])
- .keys()
- .sortBy()
- .reverse()
- .map((key) => errorFields[inputID][key])
- .first()
- .value() || '';
-
- return {
- ...propsToParse,
- ref:
- typeof propsToParse.ref === 'function'
- ? (node) => {
- propsToParse.ref(node);
- newRef.current = node;
- }
- : newRef,
- inputID,
- key: propsToParse.key || inputID,
- errorText: errors[inputID] || fieldErrorMessage,
- value: inputValues[inputID],
- // As the text input is controlled, we never set the defaultValue prop
- // as this is already happening by the value prop.
- defaultValue: undefined,
- onTouched: (event) => {
- if (!propsToParse.shouldSetTouchedOnBlurOnly) {
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
- }
- if (_.isFunction(propsToParse.onTouched)) {
- propsToParse.onTouched(event);
- }
- },
- onPress: (event) => {
- if (!propsToParse.shouldSetTouchedOnBlurOnly) {
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
- }
- if (_.isFunction(propsToParse.onPress)) {
- propsToParse.onPress(event);
- }
- },
- onPressOut: (event) => {
- // To prevent validating just pressed inputs, we need to set the touched input right after
- // onValidate and to do so, we need to delays setTouchedInput of the same amount of time
- // as the onValidate is delayed
- if (!propsToParse.shouldSetTouchedOnBlurOnly) {
- setTimeout(() => {
- setTouchedInput(inputID);
- }, VALIDATE_DELAY);
- }
- if (_.isFunction(propsToParse.onPressIn)) {
- propsToParse.onPressIn(event);
- }
- },
- onBlur: (event) => {
- // Only run validation when user proactively blurs the input.
- if (Visibility.isVisible() && Visibility.hasFocus()) {
- const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
- // We delay the validation in order to prevent Checkbox loss of focus when
- // the user is focusing a TextInput and proceeds to toggle a CheckBox in
- // web and mobile web platforms.
-
- setTimeout(() => {
- if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) {
- return;
- }
- setTouchedInput(inputID);
- if (shouldValidateOnBlur) {
- onValidate(inputValues, !hasServerError);
- }
- }, VALIDATE_DELAY);
- }
+ onSubmit(trimmedStringValues);
+ }, [enabledWhenOffline, formState.isLoading, inputValues, network.isOffline, onSubmit, onValidate]);
- if (_.isFunction(propsToParse.onBlur)) {
- propsToParse.onBlur(event);
- }
- },
- onInputChange: (value, key) => {
- const inputKey = key || inputID;
+ /**
+ * Resets the form
+ */
+ const resetForm = useCallback(
+ (optionalValue) => {
+ _.each(inputValues, (inputRef, inputID) => {
setInputValues((prevState) => {
- const newState = {
- ...prevState,
- [inputKey]: value,
- };
+ const copyPrevState = _.clone(prevState);
- if (shouldValidateOnChange) {
- onValidate(newState);
- }
- return newState;
+ touchedInputs.current[inputID] = false;
+ copyPrevState[inputID] = optionalValue[inputID] || '';
+
+ return copyPrevState;
});
+ });
+ setErrors({});
+ },
+ [inputValues],
+ );
+ useImperativeHandle(forwardedRef, () => ({
+ resetForm,
+ }));
+
+ const registerInput = useCallback(
+ (inputID, propsToParse = {}) => {
+ const newRef = inputRefs.current[inputID] || propsToParse.ref || createRef();
+ if (inputRefs.current[inputID] !== newRef) {
+ inputRefs.current[inputID] = newRef;
+ }
- if (propsToParse.shouldSaveDraft) {
- FormActions.setDraftValues(formID, {[inputKey]: value});
- }
+ if (!_.isUndefined(propsToParse.value)) {
+ inputValues[inputID] = propsToParse.value;
+ } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) {
+ inputValues[inputID] = draftValues[inputID];
+ } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) {
+ // We force the form to set the input value from the defaultValue props if there is a saved valid value
+ inputValues[inputID] = propsToParse.defaultValue;
+ } else if (_.isUndefined(inputValues[inputID])) {
+ // We want to initialize the input value if it's undefined
+ inputValues[inputID] = _.isUndefined(propsToParse.defaultValue) ? getInitialValueByType(propsToParse.valueType) : propsToParse.defaultValue;
+ }
- if (_.isFunction(propsToParse.onValueChange)) {
- propsToParse.onValueChange(value, inputKey);
- }
- },
- };
- },
- [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
- );
- const value = useMemo(() => ({registerInput}), [registerInput]);
-
- return (
-
- {/* eslint-disable react/jsx-props-no-spreading */}
-
- {_.isFunction(children) ? children({inputValues}) : children}
-
-
- );
-}
+ const errorFields = lodashGet(formState, 'errorFields', {});
+ const fieldErrorMessage =
+ _.chain(errorFields[inputID])
+ .keys()
+ .sortBy()
+ .reverse()
+ .map((key) => errorFields[inputID][key])
+ .first()
+ .value() || '';
+
+ return {
+ ...propsToParse,
+ ref:
+ typeof propsToParse.ref === 'function'
+ ? (node) => {
+ propsToParse.ref(node);
+ newRef.current = node;
+ }
+ : newRef,
+ inputID,
+ key: propsToParse.key || inputID,
+ errorText: errors[inputID] || fieldErrorMessage,
+ value: inputValues[inputID],
+ // As the text input is controlled, we never set the defaultValue prop
+ // as this is already happening by the value prop.
+ defaultValue: undefined,
+ onTouched: (event) => {
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
+ if (_.isFunction(propsToParse.onTouched)) {
+ propsToParse.onTouched(event);
+ }
+ },
+ onPress: (event) => {
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
+ if (_.isFunction(propsToParse.onPress)) {
+ propsToParse.onPress(event);
+ }
+ },
+ onPressOut: (event) => {
+ // To prevent validating just pressed inputs, we need to set the touched input right after
+ // onValidate and to do so, we need to delays setTouchedInput of the same amount of time
+ // as the onValidate is delayed
+ if (!propsToParse.shouldSetTouchedOnBlurOnly) {
+ setTimeout(() => {
+ setTouchedInput(inputID);
+ }, VALIDATE_DELAY);
+ }
+ if (_.isFunction(propsToParse.onPressIn)) {
+ propsToParse.onPressIn(event);
+ }
+ },
+ onBlur: (event) => {
+ // Only run validation when user proactively blurs the input.
+ if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
+ // We delay the validation in order to prevent Checkbox loss of focus when
+ // the user is focusing a TextInput and proceeds to toggle a CheckBox in
+ // web and mobile web platforms.
+
+ setTimeout(() => {
+ if (
+ relatedTargetId &&
+ _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)
+ ) {
+ return;
+ }
+ setTouchedInput(inputID);
+ if (shouldValidateOnBlur) {
+ onValidate(inputValues, !hasServerError);
+ }
+ }, VALIDATE_DELAY);
+ }
+
+ if (_.isFunction(propsToParse.onBlur)) {
+ propsToParse.onBlur(event);
+ }
+ },
+ onInputChange: (value, key) => {
+ const inputKey = key || inputID;
+ setInputValues((prevState) => {
+ const newState = {
+ ...prevState,
+ [inputKey]: value,
+ };
+
+ if (shouldValidateOnChange) {
+ onValidate(newState);
+ }
+ return newState;
+ });
+
+ if (propsToParse.shouldSaveDraft) {
+ FormActions.setDraftValues(formID, {[inputKey]: value});
+ }
+
+ if (_.isFunction(propsToParse.onValueChange)) {
+ propsToParse.onValueChange(value, inputKey);
+ }
+ },
+ };
+ },
+ [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
+ );
+ const value = useMemo(() => ({registerInput}), [registerInput]);
+
+ return (
+
+ {/* eslint-disable react/jsx-props-no-spreading */}
+
+ {_.isFunction(children) ? children({inputValues}) : children}
+
+
+ );
+ },
+);
FormProvider.displayName = 'Form';
FormProvider.propTypes = propTypes;
diff --git a/src/components/FormAlertWithSubmitButton.js b/src/components/FormAlertWithSubmitButton.js
index b16a4d2a08ee..6886b6fdcaaf 100644
--- a/src/components/FormAlertWithSubmitButton.js
+++ b/src/components/FormAlertWithSubmitButton.js
@@ -50,6 +50,12 @@ const propTypes = {
/** Styles for the button */
// eslint-disable-next-line react/forbid-prop-types
buttonStyles: PropTypes.arrayOf(PropTypes.object),
+
+ /** Whether to use a smaller submit button size */
+ useSmallerSubmitButtonSize: PropTypes.bool,
+
+ /** Style for the error message for submit button */
+ errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
};
const defaultProps = {
@@ -62,8 +68,10 @@ const defaultProps = {
enabledWhenOffline: false,
disablePressOnEnter: false,
isSubmitActionDangerous: false,
+ useSmallerSubmitButtonSize: false,
footerContent: null,
buttonStyles: [],
+ errorMessageStyle: [],
};
function FormAlertWithSubmitButton(props) {
@@ -77,6 +85,7 @@ function FormAlertWithSubmitButton(props) {
isMessageHtml={props.isMessageHtml}
message={props.message}
onFixTheErrorsLinkPressed={props.onFixTheErrorsLinkPressed}
+ errorMessageStyle={props.errorMessageStyle}
>
{(isOffline) => (
@@ -87,6 +96,7 @@ function FormAlertWithSubmitButton(props) {
text={props.buttonText}
style={buttonStyles}
danger={props.isSubmitActionDangerous}
+ medium={props.useSmallerSubmitButtonSize}
/>
) : (
)}
{props.footerContent}
diff --git a/src/components/FormAlertWrapper.js b/src/components/FormAlertWrapper.js
index c577048c0a1b..d6612f6ae688 100644
--- a/src/components/FormAlertWrapper.js
+++ b/src/components/FormAlertWrapper.js
@@ -35,11 +35,15 @@ const propTypes = {
/** Callback fired when the "fix the errors" link is pressed */
onFixTheErrorsLinkPressed: PropTypes.func,
+ /** Style for the error message for submit button */
+ errorMessageStyle: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+
...withLocalizePropTypes,
};
const defaultProps = {
containerStyles: [],
+ errorMessageStyle: [],
isAlertVisible: false,
isMessageHtml: false,
message: '',
@@ -74,7 +78,7 @@ function FormAlertWrapper(props) {
{props.isAlertVisible && (
{children}
diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx
index 27a1f5827d75..3d0b2df443f2 100644
--- a/src/components/FormHelpMessage.tsx
+++ b/src/components/FormHelpMessage.tsx
@@ -20,9 +20,12 @@ type FormHelpMessageProps = {
/** Container style props */
style?: StyleProp;
+
+ /** Whether to show dot indicator */
+ shouldShowRedDotIndicator?: boolean;
};
-function FormHelpMessage({message = '', children, isError = true, style}: FormHelpMessageProps) {
+function FormHelpMessage({message = '', children, isError = true, style, shouldShowRedDotIndicator = true}: FormHelpMessageProps) {
const theme = useTheme();
const styles = useThemeStyles();
if (isEmpty(message) && isEmpty(children)) {
@@ -33,13 +36,15 @@ function FormHelpMessage({message = '', children, isError = true, style}: FormHe
return (
- {isError && (
+ {isError && shouldShowRedDotIndicator && (
)}
- {children ?? {translatedMessage}}
+
+ {children ?? {translatedMessage}}
+
);
}
diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js
index a56729f630d5..679e8853742d 100644
--- a/src/components/MenuItem.js
+++ b/src/components/MenuItem.js
@@ -16,6 +16,7 @@ import CONST from '@src/CONST';
import Avatar from './Avatar';
import Badge from './Badge';
import DisplayNames from './DisplayNames';
+import FormHelpMessage from './FormHelpMessage';
import Hoverable from './Hoverable';
import Icon from './Icon';
import * as Expensicons from './Icon/Expensicons';
@@ -106,6 +107,7 @@ const MenuItem = React.forwardRef((props, ref) => {
props.interactive && props.disabled ? {...styles.userSelectNone} : undefined,
styles.ltr,
isDeleted ? styles.offlineFeedback.deleted : undefined,
+ props.titleTextStyle,
],
props.titleStyle,
);
@@ -180,6 +182,8 @@ const MenuItem = React.forwardRef((props, ref) => {
onPressOut={ControlSelection.unblock}
onSecondaryInteraction={props.onSecondaryInteraction}
style={({pressed}) => [
+ props.containerStyle,
+ props.errorText ? styles.pb5 : {},
style,
!props.interactive && styles.cursorDefault,
StyleUtils.getButtonBackgroundColorStyle(getButtonState(props.focused || isHovered, pressed, props.success, props.disabled, props.interactive), true),
@@ -385,6 +389,14 @@ const MenuItem = React.forwardRef((props, ref) => {
{props.shouldShowRightComponent && props.rightComponent}
{props.shouldShowSelectedState && }
+ {Boolean(props.errorText) && (
+
+ )}
>
)}
diff --git a/src/components/SelectionList/BaseSelectionList.js b/src/components/SelectionList/BaseSelectionList.js
index ce5d1945fd2a..4f88b3ddc78a 100644
--- a/src/components/SelectionList/BaseSelectionList.js
+++ b/src/components/SelectionList/BaseSelectionList.js
@@ -19,6 +19,7 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import Log from '@libs/Log';
import useTheme from '@styles/themes/useTheme';
+import useStyleUtils from '@styles/useStyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -55,6 +56,8 @@ function BaseSelectionList({
showConfirmButton = false,
shouldPreventDefaultFocusOnSelectRow = false,
isKeyboardShown = false,
+ containerStyle = [],
+ disableInitialFocusOptionStyle = false,
inputRef = null,
disableKeyboardShortcuts = false,
children,
@@ -63,6 +66,7 @@ function BaseSelectionList({
}) {
const theme = useTheme();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const listRef = useRef(null);
const textInputRef = useRef(null);
@@ -310,6 +314,7 @@ function BaseSelectionList({
showTooltip={showTooltip}
canSelectMultiple={canSelectMultiple}
onSelectRow={() => selectRow(item, true)}
+ disableIsFocusStyle={disableInitialFocusOptionStyle}
onDismissError={onDismissError}
shouldPreventDefaultFocusOnSelectRow={shouldPreventDefaultFocusOnSelectRow}
keyForList={item.keyForList}
@@ -393,9 +398,10 @@ function BaseSelectionList({
maxIndex={flattenedSections.allOptions.length - 1}
onFocusedIndexChanged={updateAndScrollToFocusedIndex}
>
+ {/* */}
{({safeAreaPaddingBottomStyle}) => (
-
+
{shouldShowTextInput && (
{},
+ defaultValue: '',
+};
+
+const AMOUNT_VIEW_ID = 'amountView';
+const NUM_PAD_CONTAINER_VIEW_ID = 'numPadContainerView';
+const NUM_PAD_VIEW_ID = 'numPadView';
+
+function insertAtPosition(originalString, newSubstring, selectionPositionFrom, selectionPositionTo) {
+ // Check for invalid positions
+ if (selectionPositionFrom < 0 || selectionPositionTo < 0 || selectionPositionFrom > originalString.length || selectionPositionTo > originalString.length) {
+ return;
+ }
+
+ // If the positions are the same, it means we're inserting at a point
+ if (selectionPositionFrom === selectionPositionTo) {
+ if (selectionPositionFrom === originalString.length) {
+ return originalString; // If the insertion point is at the end, simply return the original string
+ }
+ return originalString.slice(0, selectionPositionFrom) + newSubstring + originalString.slice(selectionPositionFrom);
+ }
+
+ // Replace the selected range
+ return originalString.slice(0, selectionPositionFrom) + newSubstring + originalString.slice(selectionPositionTo);
+}
+
+// if we need manually to move selection to the left we need to decrease both selection start and end by one
+function decreaseBothSelectionByOne({start, end}) {
+ if (start === 0) {
+ return {start: 0, end: 0};
+ }
+ return {start: start - 1, end: end - 1};
+}
+
+function replaceWithZeroAtPosition(originalString, position) {
+ if (position === 0 || position > 2) {
+ return originalString;
+ }
+ return `${originalString.slice(0, position - 1)}0${originalString.slice(position)}`;
+}
+
+function TimePicker({forwardedRef, defaultValue, onSubmit, onInputChange}) {
+ const {numberFormat, translate} = useLocalize();
+ const {isExtraSmallScreenHeight} = useWindowDimensions();
+ const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
+ const value = DateUtils.extractTime12Hour(defaultValue);
+ const canUseTouchScreen = DeviceCapabilities.canUseTouchScreen();
+
+ const [isError, setError] = useState(false);
+ 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 [minute, setMinute] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).minute);
+ const [amPmValue, setAmPmValue] = useState(() => DateUtils.get12HourTimeObjectFromDate(value).period);
+
+ const hourInputRef = useRef(null);
+ const minuteInputRef = useRef(null);
+
+ const focusMinuteInputOnFirstCharacter = useCallback(() => {
+ const cleanupTimer = setSelection({start: 0, end: 0}, minuteInputRef, setSelectionMinute);
+ return cleanupTimer;
+ }, []);
+ const focusHourInputOnLastCharacter = useCallback(() => {
+ setSelectionHour({start: 2, end: 2});
+ const timer = setTimeout(() => {
+ hourInputRef.current.focus();
+ }, 10);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const validate = useCallback(
+ (time) => {
+ const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({timeString: time || `${hours}:${minute} ${amPmValue}`, dateTimeString: defaultValue});
+ setError(!isValid);
+ return isValid;
+ },
+ [hours, minute, amPmValue, defaultValue],
+ );
+
+ // 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) => {
+ const isOnlyNumericValue = /^\d+$/.test(text.trim());
+ // Skip if the user is pasting the text or use non numeric characters.
+ if (selectionHour.start !== selectionHour.end || !isOnlyNumericValue) {
+ return;
+ }
+ // Remove non-numeric characters.
+ const filteredText = text.replace(/[^0-9]/g, '');
+
+ let newHour = hours;
+ let newSelection = selectionHour.start;
+
+ // Case when the cursor is at the start.
+ if (selectionHour.start === 0) {
+ // Handle cases where the hour would be > 12.
+
+ // when you entering text the filteredText would consist of three numbers
+ const formattedText = `${filteredText[0]}${filteredText[2] || 0}`;
+ if (formattedText > 12 && formattedText <= 24) {
+ newHour = String(formattedText - 12).padStart(2, '0');
+ newSelection = 2;
+ setAmPmValue(CONST.TIME_PERIOD.PM);
+ } else if (formattedText > 24) {
+ newHour = `0${formattedText[1]}`;
+ newSelection = 2;
+ } else {
+ newHour = `${formattedText[0]}${formattedText[1]}`;
+ newSelection = 1;
+ }
+ } else if (selectionHour.start === 1) {
+ // Case when the cursor is at the second position.
+ const formattedText = `${filteredText[0]}${filteredText[1]}`;
+
+ if (filteredText.length < 2) {
+ // If we remove a value, prepend 0.
+ newHour = `0${text}`;
+ newSelection = 0;
+ // If the second digit is > 2, replace the hour with 0 and the second digit.
+ } else if (formattedText > 12 && formattedText <= 24) {
+ newHour = String(formattedText - 12).padStart(2, '0');
+ newSelection = 2;
+ setAmPmValue(CONST.TIME_PERIOD.PM);
+ } else if (formattedText > 24) {
+ newHour = `0${text[1]}`;
+ newSelection = 2;
+ } else {
+ newHour = `${text[0]}${text[1]}`;
+ setHours(newHour);
+ newSelection = 2;
+ }
+ } else if (selectionHour.start === 2 && selectionHour.end === 2) {
+ // Case when the cursor is at the end and no text is selected.
+ if (filteredText.length < 2) {
+ newHour = `${text}0`;
+ newSelection = 1;
+ } else {
+ newSelection = 2;
+ }
+ }
+
+ setHours(newHour);
+ setSelectionHour({start: newSelection, end: newSelection});
+ if (newSelection === 2) {
+ focusMinuteInputOnFirstCharacter();
+ }
+ };
+
+ // This function receive value from minute input and validate it
+ // The valid format is MM(from 00 to 59). If the user input 9, it will be 09. If user try to change 09 to 99 it would skip the character
+ const handleMinutesChange = (text) => {
+ const isOnlyNumericValue = /^\d+$/.test(text.trim());
+ // Skip if the user is pasting the text or use non numeric characters.
+ if (selectionMinute.start !== selectionMinute.end || !isOnlyNumericValue) {
+ return;
+ }
+
+ // Remove non-numeric characters.
+ const filteredText = text.replace(/[^0-9]/g, '');
+
+ let newMinute = minute;
+ let newSelection = selectionMinute.start;
+ // Case when user selects and replaces the text.
+ if (selectionMinute.start !== selectionMinute.end) {
+ // If the first digit is > 5, prepend 0.
+ if (filteredText.length === 1 && filteredText > 5) {
+ newMinute = `0${filteredText}`;
+ newSelection = 2;
+ // If the first digit is <= 5, append 0 at the end.
+ } else if (filteredText.length === 1 && filteredText <= 5) {
+ newMinute = `${filteredText}0`;
+ newSelection = 1;
+ } else {
+ newMinute = `${filteredText.slice(0, 2)}`;
+ newSelection = 2;
+ }
+ } else if (selectionMinute.start === 0) {
+ // Case when the cursor is at the start.
+ const formattedText = `${filteredText[0]}${filteredText[2] || 0}`;
+ if (text[0] >= 6) {
+ newMinute = `0${formattedText[1]}`;
+ newSelection = 2;
+ } else {
+ newMinute = `${formattedText[0]}${formattedText[1]}`;
+ newSelection = 1;
+ }
+ } else if (selectionMinute.start === 1) {
+ // Case when the cursor is at the second position.
+ // If we remove a value, prepend 0.
+ if (filteredText.length < 2) {
+ newMinute = `0${text}`;
+ newSelection = 0;
+ setSelectionHour({start: 2, end: 2});
+ hourInputRef.current.focus();
+ } else {
+ newMinute = `${text[0]}${text[1]}`;
+ newSelection = 2;
+ }
+ } else if (filteredText.length < 2) {
+ // Case when the cursor is at the end and no text is selected.
+ newMinute = `${text}0`;
+ newSelection = 1;
+ }
+
+ setMinute(newMinute);
+ setSelectionMinute({start: newSelection, end: newSelection});
+ };
+
+ /**
+ * Update amount with number or Backspace pressed for BigNumberPad.
+ * Validate new amount with decimal number regex up to 6 digits and 2 decimal digit to enable Next button
+ *
+ * @param {String} key
+ */
+ const updateAmountNumberPad = useCallback(
+ (key) => {
+ const isHourFocused = hourInputRef.current.isFocused();
+ const isMinuteFocused = minuteInputRef.current.isFocused();
+ if (!isHourFocused && !isMinuteFocused) {
+ minuteInputRef.current.focus();
+ }
+
+ if (key === '.') {
+ return;
+ }
+ if (key === '<' || key === 'Backspace') {
+ if (isHourFocused) {
+ const newHour = replaceWithZeroAtPosition(hours, selectionHour.start);
+ setHours(newHour);
+ setSelectionHour(decreaseBothSelectionByOne(selectionHour));
+ } else if (isMinuteFocused) {
+ if (selectionMinute.start === 0) {
+ focusHourInputOnLastCharacter();
+ return;
+ }
+ const newMinute = replaceWithZeroAtPosition(minute, selectionMinute.start);
+ setMinute(newMinute);
+ setSelectionMinute(decreaseBothSelectionByOne(selectionMinute));
+ }
+ return;
+ }
+ const trimmedKey = key.replace(/[^0-9]/g, '');
+
+ if (isHourFocused) {
+ handleHourChange(insertAtPosition(hours, trimmedKey, selectionHour.start, selectionHour.end));
+ } else if (isMinuteFocused) {
+ handleMinutesChange(insertAtPosition(minute, trimmedKey, selectionMinute.start, selectionMinute.end));
+ }
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [minute, hours, selectionHour, selectionMinute],
+ );
+
+ useEffect(() => {
+ // we implement this to ensure the hour input focuses on the first character upon initial focus
+ // https://github.com/facebook/react-native/issues/20214
+ setSelectionHour({start: 0, end: 0});
+ }, []);
+
+ const arrowConfig = useMemo(
+ () => ({
+ shouldPreventDefault: false,
+ }),
+ [],
+ );
+
+ const arrowLeftCallback = useCallback(() => {
+ const isMinuteFocused = minuteInputRef.current.isFocused();
+ if (isMinuteFocused && selectionMinute.start === 0) {
+ focusHourInputOnLastCharacter();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectionHour, selectionMinute]);
+ const arrowRightCallback = useCallback(() => {
+ const isHourFocused = hourInputRef.current.isFocused();
+
+ if (isHourFocused && selectionHour.start === 2) {
+ focusMinuteInputOnFirstCharacter();
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [selectionHour, selectionMinute]);
+
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_LEFT, arrowLeftCallback, arrowConfig);
+ useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ARROW_RIGHT, arrowRightCallback, arrowConfig);
+
+ const handleFocusOnBackspace = useCallback(
+ (e) => {
+ if (selectionMinute.start !== 0 || e.key !== 'Backspace') {
+ return;
+ }
+ hourInputRef.current.focus();
+ },
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [selectionMinute.start],
+ );
+
+ const {styleForAM, styleForPM} = StyleUtils.getStatusAMandPMButtonStyle(amPmValue);
+
+ const numberPad = useCallback(() => {
+ if (!canUseTouchScreen) {
+ return null;
+ }
+ return (
+
+ );
+ }, [canUseTouchScreen, updateAmountNumberPad]);
+
+ useEffect(() => {
+ onInputChange(`${hours}:${minute} ${amPmValue}`);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [hours, minute, amPmValue]);
+
+ const handleSubmit = () => {
+ const time = `${hours}:${minute} ${amPmValue}`;
+ const isValid = validate(time);
+
+ if (isValid) {
+ onSubmit(time);
+ }
+ };
+
+ return (
+
+
+
+ {
+ if (typeof forwardedRef === 'function') {
+ forwardedRef({refHour: ref, minuteRef: minuteInputRef.current});
+ } else if (forwardedRef && _.has(forwardedRef, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ forwardedRef.current = {hourRef: ref, minuteRef: minuteInputRef.current};
+ }
+ hourInputRef.current = ref;
+ }}
+ onSelectionChange={(e) => {
+ setSelectionHour(e.nativeEvent.selection);
+ }}
+ style={styles.timePickerInput}
+ containerStyles={[styles.timePickerHeight100]}
+ selection={selectionHour}
+ showSoftInputOnFocus={false}
+ />
+ {CONST.COLON}
+ {
+ if (typeof forwardedRef === 'function') {
+ forwardedRef({refHour: hourInputRef.current, minuteRef: ref});
+ } else if (forwardedRef && _.has(forwardedRef, 'current')) {
+ // eslint-disable-next-line no-param-reassign
+ minuteInputRef.current = {hourRef: hourInputRef.current, minuteInputRef: ref};
+ }
+ minuteInputRef.current = ref;
+ }}
+ onSelectionChange={(e) => {
+ setSelectionMinute(e.nativeEvent.selection);
+ }}
+ style={styles.timePickerInput}
+ containerStyles={[styles.timePickerHeight100]}
+ selection={selectionMinute}
+ showSoftInputOnFocus={false}
+ />
+
+
+
+
+ {isError ? (
+
+ ) : (
+
+ )}
+
+ {numberPad()}
+
+
+
+ );
+}
+
+TimePicker.propTypes = propTypes;
+TimePicker.defaultProps = defaultProps;
+TimePicker.displayName = 'TimePicker';
+
+const TimePickerWithRef = React.forwardRef((props, ref) => (
+
+));
+
+TimePickerWithRef.displayName = 'TimePickerWithRef';
+
+export default TimePickerWithRef;
diff --git a/src/components/TimePicker/setSelection.ios.ts b/src/components/TimePicker/setSelection.ios.ts
new file mode 100644
index 000000000000..0d1dfc004bc7
--- /dev/null
+++ b/src/components/TimePicker/setSelection.ios.ts
@@ -0,0 +1,8 @@
+import {RefObject} from 'react';
+import {TextInput} from 'react-native';
+
+export default function setSelection(value: {start: number; end: number}, ref: RefObject) {
+ ref.current?.focus();
+ ref.current?.setNativeProps({selection: value});
+ return () => {};
+}
diff --git a/src/components/TimePicker/setSelection.ts b/src/components/TimePicker/setSelection.ts
new file mode 100644
index 000000000000..36304b408f29
--- /dev/null
+++ b/src/components/TimePicker/setSelection.ts
@@ -0,0 +1,18 @@
+// setSelection.ts
+import {RefObject} from 'react';
+import {TextInput} from 'react-native';
+
+const setSelection = (value: {start: number; end: number}, ref: RefObject, setSelectionCallback: (value: {start: number; end: number}) => void): (() => void) => {
+ ref.current?.focus();
+
+ const timer = setTimeout(() => {
+ setSelectionCallback(value);
+ }, 10);
+
+ // Return the cleanup function
+ return () => {
+ clearTimeout(timer);
+ };
+};
+
+export default setSelection;
diff --git a/src/languages/en.ts b/src/languages/en.ts
index c4a481cb71c0..00191eb0a7d8 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -200,6 +200,8 @@ export default {
characterLimit: ({limit}: CharacterLimitParams) => `Exceeds the maximum length of ${limit} characters`,
characterLimitExceedCounter: ({length, limit}) => `Character limit exceeded (${length}/${limit})`,
dateInvalid: 'Please select a valid date',
+ invalidDateShouldBeFuture: 'Please choose today or a future date.',
+ invalidTimeShouldBeFuture: 'Please choose a time at least one minute ahead.',
invalidCharacter: 'Invalid character',
enterMerchant: 'Enter a merchant name',
enterAmount: 'Enter an amount',
@@ -269,6 +271,8 @@ export default {
kilometers: 'kilometers',
recent: 'Recent',
all: 'All',
+ am: 'AM',
+ pm: 'PM',
tbd: 'TBD',
selectCurrency: 'Select a currency',
card: 'Card',
@@ -1164,14 +1168,25 @@ export default {
},
statusPage: {
status: 'Status',
- setStatusTitle: 'Set your status',
statusExplanation: "Add an emoji to give your colleagues and friends an easy way to know what's going on. You can optionally add a message too!",
today: 'Today',
clearStatus: 'Clear status',
save: 'Save',
message: 'Message',
+ timePeriods: {
+ never: 'Never',
+ thirtyMinutes: '30 minutes',
+ oneHour: '1 hour',
+ afterToday: 'Today',
+ afterWeek: 'A week',
+ custom: 'Custom',
+ },
untilTomorrow: 'Until tomorrow',
untilTime: ({time}: UntilTimeParams) => `Until ${time}`,
+ date: 'Date',
+ time: 'Time',
+ clearAfter: 'Clear after',
+ whenClearStatus: 'When should we clear your status?',
},
stepCounter: ({step, total, text}: StepCounterParams) => {
let result = `Step ${step}`;
diff --git a/src/languages/es.ts b/src/languages/es.ts
index a91a8768a3ee..0f14b6e6d02a 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -190,6 +190,8 @@ export default {
characterLimit: ({limit}: CharacterLimitParams) => `Supera el límite de ${limit} caracteres`,
characterLimitExceedCounter: ({length, limit}) => `Se superó el límite de caracteres (${length}/${limit})`,
dateInvalid: 'Por favor, selecciona una fecha válida',
+ invalidDateShouldBeFuture: 'Por favor, elige una fecha igual o posterior a hoy',
+ invalidTimeShouldBeFuture: 'Por favor, elige una hora al menos un minuto en el futuro',
invalidCharacter: 'Carácter invalido',
enterMerchant: 'Introduce un comerciante',
enterAmount: 'Introduce un importe',
@@ -259,6 +261,8 @@ export default {
kilometers: 'kilómetros',
recent: 'Reciente',
all: 'Todo',
+ am: 'AM',
+ pm: 'PM',
tbd: 'Por determinar',
selectCurrency: 'Selecciona una moneda',
card: 'Tarjeta',
@@ -1165,12 +1169,19 @@ export default {
},
statusPage: {
status: 'Estado',
- setStatusTitle: 'Establece tu estado',
statusExplanation: 'Agrega un emoji para que tus colegas y amigos puedan saber fácilmente qué está pasando. ¡También puedes agregar un mensaje opcionalmente!',
today: 'Hoy',
clearStatus: 'Borrar estado',
save: 'Guardar',
message: 'Mensaje',
+ timePeriods: {
+ never: 'Nunca',
+ thirtyMinutes: '30 minutos',
+ oneHour: '1 hora',
+ afterToday: 'Hoy',
+ afterWeek: 'Una semana',
+ custom: 'Personalizado',
+ },
untilTomorrow: 'Hasta mañana',
untilTime: ({time}: UntilTimeParams) => {
// Check for HH:MM AM/PM format and starts with '01:'
@@ -1188,6 +1199,10 @@ export default {
// Default case
return `Hasta ${time}`;
},
+ date: 'Fecha',
+ time: 'Hora',
+ clearAfter: 'Borrar después',
+ whenClearStatus: '¿Cuándo deberíamos borrar tu estado?',
},
stepCounter: ({step, total, text}: StepCounterParams) => {
let result = `Paso ${step}`;
diff --git a/src/libs/DateUtils.ts b/src/libs/DateUtils.ts
index 80eae24d9367..4543b72c948a 100644
--- a/src/libs/DateUtils.ts
+++ b/src/libs/DateUtils.ts
@@ -1,16 +1,22 @@
import {
addDays,
+ addHours,
+ addMinutes,
eachDayOfInterval,
eachMonthOfInterval,
endOfDay,
endOfWeek,
format,
formatDistanceToNow,
+ getDayOfYear,
isAfter,
isBefore,
isSameDay,
+ isSameSecond,
isSameYear,
isValid,
+ parse,
+ set,
setDefaultOptions,
startOfWeek,
subDays,
@@ -28,6 +34,8 @@ import {SelectedTimezone, Timezone} from '@src/types/onyx/PersonalDetails';
import * as CurrentDate from './actions/CurrentDate';
import * as Localize from './Localize';
+type CustomStatusTypes = (typeof CONST.CUSTOM_STATUS_TYPES)[keyof typeof CONST.CUSTOM_STATUS_TYPES];
+type TimePeriod = 'AM' | 'PM';
type Locale = ValueOf;
let currentUserAccountID: number | undefined;
@@ -341,6 +349,115 @@ function getDateStringFromISOTimestamp(isoTimestamp: string): string {
return dateString;
}
+/**
+ * returns {string} example: 2023-05-16 05:34:14
+ */
+function getThirtyMinutesFromNow(): string {
+ const date = addMinutes(new Date(), 30);
+ return format(date, 'yyyy-MM-dd HH:mm:ss');
+}
+
+/**
+ * returns {string} example: 2023-05-16 05:34:14
+ */
+function getOneHourFromNow(): string {
+ const date = addHours(new Date(), 1);
+ return format(date, 'yyyy-MM-dd HH:mm:ss');
+}
+
+/**
+ * returns {string} example: 2023-05-16 05:34:14
+ */
+function getEndOfToday(): string {
+ const date = endOfDay(new Date());
+ return format(date, 'yyyy-MM-dd HH:mm:ss');
+}
+
+/**
+ * returns {string} example: 2023-05-16 05:34:14
+ */
+function getOneWeekFromNow(): string {
+ const date = addDays(new Date(), 7);
+ return format(date, 'yyyy-MM-dd HH:mm:ss');
+}
+
+/**
+ * param {string} dateTimeString
+ * returns {string} example: 2023-05-16
+ */
+function extractDate(dateTimeString: string): string {
+ if (!dateTimeString) {
+ return '';
+ }
+ if (dateTimeString === 'never') {
+ return '';
+ }
+ const date = new Date(dateTimeString);
+ return format(date, 'yyyy-MM-dd');
+}
+
+/**
+ * param {string} dateTimeString
+ * returns {string} example: 11:10 PM
+ */
+function extractTime12Hour(dateTimeString: string): string {
+ if (!dateTimeString || dateTimeString === 'never') {
+ return '';
+ }
+ const date = new Date(dateTimeString);
+ return format(date, 'hh:mm a');
+}
+
+/**
+ * param {string} dateTimeString
+ * returns {string} example: 2023-05-16 11:10 PM
+ */
+function formatDateTimeTo12Hour(dateTimeString: string): string {
+ if (!dateTimeString) {
+ return '';
+ }
+ const date = new Date(dateTimeString);
+ return format(date, 'yyyy-MM-dd hh:mm a');
+}
+
+/**
+ * param {string} type - one of the values from CONST.CUSTOM_STATUS_TYPES
+ * returns {string} example: 2023-05-16 11:10:00 or ''
+ */
+function getDateFromStatusType(type: CustomStatusTypes): string {
+ switch (type) {
+ case CONST.CUSTOM_STATUS_TYPES.THIRTY_MINUTES:
+ return getThirtyMinutesFromNow();
+ case CONST.CUSTOM_STATUS_TYPES.ONE_HOUR:
+ return getOneHourFromNow();
+ case CONST.CUSTOM_STATUS_TYPES.AFTER_TODAY:
+ return getEndOfToday();
+ case CONST.CUSTOM_STATUS_TYPES.AFTER_WEEK:
+ return getOneWeekFromNow();
+ case CONST.CUSTOM_STATUS_TYPES.NEVER:
+ return CONST.CUSTOM_STATUS_TYPES.NEVER;
+ default:
+ return '';
+ }
+}
+
+/**
+ * param {string} data - either a value from CONST.CUSTOM_STATUS_TYPES or a dateTime string in the format YYYY-MM-DD HH:mm
+ * returns {string} example: 2023-05-16 11:10 PM or 'Today'
+ */
+function getLocalizedTimePeriodDescription(data: string): string {
+ const {translateLocal} = Localize;
+ switch (data) {
+ case getEndOfToday():
+ return translateLocal('statusPage.timePeriods.afterToday');
+ case CONST.CUSTOM_STATUS_TYPES.NEVER:
+ case '':
+ return translateLocal('statusPage.timePeriods.never');
+ default:
+ return formatDateTimeTo12Hour(data);
+ }
+}
+
/**
* receive date like 2020-05-16 05:34:14 and format it to show in string like "Until 05:34 PM"
*/
@@ -374,10 +491,172 @@ function getStatusUntilDate(inputDate: string): string {
}
/**
+ * Update the time for a given date.
+ *
+ * param {string} updatedTime - Time in "hh:mm A" or "HH:mm:ss" or "yyyy-MM-dd HH:mm:ss" format.
+ * param {string} inputDateTime - Date in "YYYY-MM-DD HH:mm:ss" or "YYYY-MM-DD" format.
+ * returns {string} - Date with updated time in "YYYY-MM-DD HH:mm:ss" format.
+ */
+const combineDateAndTime = (updatedTime: string, inputDateTime: string): string => {
+ if (!updatedTime || !inputDateTime) {
+ return '';
+ }
+
+ let parsedTime: Date | null = null;
+ if (updatedTime.includes('-')) {
+ // it's in "yyyy-MM-dd HH:mm:ss" format
+ const tempTime = parse(updatedTime, 'yyyy-MM-dd HH:mm:ss', new Date());
+ if (isValid(tempTime)) {
+ parsedTime = tempTime;
+ }
+ } else if (updatedTime.includes(':')) {
+ // it's in "hh:mm a" format
+ const tempTime = parse(updatedTime, 'hh:mm a', new Date());
+ if (isValid(tempTime)) {
+ parsedTime = tempTime;
+ }
+ }
+
+ if (!parsedTime) {
+ return '';
+ }
+
+ let parsedDateTime: Date | null = null;
+ if (inputDateTime.includes(':')) {
+ // Check if it includes time
+ const tempDateTime = parse(inputDateTime, 'yyyy-MM-dd HH:mm:ss', new Date());
+ if (isValid(tempDateTime)) {
+ parsedDateTime = tempDateTime;
+ }
+ } else {
+ const tempDateTime = parse(inputDateTime, 'yyyy-MM-dd', new Date());
+ if (isValid(tempDateTime)) {
+ parsedDateTime = tempDateTime;
+ }
+ }
+
+ if (!parsedDateTime) {
+ return '';
+ }
+
+ const updatedDateTime = set(parsedDateTime, {
+ hours: parsedTime.getHours(),
+ minutes: parsedTime.getMinutes(),
+ seconds: parsedTime.getSeconds(),
+ });
+
+ return format(updatedDateTime, 'yyyy-MM-dd HH:mm:ss');
+};
+
+/**
+ * param {String} dateTime in 'HH:mm:ss' format
+ * returns {Object}
+ * example {hour: '11', minute: '10', period: 'AM'}
+ */
+function get12HourTimeObjectFromDate(dateTime: string): {hour: string; minute: string; period: string} {
+ if (!dateTime) {
+ return {
+ hour: '12',
+ minute: '00',
+ period: 'PM',
+ };
+ }
+ const parsedTime = parse(dateTime, 'hh:mm a', new Date());
+ return {
+ hour: format(parsedTime, 'hh'),
+ minute: format(parsedTime, 'mm'),
+ period: format(parsedTime, 'a').toUpperCase(),
+ };
+}
+
+/**
+ * param {String} timeString
+ * returns {String}
+ * example getTimePeriod('11:10 PM') // 'PM'
+ */
+function getTimePeriod(timeString: string): TimePeriod {
+ const parts = timeString.split(' ');
+ return parts[1] as TimePeriod;
+}
+
+/**
+ * param {String} dateTimeStringFirst // YYYY-MM-DD HH:mm:ss
+ * param {String} dateTimeStringSecond // YYYY-MM-DD HH:mm:ss
+ * returns {Boolean}
+ */
+function areDatesIdentical(dateTimeStringFirst: string, dateTimeStringSecond: string): boolean {
+ const date1 = parse(dateTimeStringFirst, 'yyyy-MM-dd HH:mm:ss', new Date());
+ const date2 = parse(dateTimeStringSecond, 'yyyy-MM-dd HH:mm:ss', new Date());
+
+ return isSameSecond(date1, date2);
+}
+
+/**
+ * Checks if the time input is at least one minute in the future.
+ * param {String} timeString: '04:24 AM'
+ * param {String} dateTimeString: '2023-11-14 14:24:00'
+ * returns {Boolean}
+ */
+const isTimeAtLeastOneMinuteInFuture = ({timeString, dateTimeString}: {timeString?: string; dateTimeString: string}): boolean => {
+ let dateToCheck = dateTimeString;
+ if (timeString) {
+ // return false;
+ // Parse the hour and minute from the time input
+ const [hourStr] = timeString.split(/[:\s]+/);
+ const hour = parseInt(hourStr, 10);
+
+ if (hour === 0) {
+ return false;
+ }
+
+ dateToCheck = combineDateAndTime(timeString, dateTimeString);
+ }
+
+ // Get current date and time
+ const now = new Date();
+
+ // Check if the combinedDate is at least one minute later than the current date and time
+ return isAfter(new Date(dateToCheck), addMinutes(now, 1));
+};
+
+/**
+ * Checks if the input date is in the future compared to the reference date.
+ * param {Date} inputDate - The date to validate.
+ * param {Date} referenceDate - The date to compare against.
+ * returns {string} - Returns an error key if validation fails, otherwise an empty string.
+ */
+const getDayValidationErrorKey = (inputDate: Date): string => {
+ if (!inputDate) {
+ return '';
+ }
+ const currentYear = getDayOfYear(new Date());
+ const inputYear = getDayOfYear(inputDate);
+ if (inputYear < currentYear) {
+ return 'common.error.invalidDateShouldBeFuture';
+ }
+ return '';
+};
+
+/**
+ * Checks if the input time is at least one minute in the future compared to the reference time.
+ * param {Date} inputTime - The time to validate.
+ * param {Date} referenceTime - The time to compare against.
+ * returns {string} - Returns an error key if validation fails, otherwise an empty string.
+ */
+const getTimeValidationErrorKey = (inputTime: Date): string => {
+ const timeNowPlusOneMinute = addMinutes(new Date(), 1);
+ if (isBefore(inputTime, timeNowPlusOneMinute)) {
+ return 'common.error.invalidTimeShouldBeFuture';
+ }
+ return '';
+};
+
+/**
+ *
* Get a date and format this date using the UTC timezone.
- * @param datetime
- * @param dateFormat
- * @returns If the date is valid, returns the formatted date with the UTC timezone, otherwise returns an empty string.
+ * param datetime
+ * param dateFormat
+ * returns If the date is valid, returns the formatted date with the UTC timezone, otherwise returns an empty string.
*/
function formatWithUTCTimeZone(datetime: string, dateFormat: string = CONST.DATE.FNS_FORMAT_STRING) {
const date = new Date(datetime);
@@ -406,13 +685,29 @@ const DateUtils = {
setLocale,
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
+ getThirtyMinutesFromNow,
+ getEndOfToday,
+ getOneWeekFromNow,
+ getDateFromStatusType,
+ getOneHourFromNow,
+ extractDate,
+ formatDateTimeTo12Hour,
getStatusUntilDate,
+ extractTime12Hour,
+ get12HourTimeObjectFromDate,
+ areDatesIdentical,
+ getTimePeriod,
+ getLocalizedTimePeriodDescription,
+ combineDateAndTime,
+ getDayValidationErrorKey,
+ getTimeValidationErrorKey,
isToday,
isTomorrow,
isYesterday,
getMonthNames,
getDaysOfWeek,
formatWithUTCTimeZone,
+ isTimeAtLeastOneMinuteInFuture,
};
export default DateUtils;
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
index 29449f52ecd6..ebdfe849327e 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
@@ -219,7 +219,9 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../pages/settings/Wallet/AddDebitCardPage').default as React.ComponentType,
[SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../pages/AddPersonalBankAccountPage').default as React.ComponentType,
[SCREENS.SETTINGS.PROFILE.STATUS]: () => require('../../../pages/settings/Profile/CustomStatus/StatusPage').default as React.ComponentType,
- [SCREENS.SETTINGS.PROFILE.STATUS_SET]: () => require('../../../pages/settings/Profile/CustomStatus/StatusSetPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: () => require('../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default as React.ComponentType,
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../pages/settings/Profile/CustomStatus/SetDatePage').default as React.ComponentType,
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: () => require('../../../pages/settings/Profile/CustomStatus/SetTimePage').default as React.ComponentType,
[SCREENS.WORKSPACE.INITIAL]: () => require('../../../pages/workspace/WorkspaceInitialPage').default as React.ComponentType,
[SCREENS.WORKSPACE.SETTINGS]: () => require('../../../pages/workspace/WorkspaceSettingsPage').default as React.ComponentType,
[SCREENS.WORKSPACE.CURRENCY]: () => require('../../../pages/workspace/WorkspaceSettingsCurrencyPage').default as React.ComponentType,
diff --git a/src/libs/Navigation/linkingConfig.ts b/src/libs/Navigation/linkingConfig.ts
index e9e76f4a2e82..0383455a5946 100644
--- a/src/libs/Navigation/linkingConfig.ts
+++ b/src/libs/Navigation/linkingConfig.ts
@@ -214,9 +214,14 @@ const linkingConfig: LinkingOptions = {
path: ROUTES.SETTINGS_STATUS,
exact: true,
},
- [SCREENS.SETTINGS.PROFILE.STATUS_SET]: {
- path: ROUTES.SETTINGS_STATUS_SET,
- exact: true,
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_DATE,
+ },
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: {
+ path: ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME,
},
[SCREENS.WORKSPACE.INITIAL]: {
path: ROUTES.WORKSPACE_INITIAL.route,
diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts
index b69552f6fe0f..94a07ddc6b73 100644
--- a/src/libs/Navigation/types.ts
+++ b/src/libs/Navigation/types.ts
@@ -82,7 +82,9 @@ type SettingsNavigatorParamList = {
[SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined;
[SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined;
[SCREENS.SETTINGS.PROFILE.STATUS]: undefined;
- [SCREENS.SETTINGS.PROFILE.STATUS_SET]: undefined;
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined;
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined;
+ [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_TIME]: undefined;
[SCREENS.WORKSPACE.INITIAL]: undefined;
[SCREENS.WORKSPACE.SETTINGS]: undefined;
[SCREENS.WORKSPACE.CURRENCY]: undefined;
diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts
index 388020bc0d6d..ba977312fcfb 100644
--- a/src/libs/ValidationUtils.ts
+++ b/src/libs/ValidationUtils.ts
@@ -1,5 +1,5 @@
import {parsePhoneNumber} from 'awesome-phonenumber';
-import {addYears, endOfMonth, format, isAfter, isBefore, isSameDay, isValid, isWithinInterval, parse, startOfDay, subYears} from 'date-fns';
+import {addYears, endOfMonth, format, isAfter, isBefore, isSameDay, isValid, isWithinInterval, parse, parseISO, startOfDay, subYears} from 'date-fns';
import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url';
import isDate from 'lodash/isDate';
import isEmpty from 'lodash/isEmpty';
@@ -8,6 +8,7 @@ import CONST from '@src/CONST';
import {Report} from '@src/types/onyx';
import * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import * as CardUtils from './CardUtils';
+import DateUtils from './DateUtils';
import * as LoginUtils from './LoginUtils';
import StringUtils from './StringUtils';
@@ -195,6 +196,28 @@ function getAgeRequirementError(date: string, minimumAge: number, maximumAge: nu
return ['privatePersonalDetails.error.dateShouldBeAfter', {dateString: format(minimalDate, CONST.DATE.FNS_FORMAT_STRING)}];
}
+/**
+ * Validate that given date is not in the past.
+ */
+function getDatePassedError(inputDate: string): string {
+ const currentDate = new Date();
+ const parsedDate = new Date(`${inputDate}T00:00:00`); // set time to 00:00:00 for accurate comparison
+
+ // If input date is not valid, return an error
+ if (!isValid(parsedDate)) {
+ return 'common.error.dateInvalid';
+ }
+
+ // Clear time for currentDate so comparison is based solely on the date
+ currentDate.setHours(0, 0, 0, 0);
+
+ if (parsedDate < currentDate) {
+ return 'common.error.dateInvalid';
+ }
+
+ return '';
+}
+
/**
* Similar to backend, checks whether a website has a valid URL or not.
* http/https/ftp URL scheme required.
@@ -361,6 +384,27 @@ function isValidAccountRoute(accountID: number): boolean {
return CONST.REGEX.NUMBER.test(String(accountID)) && accountID > 0;
}
+/**
+ * Validates that the date and time are at least one minute in the future.
+ * data - A date and time string in 'YYYY-MM-DD HH:mm:ss.sssZ' format
+ * returns an object containing the error messages for the date and time
+ */
+const validateDateTimeIsAtLeastOneMinuteInFuture = (data: string): {dateValidationErrorKey: string; timeValidationErrorKey: string} => {
+ if (!data) {
+ return {
+ dateValidationErrorKey: '',
+ timeValidationErrorKey: '',
+ };
+ }
+ const parsedInputData = parseISO(data);
+
+ const dateValidationErrorKey = DateUtils.getDayValidationErrorKey(parsedInputData);
+ const timeValidationErrorKey = DateUtils.getTimeValidationErrorKey(parsedInputData);
+ return {
+ dateValidationErrorKey,
+ timeValidationErrorKey,
+ };
+};
type ValuesType = Record;
/**
@@ -412,7 +456,9 @@ export {
doesContainReservedWord,
isNumeric,
isValidAccountRoute,
+ getDatePassedError,
isValidRecoveryCode,
+ validateDateTimeIsAtLeastOneMinuteInFuture,
prepareValues,
isValidPersonName,
};
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 3aa0e9642cdb..8e24a2a92310 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -859,7 +859,7 @@ function updateDraftCustomStatus(status) {
*
*/
function clearDraftCustomStatus() {
- Onyx.merge(ONYXKEYS.CUSTOM_STATUS_DRAFT, {text: '', emojiCode: '', clearAfter: ''});
+ Onyx.merge(ONYXKEYS.CUSTOM_STATUS_DRAFT, {text: '', emojiCode: '', clearAfter: '', customDateTemporary: ''});
}
export {
diff --git a/src/pages/home/sidebar/AvatarWithOptionalStatus.js b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
index 20f9447f52ce..487514ce199f 100644
--- a/src/pages/home/sidebar/AvatarWithOptionalStatus.js
+++ b/src/pages/home/sidebar/AvatarWithOptionalStatus.js
@@ -4,6 +4,7 @@ import React, {useCallback} from 'react';
import {View} from 'react-native';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import Text from '@components/Text';
+import Tooltip from '@components/Tooltip';
import useLocalize from '@hooks/useLocalize';
import Navigation from '@libs/Navigation/Navigation';
import useThemeStyles from '@styles/useThemeStyles';
@@ -46,14 +47,16 @@ function AvatarWithOptionalStatus({emojiStatus, isCreateMenuOpen}) {
onPress={showStatusPage}
style={styles.flex1}
>
-
-
- {emojiStatus}
-
-
+
+
+
+ {emojiStatus}
+
+
+
diff --git a/src/pages/settings/Profile/CustomStatus/SetDatePage.js b/src/pages/settings/Profile/CustomStatus/SetDatePage.js
new file mode 100644
index 000000000000..30bd89b60177
--- /dev/null
+++ b/src/pages/settings/Profile/CustomStatus/SetDatePage.js
@@ -0,0 +1,88 @@
+import lodashGet from 'lodash/get';
+import React, {useCallback} from 'react';
+import {withOnyx} from 'react-native-onyx';
+import DatePicker from '@components/DatePicker';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import * as User from '@libs/actions/User';
+import compose from '@libs/compose';
+import DateUtils from '@libs/DateUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import useThemeStyles from '@styles/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+function SetDatePage({translate, customStatus}) {
+ const styles = useThemeStyles();
+ const customClearAfter = lodashGet(customStatus, 'clearAfter', '');
+
+ const onSubmit = (v) => {
+ User.updateDraftCustomStatus({clearAfter: DateUtils.combineDateAndTime(customClearAfter, v.dateTime)});
+ Navigation.goBack(ROUTES.SETTINGS_STATUS_CLEAR_AFTER);
+ };
+
+ const validate = useCallback((values) => {
+ const requiredFields = ['dateTime'];
+ const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields);
+ const dateError = ValidationUtils.getDatePassedError(values.dateTime);
+
+ if (values.dateTime && dateError) {
+ errors.dateTime = dateError;
+ }
+
+ return errors;
+ }, []);
+
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_STATUS_CLEAR_AFTER)}
+ />
+
+
+
+
+ );
+}
+
+SetDatePage.propTypes = propTypes;
+SetDatePage.displayName = 'SetDatePage';
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ customStatus: {
+ key: ONYXKEYS.CUSTOM_STATUS_DRAFT,
+ },
+ clearDateForm: {
+ key: `${ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM}Draft`,
+ },
+ }),
+)(SetDatePage);
diff --git a/src/pages/settings/Profile/CustomStatus/SetTimePage.js b/src/pages/settings/Profile/CustomStatus/SetTimePage.js
new file mode 100644
index 000000000000..9e48b5826e7d
--- /dev/null
+++ b/src/pages/settings/Profile/CustomStatus/SetTimePage.js
@@ -0,0 +1,73 @@
+import lodashGet from 'lodash/get';
+import React from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import FullscreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import TimePicker from '@components/TimePicker/TimePicker';
+import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
+import usePrivatePersonalDetails from '@hooks/usePrivatePersonalDetails';
+import * as User from '@libs/actions/User';
+import compose from '@libs/compose';
+import DateUtils from '@libs/DateUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import useThemeStyles from '@styles/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+
+const propTypes = {
+ ...withLocalizePropTypes,
+};
+
+function SetTimePage({translate, privatePersonalDetails, customStatus}) {
+ usePrivatePersonalDetails();
+
+ const styles = useThemeStyles();
+ const clearAfter = lodashGet(customStatus, 'clearAfter', '');
+
+ const onSubmit = (time) => {
+ const timeToUse = DateUtils.combineDateAndTime(time, clearAfter);
+
+ User.updateDraftCustomStatus({clearAfter: timeToUse});
+ Navigation.goBack(ROUTES.SETTINGS_STATUS_CLEAR_AFTER);
+ };
+
+ if (lodashGet(privatePersonalDetails, 'isLoading', true)) {
+ return ;
+ }
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_STATUS_CLEAR_AFTER)}
+ />
+
+
+
+
+ );
+}
+
+SetTimePage.propTypes = propTypes;
+SetTimePage.displayName = 'SetTimePage';
+
+export default compose(
+ withLocalize,
+ withOnyx({
+ privatePersonalDetails: {
+ key: ONYXKEYS.PRIVATE_PERSONAL_DETAILS,
+ },
+ customStatus: {
+ key: ONYXKEYS.CUSTOM_STATUS_DRAFT,
+ },
+ }),
+)(SetTimePage);
diff --git a/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
new file mode 100644
index 000000000000..a4f9c1c3a4e5
--- /dev/null
+++ b/src/pages/settings/Profile/CustomStatus/StatusClearAfterPage.js
@@ -0,0 +1,247 @@
+import _ from 'lodash';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
+import {View} from 'react-native';
+import {withOnyx} from 'react-native-onyx';
+import FormProvider from '@components/Form/FormProvider';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import ScreenWrapper from '@components/ScreenWrapper';
+import BaseListItem from '@components/SelectionList/BaseListItem';
+import Text from '@components/Text';
+import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsDefaultProps} from '@components/withCurrentUserPersonalDetails';
+import withLocalize from '@components/withLocalize';
+import useLocalize from '@hooks/useLocalize';
+import * as User from '@libs/actions/User';
+import compose from '@libs/compose';
+import DateUtils from '@libs/DateUtils';
+import Navigation from '@libs/Navigation/Navigation';
+import * as ValidationUtils from '@libs/ValidationUtils';
+import personalDetailsPropType from '@pages/personalDetailsPropType';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+
+const defaultProps = {
+ ...withCurrentUserPersonalDetailsDefaultProps,
+};
+
+const propTypes = {
+ currentUserPersonalDetails: personalDetailsPropType,
+ customStatus: PropTypes.shape({
+ clearAfter: PropTypes.string,
+ }),
+};
+
+/**
+ * @param {string} data - either a value from CONST.CUSTOM_STATUS_TYPES or a dateTime string in the format YYYY-MM-DD HH:mm
+ * @returns {string}
+ */
+function getSelectedStatusType(data) {
+ switch (data) {
+ case DateUtils.getEndOfToday():
+ return CONST.CUSTOM_STATUS_TYPES.AFTER_TODAY;
+ case CONST.CUSTOM_STATUS_TYPES.NEVER:
+ case '':
+ return CONST.CUSTOM_STATUS_TYPES.NEVER;
+ case false:
+ return CONST.CUSTOM_STATUS_TYPES.AFTER_TODAY;
+ default:
+ return CONST.CUSTOM_STATUS_TYPES.CUSTOM;
+ }
+}
+
+const useValidateCustomDate = (data) => {
+ const {translate} = useLocalize();
+ const [customDateError, setCustomDateError] = useState('');
+ const [customTimeError, setCustomTimeError] = useState('');
+ const validate = () => {
+ const {dateValidationErrorKey, timeValidationErrorKey} = ValidationUtils.validateDateTimeIsAtLeastOneMinuteInFuture(data);
+
+ const dateError = dateValidationErrorKey ? translate(dateValidationErrorKey) : '';
+ setCustomDateError(dateError);
+
+ const timeError = timeValidationErrorKey ? translate(timeValidationErrorKey) : '';
+ setCustomTimeError(timeError);
+
+ return {
+ dateError,
+ timeError,
+ };
+ };
+
+ useEffect(() => {
+ if (!data) {
+ return;
+ }
+ validate();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [data]);
+
+ const validateCustomDate = () => validate();
+
+ return {customDateError, customTimeError, validateCustomDate};
+};
+
+function StatusClearAfterPage({currentUserPersonalDetails, customStatus}) {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const clearAfter = lodashGet(currentUserPersonalDetails, 'status.clearAfter', '');
+ const draftClearAfter = lodashGet(customStatus, 'clearAfter', '');
+ const [draftPeriod, setDraftPeriod] = useState(getSelectedStatusType(draftClearAfter || clearAfter));
+ const statusType = useMemo(
+ () =>
+ _.map(CONST.CUSTOM_STATUS_TYPES, (value, key) => ({
+ value,
+ text: translate(`statusPage.timePeriods.${value}`),
+ keyForList: key,
+ isSelected: draftPeriod === value,
+ })),
+ [draftPeriod, translate],
+ );
+
+ const {customDateError, customTimeError, validateCustomDate} = useValidateCustomDate(draftClearAfter);
+
+ const {redBrickDateIndicator, redBrickTimeIndicator} = useMemo(
+ () => ({
+ redBrickDateIndicator: customDateError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : null,
+ redBrickTimeIndicator: customTimeError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : null,
+ }),
+ [customTimeError, customDateError],
+ );
+
+ const onSubmit = () => {
+ const {dateError, timeError} = validateCustomDate();
+ if (dateError || timeError) {
+ return;
+ }
+ let calculatedDraftDate = '';
+ if (draftPeriod === CONST.CUSTOM_STATUS_TYPES.CUSTOM) {
+ calculatedDraftDate = draftClearAfter;
+ } else {
+ const selectedRange = _.find(statusType, (item) => item.isSelected);
+ calculatedDraftDate = DateUtils.getDateFromStatusType(selectedRange.value);
+ }
+ User.updateDraftCustomStatus({clearAfter: calculatedDraftDate});
+ Navigation.goBack(ROUTES.SETTINGS_STATUS);
+ };
+
+ const updateMode = useCallback(
+ (mode) => {
+ if (mode.value === draftPeriod) {
+ return;
+ }
+ setDraftPeriod(mode.value);
+
+ if (mode.value === CONST.CUSTOM_STATUS_TYPES.CUSTOM) {
+ User.updateDraftCustomStatus({clearAfter: DateUtils.getOneHourFromNow()});
+ } else {
+ const selectedRange = _.find(statusType, (item) => item.value === mode.value);
+ const calculatedDraftDate = DateUtils.getDateFromStatusType(selectedRange.value);
+ User.updateDraftCustomStatus({clearAfter: calculatedDraftDate});
+ Navigation.goBack(ROUTES.SETTINGS_STATUS);
+ }
+ },
+ [draftPeriod, statusType],
+ );
+
+ useEffect(() => {
+ User.updateDraftCustomStatus({
+ clearAfter: draftClearAfter || clearAfter,
+ });
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const customStatusDate = DateUtils.extractDate(draftClearAfter);
+ const customStatusTime = DateUtils.extractTime12Hour(draftClearAfter);
+
+ const timePeriodOptions = useCallback(
+ () =>
+ _.map(statusType, (item, index) => (
+ updateMode(item)}
+ showTooltip={false}
+ />
+ )),
+ [statusType, updateMode],
+ );
+
+ return (
+
+ Navigation.goBack(ROUTES.SETTINGS_STATUS)}
+ />
+ {translate('statusPage.whenClearStatus')}
+
+
+ {timePeriodOptions()}
+ {draftPeriod === CONST.CUSTOM_STATUS_TYPES.CUSTOM && (
+ <>
+ Navigation.navigate(ROUTES.SETTINGS_STATUS_CLEAR_AFTER_DATE)}
+ errorText={customDateError}
+ titleTextStyle={styles.flex1}
+ brickRoadIndicator={redBrickDateIndicator}
+ />
+ Navigation.navigate(ROUTES.SETTINGS_STATUS_CLEAR_AFTER_TIME)}
+ errorText={customTimeError}
+ titleTextStyle={styles.flex1}
+ brickRoadIndicator={redBrickTimeIndicator}
+ />
+ >
+ )}
+
+
+
+ );
+}
+
+StatusClearAfterPage.displayName = 'StatusClearAfterPage';
+StatusClearAfterPage.propTypes = propTypes;
+StatusClearAfterPage.defaultProps = defaultProps;
+
+export default compose(
+ withCurrentUserPersonalDetails,
+ withLocalize,
+ withOnyx({
+ timePeriodType: {
+ key: `${ONYXKEYS.FORMS.SETTINGS_STATUS_SET_CLEAR_AFTER_FORM}Draft`,
+ },
+ clearDateForm: {
+ key: `${ONYXKEYS.FORMS.SETTINGS_STATUS_CLEAR_DATE_FORM}Draft`,
+ },
+ customStatus: {
+ key: ONYXKEYS.CUSTOM_STATUS_DRAFT,
+ },
+ preferredLocale: {
+ key: ONYXKEYS.NVP_PREFERRED_LOCALE,
+ },
+ }),
+)(StatusClearAfterPage);
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index 226d0fed8044..1bf4165c15c5 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -1,122 +1,195 @@
import lodashGet from 'lodash/get';
-import React, {useCallback, useEffect, useMemo} from 'react';
-import {View} from 'react-native';
+import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {InteractionManager, View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
-import MobileBackgroundImage from '@assets/images/money-stack.svg';
-import Button from '@components/Button';
+import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderPageLayout from '@components/HeaderPageLayout';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import MenuItem from '@components/MenuItem';
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
+import TextInput from '@components/TextInput';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
+import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
import useTheme from '@styles/themes/useTheme';
+import useStyleUtils from '@styles/useStyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
import * as User from '@userActions/User';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
+const INPUT_IDS = {
+ EMOJI_CODE: 'emojiCode',
+ STATUS_TEXT: 'statusText',
+};
+
const propTypes = {
...withCurrentUserPersonalDetailsPropTypes,
};
+const initialEmoji = '💬';
+
function StatusPage({draftStatus, currentUserPersonalDetails}) {
const theme = useTheme();
const styles = useThemeStyles();
+ const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
+ const formRef = useRef(null);
+ const [brickRoadIndicator, setBrickRoadIndicator] = useState('');
const currentUserEmojiCode = lodashGet(currentUserPersonalDetails, 'status.emojiCode', '');
const currentUserStatusText = lodashGet(currentUserPersonalDetails, 'status.text', '');
+ const currentUserClearAfter = lodashGet(currentUserPersonalDetails, 'status.clearAfter', '');
const draftEmojiCode = lodashGet(draftStatus, 'emojiCode');
const draftText = lodashGet(draftStatus, 'text');
+ const draftClearAfter = lodashGet(draftStatus, 'clearAfter');
- const defaultEmoji = draftEmojiCode || currentUserEmojiCode;
- const defaultText = draftEmojiCode ? draftText : currentUserStatusText;
- const hasDraftStatus = !!draftEmojiCode || !!draftText;
- const customStatus = useMemo(() => {
- if (draftEmojiCode) {
- return `${draftEmojiCode} ${draftText}`;
- }
- if (currentUserEmojiCode || currentUserStatusText) {
- return `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`;
+ const defaultEmoji = draftEmojiCode || currentUserEmojiCode || initialEmoji;
+ const defaultText = draftText || currentUserStatusText;
+
+ const customClearAfter = useMemo(() => {
+ const dataToShow = draftClearAfter || currentUserClearAfter;
+ return DateUtils.getLocalizedTimePeriodDescription(dataToShow);
+ }, [draftClearAfter, currentUserClearAfter]);
+
+ const isValidClearAfterDate = useCallback(() => {
+ const clearAfterTime = draftClearAfter || currentUserClearAfter;
+ if (clearAfterTime === CONST.CUSTOM_STATUS_TYPES.NEVER || clearAfterTime === '') {
+ return true;
}
- return '';
- }, [draftEmojiCode, draftText, currentUserEmojiCode, currentUserStatusText]);
+
+ return DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime});
+ }, [draftClearAfter, currentUserClearAfter]);
+
+ const navigateBackToPreviousScreen = useCallback(() => Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true), []);
+ const updateStatus = useCallback(
+ ({emojiCode, statusText}) => {
+ const clearAfterTime = draftClearAfter || currentUserClearAfter;
+ const isValid = DateUtils.isTimeAtLeastOneMinuteInFuture({dateTimeString: clearAfterTime});
+ if (!isValid && clearAfterTime !== CONST.CUSTOM_STATUS_TYPES.NEVER) {
+ setBrickRoadIndicator(isValidClearAfterDate() ? null : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR);
+ return;
+ }
+
+ User.updateCustomStatus({
+ text: statusText,
+ emojiCode,
+ clearAfter: clearAfterTime !== CONST.CUSTOM_STATUS_TYPES.NEVER ? clearAfterTime : '',
+ });
+
+ User.clearDraftCustomStatus();
+ InteractionManager.runAfterInteractions(() => {
+ navigateBackToPreviousScreen();
+ });
+ },
+ [currentUserClearAfter, draftClearAfter, isValidClearAfterDate, navigateBackToPreviousScreen],
+ );
const clearStatus = () => {
User.clearCustomStatus();
- User.clearDraftCustomStatus();
+ User.updateDraftCustomStatus({
+ text: '',
+ emojiCode: '',
+ clearAfter: DateUtils.getEndOfToday(),
+ });
+ formRef.current.resetForm({[INPUT_IDS.EMOJI_CODE]: initialEmoji});
};
- const navigateBackToSettingsPage = useCallback(() => {
- const topMostReportID = Navigation.getTopmostReportId();
- if (topMostReportID) {
- Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(topMostReportID));
+ useEffect(() => setBrickRoadIndicator(isValidClearAfterDate() ? null : CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR), [isValidClearAfterDate]);
+
+ useEffect(() => {
+ if (!currentUserEmojiCode && !currentUserClearAfter && !draftClearAfter) {
+ User.updateDraftCustomStatus({clearAfter: DateUtils.getEndOfToday()});
} else {
- Navigation.goBack(ROUTES.SETTINGS_PROFILE, false, true);
+ User.updateDraftCustomStatus({clearAfter: currentUserClearAfter});
}
- }, []);
- const updateStatus = useCallback(() => {
- User.updateCustomStatus({text: defaultText, emojiCode: defaultEmoji});
-
- User.clearDraftCustomStatus();
- Navigation.goBack(ROUTES.SETTINGS_PROFILE);
- }, [defaultText, defaultEmoji]);
- const footerComponent = useMemo(
- () =>
- hasDraftStatus ? (
-
- ) : null,
- [hasDraftStatus, translate, updateStatus],
- );
+ return () => User.clearDraftCustomStatus();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
- useEffect(() => () => User.clearDraftCustomStatus(), []);
+ const validateForm = useCallback(() => {
+ if (brickRoadIndicator) {
+ return {clearAfter: ''};
+ }
+ return {};
+ }, [brickRoadIndicator]);
return (
-
- }
- headerContainerStyles={[styles.staticHeaderImage]}
- backgroundColor={theme.PAGE_THEMES[SCREENS.SETTINGS.PROFILE.STATUS].backgroundColor}
- footer={footerComponent}
+
-
- {translate('statusPage.setStatusTitle')}
- {translate('statusPage.statusExplanation')}
-
- Navigation.navigate(ROUTES.SETTINGS_STATUS_SET)}
+
-
- {(!!currentUserEmojiCode || !!currentUserStatusText) && (
-
- )}
-
+
+
+ {translate('statusPage.statusExplanation')}
+
+
+
+
+
+
+ Navigation.navigate(ROUTES.SETTINGS_STATUS_CLEAR_AFTER)}
+ containerStyle={styles.pr2}
+ brickRoadIndicator={brickRoadIndicator}
+ />
+ {(!!currentUserEmojiCode || !!currentUserStatusText) && (
+
+ )}
+
+
+
);
}
diff --git a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js b/src/pages/settings/Profile/CustomStatus/StatusSetPage.js
deleted file mode 100644
index b9ae9c8d2f51..000000000000
--- a/src/pages/settings/Profile/CustomStatus/StatusSetPage.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import lodashGet from 'lodash/get';
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import EmojiPickerButtonDropdown from '@components/EmojiPicker/EmojiPickerButtonDropdown';
-import FormProvider from '@components/Form/FormProvider';
-import InputWrapper from '@components/Form/InputWrapper';
-import HeaderWithBackButton from '@components/HeaderWithBackButton';
-import ScreenWrapper from '@components/ScreenWrapper';
-import TextInput from '@components/TextInput';
-import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '@components/withCurrentUserPersonalDetails';
-import useLocalize from '@hooks/useLocalize';
-import compose from '@libs/compose';
-import Navigation from '@libs/Navigation/Navigation';
-import useThemeStyles from '@styles/useThemeStyles';
-import * as User from '@userActions/User';
-import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
-import ROUTES from '@src/ROUTES';
-
-const propTypes = {
- /** The draft status of the user */
- // eslint-disable-next-line react/require-default-props
- draftStatus: PropTypes.shape({
- /** The emoji code of the draft status */
- emojiCode: PropTypes.string,
- /** The text of the draft status */
- text: PropTypes.string,
- }),
-
- ...withCurrentUserPersonalDetailsPropTypes,
-};
-
-const INPUT_IDS = {
- EMOJI_CODE: 'emojiCode',
- STATUS_TEXT: 'statusText',
-};
-
-function StatusSetPage({draftStatus, currentUserPersonalDetails}) {
- const styles = useThemeStyles();
- const {translate} = useLocalize();
- const defaultEmoji = lodashGet(draftStatus, 'emojiCode') || lodashGet(currentUserPersonalDetails, 'status.emojiCode', '💬');
- const defaultText = lodashGet(draftStatus, 'text') || lodashGet(currentUserPersonalDetails, 'status.text', '');
-
- const onSubmit = (value) => {
- User.updateDraftCustomStatus({text: value.statusText.trim(), emojiCode: value.emojiCode});
- Navigation.goBack(ROUTES.SETTINGS_STATUS);
- };
-
- return (
-
- Navigation.goBack(ROUTES.SETTINGS_STATUS)}
- />
-
-
-
-
-
-
-
-
-
-
-
- );
-}
-
-StatusSetPage.displayName = 'StatusSetPage';
-StatusSetPage.propTypes = propTypes;
-
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- draftStatus: {
- key: ONYXKEYS.CUSTOM_STATUS_DRAFT,
- },
- }),
-)(StatusSetPage);
diff --git a/src/stories/MenuItem.stories.js b/src/stories/MenuItem.stories.js
index 47b74b1f0ef8..0e7260fa4d1a 100644
--- a/src/stories/MenuItem.stories.js
+++ b/src/stories/MenuItem.stories.js
@@ -128,6 +128,17 @@ BrickRoadIndicatorFailure.args = {
brickRoadIndicator: 'error',
};
+const ErrorMessage = Template.bind({});
+ErrorMessage.args = {
+ title: 'Alberta Bobbeth Charleson',
+ icon: Chase,
+ iconHeight: variables.iconSizeExtraLarge,
+ iconWidth: variables.iconSizeExtraLarge,
+ shouldShowRightIcon: true,
+ errorText: 'Error text which describes the error',
+ brickRoadIndicator: 'error',
+};
+
export default story;
export {
Default,
@@ -141,4 +152,5 @@ export {
BrickRoadIndicatorSuccess,
BrickRoadIndicatorFailure,
RightIconAndDescriptionWithLabel,
+ ErrorMessage,
};
diff --git a/src/styles/styles.ts b/src/styles/styles.ts
index fb851ecfcf54..244a6f2b11dc 100644
--- a/src/styles/styles.ts
+++ b/src/styles/styles.ts
@@ -2,7 +2,7 @@
import {LineLayerStyleProps} from '@rnmapbox/maps/src/utils/MapboxStyles';
import lodashClamp from 'lodash/clamp';
import {LineLayer} from 'react-map-gl';
-import {AnimatableNumericValue, Animated, ImageStyle, TextStyle, ViewStyle} from 'react-native';
+import {AnimatableNumericValue, Animated, ImageStyle, StyleSheet, TextStyle, ViewStyle} from 'react-native';
import {CustomAnimation} from 'react-native-animatable';
import {PickerStyle} from 'react-native-picker-select';
import {MixedStyleDeclaration, MixedStyleRecord} from 'react-native-render-html';
@@ -2422,6 +2422,11 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
padding: 20,
},
+ numberPadWrapper: {
+ width: '100%',
+ alignItems: 'center',
+ paddingHorizontal: 20,
+ },
avatarSectionWrapper: {
width: '100%',
@@ -3677,7 +3682,6 @@ const styles = (theme: ThemeColors) =>
alignItems: 'center',
paddingLeft: 10,
paddingRight: 4,
- marginBottom: 32,
alignSelf: 'flex-start',
...userSelect.userSelectNone,
},
@@ -3897,10 +3901,59 @@ const styles = (theme: ThemeColors) =>
fontSize: variables.fontSizeNormal,
marginRight: 4,
},
+ timePickerInput: {
+ fontSize: 69,
+ minWidth: 56,
+ alignSelf: 'center',
+ },
+ timePickerWidth100: {
+ width: 100,
+ },
+ timePickerHeight100: {
+ height: 100,
+ },
+ timePickerSemiDot: {
+ fontSize: 69,
+ height: 84,
+ alignSelf: 'center',
+ },
+ timePickerSwitcherContainer: {
+ flexDirection: 'row',
+ alignItems: 'flex-start',
+ justifyContent: 'center',
+ },
+ selectionListRadioSeparator: {
+ height: StyleSheet.hairlineWidth,
+ backgroundColor: theme.border,
+ marginHorizontal: 20,
+ },
+
draggableTopBar: {
height: 30,
width: '100%',
},
+ menuItemError: {
+ position: 'absolute',
+ bottom: -4,
+ left: 20,
+ right: 20,
+ },
+ formHelperMessage: {
+ height: 32,
+ },
+ timePickerInputExtraSmall: {
+ fontSize: 50,
+ },
+ setTimeFormButtonContainer: {
+ minHeight: 54,
+ },
+ timePickerInputsContainer: {
+ maxHeight: 100,
+ },
+ timePickerButtonErrorText: {
+ position: 'absolute',
+ top: -36,
+ },
chatBottomLoader: {
position: 'absolute',
diff --git a/src/styles/themes/default.ts b/src/styles/themes/default.ts
index d46e94da87dd..ca48b139b107 100644
--- a/src/styles/themes/default.ts
+++ b/src/styles/themes/default.ts
@@ -120,7 +120,7 @@ const darkTheme = {
statusBarStyle: CONST.STATUS_BAR_STYLE.DARK_CONTENT,
},
[SCREENS.SETTINGS.PROFILE.STATUS]: {
- backgroundColor: colors.green700,
+ backgroundColor: colors.darkAppBackground,
statusBarStyle: CONST.STATUS_BAR_STYLE.LIGHT_CONTENT,
},
[SCREENS.SETTINGS.ROOT]: {
diff --git a/src/styles/utilities/spacing.ts b/src/styles/utilities/spacing.ts
index 7d568847ab65..b2597fc64603 100644
--- a/src/styles/utilities/spacing.ts
+++ b/src/styles/utilities/spacing.ts
@@ -271,6 +271,10 @@ export default {
marginBottom: 40,
},
+ mb12: {
+ marginBottom: 48,
+ },
+
mb15: {
marginBottom: 60,
},
diff --git a/src/styles/utils/index.ts b/src/styles/utils/index.ts
index 8d52c8de200a..59bba2073a0c 100644
--- a/src/styles/utils/index.ts
+++ b/src/styles/utils/index.ts
@@ -1247,6 +1247,19 @@ const createStyleUtils = (theme: ThemeColors, styles: ThemeStyles) => ({
};
},
+ /**
+ * Get the style for the AM and PM buttons in the TimePicker
+ */
+ getStatusAMandPMButtonStyle: (amPmValue: string): {styleForAM: ViewStyle; styleForPM: ViewStyle} => {
+ const computedStyleForAM: ViewStyle = amPmValue !== CONST.TIME_PERIOD.AM ? {backgroundColor: theme.componentBG} : {};
+ const computedStyleForPM: ViewStyle = amPmValue !== CONST.TIME_PERIOD.PM ? {backgroundColor: theme.componentBG} : {};
+
+ return {
+ styleForAM: [styles.timePickerWidth100, computedStyleForAM] as unknown as ViewStyle,
+ styleForPM: [styles.timePickerWidth100, computedStyleForPM] as unknown as ViewStyle,
+ };
+ },
+
/**
* Get the styles of the text next to dot indicators
*/