diff --git a/android/app/build.gradle b/android/app/build.gradle index bcac489f6828..afe24fc37700 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,8 +90,8 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion multiDexEnabled rootProject.ext.multiDexEnabled - versionCode 1001037402 - versionName "1.3.74-2" + versionCode 1001037403 + versionName "1.3.74-3" } flavorDimensions "default" diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md b/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md index b71fd1a3c8bf..29380dab5a5b 100644 --- a/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md +++ b/docs/articles/expensify-classic/get-paid-back/expenses/Upload-Receipts.md @@ -1,5 +1,36 @@ --- -title: Upload Receipts -description: Upload Receipts +title: Upload-Receipts.md +description: This article shows you all the ways that you can upload your receipts to Expensify! --- -## Resource Coming Soon! + + +# About +Need to get paid? Check out this guide to see all the ways that you can upload your receipts to Expensify - whether it’s by SmartScanning them by forwarding via email or manually by taking a picture of a receipt, we’ll cover it here! + +# How-to Upload Receipts +## SmartScan +The easiest way to upload your receipts to Expensify is to SmartScan them with Expensify’s mobile app or forward a receipt from your email inbox! + +When you SmartScan a receipt, we’ll read the Merchant, Date and Amount of the transaction, create an expense, and add it to your Expensify account automatically. The best practice is to take a picture of the receipt at the time of purchase or forward it to your Expensify account from the point of sale system. If you have a credit card connected and you upload a receipt that matches a card expense, the SmartScanned receipt will automatically merge with the imported card expense instead. + +## Email Receipts +To SmartScan a receipt on your mobile app, tap the green camera button, point and shoot! You can also forward your digital receipts (or photos of receipts) to receipts@expensify.com from the email address associated with your Expensify account, and they’ll be SmartScanned. This may take a few minutes because Expensify aims to have the most accurate OCR. + +## Manually Upload +To upload receipts on the web, simply navigate to the Expenses page and click on **New Expense**. Select **Scan Receipt** and choose the file you would like to upload, or drag-and-drop your image directly into the Expenses page, and that will start the SmartScanning process! + +# FAQ +## How do you SmartScan multiple receipts? +You can utilize the Rapid Fire Mode to quickly SmartScan multiple receipts at once! + +To activate it, tap on the green camera button in the mobile app and then tap on the camera icon on the bottom right. When you see the little fire icon on the camera, Rapid Fire Mode has been activated - tap the camera icon again to disable Rapid Fire Mode. + +## How do you create an expense from an email address that is different from your Expensify login? +You can email a receipt from a different email address by adding it as a Secondary Login to your Expensify account - this ensures that any receipts sent from this email to receipts@expensify.com will be associated with your current Expensify account. + +Once that email address has been added as a Secondary Login, simply forward your receipt image or emails to receipts@expensify.com. + +## How do you crop or rotate a receipt image? +You can crop and rotate a receipt image on the web app, and you can only edit one expense at a time. + +Navigate to your Expenses page and locate the expense whose receipt image you'd like to edit, then click the expense to open the Edit screen. If there is an image file associated with the receipt, you will see the Rotate and Crop buttons. Alternatively, you can also navigate to your Reports page, click on a report, and locate the individual expense. diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md index 3ee1c8656b4b..a65dc378a793 100644 --- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md +++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/User-Roles.md @@ -1,5 +1,63 @@ --- -title: Coming Soon -description: Coming Soon +title: User Roles +description: Each member has a role that defines what they can see and do in the workspace. --- -## Resource Coming Soon! + +# Overview + +This guide is for those who are part of a **Group Workspace**. + +Each member has a role that defines what they can see and do in the workspace. Most members will have the role of "Employee." + +# How to Manage User Roles + +To find and edit the roles of group workspace members, go to **Settings > Workspaces > Group > [Your Specific Workspace Name] > Members > Workspace Members** + +Here you'll see the list of members in your group workspace. To change their roles, click **Settings** next to the member’s name and choose the role that the member needs. + +Next, let’s go over the various user roles that are available on a group workspace. + +## The Employee Role + +- **What can they do:** Employees can only see their own expense reports or reports that have been submitted to or shared with them. They can't change settings or invite new users. +- **Who is it for:** Regular employees who only need to manage their own expenses, or managers who are reviewing expense reports for a few users but don’t need global visibility. +- **Approvers:** Members who approve expenses can either be Employees, Admins, or Workspace Auditors, depending on how much control they need. +- **Billable:** Employees are billable actors if they take actions on a report on your Group Workspace (including **SmartScanning** a receipt). + +## Workspace Admin Role + +- **What can they do:** Admins have full control. They can change settings, invite members, and view all reports. They can also process reimbursements if they have access to the company’s account. +- **Billing Owners:** Billing owners are Admins by default. **Workspace Admins** are assigned by the owner or another admin. +- **Billable:** Yes, if they perform actions like changing settings or inviting users. Just viewing reports is not billable. + +## Workspace Auditor Role + +- **What can they do:** Workspace Auditors can see all reports, make comments, and export them. They can also mark reports as reimbursed if they're the final approver. +- **Who is it for:** Accountants, bookkeepers, and internal or external audit agents who need to view but not edit workspace settings. +- **Billable:** Yes, if they perform any actions like commenting or exporting a report. Viewing alone doesn't incur a charge. + +## Technical Contact + +- **What can they do:** In case of connection issues, alerts go to the billing owner by default. You can set a technical contact if you want alerts to go to an IT administrator instead. +- **How to set one:** Go to **Settings > Workspaces > Group > [Workspace Name] > Connections > Technical Contact**. +- **Billable:** The technical contact doesn’t need to be a group workspace member and so is not counted towards your billable activity. + +Note: running expense analytics from **Insights** follows the same rules. All the reports and data graphs you generate will be created based on the expense data you have access to. + +# Deep Dive + +## Expense Data Visibility + +The amount of expense data you can see depends on your role within any group workspaces you're part of: + +- **Employees:** Whether you're on a free or paid plan, if you're not approving expenses, you'll only see your own expenses. +- **Approvers:** If you approve expenses for your team and also submit your own, you can view both individual and team-wide expenses and analytics. +- **Admins:** Users with an admin role can see analytics and data for every expense report made by anyone on the workspace. + +If you need to see more data, here are some options: + +- **Become an Admin:** Check within your organization if you can be upgraded to an admin role in your group workspaces. +- **Become a Copilot:** Ask to be added as a **Copilot** to an existing admin account, which will allow you some additional viewing privileges. +- **Become an Approver:** You could also be added as an **Approver** in an existing workflow to view more data. + + diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist index f41740a8bcb2..73e22053eda1 100644 --- a/ios/NewExpensify/Info.plist +++ b/ios/NewExpensify/Info.plist @@ -40,7 +40,7 @@ CFBundleVersion - 1.3.74.2 + 1.3.74.3 ITSAppUsesNonExemptEncryption LSApplicationQueriesSchemes diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist index 95714ea2cc9f..5e7f02699579 100644 --- a/ios/NewExpensifyTests/Info.plist +++ b/ios/NewExpensifyTests/Info.plist @@ -19,6 +19,6 @@ CFBundleSignature ???? CFBundleVersion - 1.3.74.2 + 1.3.74.3 diff --git a/package-lock.json b/package-lock.json index 64ee3cf6308f..d8cba15c32af 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "new.expensify", - "version": "1.3.74-2", + "version": "1.3.74-3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "new.expensify", - "version": "1.3.74-2", + "version": "1.3.74-3", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index cd93f718679e..24fdeaaed66d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "new.expensify", - "version": "1.3.74-2", + "version": "1.3.74-3", "author": "Expensify, Inc.", "homepage": "https://new.expensify.com", "description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.", diff --git a/src/CONST.ts b/src/CONST.ts index 528cb6ca1e9e..4f34e7cb2136 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -440,6 +440,12 @@ const CONST = { INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev', STAGING_EXPENSIFY_URL: 'https://staging.expensify.com', EXPENSIFY_URL: 'https://www.expensify.com', + BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL: + 'https://community.expensify.com/discussion/6983/faq-why-do-i-need-to-provide-personal-documentation-when-setting-up-updating-my-bank-account', + PERSONAL_DATA_PROTECTION_INFO_URL: 'https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information', + ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', + ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', + ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'http://localhost:', diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js index 408f8c2c2b7f..90f5c22e5b3c 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -239,6 +239,7 @@ function FormProvider({validate, shouldValidateOnBlur, shouldValidateOnChange, c onSubmit={submit} inputRefs={inputRefs} errors={errors} + enabledWhenOffline={enabledWhenOffline} > {children} diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js index 4b61d55ae228..bba62cc4f4e0 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.js @@ -58,7 +58,7 @@ const defaultViewProps = {style: [styles.alignItemsStart, styles.userSelectText] // costly invalidations and commits. function BaseHTMLEngineProvider(props) { // We need to memoize this prop to make it referentially stable. - const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false}), [props.textSelectable]); + const defaultTextProps = useMemo(() => ({selectable: props.textSelectable, allowFontScaling: false, textBreakStrategy: 'simple'}), [props.textSelectable]); // We need to pass multiple system-specific fonts for emojis but // we can't apply multiple fonts at once so we need to pass fallback fonts. diff --git a/src/components/MenuItem.js b/src/components/MenuItem.js index 268351699567..11f7d547962b 100644 --- a/src/components/MenuItem.js +++ b/src/components/MenuItem.js @@ -56,7 +56,6 @@ const defaultProps = { disabled: false, isSelected: false, subtitle: undefined, - subtitleTextStyle: {}, iconType: CONST.ICON_TYPE_ICON, onPress: () => {}, onSecondaryInteraction: undefined, @@ -76,6 +75,7 @@ const defaultProps = { title: '', numberOfLinesTitle: 1, shouldGreyOutWhenDisabled: true, + error: '', shouldRenderAsHTML: false, }; @@ -276,6 +276,11 @@ const MenuItem = React.forwardRef((props, ref) => { {props.description} )} + {Boolean(props.error) && ( + + {props.error} + + )} {Boolean(props.furtherDetails) && ( { {/* Since subtitle can be of type number, we should allow 0 to be shown */} {(props.subtitle || props.subtitle === 0) && ( - {props.subtitle} + {props.subtitle} )} {!_.isEmpty(props.floatRightAvatars) && ( diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js index 8d797540fde9..808babdec779 100644 --- a/src/components/ReportActionItem/MoneyRequestView.js +++ b/src/components/ReportActionItem/MoneyRequestView.js @@ -167,8 +167,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should shouldShowRightIcon={canEdit} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))} brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} - subtitleTextStyle={styles.textLabelError} + error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''} /> @@ -193,8 +192,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should titleStyle={styles.flex1} onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DATE))} brickRoadIndicator={hasErrors && transactionDate === '' ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''} - subtitle={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} - subtitleTextStyle={styles.textLabelError} + error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''} /> {isDistanceRequest ? ( diff --git a/src/components/menuItemPropTypes.js b/src/components/menuItemPropTypes.js index 6272a7a2ef7d..e1d10ca95971 100644 --- a/src/components/menuItemPropTypes.js +++ b/src/components/menuItemPropTypes.js @@ -88,9 +88,6 @@ const propTypes = { /** A right-aligned subtitle for this menu option */ subtitle: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** Style for the subtitle */ - subtitleTextStyle: stylePropTypes, - /** Flag to choose between avatar image or an icon */ iconType: PropTypes.oneOf([CONST.ICON_TYPE_AVATAR, CONST.ICON_TYPE_ICON, CONST.ICON_TYPE_WORKSPACE]), @@ -145,6 +142,9 @@ const propTypes = { /** Should we grey out the menu item when it is disabled? */ shouldGreyOutWhenDisabled: PropTypes.bool, + /** Error to display below the title */ + error: PropTypes.string, + /** Should render the content in HTML format */ shouldRenderAsHTML: PropTypes.bool, }; diff --git a/src/components/withKeyboardState.js b/src/components/withKeyboardState.js index 667e8865a0e3..8ddf667b4e30 100755 --- a/src/components/withKeyboardState.js +++ b/src/components/withKeyboardState.js @@ -1,7 +1,6 @@ -/* eslint-disable react/no-unused-state */ -import React, {forwardRef, createContext} from 'react'; -import PropTypes from 'prop-types'; +import React, {forwardRef, createContext, useEffect, useState} from 'react'; import {Keyboard} from 'react-native'; +import PropTypes from 'prop-types'; import getComponentDisplayName from '../libs/getComponentDisplayName'; const KeyboardStateContext = createContext(null); @@ -15,32 +14,24 @@ const keyboardStateProviderPropTypes = { children: PropTypes.node.isRequired, }; -class KeyboardStateProvider extends React.Component { - constructor(props) { - super(props); - - this.state = { - isKeyboardShown: false, - }; - } - - componentDidMount() { - this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { - this.setState({isKeyboardShown: true}); +function KeyboardStateProvider(props) { + const {children} = props; + const [isKeyboardShown, setIsKeyboardShown] = useState(false); + useEffect(() => { + const keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', () => { + setIsKeyboardShown(true); }); - this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { - this.setState({isKeyboardShown: false}); + const keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', () => { + setIsKeyboardShown(false); }); - } - componentWillUnmount() { - this.keyboardDidShowListener.remove(); - this.keyboardDidHideListener.remove(); - } + return () => { + keyboardDidShowListener.remove(); + keyboardDidHideListener.remove(); + }; + }, []); - render() { - return {this.props.children}; - } + return {children}; } KeyboardStateProvider.propTypes = keyboardStateProviderPropTypes; diff --git a/src/libs/Accessibility/index.js b/src/libs/Accessibility/index.ts similarity index 74% rename from src/libs/Accessibility/index.js rename to src/libs/Accessibility/index.ts index 59a6738dfb14..213d28139c2c 100644 --- a/src/libs/Accessibility/index.js +++ b/src/libs/Accessibility/index.ts @@ -1,25 +1,28 @@ import {useEffect, useState, useCallback} from 'react'; -import {AccessibilityInfo} from 'react-native'; -import _ from 'underscore'; +import {AccessibilityInfo, LayoutChangeEvent} from 'react-native'; import moveAccessibilityFocus from './moveAccessibilityFocus'; -const useScreenReaderStatus = () => { +type HitSlop = {x: number; y: number}; + +const useScreenReaderStatus = (): boolean => { const [isScreenReaderEnabled, setIsScreenReaderEnabled] = useState(false); useEffect(() => { const subscription = AccessibilityInfo.addEventListener('screenReaderChanged', setIsScreenReaderEnabled); - return subscription && subscription.remove; + return () => { + subscription?.remove(); + }; }, []); return isScreenReaderEnabled; }; -const getHitSlopForSize = ({x, y}) => { +const getHitSlopForSize = ({x, y}: HitSlop) => { /* according to https://developer.apple.com/design/human-interface-guidelines/ios/visual-design/adaptivity-and-layout/ the minimum tappable area is 44x44 points */ const minimumSize = 44; - const hitSlopVertical = _.max([minimumSize - x, 0]) / 2; - const hitSlopHorizontal = _.max([minimumSize - y, 0]) / 2; + const hitSlopVertical = Math.max(minimumSize - x, 0) / 2; + const hitSlopHorizontal = Math.max(minimumSize - y, 0) / 2; return { top: hitSlopVertical, bottom: hitSlopVertical, @@ -31,7 +34,7 @@ const getHitSlopForSize = ({x, y}) => { const useAutoHitSlop = () => { const [frameSize, setFrameSize] = useState({x: 0, y: 0}); const onLayout = useCallback( - (event) => { + (event: LayoutChangeEvent) => { const {layout} = event.nativeEvent; if (layout.width !== frameSize.x && layout.height !== frameSize.y) { setFrameSize({x: layout.width, y: layout.height}); diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.js b/src/libs/Accessibility/moveAccessibilityFocus/index.js deleted file mode 100644 index c9130c7e34be..000000000000 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.js +++ /dev/null @@ -1,8 +0,0 @@ -const moveAccessibilityFocus = (ref) => { - if (!ref || !ref.current) { - return; - } - ref.current.focus(); -}; - -export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.native.js b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts similarity index 62% rename from src/libs/Accessibility/moveAccessibilityFocus/index.native.js rename to src/libs/Accessibility/moveAccessibilityFocus/index.native.ts index 91605e06243d..2e027c59be39 100644 --- a/src/libs/Accessibility/moveAccessibilityFocus/index.native.js +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.native.ts @@ -1,9 +1,11 @@ import {AccessibilityInfo} from 'react-native'; +import MoveAccessibilityFocus from './types'; -const moveAccessibilityFocus = (ref) => { +const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { if (!ref) { return; } + AccessibilityInfo.sendAccessibilityEvent(ref, 'focus'); }; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/index.ts b/src/libs/Accessibility/moveAccessibilityFocus/index.ts new file mode 100644 index 000000000000..b381c1d814c1 --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/index.ts @@ -0,0 +1,10 @@ +import MoveAccessibilityFocus from './types'; + +const moveAccessibilityFocus: MoveAccessibilityFocus = (ref) => { + if (!ref?.current) { + return; + } + ref.current.focus(); +}; + +export default moveAccessibilityFocus; diff --git a/src/libs/Accessibility/moveAccessibilityFocus/types.ts b/src/libs/Accessibility/moveAccessibilityFocus/types.ts new file mode 100644 index 000000000000..1344c3f98e3e --- /dev/null +++ b/src/libs/Accessibility/moveAccessibilityFocus/types.ts @@ -0,0 +1,6 @@ +import {ElementRef, RefObject} from 'react'; +import {HostComponent} from 'react-native'; + +type MoveAccessibilityFocus = (ref?: ElementRef> & RefObject) => void; + +export default MoveAccessibilityFocus; diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index dee5b7d4b489..16d0e2225007 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -71,10 +71,8 @@ Onyx.connect({ // If the current timezone is different than the user's timezone, and their timezone is set to automatic // then update their timezone. if (_.isObject(timezone) && timezone.automatic && timezone.selected !== currentTimezone) { - PersonalDetails.updateAutomaticTimezone({ - automatic: true, - selected: currentTimezone, - }); + timezone.selected = currentTimezone; + PersonalDetails.updateAutomaticTimezone(timezone); } }, }); diff --git a/src/libs/ReceiptUtils.js b/src/libs/ReceiptUtils.ts similarity index 69% rename from src/libs/ReceiptUtils.js rename to src/libs/ReceiptUtils.ts index 8f352c182171..cdc45cb119d5 100644 --- a/src/libs/ReceiptUtils.js +++ b/src/libs/ReceiptUtils.ts @@ -1,4 +1,5 @@ import Str from 'expensify-common/lib/str'; +import {ImageSourcePropType} from 'react-native'; import * as FileUtils from './fileDownload/FileUtils'; import CONST from '../CONST'; import ReceiptHTML from '../../assets/images/receipt-html.png'; @@ -6,14 +7,23 @@ import ReceiptDoc from '../../assets/images/receipt-doc.png'; import ReceiptGeneric from '../../assets/images/receipt-generic.png'; import ReceiptSVG from '../../assets/images/receipt-svg.png'; +type ThumbnailAndImageURI = { + image: ImageSourcePropType | string; + thumbnail: string | null; +}; + +type FileNameAndExtension = { + fileExtension?: string; + fileName?: string; +}; + /** * Grab the appropriate receipt image and thumbnail URIs based on file type * - * @param {String} path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg - * @param {String} filename of uploaded image or last part of remote URI - * @returns {Object} + * @param path URI to image, i.e. blob:new.expensify.com/9ef3a018-4067-47c6-b29f-5f1bd35f213d or expensify.com/receipts/w_e616108497ef940b7210ec6beb5a462d01a878f4.jpg + * @param filename of uploaded image or last part of remote URI */ -function getThumbnailAndImageURIs(path, filename) { +function getThumbnailAndImageURIs(path: string, filename: string): ThumbnailAndImageURI { const isReceiptImage = Str.isImage(filename); // For local files, we won't have a thumbnail yet @@ -25,7 +35,7 @@ function getThumbnailAndImageURIs(path, filename) { return {thumbnail: `${path}.1024.jpg`, image: path}; } - const {fileExtension} = FileUtils.splitExtensionFromFileName(filename); + const {fileExtension} = FileUtils.splitExtensionFromFileName(filename) as FileNameAndExtension; let image = ReceiptGeneric; if (fileExtension === CONST.IOU.FILE_TYPES.HTML) { image = ReceiptHTML; @@ -38,6 +48,7 @@ function getThumbnailAndImageURIs(path, filename) { if (fileExtension === CONST.IOU.FILE_TYPES.SVG) { image = ReceiptSVG; } + return {thumbnail: null, image}; } diff --git a/src/libs/SidebarUtils.js b/src/libs/SidebarUtils.js index df676f23ebc7..a280947a97b5 100644 --- a/src/libs/SidebarUtils.js +++ b/src/libs/SidebarUtils.js @@ -331,9 +331,9 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, } : null; } - let lastMessageText = - hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID ? `${lastActorDetails.displayName}: ` : ''; - lastMessageText += report ? lastMessageTextFromReport : ''; + const lastActorDisplayName = + hasMultipleParticipants && lastActorDetails && lastActorDetails.accountID && Number(lastActorDetails.accountID) !== currentUserAccountID ? lastActorDetails.displayName : ''; + let lastMessageText = lastMessageTextFromReport; if (result.isArchivedRoom) { const archiveReason = @@ -354,6 +354,8 @@ function getOptionData(report, reportActions, personalDetails, preferredLocale, result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.reopened')}: ${report.reportName}`; } else if (lodashGet(lastAction, 'actionName', '') === CONST.REPORT.ACTIONS.TYPE.TASKCOMPLETED) { result.alternateText = `${Localize.translate(preferredLocale, 'task.messages.completed')}: ${report.reportName}`; + } else if (lodashGet(lastAction, 'actionName', '') !== CONST.REPORT.ACTIONS.TYPE.REPORTPREVIEW && lastActorDisplayName && lastMessageTextFromReport) { + result.alternateText = `${lastActorDisplayName}: ${lastMessageText}`; } else { result.alternateText = lastMessageTextFromReport.length > 0 ? lastMessageText : Localize.translate(preferredLocale, 'report.noActivityYet'); } diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.ts similarity index 65% rename from src/libs/ValidationUtils.js rename to src/libs/ValidationUtils.ts index a85a623bd3ec..80b15690ac46 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.ts @@ -1,22 +1,23 @@ import {subYears, addYears, startOfDay, endOfMonth, parse, isAfter, isBefore, isValid, isWithinInterval, isSameDay, format} from 'date-fns'; -import _ from 'underscore'; import {URL_REGEX_WITH_REQUIRED_PROTOCOL} from 'expensify-common/lib/Url'; import {parsePhoneNumber} from 'awesome-phonenumber'; +import isDate from 'lodash/isDate'; +import isEmpty from 'lodash/isEmpty'; +import isObject from 'lodash/isObject'; import CONST from '../CONST'; import * as CardUtils from './CardUtils'; import * as LoginUtils from './LoginUtils'; +import {Report} from '../types/onyx'; +import * as OnyxCommon from '../types/onyx/OnyxCommon'; /** * Implements the Luhn Algorithm, a checksum formula used to validate credit card * numbers. - * - * @param {String} val - * @returns {Boolean} */ -function validateCardNumber(val) { +function validateCardNumber(value: string): boolean { let sum = 0; - for (let i = 0; i < val.length; i++) { - let intVal = parseInt(val.substr(i, 1), 10); + for (let i = 0; i < value.length; i++) { + let intVal = parseInt(value.substr(i, 1), 10); if (i % 2 === 0) { intVal *= 2; if (intVal > 9) { @@ -30,11 +31,8 @@ function validateCardNumber(val) { /** * Validating that this is a valid address (PO boxes are not allowed) - * - * @param {String} value - * @returns {Boolean} */ -function isValidAddress(value) { +function isValidAddress(value: string): boolean { if (!CONST.REGEX.ANY_VALUE.test(value)) { return false; } @@ -44,11 +42,8 @@ function isValidAddress(value) { /** * Validate date fields - * - * @param {String|Date} date - * @returns {Boolean} true if valid */ -function isValidDate(date) { +function isValidDate(date: string | Date): boolean { if (!date) { return false; } @@ -61,11 +56,8 @@ function isValidDate(date) { /** * Validate that date entered isn't a future date. - * - * @param {String|Date} date - * @returns {Boolean} true if valid */ -function isValidPastDate(date) { +function isValidPastDate(date: string | Date): boolean { if (!date) { return false; } @@ -78,33 +70,27 @@ function isValidPastDate(date) { /** * Used to validate a value that is "required". - * - * @param {*} value - * @returns {Boolean} */ -function isRequiredFulfilled(value) { - if (_.isString(value)) { - return !_.isEmpty(value.trim()); +function isRequiredFulfilled(value: string | Date | unknown[] | Record): boolean { + if (typeof value === 'string') { + return value.trim().length > 0; } - if (_.isDate(value)) { + + if (isDate(value)) { return isValidDate(value); } - if (_.isArray(value) || _.isObject(value)) { - return !_.isEmpty(value); + if (Array.isArray(value) || isObject(value)) { + return !isEmpty(value); } return Boolean(value); } /** * Used to add requiredField error to the fields passed. - * - * @param {Object} values - * @param {Array} requiredFields - * @returns {Object} */ -function getFieldRequiredErrors(values, requiredFields) { - const errors = {}; - _.each(requiredFields, (fieldKey) => { +function getFieldRequiredErrors(values: OnyxCommon.Errors, requiredFields: string[]) { + const errors: OnyxCommon.Errors = {}; + requiredFields.forEach((fieldKey) => { if (isRequiredFulfilled(values[fieldKey])) { return; } @@ -119,11 +105,8 @@ function getFieldRequiredErrors(values, requiredFields) { * 2. MM/YYYY * 3. MMYY * 4. MMYYYY - * - * @param {String} string - * @returns {Boolean} */ -function isValidExpirationDate(string) { +function isValidExpirationDate(string: string): boolean { if (!CONST.REGEX.CARD_EXPIRATION_DATE.test(string)) { return false; } @@ -136,21 +119,15 @@ function isValidExpirationDate(string) { /** * Validates that this is a valid security code * in the XXX or XXXX format. - * - * @param {String} string - * @returns {Boolean} */ -function isValidSecurityCode(string) { +function isValidSecurityCode(string: string): boolean { return CONST.REGEX.CARD_SECURITY_CODE.test(string); } /** * Validates a debit card number (15 or 16 digits). - * - * @param {String} string - * @returns {Boolean} */ -function isValidDebitCard(string) { +function isValidDebitCard(string: string): boolean { if (!CONST.REGEX.CARD_NUMBER.test(string)) { return false; } @@ -158,45 +135,26 @@ function isValidDebitCard(string) { return validateCardNumber(string); } -/** - * @param {String} code - * @returns {Boolean} - */ -function isValidIndustryCode(code) { +function isValidIndustryCode(code: string): boolean { return CONST.REGEX.INDUSTRY_CODE.test(code); } -/** - * @param {String} zipCode - * @returns {Boolean} - */ -function isValidZipCode(zipCode) { +function isValidZipCode(zipCode: string): boolean { return CONST.REGEX.ZIP_CODE.test(zipCode); } -/** - * @param {String} ssnLast4 - * @returns {Boolean} - */ -function isValidSSNLastFour(ssnLast4) { +function isValidSSNLastFour(ssnLast4: string): boolean { return CONST.REGEX.SSN_LAST_FOUR.test(ssnLast4); } -/** - * @param {String} ssnFull9 - * @returns {Boolean} - */ -function isValidSSNFullNine(ssnFull9) { +function isValidSSNFullNine(ssnFull9: string): boolean { return CONST.REGEX.SSN_FULL_NINE.test(ssnFull9); } /** * Validate that a date meets the minimum age requirement. - * - * @param {String} date - * @returns {Boolean} */ -function meetsMinimumAgeRequirement(date) { +function meetsMinimumAgeRequirement(date: string): boolean { const testDate = new Date(date); const minDate = subYears(new Date(), CONST.DATE_BIRTH.MIN_AGE_FOR_PAYMENT); return isValid(testDate) && (isSameDay(testDate, minDate) || isBefore(testDate, minDate)); @@ -204,11 +162,8 @@ function meetsMinimumAgeRequirement(date) { /** * Validate that a date meets the maximum age requirement. - * - * @param {String} date - * @returns {Boolean} */ -function meetsMaximumAgeRequirement(date) { +function meetsMaximumAgeRequirement(date: string): boolean { const testDate = new Date(date); const maxDate = subYears(new Date(), CONST.DATE_BIRTH.MAX_AGE); return isValid(testDate) && (isSameDay(testDate, maxDate) || isAfter(testDate, maxDate)); @@ -216,13 +171,8 @@ function meetsMaximumAgeRequirement(date) { /** * Validate that given date is in a specified range of years before now. - * - * @param {String} date - * @param {Number} minimumAge - * @param {Number} maximumAge - * @returns {String|Array} */ -function getAgeRequirementError(date, minimumAge, maximumAge) { +function getAgeRequirementError(date: string, minimumAge: number, maximumAge: number): string | Array> { const currentDate = startOfDay(new Date()); const testDate = parse(date, CONST.DATE.FNS_FORMAT_STRING, currentDate); @@ -247,24 +197,17 @@ function getAgeRequirementError(date, minimumAge, maximumAge) { /** * Similar to backend, checks whether a website has a valid URL or not. * http/https/ftp URL scheme required. - * - * @param {String} url - * @returns {Boolean} */ -function isValidWebsite(url) { +function isValidWebsite(url: string): boolean { return new RegExp(`^${URL_REGEX_WITH_REQUIRED_PROTOCOL}$`, 'i').test(url); } -/** - * @param {Object} identity - * @returns {Object} - */ -function validateIdentity(identity) { +function validateIdentity(identity: Record): Record { const requiredFields = ['firstName', 'lastName', 'street', 'city', 'zipCode', 'state', 'ssnLast4', 'dob']; - const errors = {}; + const errors: Record = {}; // Check that all required fields are filled - _.each(requiredFields, (fieldName) => { + requiredFields.forEach((fieldName) => { if (isRequiredFulfilled(identity[fieldName])) { return; } @@ -293,58 +236,41 @@ function validateIdentity(identity) { return errors; } -/** - * @param {String} phoneNumber - * @param {Boolean} [isCountryCodeOptional] - * @returns {Boolean} - */ -function isValidUSPhone(phoneNumber = '', isCountryCodeOptional) { +function isValidUSPhone(phoneNumber = '', isCountryCodeOptional?: boolean): boolean { const phone = phoneNumber || ''; - const regionCode = isCountryCodeOptional ? CONST.COUNTRY.US : null; + const regionCode = isCountryCodeOptional ? CONST.COUNTRY.US : undefined; const parsedPhoneNumber = parsePhoneNumber(phone, {regionCode}); return parsedPhoneNumber.possible && parsedPhoneNumber.regionCode === CONST.COUNTRY.US; } -/** - * @param {string} validateCode - * @returns {Boolean} - */ -function isValidValidateCode(validateCode) { - return validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING); +function isValidValidateCode(validateCode: string): boolean { + return Boolean(validateCode.match(CONST.VALIDATE_CODE_REGEX_STRING)); } -function isValidRecoveryCode(recoveryCode) { - return recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING); +function isValidRecoveryCode(recoveryCode: string): boolean { + return Boolean(recoveryCode.match(CONST.RECOVERY_CODE_REGEX_STRING)); } -/** - * @param {String} code - * @returns {Boolean} - */ -function isValidTwoFactorCode(code) { +function isValidTwoFactorCode(code: string): boolean { return Boolean(code.match(CONST.REGEX.CODE_2FA)); } /** * Checks whether a value is a numeric string including `(`, `)`, `-` and optional leading `+` - * @param {String} input - * @returns {Boolean} */ -function isNumericWithSpecialChars(input) { +function isNumericWithSpecialChars(input: string): boolean { return /^\+?[\d\\+]*$/.test(LoginUtils.getPhoneNumberWithoutSpecialChars(input)); } /** * Checks the given number is a valid US Routing Number * using ABA routingNumber checksum algorithm: http://www.brainjar.com/js/validation/ - * @param {String} number - * @returns {Boolean} */ -function isValidRoutingNumber(number) { +function isValidRoutingNumber(routingNumber: string): boolean { let n = 0; - for (let i = 0; i < number.length; i += 3) { - n += parseInt(number.charAt(i), 10) * 3 + parseInt(number.charAt(i + 1), 10) * 7 + parseInt(number.charAt(i + 2), 10); + for (let i = 0; i < routingNumber.length; i += 3) { + n += parseInt(routingNumber.charAt(i), 10) * 3 + parseInt(routingNumber.charAt(i + 1), 10) * 7 + parseInt(routingNumber.charAt(i + 2), 10); } // If the resulting sum is an even multiple of ten (but not zero), @@ -357,57 +283,39 @@ function isValidRoutingNumber(number) { /** * Checks that the provided name doesn't contain any commas or semicolons - * - * @param {String} name - * @returns {Boolean} */ -function isValidDisplayName(name) { +function isValidDisplayName(name: string): boolean { return !name.includes(',') && !name.includes(';'); } /** * Checks that the provided legal name doesn't contain special characters - * - * @param {String} name - * @returns {Boolean} */ -function isValidLegalName(name) { +function isValidLegalName(name: string): boolean { return CONST.REGEX.ALPHABETIC_AND_LATIN_CHARS.test(name); } /** * Checks if the provided string includes any of the provided reserved words - * - * @param {String} value - * @param {String[]} reservedWords - * @returns {Boolean} */ -function doesContainReservedWord(value, reservedWords) { +function doesContainReservedWord(value: string, reservedWords: string[]): boolean { const valueToCheck = value.trim().toLowerCase(); - return _.some(reservedWords, (reservedWord) => valueToCheck.includes(reservedWord.toLowerCase())); + return reservedWords.some((reservedWord) => valueToCheck.includes(reservedWord.toLowerCase())); } /** * Checks if is one of the certain names which are reserved for default rooms * and should not be used for policy rooms. - * - * @param {String} roomName - * @returns {Boolean} */ -function isReservedRoomName(roomName) { - return _.contains(CONST.REPORT.RESERVED_ROOM_NAMES, roomName); +function isReservedRoomName(roomName: string): boolean { + return (CONST.REPORT.RESERVED_ROOM_NAMES as readonly string[]).includes(roomName); } /** * Checks if the room name already exists. - * - * @param {String} roomName - * @param {Object} reports - * @param {String} policyID - * @returns {Boolean} */ -function isExistingRoomName(roomName, reports, policyID) { - return _.some(reports, (report) => report && report.policyID === policyID && report.reportName === roomName); +function isExistingRoomName(roomName: string, reports: Report[], policyID: string): boolean { + return reports.some((report) => report && report.policyID === policyID && report.reportName === roomName); } /** @@ -415,31 +323,22 @@ function isExistingRoomName(roomName, reports, policyID) { * - It starts with a hash '#' * - After the first character, it contains only lowercase letters, numbers, and dashes * - It's between 1 and MAX_ROOM_NAME_LENGTH characters long - * - * @param {String} roomName - * @returns {Boolean} */ -function isValidRoomName(roomName) { +function isValidRoomName(roomName: string): boolean { return CONST.REGEX.ROOM_NAME.test(roomName); } /** * Checks if tax ID consists of 9 digits - * - * @param {String} taxID - * @returns {Boolean} */ -function isValidTaxID(taxID) { - return taxID && CONST.REGEX.TAX_ID.test(taxID); +function isValidTaxID(taxID: string): boolean { + return CONST.REGEX.TAX_ID.test(taxID); } /** * Checks if a string value is a number. - * - * @param {String} value - * @returns {Boolean} */ -function isNumeric(value) { +function isNumeric(value: string): boolean { if (typeof value !== 'string') { return false; } @@ -448,12 +347,9 @@ function isNumeric(value) { /** * Checks that the provided accountID is a number and bigger than 0. - * - * @param {Number} accountID - * @returns {Boolean} */ -function isValidAccountRoute(accountID) { - return CONST.REGEX.NUMBER.test(accountID) && accountID > 0; +function isValidAccountRoute(accountID: number): boolean { + return CONST.REGEX.NUMBER.test(String(accountID)) && accountID > 0; } export { diff --git a/src/libs/actions/EmojiPickerAction.js b/src/libs/actions/EmojiPickerAction.js deleted file mode 100644 index 70c7ebabbe20..000000000000 --- a/src/libs/actions/EmojiPickerAction.js +++ /dev/null @@ -1,69 +0,0 @@ -import React from 'react'; - -const emojiPickerRef = React.createRef(); - -/** - * Show the EmojiPicker modal popover. - * - * @param {Function} [onModalHide=() => {}] - Run a callback when Modal hides. - * @param {Function} [onEmojiSelected=() => {}] - Run a callback when Emoji selected. - * @param {Element} emojiPopoverAnchor - Element on which EmojiPicker is anchored - * @param {Object} [anchorOrigin] - Anchor origin for Popover - * @param {Function} [onWillShow=() => {}] - Run a callback when Popover will show - * @param {String} id - Unique id for EmojiPicker - */ -function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor, anchorOrigin = undefined, onWillShow = () => {}, id) { - if (!emojiPickerRef.current) { - return; - } - - emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id); -} - -/** - * Hide the Emoji Picker modal. - * - * @param {Boolean} isNavigating - */ -function hideEmojiPicker(isNavigating) { - if (!emojiPickerRef.current) { - return; - } - emojiPickerRef.current.hideEmojiPicker(isNavigating); -} - -/** - * Whether Emoji Picker is active for the given id. - * - * @param {String} id - * @return {Boolean} - */ -function isActive(id) { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.isActive(id); -} - -function clearActive() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.clearActive(); -} - -function isEmojiPickerVisible() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.isEmojiPickerVisible; -} - -function resetEmojiPopoverAnchor() { - if (!emojiPickerRef.current) { - return; - } - return emojiPickerRef.current.resetEmojiPopoverAnchor(); -} - -export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, clearActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; diff --git a/src/libs/actions/EmojiPickerAction.ts b/src/libs/actions/EmojiPickerAction.ts new file mode 100644 index 000000000000..edf82eb46da3 --- /dev/null +++ b/src/libs/actions/EmojiPickerAction.ts @@ -0,0 +1,87 @@ +import {ValueOf} from 'type-fest'; +import React from 'react'; +import {View} from 'react-native'; +import CONST from '../../CONST'; + +type AnchorOrigin = { + horizontal: ValueOf; + vertical: ValueOf; +}; + +// TODO: Move this type to src/components/EmojiPicker/EmojiPicker.js once it is converted to TS +type EmojiPickerRef = { + showEmojiPicker: (onModalHideValue?: () => void, onEmojiSelectedValue?: () => void, emojiPopoverAnchor?: View, anchorOrigin?: AnchorOrigin, onWillShow?: () => void, id?: string) => void; + isActive: (id: string) => boolean; + clearActive: () => void; + hideEmojiPicker: (isNavigating: boolean) => void; + isEmojiPickerVisible: boolean; + resetEmojiPopoverAnchor: () => void; +}; + +const emojiPickerRef = React.createRef(); + +/** + * Show the EmojiPicker modal popover. + * + * @param onModalHide - Run a callback when Modal hides. + * @param onEmojiSelected - Run a callback when Emoji selected. + * @param emojiPopoverAnchor - Element on which EmojiPicker is anchored + * @param anchorOrigin - Anchor origin for Popover + * @param onWillShow - Run a callback when Popover will show + * @param id - Unique id for EmojiPicker + */ +function showEmojiPicker(onModalHide = () => {}, onEmojiSelected = () => {}, emojiPopoverAnchor = undefined, anchorOrigin = undefined, onWillShow = () => {}, id = undefined) { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.showEmojiPicker(onModalHide, onEmojiSelected, emojiPopoverAnchor, anchorOrigin, onWillShow, id); +} + +/** + * Hide the Emoji Picker modal. + */ +function hideEmojiPicker(isNavigating: boolean) { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.hideEmojiPicker(isNavigating); +} + +/** + * Whether Emoji Picker is active for the given id. + */ +function isActive(id: string): boolean { + if (!emojiPickerRef.current) { + return false; + } + + return emojiPickerRef.current.isActive(id); +} + +function clearActive() { + if (!emojiPickerRef.current) { + return; + } + + return emojiPickerRef.current.clearActive(); +} + +function isEmojiPickerVisible(): boolean { + if (!emojiPickerRef.current) { + return false; + } + + return emojiPickerRef.current.isEmojiPickerVisible; +} + +function resetEmojiPopoverAnchor() { + if (!emojiPickerRef.current) { + return; + } + + emojiPickerRef.current.resetEmojiPopoverAnchor(); +} + +export {emojiPickerRef, showEmojiPicker, hideEmojiPicker, isActive, clearActive, isEmojiPickerVisible, resetEmojiPopoverAnchor}; diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js index 296abc6a9cfa..2de53293853a 100644 --- a/src/libs/actions/Report.js +++ b/src/libs/actions/Report.js @@ -1249,11 +1249,12 @@ function editReportComment(reportID, originalReportAction, textForNewComment) { * Saves the draft for a comment report action. This will put the comment into "edit mode" * * @param {String} reportID - * @param {Number} reportActionID + * @param {Object} reportAction * @param {String} draftMessage */ -function saveReportActionDraft(reportID, reportActionID, draftMessage) { - Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}_${reportActionID}`, draftMessage); +function saveReportActionDraft(reportID, reportAction, draftMessage) { + const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction); + Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${reportAction.reportActionID}`, draftMessage); } /** diff --git a/src/pages/ReimbursementAccount/RequestorStep.js b/src/pages/ReimbursementAccount/RequestorStep.js index cb1d306eebb6..53ca279c2cb2 100644 --- a/src/pages/ReimbursementAccount/RequestorStep.js +++ b/src/pages/ReimbursementAccount/RequestorStep.js @@ -1,9 +1,8 @@ -import React from 'react'; +import React, {useCallback, useMemo} from 'react'; import {View} from 'react-native'; import PropTypes from 'prop-types'; -import lodashGet from 'lodash/get'; +import _ from 'lodash'; import styles from '../../styles/styles'; -import withLocalize from '../../components/withLocalize'; import HeaderWithBackButton from '../../components/HeaderWithBackButton'; import CONST from '../../CONST'; import TextLink from '../../components/TextLink'; @@ -16,182 +15,188 @@ import ONYXKEYS from '../../ONYXKEYS'; import RequestorOnfidoStep from './RequestorOnfidoStep'; import Form from '../../components/Form'; import ScreenWrapper from '../../components/ScreenWrapper'; -import StepPropTypes from './StepPropTypes'; +import useLocalize from '../../hooks/useLocalize'; +import {reimbursementAccountPropTypes} from './reimbursementAccountPropTypes'; +import ReimbursementAccountDraftPropTypes from './ReimbursementAccountDraftPropTypes'; const propTypes = { - ...StepPropTypes, + onBackButtonPress: PropTypes.func.isRequired, + getDefaultStateForField: PropTypes.func.isRequired, + reimbursementAccount: reimbursementAccountPropTypes.isRequired, + reimbursementAccountDraft: ReimbursementAccountDraftPropTypes.isRequired, /** If we should show Onfido flow */ shouldShowOnfido: PropTypes.bool.isRequired, }; -class RequestorStep extends React.Component { - constructor(props) { - super(props); - - this.validate = this.validate.bind(this); - this.submit = this.submit.bind(this); - } - - /** - * @param {Object} values - * @returns {Object} - */ - validate(values) { - const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'requestorAddressStreet', 'requestorAddressCity', 'requestorAddressState', 'requestorAddressZipCode']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); - - if (values.dob) { - if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { - errors.dob = 'bankAccount.error.dob'; - } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { - errors.dob = 'bankAccount.error.age'; - } - } - - if (values.ssnLast4 && !ValidationUtils.isValidSSNLastFour(values.ssnLast4)) { - errors.ssnLast4 = 'bankAccount.error.ssnLast4'; - } +const REQUIRED_FIELDS = ['firstName', 'lastName', 'dob', 'ssnLast4', 'requestorAddressStreet', 'requestorAddressCity', 'requestorAddressState', 'requestorAddressZipCode']; +const INPUT_KEYS = { + firstName: 'firstName', + lastName: 'lastName', + dob: 'dob', + ssnLast4: 'ssnLast4', + street: 'requestorAddressStreet', + city: 'requestorAddressCity', + state: 'requestorAddressState', + zipCode: 'requestorAddressZipCode', +}; +const STEP_COUNTER = {step: 3, total: 5}; - if (values.requestorAddressStreet && !ValidationUtils.isValidAddress(values.requestorAddressStreet)) { - errors.requestorAddressStreet = 'bankAccount.error.addressStreet'; - } +const validate = (values) => { + const errors = ValidationUtils.getFieldRequiredErrors(values, REQUIRED_FIELDS); - if (values.requestorAddressZipCode && !ValidationUtils.isValidZipCode(values.requestorAddressZipCode)) { - errors.requestorAddressZipCode = 'bankAccount.error.zipCode'; + if (values.dob) { + if (!ValidationUtils.isValidPastDate(values.dob) || !ValidationUtils.meetsMaximumAgeRequirement(values.dob)) { + errors.dob = 'bankAccount.error.dob'; + } else if (!ValidationUtils.meetsMinimumAgeRequirement(values.dob)) { + errors.dob = 'bankAccount.error.age'; } + } - if (!ValidationUtils.isRequiredFulfilled(values.isControllingOfficer)) { - errors.isControllingOfficer = 'requestorStep.isControllingOfficerError'; - } + if (values.ssnLast4 && !ValidationUtils.isValidSSNLastFour(values.ssnLast4)) { + errors.ssnLast4 = 'bankAccount.error.ssnLast4'; + } - return errors; + if (values.requestorAddressStreet && !ValidationUtils.isValidAddress(values.requestorAddressStreet)) { + errors.requestorAddressStreet = 'bankAccount.error.addressStreet'; } - submit(values) { - const payload = { - bankAccountID: lodashGet(this.props.reimbursementAccount, 'achData.bankAccountID') || 0, - ...values, - }; + if (values.requestorAddressZipCode && !ValidationUtils.isValidZipCode(values.requestorAddressZipCode)) { + errors.requestorAddressZipCode = 'bankAccount.error.zipCode'; + } - BankAccounts.updatePersonalInformationForBankAccount(payload); + if (!ValidationUtils.isRequiredFulfilled(values.isControllingOfficer)) { + errors.isControllingOfficer = 'requestorStep.isControllingOfficerError'; } - render() { - if (this.props.shouldShowOnfido) { - return ( - - ); - } + return errors; +}; +function RequestorStep({reimbursementAccount, shouldShowOnfido, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField}) { + const {translate} = useLocalize(); + + const defaultValues = useMemo( + () => ({ + firstName: getDefaultStateForField(INPUT_KEYS.firstName), + lastName: getDefaultStateForField(INPUT_KEYS.lastName), + street: getDefaultStateForField(INPUT_KEYS.street), + city: getDefaultStateForField(INPUT_KEYS.city), + state: getDefaultStateForField(INPUT_KEYS.state), + zipCode: getDefaultStateForField(INPUT_KEYS.zipCode), + dob: getDefaultStateForField(INPUT_KEYS.dob), + ssnLast4: getDefaultStateForField(INPUT_KEYS.ssnLast4), + }), + [getDefaultStateForField], + ); + + const submit = useCallback( + (values) => { + const payload = { + bankAccountID: _.get(reimbursementAccount, 'achData.bankAccountID', 0), + ...values, + }; + + BankAccounts.updatePersonalInformationForBankAccount(payload); + }, + [reimbursementAccount], + ); + + const renderLabelComponent = () => ( + + {translate('requestorStep.isControllingOfficer')} + + ); + + if (shouldShowOnfido) { return ( - - -
- {this.props.translate('requestorStep.subtitle')} - - - {`${this.props.translate('requestorStep.learnMore')}`} - - {' | '} - - {`${this.props.translate('requestorStep.isMyDataSafe')}`} - - - - ( - - {this.props.translate('requestorStep.isControllingOfficer')} - - )} - style={[styles.mt4]} - shouldSaveDraft - /> - - {this.props.translate('requestorStep.onFidoConditions')} - - {this.props.translate('onfidoStep.facialScan')} - - {', '} - - {this.props.translate('common.privacy')} - - {` ${this.props.translate('common.and')} `} - - {this.props.translate('common.termsOfService')} - - - -
+ ); } + + return ( + + +
+ {translate('requestorStep.subtitle')} + + + {translate('requestorStep.learnMore')} + + {' | '} + + {translate('requestorStep.isMyDataSafe')} + + + + + + {translate('requestorStep.onFidoConditions')} + + {translate('onfidoStep.facialScan')} + + {', '} + + {translate('common.privacy')} + + {` ${translate('common.and')} `} + + {translate('common.termsOfService')} + + + +
+ ); } RequestorStep.propTypes = propTypes; +RequestorStep.displayName = 'RequestorStep'; -export default withLocalize(RequestorStep); +export default React.forwardRef(RequestorStep); diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js index 0607404c6f66..157ae66dc918 100644 --- a/src/pages/home/report/ContextMenu/ContextMenuActions.js +++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js @@ -295,7 +295,7 @@ export default [ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(childReportID)); return; } - const editAction = () => Report.saveReportActionDraft(reportID, reportAction.reportActionID, _.isEmpty(draftMessage) ? getActionText(reportAction) : ''); + const editAction = () => Report.saveReportActionDraft(reportID, reportAction, _.isEmpty(draftMessage) ? getActionText(reportAction) : ''); if (closePopover) { // Hide popover, then call editAction diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js index 95a33fe6b721..1c537f9b4e77 100644 --- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js +++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js @@ -327,7 +327,7 @@ function ComposerWithSuggestions({ (action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action), ); if (lastReportAction) { - Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html); + Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html); } } }, diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js index 3ff7972e06a5..645f7d32abdb 100644 --- a/src/pages/home/report/ReportActionItem.js +++ b/src/pages/home/report/ReportActionItem.js @@ -668,7 +668,8 @@ export default compose( withReportActionsDrafts({ propName: 'draftMessage', transformValue: (drafts, props) => { - const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.report.reportID}_${props.action.reportActionID}`; + const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action); + const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${props.action.reportActionID}`; return lodashGet(drafts, draftKey, ''); }, }), diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js index 6ce826a2a34c..3ceaf69b52f5 100644 --- a/src/pages/home/report/ReportActionItemMessageEdit.js +++ b/src/pages/home/report/ReportActionItemMessageEdit.js @@ -7,13 +7,11 @@ import ExpensiMark from 'expensify-common/lib/ExpensiMark'; import Str from 'expensify-common/lib/str'; import reportActionPropTypes from './reportActionPropTypes'; import styles from '../../../styles/styles'; -import compose from '../../../libs/compose'; import themeColors from '../../../styles/themes/default'; import * as StyleUtils from '../../../styles/StyleUtils'; import containerComposeStyles from '../../../styles/containerComposeStyles'; import Composer from '../../../components/Composer'; import * as Report from '../../../libs/actions/Report'; -import {withReportActionsDrafts} from '../../../components/OnyxProvider'; import setShouldShowComposeInputKeyboardAware from '../../../libs/setShouldShowComposeInputKeyboardAware'; import ReportActionComposeFocusManager from '../../../libs/ReportActionComposeFocusManager'; import EmojiPickerButton from '../../../components/EmojiPicker/EmojiPickerButton'; @@ -22,6 +20,7 @@ import * as Expensicons from '../../../components/Icon/Expensicons'; import Tooltip from '../../../components/Tooltip'; import * as ReportActionContextMenu from './ContextMenu/ReportActionContextMenu'; import * as ReportUtils from '../../../libs/ReportUtils'; +import * as ReportActionsUtils from '../../../libs/ReportActionsUtils'; import * as EmojiUtils from '../../../libs/EmojiUtils'; import reportPropTypes from '../../reportPropTypes'; import ExceededCommentLength from '../../../components/ExceededCommentLength'; @@ -31,14 +30,12 @@ import * as ComposerUtils from '../../../libs/ComposerUtils'; import * as User from '../../../libs/actions/User'; import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback'; import getButtonState from '../../../libs/getButtonState'; -import withLocalize, {withLocalizePropTypes} from '../../../components/withLocalize'; import useLocalize from '../../../hooks/useLocalize'; import useKeyboardState from '../../../hooks/useKeyboardState'; import useWindowDimensions from '../../../hooks/useWindowDimensions'; import useReportScrollManager from '../../../hooks/useReportScrollManager'; import * as EmojiPickerAction from '../../../libs/actions/EmojiPickerAction'; import focusWithDelay from '../../../libs/focusWithDelay'; -import ONYXKEYS from '../../../ONYXKEYS'; import * as Browser from '../../../libs/Browser'; const propTypes = { @@ -64,14 +61,8 @@ const propTypes = { /** Whether or not the emoji picker is disabled */ shouldDisableEmojiPicker: PropTypes.bool, - /** Draft message - if this is set the comment is in 'edit' mode */ - // eslint-disable-next-line react/forbid-prop-types - drafts: PropTypes.object, - /** Stores user's preferred skin tone */ preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - ...withLocalizePropTypes, }; const defaultProps = { @@ -79,7 +70,6 @@ const defaultProps = { report: {}, shouldDisableEmojiPicker: false, preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE, - drafts: {}, }; // native ids @@ -90,7 +80,7 @@ const isMobileSafari = Browser.isMobileSafari(); function ReportActionItemMessageEdit(props) { const reportScrollManager = useReportScrollManager(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {isKeyboardShown} = useKeyboardState(); const {isSmallScreenWidth} = useWindowDimensions(); @@ -122,6 +112,13 @@ function ReportActionItemMessageEdit(props) { const isFocusedRef = useRef(false); const insertedEmojis = useRef([]); + useEffect(() => { + if (ReportActionsUtils.isDeletedAction(props.action) || props.draftMessage === props.action.message[0].html) { + return; + } + setDraft(Str.htmlDecode(props.draftMessage)); + }, [props.draftMessage, props.action]); + useEffect(() => { // required for keeping last state of isFocused variable isFocusedRef.current = isFocused; @@ -175,9 +172,9 @@ function ReportActionItemMessageEdit(props) { const debouncedSaveDraft = useMemo( () => _.debounce((newDraft) => { - Report.saveReportActionDraft(props.reportID, props.action.reportActionID, newDraft); + Report.saveReportActionDraft(props.reportID, props.action, newDraft); }, 1000), - [props.reportID, props.action.reportActionID], + [props.reportID, props.action], ); /** @@ -200,7 +197,7 @@ function ReportActionItemMessageEdit(props) { */ const updateDraft = useCallback( (newDraftInput) => { - const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, props.preferredLocale); + const {text: newDraft, emojis} = EmojiUtils.replaceAndExtractEmojis(newDraftInput, props.preferredSkinTone, preferredLocale); if (!_.isEmpty(emojis)) { insertedEmojis.current = [...insertedEmojis.current, ...emojis]; @@ -228,7 +225,7 @@ function ReportActionItemMessageEdit(props) { debouncedSaveDraft(props.action.message[0].html); } }, - [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, props.preferredLocale], + [props.action.message, debouncedSaveDraft, debouncedUpdateFrequentlyUsedEmojis, props.preferredSkinTone, preferredLocale], ); /** @@ -236,7 +233,7 @@ function ReportActionItemMessageEdit(props) { */ const deleteDraft = useCallback(() => { debouncedSaveDraft.cancel(); - Report.saveReportActionDraft(props.reportID, props.action.reportActionID, ''); + Report.saveReportActionDraft(props.reportID, props.action, ''); if (isActive()) { ReportActionComposeFocusManager.clear(); @@ -250,7 +247,7 @@ function ReportActionItemMessageEdit(props) { keyboardDidHideListener.remove(); }); } - }, [props.action.reportActionID, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); + }, [props.action, debouncedSaveDraft, props.index, props.reportID, reportScrollManager, isActive]); /** * Save the draft of the comment to be the new comment message. This will take the comment out of "edit mode" with @@ -268,21 +265,6 @@ function ReportActionItemMessageEdit(props) { const trimmedNewDraft = draft.trim(); - const report = ReportUtils.getReport(props.reportID); - - // Updates in child message should cause the parent draft message to change - if (report.parentReportActionID && lodashGet(props.action, 'childType', '') === CONST.REPORT.TYPE.CHAT) { - if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${report.parentReportID}_${props.action.reportActionID}`], undefined)) { - Report.saveReportActionDraft(report.parentReportID, props.action.reportActionID, trimmedNewDraft); - } - } - // Updates in the parent message should cause the child draft message to change - if (props.action.childReportID) { - if (lodashGet(props.drafts, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.action.childReportID}_${props.action.reportActionID}`], undefined)) { - Report.saveReportActionDraft(props.action.childReportID, props.action.reportActionID, trimmedNewDraft); - } - } - // When user tries to save the empty message, it will delete it. Prompt the user to confirm deleting. if (!trimmedNewDraft) { textInputRef.current.blur(); @@ -291,7 +273,7 @@ function ReportActionItemMessageEdit(props) { } Report.editReportComment(props.reportID, props.action, trimmedNewDraft); deleteDraft(); - }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID, props.drafts]); + }, [props.action, debouncedSaveDraft, deleteDraft, draft, props.reportID]); /** * @param {String} emoji @@ -453,17 +435,10 @@ ReportActionItemMessageEdit.propTypes = propTypes; ReportActionItemMessageEdit.defaultProps = defaultProps; ReportActionItemMessageEdit.displayName = 'ReportActionItemMessageEdit'; -export default compose( - withLocalize, - withReportActionsDrafts({ - propName: 'drafts', - }), -)( - React.forwardRef((props, ref) => ( - - )), -); +export default React.forwardRef((props, ref) => ( + +)); diff --git a/src/pages/settings/Report/RoomNamePage.js b/src/pages/settings/Report/RoomNamePage.js index 42d7156660f9..985d83e7fd95 100644 --- a/src/pages/settings/Report/RoomNamePage.js +++ b/src/pages/settings/Report/RoomNamePage.js @@ -2,6 +2,7 @@ import React, {useCallback, useRef} from 'react'; import {withOnyx} from 'react-native-onyx'; import PropTypes from 'prop-types'; import {View} from 'react-native'; +import {useIsFocused} from '@react-navigation/native'; import CONST from '../../../CONST'; import ScreenWrapper from '../../../components/ScreenWrapper'; import HeaderWithBackButton from '../../../components/HeaderWithBackButton'; @@ -48,6 +49,7 @@ function RoomNamePage(props) { const translate = props.translate; const roomNameInputRef = useRef(null); + const isFocused = useIsFocused(); const validate = useCallback( (values) => { @@ -101,6 +103,7 @@ function RoomNamePage(props) { ref={(ref) => (roomNameInputRef.current = ref)} inputID="roomName" defaultValue={report.reportName} + isFocused={isFocused} />