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/get-paid-back/reports/Reimbursements.md b/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md
index c2cc25b32373..a31c0a582fd7 100644
--- a/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md
+++ b/docs/articles/expensify-classic/get-paid-back/reports/Reimbursements.md
@@ -1,5 +1,42 @@
----
-title: Reimbursements
-description: Reimbursements
----
-## Resource Coming Soon!
+# Overview
+
+If you want to know more about how and when you’ll be reimbursed through Expensify, we’ve answered your questions below.
+
+# How to Get Reimbursed
+
+To get paid back after submitting a report for reimbursement, you’ll want to be sure to connect your bank account. You can do that under **Settings** > **Account** > **Payments** > **Add a Deposit Account**. Once your employer has approved your report, the reimbursement will be paid into the account you added.
+
+# Deep Dive
+
+## Reimbursement Timing
+
+### US Bank Accounts
+
+If your company uses Expensify's ACH reimbursement we'll first check to see if the report is eligible for Rapid Reimbursement (next business day). For a report to be eligible for Rapid Reimbursement it must fall under two limits:
+
+ - $100 per deposit bank account per day or less for the individuals being reimbursed or businesses receiving payments for bills.
+ - Less than $10,000 being disbursed in a 24-hour time period from the verified bank account being used to pay the reimbursement.
+
+If the request passes both checks, then you can expect to see funds deposited into your bank account on the next business day.
+
+If either limit has been reached, then you can expect to see funds deposited within your bank account within the typical ACH timeframe of 3-5 business days.
+
+### International Bank Accounts
+
+If receiving reimbursement to an international deposit account via Global Reimbursement, you should expect to see funds deposited in your bank account within 4 business days.
+
+## Bank Processing Timeframes
+
+Banks only process transactions and ACH activity on weekdays that are not bank holidays. These are considered business days. Additionally, the business day on which a transaction will be processed depends upon whether or not a request is created before or after the cutoff time, which is typically 3 pm PST.
+For example, if your reimbursement is initiated at 4 pm on Wednesday, this is past the bank's cutoff time, and it will not begin processing until the next business day.
+If that same reimbursement starts processing on Thursday, and it's estimated to take 3-5 business days, this will cover a weekend, and both days are not considered business days. So, assuming there are no bank holidays added into this mix, here is how that reimbursement timeline would play out:
+
+**Wednesday**: Reimbursement initiated after 3 pm PST; will be processed the next business day by your company’s bank.
+**Thursday**: Your company's bank will begin processing the withdrawal request
+**Friday**: Business day 1
+**Saturday**: Weekend
+**Sunday**: Weekend
+**Monday**: Business day 2
+**Tuesday**: Business day 3
+**Wednesday**: Business day 4
+**Thursday**: Business day 5
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..8c63ba6ce9b3 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": {
@@ -90,7 +90,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.87",
+ "react-native-onyx": "1.0.89",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
@@ -41204,9 +41204,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "1.0.87",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.87.tgz",
- "integrity": "sha512-6mIhobSwpClDDGnJm9XEdjnpEdWfFesJ18J8Ifsb4tL6AVi+uxos5bnlZcOoMbtlUk3UozrgSyTjMfFrkD/aZA==",
+ "version": "1.0.89",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
+ "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
"dependencies": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
@@ -77269,9 +77269,9 @@
}
},
"react-native-onyx": {
- "version": "1.0.87",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.87.tgz",
- "integrity": "sha512-6mIhobSwpClDDGnJm9XEdjnpEdWfFesJ18J8Ifsb4tL6AVi+uxos5bnlZcOoMbtlUk3UozrgSyTjMfFrkD/aZA==",
+ "version": "1.0.89",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-1.0.89.tgz",
+ "integrity": "sha512-bSC8YwVbMBJYm6BMtuhuYmZi6zMh13e1t8Kaxp7K5EDLcSoTWsWPkuWX4wBvewlkLfw+HgB1IdgnXpa6+jS+ag==",
"requires": {
"ascii-table": "0.0.9",
"fast-equals": "^4.0.3",
diff --git a/package.json b/package.json
index cd93f718679e..d013caa1c402 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.",
@@ -133,7 +133,7 @@
"react-native-linear-gradient": "^2.8.1",
"react-native-localize": "^2.2.6",
"react-native-modal": "^13.0.0",
- "react-native-onyx": "1.0.87",
+ "react-native-onyx": "1.0.89",
"react-native-pager-view": "^6.2.0",
"react-native-pdf": "^6.7.1",
"react-native-performance": "^5.1.0",
diff --git a/src/CONST.ts b/src/CONST.ts
index 8aee1b9f1af4..dbe47c6ed1a7 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:',
@@ -1226,6 +1232,7 @@ const CONST = {
EMOJI_NAME: /:[\w+-]+:/g,
EMOJI_SUGGESTIONS: /:[a-zA-Z0-9_+-]{1,40}$/,
AFTER_FIRST_LINE_BREAK: /\n.*/g,
+ LINE_BREAK: /\n/g,
CODE_2FA: /^\d{6}$/,
ATTACHMENT_ID: /chat-attachments\/(\d+)/,
HAS_COLON_ONLY_AT_THE_BEGINNING: /^:[^:]+$/,
@@ -1363,6 +1370,7 @@ const CONST = {
MERCHANT: 'merchant',
CATEGORY: 'category',
RECEIPT: 'receipt',
+ DISTANCE: 'distance',
TAG: 'tag',
},
FOOTER: {
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 6649a33fe15e..d2b3031220f1 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -242,6 +242,7 @@ const ONYXKEYS = {
POLICY_RECENTLY_USED_TAGS: 'policyRecentlyUsedTags_',
WORKSPACE_INVITE_MEMBERS_DRAFT: 'workspaceInviteMembersDraft_',
REPORT: 'report_',
+ REPORT_METADATA: 'reportMetadata_',
REPORT_ACTIONS: 'reportActions_',
REPORT_ACTIONS_DRAFTS: 'reportActionsDrafts_',
REPORT_ACTIONS_REACTIONS: 'reportActionsReactions_',
@@ -380,6 +381,7 @@ type OnyxValues = {
[ONYXKEYS.COLLECTION.DEPRECATED_POLICY_MEMBER_LIST]: OnyxTypes.PolicyMember;
[ONYXKEYS.COLLECTION.WORKSPACE_INVITE_MEMBERS_DRAFT]: Record;
[ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report;
+ [ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata;
[ONYXKEYS.COLLECTION.REPORT_ACTIONS]: OnyxTypes.ReportAction;
[ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS]: string;
[ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS]: OnyxTypes.ReportActionReactions;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 78d5f4d54888..00f3a4012664 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -5,19 +5,18 @@ import CONST from './CONST';
* This is a file containing constants for all of the routes we want to be able to go to
*/
-// prettier-ignore
export default {
HOME: '',
/** This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated */
CONCIERGE: 'concierge',
FLAG_COMMENT: {
route: 'flag/:reportID/:reportActionID',
- getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`
+ getRoute: (reportID: string, reportActionID: string) => `flag/${reportID}/${reportActionID}`,
},
SEARCH: 'search',
DETAILS: {
route: 'details',
- getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`
+ getRoute: (login: string) => `details?login=${encodeURIComponent(login)}`,
},
PROFILE: {
route: 'a/:accountID',
@@ -31,7 +30,7 @@ export default {
VALIDATE_LOGIN: 'v/:accountID/:validateCode',
GET_ASSISTANCE: {
route: 'get-assistance/:taskID',
- getRoute: (taskID: string) => `get-assistance/${taskID}`
+ getRoute: (taskID: string) => `get-assistance/${taskID}`,
},
UNLINK_LOGIN: 'u/:accountID/:validateCode',
APPLE_SIGN_IN: 'sign-in-with-apple',
@@ -102,11 +101,11 @@ export default {
REPORT: 'r',
REPORT_WITH_ID: {
route: 'r/:reportID?/:reportActionID?',
- getRoute: (reportID: string) => `r/${reportID}`
+ getRoute: (reportID: string) => `r/${reportID}`,
},
EDIT_REQUEST: {
route: 'r/:threadReportID/edit/:field',
- getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`
+ getRoute: (threadReportID: string, field: ValueOf) => `r/${threadReportID}/edit/${field}`,
},
EDIT_CURRENCY_REQUEST: {
route: 'r/:threadReportID/edit/currency',
@@ -114,89 +113,89 @@ export default {
},
REPORT_WITH_ID_DETAILS_SHARE_CODE: {
route: 'r/:reportID/details/shareCode',
- getRoute: (reportID: string) => `r/${reportID}/details/shareCode`
+ getRoute: (reportID: string) => `r/${reportID}/details/shareCode`,
},
REPORT_ATTACHMENTS: {
route: 'r/:reportID/attachment',
- getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`
+ getRoute: (reportID: string, source: string) => `r/${reportID}/attachment?source=${encodeURI(source)}`,
},
REPORT_PARTICIPANTS: {
route: 'r/:reportID/participants',
- getRoute: (reportID: string) => `r/${reportID}/participants`
+ getRoute: (reportID: string) => `r/${reportID}/participants`,
},
REPORT_WITH_ID_DETAILS: {
route: 'r/:reportID/details',
- getRoute: (reportID: string) => `r/${reportID}/details`
+ getRoute: (reportID: string) => `r/${reportID}/details`,
},
REPORT_SETTINGS: {
route: 'r/:reportID/settings',
- getRoute: (reportID: string) => `r/${reportID}/settings`
+ getRoute: (reportID: string) => `r/${reportID}/settings`,
},
REPORT_SETTINGS_ROOM_NAME: {
route: 'r/:reportID/settings/room-name',
- getRoute: (reportID: string) => `r/${reportID}/settings/room-name`
+ getRoute: (reportID: string) => `r/${reportID}/settings/room-name`,
},
REPORT_SETTINGS_NOTIFICATION_PREFERENCES: {
route: 'r/:reportID/settings/notification-preferences',
- getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`
+ getRoute: (reportID: string) => `r/${reportID}/settings/notification-preferences`,
},
REPORT_SETTINGS_WRITE_CAPABILITY: {
route: 'r/:reportID/settings/who-can-post',
- getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`
+ getRoute: (reportID: string) => `r/${reportID}/settings/who-can-post`,
},
REPORT_WELCOME_MESSAGE: {
route: 'r/:reportID/welcomeMessage',
- getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`
+ getRoute: (reportID: string) => `r/${reportID}/welcomeMessage`,
},
SPLIT_BILL_DETAILS: {
route: 'r/:reportID/split/:reportActionID',
- getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`
+ getRoute: (reportID: string, reportActionID: string) => `r/${reportID}/split/${reportActionID}`,
},
TASK_TITLE: {
route: 'r/:reportID/title',
- getRoute: (reportID: string) => `r/${reportID}/title`
+ getRoute: (reportID: string) => `r/${reportID}/title`,
},
TASK_DESCRIPTION: {
route: 'r/:reportID/description',
- getRoute: (reportID: string) => `r/${reportID}/description`
+ getRoute: (reportID: string) => `r/${reportID}/description`,
},
TASK_ASSIGNEE: {
route: 'r/:reportID/assignee',
- getRoute: (reportID: string) => `r/${reportID}/assignee`
+ getRoute: (reportID: string) => `r/${reportID}/assignee`,
},
PRIVATE_NOTES_VIEW: {
route: 'r/:reportID/notes/:accountID',
- getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`
+ getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}`,
},
PRIVATE_NOTES_LIST: {
route: 'r/:reportID/notes',
- getRoute: (reportID: string) => `r/${reportID}/notes`
+ getRoute: (reportID: string) => `r/${reportID}/notes`,
},
PRIVATE_NOTES_EDIT: {
route: 'r/:reportID/notes/:accountID/edit',
- getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`
+ getRoute: (reportID: string, accountID: string | number) => `r/${reportID}/notes/${accountID}/edit`,
},
// To see the available iouType, please refer to CONST.IOU.MONEY_REQUEST_TYPE
MONEY_REQUEST: {
route: ':iouType/new/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}`,
},
MONEY_REQUEST_AMOUNT: {
route: ':iouType/new/amount/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/amount/${reportID}`,
},
MONEY_REQUEST_PARTICIPANTS: {
route: ':iouType/new/participants/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/participants/${reportID}`,
},
MONEY_REQUEST_CONFIRMATION: {
route: ':iouType/new/confirmation/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/confirmation/${reportID}`,
},
MONEY_REQUEST_DATE: {
route: ':iouType/new/date/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/date/${reportID}`,
},
MONEY_REQUEST_CURRENCY: {
route: ':iouType/new/currency/:reportID?',
@@ -204,35 +203,39 @@ export default {
},
MONEY_REQUEST_DESCRIPTION: {
route: ':iouType/new/description/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/description/${reportID}`,
},
MONEY_REQUEST_CATEGORY: {
route: ':iouType/new/category/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/category/${reportID}`,
},
MONEY_REQUEST_TAG: {
route: ':iouType/new/tag/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/tag/${reportID}`,
},
MONEY_REQUEST_MERCHANT: {
route: ':iouType/new/merchant/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/merchant/${reportID}`,
},
MONEY_REQUEST_WAYPOINT: {
route: ':iouType/new/waypoint/:waypointIndex',
- getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`
+ getRoute: (iouType: string, waypointIndex: number) => `${iouType}/new/waypoint/${waypointIndex}`,
},
MONEY_REQUEST_RECEIPT: {
route: ':iouType/new/receipt/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/receipt/${reportID}`,
},
- MONEY_REQUEST_ADDRESS: {
+ MONEY_REQUEST_DISTANCE: {
route: ':iouType/new/address/:reportID?',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/address/${reportID}`,
+ },
+ MONEY_REQUEST_EDIT_WAYPOINT: {
+ route: 'r/:threadReportID/edit/distance/:transactionID/waypoint/:waypointIndex',
+ getRoute: (threadReportID: number, transactionID: string, waypointIndex: number) => `r/${threadReportID}/edit/distance/${transactionID}/waypoint/${waypointIndex}`,
},
MONEY_REQUEST_DISTANCE_TAB: {
route: ':iouType/new/:reportID?/distance',
- getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`
+ getRoute: (iouType: string, reportID = '') => `${iouType}/new/${reportID}/distance`,
},
MONEY_REQUEST_MANUAL_TAB: ':iouType/new/:reportID?/manual',
MONEY_REQUEST_SCAN_TAB: ':iouType/new/:reportID?/scan',
@@ -259,47 +262,47 @@ export default {
WORKSPACE_NEW_ROOM: 'workspace/new-room',
WORKSPACE_INITIAL: {
route: 'workspace/:policyID',
- getRoute: (policyID: string) => `workspace/${policyID}`
+ getRoute: (policyID: string) => `workspace/${policyID}`,
},
WORKSPACE_INVITE: {
route: 'workspace/:policyID/invite',
- getRoute: (policyID: string) => `workspace/${policyID}/invite`
+ getRoute: (policyID: string) => `workspace/${policyID}/invite`,
},
WORKSPACE_INVITE_MESSAGE: {
route: 'workspace/:policyID/invite-message',
- getRoute: (policyID: string) => `workspace/${policyID}/invite-message`
+ getRoute: (policyID: string) => `workspace/${policyID}/invite-message`,
},
WORKSPACE_SETTINGS: {
route: 'workspace/:policyID/settings',
- getRoute: (policyID: string) => `workspace/${policyID}/settings`
+ getRoute: (policyID: string) => `workspace/${policyID}/settings`,
},
WORKSPACE_CARD: {
route: 'workspace/:policyID/card',
- getRoute: (policyID: string) => `workspace/${policyID}/card`
+ getRoute: (policyID: string) => `workspace/${policyID}/card`,
},
WORKSPACE_REIMBURSE: {
route: 'workspace/:policyID/reimburse',
- getRoute: (policyID: string) => `workspace/${policyID}/reimburse`
+ getRoute: (policyID: string) => `workspace/${policyID}/reimburse`,
},
WORKSPACE_RATE_AND_UNIT: {
route: 'workspace/:policyID/rateandunit',
- getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`
+ getRoute: (policyID: string) => `workspace/${policyID}/rateandunit`,
},
WORKSPACE_BILLS: {
route: 'workspace/:policyID/bills',
- getRoute: (policyID: string) => `workspace/${policyID}/bills`
+ getRoute: (policyID: string) => `workspace/${policyID}/bills`,
},
WORKSPACE_INVOICES: {
route: 'workspace/:policyID/invoices',
- getRoute: (policyID: string) => `workspace/${policyID}/invoices`
+ getRoute: (policyID: string) => `workspace/${policyID}/invoices`,
},
WORKSPACE_TRAVEL: {
route: 'workspace/:policyID/travel',
- getRoute: (policyID: string) => `workspace/${policyID}/travel`
+ getRoute: (policyID: string) => `workspace/${policyID}/travel`,
},
WORKSPACE_MEMBERS: {
route: 'workspace/:policyID/members',
- getRoute: (policyID: string) => `workspace/${policyID}/members`
+ getRoute: (policyID: string) => `workspace/${policyID}/members`,
},
// These are some on-off routes that will be removed once they're no longer needed (see GH issues for details)
diff --git a/src/components/DistanceRequest.js b/src/components/DistanceRequest.js
index 5e9b73f2eb3a..30e5adfc62b0 100644
--- a/src/components/DistanceRequest.js
+++ b/src/components/DistanceRequest.js
@@ -5,39 +5,31 @@ import lodashGet from 'lodash/get';
import lodashIsNil from 'lodash/isNil';
import PropTypes from 'prop-types';
import _ from 'underscore';
-
import CONST from '../CONST';
import ROUTES from '../ROUTES';
import ONYXKEYS from '../ONYXKEYS';
-
import styles from '../styles/styles';
import variables from '../styles/variables';
-import theme from '../styles/themes/default';
-
-import transactionPropTypes from './transactionPropTypes';
-
+import LinearGradient from './LinearGradient';
+import * as MapboxToken from '../libs/actions/MapboxToken';
import useNetwork from '../hooks/useNetwork';
-import usePrevious from '../hooks/usePrevious';
import useLocalize from '../hooks/useLocalize';
-
-import * as ErrorUtils from '../libs/ErrorUtils';
import Navigation from '../libs/Navigation/Navigation';
-import * as MapboxToken from '../libs/actions/MapboxToken';
+import reportPropTypes from '../pages/reportPropTypes';
+import DotIndicatorMessage from './DotIndicatorMessage';
+import * as ErrorUtils from '../libs/ErrorUtils';
+import usePrevious from '../hooks/usePrevious';
+import theme from '../styles/themes/default';
import * as Transaction from '../libs/actions/Transaction';
import * as TransactionUtils from '../libs/TransactionUtils';
import * as IOUUtils from '../libs/IOUUtils';
-
import Button from './Button';
import DistanceMapView from './DistanceMapView';
-import LinearGradient from './LinearGradient';
import * as Expensicons from './Icon/Expensicons';
import PendingMapView from './MapView/PendingMapView';
-import DotIndicatorMessage from './DotIndicatorMessage';
import MenuItemWithTopDescription from './MenuItemWithTopDescription';
-import {iouPropTypes} from '../pages/iou/propTypes';
-import reportPropTypes from '../pages/reportPropTypes';
-import * as IOU from '../libs/actions/IOU';
import * as StyleUtils from '../styles/StyleUtils';
+import transactionPropTypes from './transactionPropTypes';
import ScreenWrapper from './ScreenWrapper';
import FullPageNotFoundView from './BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from './HeaderWithBackButton';
@@ -46,18 +38,12 @@ const MAX_WAYPOINTS = 25;
const MAX_WAYPOINTS_TO_DISPLAY = 4;
const propTypes = {
- /** Holds data related to Money Request view state, rather than the underlying Money Request data. */
- iou: iouPropTypes,
-
- /** Type of money request (i.e. IOU) */
- iouType: PropTypes.oneOf(_.values(CONST.IOU.MONEY_REQUEST_TYPE)),
+ /** The transactionID of this request */
+ transactionID: PropTypes.string,
/** The report to which the distance request is associated */
report: reportPropTypes,
- /** The optimistic transaction for this request */
- transaction: transactionPropTypes,
-
/** Data about Mapbox token for calling Mapbox API */
mapboxAccessToken: PropTypes.shape({
/** Temporary token for Mapbox API */
@@ -67,6 +53,15 @@ const propTypes = {
expiration: PropTypes.string,
}),
+ /** Are we editing an existing distance request, or creating a new one? */
+ isEditingRequest: PropTypes.bool,
+
+ /** Called on submit of this page */
+ onSubmit: PropTypes.func.isRequired,
+
+ /* Onyx Props */
+ transaction: transactionPropTypes,
+
/** React Navigation route */
route: PropTypes.shape({
/** Params from the route */
@@ -81,16 +76,16 @@ const propTypes = {
};
const defaultProps = {
- iou: {},
- iouType: '',
+ transactionID: '',
report: {},
- transaction: {},
+ isEditingRequest: false,
mapboxAccessToken: {
token: '',
},
+ transaction: {},
};
-function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken, route}) {
+function DistanceRequest({transactionID, report, transaction, mapboxAccessToken, route, isEditingRequest, onSubmit}) {
const [shouldShowGradient, setShouldShowGradient] = useState(false);
const [scrollContainerHeight, setScrollContainerHeight] = useState(0);
const [scrollContentHeight, setScrollContentHeight] = useState(0);
@@ -99,6 +94,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
const isEditing = lodashGet(route, 'path', '').includes('address');
const reportID = lodashGet(report, 'reportID', '');
+ const iouType = lodashGet(route, 'params.iouType', '');
const waypoints = useMemo(() => lodashGet(transaction, 'comment.waypoints', {}), [transaction]);
const previousWaypoints = usePrevious(waypoints);
const numberOfWaypoints = _.size(waypoints);
@@ -107,6 +103,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
const lastWaypointIndex = numberOfWaypoints - 1;
const isLoadingRoute = lodashGet(transaction, 'comment.isLoading', false);
+ const isLoading = lodashGet(transaction, 'isLoading', false);
const hasRouteError = !!lodashGet(transaction, 'errorFields.route');
const hasRoute = TransactionUtils.hasRoute(transaction);
const validatedWaypoints = TransactionUtils.getValidWaypoints(waypoints);
@@ -159,12 +156,12 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
}, []);
useEffect(() => {
- if (!iou.transactionID || !_.isEmpty(waypoints)) {
+ if (!transactionID || !_.isEmpty(waypoints)) {
return;
}
// Create the initial start and stop waypoints
- Transaction.createInitialWaypoints(iou.transactionID);
- }, [iou.transactionID, waypoints]);
+ Transaction.createInitialWaypoints(transactionID);
+ }, [transactionID, waypoints]);
const updateGradientVisibility = (event = {}) => {
// If a waypoint extends past the bottom of the visible area show the gradient, else hide it.
@@ -176,8 +173,8 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
return;
}
- Transaction.getRoute(iou.transactionID, validatedWaypoints);
- }, [shouldFetchRoute, iou.transactionID, validatedWaypoints, isOffline]);
+ Transaction.getRoute(transactionID, validatedWaypoints);
+ }, [shouldFetchRoute, transactionID, validatedWaypoints, isOffline]);
useEffect(() => {
if (numberOfWaypoints <= numberOfPreviousWaypoints) {
@@ -192,13 +189,12 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
Navigation.goBack(isEditing ? ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID) : ROUTES.HOME);
};
- const navigateToNextPage = () => {
- if (isEditing) {
- Navigation.goBack(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID));
- return;
- }
-
- IOU.navigateToNextPage(iou, iouType, reportID, report);
+ /**
+ * Takes the user to the page for editing a specific waypoint
+ * @param {Number} index of the waypoint to edit
+ */
+ const navigateToWaypointEditPage = (index) => {
+ Navigation.navigate(isEditingRequest ? ROUTES.MONEY_REQUEST_EDIT_WAYPOINT.getRoute(report.reportID, transactionID, index) : ROUTES.MONEY_REQUEST_WAYPOINT.getRoute('request', index));
};
const content = (
@@ -237,7 +233,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
secondaryIcon={waypointIcon}
secondaryIconFill={theme.icon}
shouldShowRightIcon
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_WAYPOINT.getRoute('request', index))}
+ onPress={() => navigateToWaypointEditPage(index)}
key={key}
/>
);
@@ -261,10 +257,7 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
{
- const newIndex = _.size(lodashGet(transaction, 'comment.waypoints', {}));
- Navigation.navigate(ROUTES.MONEY_REQUEST_WAYPOINT.getRoute('request', newIndex));
- }}
+ onPress={() => navigateToWaypointEditPage(_.size(lodashGet(transaction, 'comment.waypoints', {})))}
text={translate('distance.addStop')}
isDisabled={numberOfWaypoints === MAX_WAYPOINTS}
innerStyles={[styles.ph10]}
@@ -296,10 +289,10 @@ function DistanceRequest({iou, iouType, report, transaction, mapboxAccessToken,
onSubmit(waypoints)}
+ isDisabled={!isOffline && (_.size(validatedWaypoints) < 2 || hasRouteError || isLoadingRoute || isLoading)}
+ text={translate(isEditingRequest ? 'common.save' : 'common.next')}
+ isLoading={!isOffline && (isLoadingRoute || shouldFetchRoute || isLoading)}
/>
);
@@ -334,7 +327,7 @@ DistanceRequest.propTypes = propTypes;
DistanceRequest.defaultProps = defaultProps;
export default withOnyx({
transaction: {
- key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.iou.transactionID}`,
+ key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
},
mapboxAccessToken: {
key: ONYXKEYS.MAPBOX_ACCESS_TOKEN,
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.js b/src/components/EmojiPicker/EmojiPickerMenu/index.js
index 27fd199a3895..444f0c58b914 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.js
@@ -21,6 +21,7 @@ import * as EmojiUtils from '../../../libs/EmojiUtils';
import CategoryShortcutBar from '../CategoryShortcutBar';
import TextInput from '../../TextInput';
import isEnterWhileComposition from '../../../libs/KeyboardShortcut/isEnterWhileComposition';
+import canFocusInputOnScreenFocus from '../../../libs/canFocusInputOnScreenFocus';
const propTypes = {
/** Function to add the selected emoji to the main compose text input */
@@ -58,6 +59,10 @@ class EmojiPickerMenu extends Component {
// Ref for emoji FlatList
this.emojiList = undefined;
+ // We want consistent auto focus behavior on input between native and mWeb so we have some auto focus management code that will
+ // prevent auto focus when open picker for mobile device
+ this.shouldFocusInputOnScreenFocus = canFocusInputOnScreenFocus();
+
this.filterEmojis = _.debounce(this.filterEmojis.bind(this), 300);
this.highlightAdjacentEmoji = this.highlightAdjacentEmoji.bind(this);
this.setupEventHandlers = this.setupEventHandlers.bind(this);
@@ -96,7 +101,7 @@ class EmojiPickerMenu extends Component {
// get a ref to the inner textInput element e.g. if we do
// this.textInput = el} /> this will not
// return a ref to the component, but rather the HTML element by default
- if (this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
+ if (this.shouldFocusInputOnScreenFocus && this.props.forwardedRef && _.isFunction(this.props.forwardedRef)) {
this.props.forwardedRef(this.searchInput);
}
this.setupEventHandlers();
@@ -504,6 +509,7 @@ class EmojiPickerMenu extends Component {
onChangeText={this.filterEmojis}
defaultValue=""
ref={(el) => (this.searchInput = el)}
+ autoFocus={this.shouldFocusInputOnScreenFocus}
selectTextOnFocus={this.state.selectTextOnFocus}
onSelectionChange={this.onSelectionChange}
onFocus={() => this.setState({isFocused: true, highlightedIndex: -1, isUsingKeyboardMovement: false})}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
index 5cd956dae56b..bfdaf1c13d1b 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.js
@@ -25,9 +25,6 @@ const propTypes = {
/** Function to add the selected emoji to the main compose text input */
onEmojiSelected: PropTypes.func.isRequired,
- /** The ref to the search input (may be null on small screen widths) */
- forwardedRef: PropTypes.func,
-
/** Stores user's preferred skin tone */
preferredSkinTone: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
@@ -40,12 +37,11 @@ const propTypes = {
};
const defaultProps = {
- forwardedRef: () => {},
preferredSkinTone: CONST.EMOJI_DEFAULT_SKIN_TONE,
frequentlyUsedEmojis: [],
};
-function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, translate, frequentlyUsedEmojis, forwardedRef}) {
+function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, translate, frequentlyUsedEmojis}) {
const emojiList = useAnimatedRef();
// eslint-disable-next-line react-hooks/exhaustive-deps
const allEmojis = useMemo(() => EmojiUtils.mergeEmojisWithFrequentlyUsedEmojis(emojis), [frequentlyUsedEmojis]);
@@ -172,7 +168,6 @@ function EmojiPickerMenu({preferredLocale, onEmojiSelected, preferredSkinTone, t
accessibilityLabel={translate('common.search')}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
onChangeText={filterEmojis}
- ref={forwardedRef}
/>
{!isFiltered && (
diff --git a/src/components/ExceededCommentLength.js b/src/components/ExceededCommentLength.js
index 7c9ec4d2db25..9c785cec0395 100644
--- a/src/components/ExceededCommentLength.js
+++ b/src/components/ExceededCommentLength.js
@@ -63,5 +63,6 @@ ExceededCommentLength.displayName = 'ExceededCommentLength';
export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ initialValue: '',
},
})(ExceededCommentLength);
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/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index fddcede3a4b0..3cfd7c4c4138 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -85,8 +85,8 @@ function OptionRowLHN(props) {
const displayNameStyle = StyleUtils.combineStyles([styles.optionDisplayName, styles.optionDisplayNameCompact, styles.pre, ...textUnreadStyle], props.style);
const alternateTextStyle = StyleUtils.combineStyles(
props.viewMode === CONST.OPTION_MODE.COMPACT
- ? [textStyle, styles.optionAlternateText, styles.noWrap, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2]
- : [textStyle, styles.optionAlternateText, styles.noWrap, styles.textLabelSupporting],
+ ? [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting, styles.optionAlternateTextCompact, styles.ml2]
+ : [textStyle, styles.optionAlternateText, styles.pre, styles.textLabelSupporting],
props.style,
);
const contentContainerStyles =
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/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 0d554ff0eca4..695d935d7183 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -508,7 +508,7 @@ function MoneyRequestConfirmationList(props) {
description={translate('common.distance')}
style={[styles.moneyRequestMenuItem, styles.mb2]}
titleStyle={styles.flex1}
- onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_ADDRESS.getRoute(props.iouType, props.reportID))}
+ onPress={() => Navigation.navigate(ROUTES.MONEY_REQUEST_DISTANCE.getRoute(props.iouType, props.reportID))}
disabled={didConfirm || props.isReadOnly || !isTypeRequest}
/>
) : (
diff --git a/src/components/Reactions/ReportActionItemEmojiReactions.js b/src/components/Reactions/ReportActionItemEmojiReactions.js
index c1e1764ed9f1..e72c9d9381fa 100644
--- a/src/components/Reactions/ReportActionItemEmojiReactions.js
+++ b/src/components/Reactions/ReportActionItemEmojiReactions.js
@@ -13,7 +13,7 @@ import EmojiReactionsPropTypes from './EmojiReactionsPropTypes';
import Tooltip from '../Tooltip';
import ReactionTooltipContent from './ReactionTooltipContent';
import * as EmojiUtils from '../../libs/EmojiUtils';
-import ReportScreenContext from '../../pages/home/ReportScreenContext';
+import {ReactionListContext} from '../../pages/home/ReportScreenContext';
const propTypes = {
emojiReactions: EmojiReactionsPropTypes,
@@ -41,8 +41,9 @@ const defaultProps = {
};
function ReportActionItemEmojiReactions(props) {
- const {reactionListRef} = useContext(ReportScreenContext);
+ const reactionListRef = useContext(ReactionListContext);
const popoverReactionListAnchors = useRef({});
+
let totalReactionCount = 0;
// Each emoji is sorted by the oldest timestamp of user reactions so that they will always appear in the same order for everyone
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index ec91fb292257..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,23 +192,35 @@ 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}
- />
-
-
- Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
- brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
- subtitle={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''}
- subtitleTextStyle={styles.textLabelError}
+ error={hasErrors && transactionDate === '' ? translate('common.error.enterDate') : ''}
/>
+ {isDistanceRequest ? (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.DISTANCE))}
+ />
+
+ ) : (
+
+ Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.MERCHANT))}
+ brickRoadIndicator={hasErrors && isEmptyMerchant ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
+ subtitle={hasErrors && isEmptyMerchant ? translate('common.error.enterMerchant') : ''}
+ subtitleTextStyle={styles.textLabelError}
+ />
+
+ )}
{shouldShowCategory && (
`${ONYXKEYS.COLLECTION.REPORT}${taskReportID}`,
+ initialValue: {},
},
personalDetailsList: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ initialValue: {},
},
}),
)(TaskPreview);
diff --git a/src/components/ReportActionsSkeletonView/index.js b/src/components/ReportActionsSkeletonView/index.js
index 2fe7e590afef..6bdc993c2055 100644
--- a/src/components/ReportActionsSkeletonView/index.js
+++ b/src/components/ReportActionsSkeletonView/index.js
@@ -1,13 +1,10 @@
import React from 'react';
import PropTypes from 'prop-types';
-import {View} from 'react-native';
+import {View, Dimensions} from 'react-native';
import SkeletonViewLines from './SkeletonViewLines';
import CONST from '../../CONST';
const propTypes = {
- /** Height of the container component */
- containerHeight: PropTypes.number.isRequired,
-
/** Whether to animate the skeleton view */
shouldAnimate: PropTypes.bool,
};
@@ -18,7 +15,7 @@ const defaultProps = {
function ReportActionsSkeletonView(props) {
// Determines the number of content items based on container height
- const possibleVisibleContentItems = Math.ceil(props.containerHeight / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT);
+ const possibleVisibleContentItems = Math.ceil(Dimensions.get('window').height / CONST.CHAT_SKELETON_VIEW.AVERAGE_ROW_HEIGHT);
const skeletonViewLines = [];
for (let index = 0; index < possibleVisibleContentItems; index++) {
const iconIndex = (index + 1) % 4;
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/components/withLocalize.js b/src/components/withLocalize.js
index 6c588698ce9d..5ce1b0bc6d74 100755
--- a/src/components/withLocalize.js
+++ b/src/components/withLocalize.js
@@ -28,6 +28,9 @@ const withLocalizePropTypes = {
/** Formats a datetime to local date and time string */
datetimeToCalendarTime: PropTypes.func.isRequired,
+ /** Updates date-fns internal locale */
+ updateLocale: PropTypes.func.isRequired,
+
/** Returns a locally converted phone number for numbers from the same region
* and an internationally converted phone number with the country code for numbers from other regions */
formatPhoneNumber: PropTypes.func.isRequired,
@@ -79,6 +82,7 @@ class LocaleContextProvider extends React.Component {
numberFormat: this.numberFormat.bind(this),
datetimeToRelative: this.datetimeToRelative.bind(this),
datetimeToCalendarTime: this.datetimeToCalendarTime.bind(this),
+ updateLocale: this.updateLocale.bind(this),
formatPhoneNumber: this.formatPhoneNumber.bind(this),
fromLocaleDigit: this.fromLocaleDigit.bind(this),
toLocaleDigit: this.toLocaleDigit.bind(this),
@@ -122,6 +126,13 @@ class LocaleContextProvider extends React.Component {
return DateUtils.datetimeToCalendarTime(this.props.preferredLocale, datetime, includeTimezone, lodashGet(this.props, 'currentUserPersonalDetails.timezone.selected'), isLowercase);
}
+ /**
+ * Updates date-fns internal locale to the user preferredLocale
+ */
+ updateLocale() {
+ DateUtils.setLocale(this.props.preferredLocale);
+ }
+
/**
* @param {String} phoneNumber
* @returns {String}
diff --git a/src/hooks/useReportScrollManager/index.js b/src/hooks/useReportScrollManager/index.js
index 0cf09146553c..9a3303504b92 100644
--- a/src/hooks/useReportScrollManager/index.js
+++ b/src/hooks/useReportScrollManager/index.js
@@ -1,8 +1,8 @@
import {useContext, useCallback} from 'react';
-import ReportScreenContext from '../../pages/home/ReportScreenContext';
+import {ActionListContext} from '../../pages/home/ReportScreenContext';
function useReportScrollManager() {
- const {flatListRef} = useContext(ReportScreenContext);
+ const flatListRef = useContext(ActionListContext);
/**
* Scroll to the provided index. On non-native implementations we do not want to scroll when we are scrolling because
diff --git a/src/hooks/useReportScrollManager/index.native.js b/src/hooks/useReportScrollManager/index.native.js
index 35af064cb062..d44a40222ca5 100644
--- a/src/hooks/useReportScrollManager/index.native.js
+++ b/src/hooks/useReportScrollManager/index.native.js
@@ -1,8 +1,8 @@
import {useContext, useCallback} from 'react';
-import ReportScreenContext from '../../pages/home/ReportScreenContext';
+import {ActionListContext} from '../../pages/home/ReportScreenContext';
function useReportScrollManager() {
- const {flatListRef} = useContext(ReportScreenContext);
+ const flatListRef = useContext(ActionListContext);
/**
* Scroll to the provided index.
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/DateUtils.js b/src/libs/DateUtils.js
index 5c0171067870..a9ec4a3fd35a 100644
--- a/src/libs/DateUtils.js
+++ b/src/libs/DateUtils.js
@@ -386,6 +386,7 @@ const DateUtils = {
subtractMillisecondsFromDateTime,
getDateStringFromISOTimestamp,
getStatusUntilDate,
+ setLocale,
isToday,
isTomorrow,
isYesterday,
diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js
index dee5b7d4b489..428550a43aa8 100644
--- a/src/libs/Navigation/AppNavigator/AuthScreens.js
+++ b/src/libs/Navigation/AppNavigator/AuthScreens.js
@@ -71,6 +71,7 @@ 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) {
+ timezone.selected = currentTimezone;
PersonalDetails.updateAutomaticTimezone({
automatic: true,
selected: currentTimezone,
diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
index fc284f566c80..12424bf1e1f2 100644
--- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
+++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js
@@ -31,7 +31,6 @@ function createModalStackNavigator(screens) {
);
}
-// We use getComponent/require syntax so that file used by screens are not loaded until we need them.
const MoneyRequestModalStackNavigator = createModalStackNavigator({
Money_Request: () => require('../../../pages/iou/MoneyRequestSelectorPage').default,
Money_Request_Amount: () => require('../../../pages/iou/steps/NewRequestAmountPage').default,
@@ -46,9 +45,10 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator({
IOU_Send_Add_Bank_Account: () => require('../../../pages/AddPersonalBankAccountPage').default,
IOU_Send_Add_Debit_Card: () => require('../../../pages/settings/Wallet/AddDebitCardPage').default,
IOU_Send_Enable_Payments: () => require('../../../pages/EnablePayments/EnablePaymentsPage').default,
- Money_Request_Waypoint: () => require('../../../pages/iou/WaypointEditorPage').default,
+ Money_Request_Waypoint: () => require('../../../pages/iou/NewDistanceRequestWaypointEditorPage').default,
+ Money_Request_Edit_Waypoint: () => require('../../../pages/iou/MoneyRequestEditWaypointPage').default,
+ Money_Request_Distance: () => require('../../../pages/iou/NewDistanceRequestPage').default,
Money_Request_Receipt: () => require('../../../pages/EditRequestReceiptPage').default,
- Money_Request_Address: () => require('../../../pages/iou/DistanceRequestPage').default,
});
const SplitDetailsModalStackNavigator = createModalStackNavigator({
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js
new file mode 100644
index 000000000000..24f855645870
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/ReportScreenIDSetter.js
@@ -0,0 +1,121 @@
+import {useEffect} from 'react';
+import PropTypes from 'prop-types';
+import lodashGet from 'lodash/get';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../../../ONYXKEYS';
+import * as ReportUtils from '../../ReportUtils';
+import reportPropTypes from '../../../pages/reportPropTypes';
+import {withNavigationPropTypes} from '../../../components/withNavigation';
+import * as App from '../../actions/App';
+import usePermissions from '../../../hooks/usePermissions';
+import CONST from '../../../CONST';
+import Navigation from '../Navigation';
+
+const propTypes = {
+ /** Available reports that would be displayed in this navigator */
+ reports: PropTypes.objectOf(reportPropTypes),
+
+ /** The policies which the user has access to */
+ policies: PropTypes.objectOf(
+ PropTypes.shape({
+ /** The policy name */
+ name: PropTypes.string,
+
+ /** The type of the policy */
+ type: PropTypes.string,
+ }),
+ ),
+
+ isFirstTimeNewExpensifyUser: PropTypes.bool,
+
+ /** Navigation route context info provided by react navigation */
+ route: PropTypes.shape({
+ /** Route specific parameters used on this screen */
+ params: PropTypes.shape({
+ /** If the admin room should be opened */
+ openOnAdminRoom: PropTypes.bool,
+
+ /** The ID of the report this screen should display */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ ...withNavigationPropTypes,
+};
+
+const defaultProps = {
+ reports: {},
+ policies: {},
+ isFirstTimeNewExpensifyUser: false,
+};
+
+/**
+ * Get the most recently accessed report for the user
+ *
+ * @param {Object} reports
+ * @param {Boolean} ignoreDefaultRooms
+ * @param {Object} policies
+ * @param {Boolean} isFirstTimeNewExpensifyUser
+ * @param {Boolean} openOnAdminRoom
+ * @returns {Number}
+ */
+const getLastAccessedReportID = (reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom) => {
+ // If deeplink url is of an attachment, we should show the report that the attachment comes from.
+ const currentRoute = Navigation.getActiveRoute();
+ const matches = CONST.REGEX.ATTACHMENT_ROUTE.exec(currentRoute);
+ const reportID = lodashGet(matches, 1, null);
+ if (reportID) {
+ return reportID;
+ }
+
+ const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom);
+
+ return lodashGet(lastReport, 'reportID');
+};
+
+// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params
+function ReportScreenIDSetter({route, reports, policies, isFirstTimeNewExpensifyUser, navigation}) {
+ const {canUseDefaultRooms} = usePermissions();
+
+ useEffect(() => {
+ // Don't update if there is a reportID in the params already
+ if (lodashGet(route, 'params.reportID', null)) {
+ App.confirmReadyToOpenApp();
+ return;
+ }
+
+ // If there is no reportID in route, try to find last accessed and use it for setParams
+ const reportID = getLastAccessedReportID(reports, !canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser, lodashGet(route, 'params.openOnAdminRoom', false));
+
+ // It's possible that reports aren't fully loaded yet
+ // in that case the reportID is undefined
+ if (reportID) {
+ navigation.setParams({reportID: String(reportID)});
+ } else {
+ App.confirmReadyToOpenApp();
+ }
+ }, [route, navigation, reports, canUseDefaultRooms, policies, isFirstTimeNewExpensifyUser]);
+
+ // The ReportScreen without the reportID set will display a skeleton
+ // until the reportID is loaded and set in the route param
+ return null;
+}
+
+ReportScreenIDSetter.propTypes = propTypes;
+ReportScreenIDSetter.defaultProps = defaultProps;
+ReportScreenIDSetter.displayName = 'ReportScreenIDSetter';
+
+export default withOnyx({
+ reports: {
+ key: ONYXKEYS.COLLECTION.REPORT,
+ allowStaleData: true,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ allowStaleData: true,
+ },
+ isFirstTimeNewExpensifyUser: {
+ key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER,
+ initialValue: false,
+ },
+})(ReportScreenIDSetter);
diff --git a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js b/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js
index f1743e1a2269..767bd9793ac2 100644
--- a/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js
+++ b/src/libs/Navigation/AppNavigator/ReportScreenWrapper.js
@@ -1,36 +1,10 @@
-import React, {useEffect} from 'react';
import PropTypes from 'prop-types';
-import lodashGet from 'lodash/get';
-import {withOnyx} from 'react-native-onyx';
-
-import ONYXKEYS from '../../../ONYXKEYS';
-
-import ReportScreen from '../../../pages/home/ReportScreen';
-import * as ReportUtils from '../../ReportUtils';
-import reportPropTypes from '../../../pages/reportPropTypes';
+import React from 'react';
import {withNavigationPropTypes} from '../../../components/withNavigation';
-import * as App from '../../actions/App';
-import usePermissions from '../../../hooks/usePermissions';
-import CONST from '../../../CONST';
-import Navigation from '../Navigation';
+import ReportScreen from '../../../pages/home/ReportScreen';
+import ReportScreenIDSetter from './ReportScreenIDSetter';
const propTypes = {
- /** Available reports that would be displayed in this navigator */
- reports: PropTypes.objectOf(reportPropTypes),
-
- /** The policies which the user has access to */
- policies: PropTypes.objectOf(
- PropTypes.shape({
- /** The policy name */
- name: PropTypes.string,
-
- /** The type of the policy */
- type: PropTypes.string,
- }),
- ),
-
- isFirstTimeNewExpensifyUser: PropTypes.bool,
-
/** Navigation route context info provided by react navigation */
route: PropTypes.shape({
/** Route specific parameters used on this screen */
@@ -46,82 +20,24 @@ const propTypes = {
...withNavigationPropTypes,
};
-const defaultProps = {
- reports: {},
- policies: {},
- isFirstTimeNewExpensifyUser: false,
-};
-
-/**
- * Get the most recently accessed report for the user
- *
- * @param {Object} reports
- * @param {Boolean} [ignoreDefaultRooms]
- * @param {Object} policies
- * @param {Boolean} isFirstTimeNewExpensifyUser
- * @param {Boolean} openOnAdminRoom
- * @returns {Number}
- */
-const getLastAccessedReportID = (reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom) => {
- // If deeplink url is of an attachment, we should show the report that the attachment comes from.
- const currentRoute = Navigation.getActiveRoute();
- const matches = CONST.REGEX.ATTACHMENT_ROUTE.exec(currentRoute);
- const reportID = lodashGet(matches, 1, null);
- if (reportID) {
- return reportID;
- }
-
- const lastReport = ReportUtils.findLastAccessedReport(reports, ignoreDefaultRooms, policies, isFirstTimeNewExpensifyUser, openOnAdminRoom);
-
- return lodashGet(lastReport, 'reportID');
-};
+const defaultProps = {};
-// This wrapper is reponsible for opening the last accessed report if there is no reportID specified in the route params
function ReportScreenWrapper(props) {
- const {canUseDefaultRooms} = usePermissions();
-
- useEffect(() => {
- // Don't update if there is a reportID in the params already
- if (lodashGet(props.route, 'params.reportID', null)) {
- App.confirmReadyToOpenApp();
- return;
- }
-
- // If there is no reportID in route, try to find last accessed and use it for setParams
- const reportID = getLastAccessedReportID(
- props.reports,
- !canUseDefaultRooms,
- props.policies,
- props.isFirstTimeNewExpensifyUser,
- lodashGet(props.route, 'params.openOnAdminRoom', false),
- );
-
- // It's possible that props.reports aren't fully loaded yet
- // in that case the reportID is undefined
- if (reportID) {
- props.navigation.setParams({reportID: String(reportID)});
- } else {
- App.confirmReadyToOpenApp();
- }
- }, [props.route, props.navigation, props.reports, canUseDefaultRooms, props.policies, props.isFirstTimeNewExpensifyUser]);
-
// The ReportScreen without the reportID set will display a skeleton
// until the reportID is loaded and set in the route param
- return ;
+ return (
+ <>
+
+
+ >
+ );
}
ReportScreenWrapper.propTypes = propTypes;
ReportScreenWrapper.defaultProps = defaultProps;
ReportScreenWrapper.displayName = 'ReportScreenWrapper';
-export default withOnyx({
- reports: {
- key: ONYXKEYS.COLLECTION.REPORT,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- isFirstTimeNewExpensifyUser: {
- key: ONYXKEYS.NVP_IS_FIRST_TIME_NEW_EXPENSIFY_USER,
- },
-})(ReportScreenWrapper);
+export default ReportScreenWrapper;
diff --git a/src/libs/Navigation/NavigationRoot.js b/src/libs/Navigation/NavigationRoot.js
index 4d50a1cd6a68..0dda39517d3c 100644
--- a/src/libs/Navigation/NavigationRoot.js
+++ b/src/libs/Navigation/NavigationRoot.js
@@ -122,7 +122,10 @@ function NavigationRoot(props) {
if (!state) {
return;
}
- updateCurrentReportID(state);
+ // Performance optimization to avoid context consumers to delay first render
+ setTimeout(() => {
+ updateCurrentReportID(state);
+ }, 0);
parseAndLogRoute(state);
animateStatusBarBackgroundColor();
};
diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js
index 116e1b9d55a5..533dbf51633a 100644
--- a/src/libs/Navigation/linkingConfig.js
+++ b/src/libs/Navigation/linkingConfig.js
@@ -329,8 +329,9 @@ export default {
Money_Request_Tag: ROUTES.MONEY_REQUEST_TAG.route,
Money_Request_Merchant: ROUTES.MONEY_REQUEST_MERCHANT.route,
Money_Request_Waypoint: ROUTES.MONEY_REQUEST_WAYPOINT.route,
+ Money_Request_Edit_Waypoint: ROUTES.MONEY_REQUEST_EDIT_WAYPOINT.route,
Money_Request_Receipt: ROUTES.MONEY_REQUEST_RECEIPT.route,
- Money_Request_Address: ROUTES.MONEY_REQUEST_ADDRESS.route,
+ Money_Request_Distance: ROUTES.MONEY_REQUEST_DISTANCE.route,
IOU_Send_Enable_Payments: ROUTES.IOU_SEND_ENABLE_PAYMENTS,
IOU_Send_Add_Bank_Account: ROUTES.IOU_SEND_ADD_BANK_ACCOUNT,
IOU_Send_Add_Debit_Card: ROUTES.IOU_SEND_ADD_DEBIT_CARD,
diff --git a/src/libs/OptionsListUtils.js b/src/libs/OptionsListUtils.js
index 7c36fa095029..e0f334ca36af 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -395,7 +395,8 @@ function getLastMessageTextForReport(report) {
const iouReport = ReportUtils.getReport(ReportActionUtils.getIOUReportIDFromReportActionPreview(lastReportAction));
lastMessageTextFromReport = ReportUtils.getReportPreviewMessage(iouReport, lastReportAction);
} else if (ReportActionUtils.isModifiedExpenseAction(lastReportAction)) {
- lastMessageTextFromReport = ReportUtils.getModifiedExpenseMessage(lastReportAction);
+ const properSchemaForModifiedExpenseMessage = ReportUtils.getModifiedExpenseMessage(lastReportAction);
+ lastMessageTextFromReport = ReportUtils.formatReportLastMessageText(properSchemaForModifiedExpenseMessage, true);
} else {
lastMessageTextFromReport = report ? report.lastMessageText || '' : '';
@@ -814,6 +815,24 @@ function getTagListSections(tags, recentlyUsedTags, selectedOptions, searchInput
const numberOfTags = _.size(enabledTags);
let indexOffset = 0;
+ // If all tags are disabled but there's a previously selected tag, show only the selected tag
+ if (numberOfTags === 0 && selectedOptions.length > 0) {
+ const selectedTagOptions = _.map(selectedOptions, (option) => ({
+ name: option.name,
+ // Should be marked as enabled to be able to be de-selected
+ enabled: true,
+ }));
+ tagSections.push({
+ // "Selected" section
+ title: '',
+ shouldShow: false,
+ indexOffset,
+ data: getTagsOptions(selectedTagOptions),
+ });
+
+ return tagSections;
+ }
+
if (!_.isEmpty(searchInputValue)) {
const searchTags = _.filter(enabledTags, (tag) => tag.name.toLowerCase().includes(searchInputValue.toLowerCase()));
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/ReportUtils.js b/src/libs/ReportUtils.js
index 7fc151ce6f26..57f6c0da72d0 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -903,9 +903,13 @@ function canShowReportRecipientLocalTime(personalDetails, report, accountID) {
/**
* Shorten last message text to fixed length and trim spaces.
* @param {String} lastMessageText
+ * @param {Boolean} isModifiedExpenseMessage
* @returns {String}
*/
-function formatReportLastMessageText(lastMessageText) {
+function formatReportLastMessageText(lastMessageText, isModifiedExpenseMessage = false) {
+ if (isModifiedExpenseMessage) {
+ return String(lastMessageText).trim().replace(CONST.REGEX.LINE_BREAK, '').trim();
+ }
return String(lastMessageText).trim().replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
}
@@ -1341,7 +1345,8 @@ function getMoneyRequestReportName(report, policy = undefined) {
}
/**
- * Gets transaction created, amount, currency and comment
+ * Gets transaction created, amount, currency, comment, and waypoints (for distance request)
+ * into a flat object. Used for displaying transactions and sending them in API commands
*
* @param {Object} transaction
* @returns {Object}
@@ -1354,6 +1359,7 @@ function getTransactionDetails(transaction) {
currency: TransactionUtils.getCurrency(transaction),
comment: TransactionUtils.getDescription(transaction),
merchant: TransactionUtils.getMerchant(transaction),
+ waypoints: TransactionUtils.getWaypoints(transaction),
category: TransactionUtils.getCategory(transaction),
billable: TransactionUtils.getBillable(transaction),
tag: TransactionUtils.getTag(transaction),
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/TransactionUtils.js b/src/libs/TransactionUtils.js
index 38acf5fac119..aff1068546d1 100644
--- a/src/libs/TransactionUtils.js
+++ b/src/libs/TransactionUtils.js
@@ -147,6 +147,11 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep
shouldStopSmartscan = true;
}
+ if (_.has(transactionChanges, 'waypoints')) {
+ updatedTransaction.modifiedWaypoints = transactionChanges.waypoints;
+ shouldStopSmartscan = true;
+ }
+
if (_.has(transactionChanges, 'billable')) {
updatedTransaction.billable = transactionChanges.billable;
}
@@ -169,6 +174,7 @@ function getUpdatedTransaction(transaction, transactionChanges, isFromExpenseRep
...(_.has(transactionChanges, 'amount') && {amount: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'currency') && {currency: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'merchant') && {merchant: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
+ ...(_.has(transactionChanges, 'waypoints') && {waypoints: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'billable') && {billable: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'category') && {category: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
...(_.has(transactionChanges, 'tag') && {tag: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
@@ -253,6 +259,16 @@ function getMerchant(transaction) {
return lodashGet(transaction, 'modifiedMerchant', null) || lodashGet(transaction, 'merchant', '');
}
+/**
+ * Return the waypoints field from the transaction, return the modifiedWaypoints if present.
+ *
+ * @param {Object} transaction
+ * @returns {String}
+ */
+function getWaypoints(transaction) {
+ return lodashGet(transaction, 'modifiedWaypoints', null) || lodashGet(transaction, ['comment', 'waypoints']);
+}
+
/**
* Return the category from the transaction. This "category" field has no "modified" complement.
*
@@ -438,6 +454,7 @@ export {
isReceiptBeingScanned,
getValidWaypoints,
isDistanceRequest,
+ getWaypoints,
hasMissingSmartscanFields,
getWaypointIndex,
waypointHasValidAddress,
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/IOU.js b/src/libs/actions/IOU.js
index b346900b37dc..198ceb2b8172 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -650,6 +650,154 @@ function createDistanceRequest(report, participant, comment, created, transactio
Report.notifyNewAction(chatReport.reportID, userAccountID);
}
+/**
+ * Edits an existing distance request
+ *
+ * @param {String} transactionID
+ * @param {Number} transactionThreadReportID
+ * @param {Object} transactionChanges
+ * @param {String} [transactionChanges.created]
+ * @param {Number} [transactionChanges.amount]
+ * @param {Object} [transactionChanges.comment]
+ * @param {Object} [transactionChanges.waypoints]
+ *
+ */
+function updateDistanceRequest(transactionID, transactionThreadReportID, transactionChanges) {
+ const optimisticData = [];
+ const successData = [];
+ const failureData = [];
+
+ // Step 1: Set any "pending fields" (ones updated while the user was offline) to have error messages in the failureData
+ const pendingFields = _.mapObject(transactionChanges, () => CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE);
+ const clearedPendingFields = _.mapObject(transactionChanges, () => null);
+ const errorFields = _.mapObject(pendingFields, () => ({
+ [DateUtils.getMicroseconds()]: Localize.translateLocal('iou.error.genericEditFailureMessage'),
+ }));
+
+ // Step 2: Get all the collections being updated
+ const transactionThread = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThreadReportID}`];
+ const transaction = allTransactions[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];
+ const iouReport = allReports[`${ONYXKEYS.COLLECTION.REPORT}${transactionThread.parentReportID}`];
+ const isFromExpenseReport = ReportUtils.isExpenseReport(iouReport);
+ const updatedTransaction = TransactionUtils.getUpdatedTransaction(transaction, transactionChanges, isFromExpenseReport);
+ const transactionDetails = ReportUtils.getTransactionDetails(updatedTransaction);
+
+ const params = {
+ ...transactionDetails,
+ transactionID,
+ // This needs to be a JSON string since we're sending this to the MapBox API
+ waypoints: JSON.stringify(transactionDetails.waypoints),
+ };
+
+ // Step 3: Build the modified expense report actions
+ // We don't create a modified report action if we're updating the waypoints,
+ // since there isn't actually any optimistic data we can create for them and the report action is created on the server
+ // with the response from the MapBox API
+ if (!_.has(transactionChanges, 'waypoints')) {
+ const updatedReportAction = ReportUtils.buildOptimisticModifiedExpenseReportAction(transactionThread, transaction, transactionChanges, isFromExpenseReport);
+ params.reportActionID = updatedReportAction.reportActionID;
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: updatedReportAction,
+ },
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: {pendingAction: null},
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThread.reportID}`,
+ value: {
+ [updatedReportAction.reportActionID]: updatedReportAction,
+ },
+ });
+
+ // Step 4: Compute the IOU total and update the report preview message (and report header) so LHN amount owed is correct.
+ // Should only update if the transaction matches the currency of the report, else we wait for the update
+ // from the server with the currency conversion
+ let updatedMoneyRequestReport = {...iouReport};
+ if (updatedTransaction.currency === iouReport.currency && updatedTransaction.modifiedAmount) {
+ const diff = TransactionUtils.getAmount(transaction, true) - TransactionUtils.getAmount(updatedTransaction, true);
+ if (ReportUtils.isExpenseReport(iouReport)) {
+ updatedMoneyRequestReport.total += diff;
+ } else {
+ updatedMoneyRequestReport = IOUUtils.updateIOUOwnerAndTotal(iouReport, updatedReportAction.actorAccountID, diff, TransactionUtils.getCurrency(transaction), false);
+ }
+
+ updatedMoneyRequestReport.cachedTotal = CurrencyUtils.convertToDisplayString(updatedMoneyRequestReport.total, updatedTransaction.currency);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ value: updatedMoneyRequestReport,
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ value: {pendingAction: null},
+ });
+ }
+ }
+
+ // Optimistically modify the transaction
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ ...updatedTransaction,
+ pendingFields,
+ isLoading: _.has(transactionChanges, 'waypoints'),
+ errorFields: null,
+ },
+ });
+
+ // Clear out the error fields and loading states on success
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ pendingFields: clearedPendingFields,
+ isLoading: false,
+ errorFields: null,
+ },
+ });
+
+ if (_.has(transactionChanges, 'waypoints')) {
+ // Delete the backup transaction when editing waypoints when the server responds successfully and there are no errors
+ successData.push({
+ onyxMethod: Onyx.METHOD.SET,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ value: null,
+ });
+ }
+
+ // Clear out loading states, pending fields, and add the error fields
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ pendingFields: clearedPendingFields,
+ isLoading: false,
+ errorFields,
+ },
+ });
+
+ // Reset the iouReport to it's original state
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`,
+ value: iouReport,
+ });
+
+ API.write('UpdateDistanceRequest', params, {optimisticData, successData, failureData});
+}
+
/**
* Request money from another user
*
@@ -2113,12 +2261,12 @@ function createEmptyTransaction() {
*
* @param {Object} iou
* @param {String} iouType
- * @param {String} reportID
* @param {Object} report
+ * @param {String} report.reportID
* @param {String} path
*/
-function navigateToNextPage(iou, iouType, reportID, report, path = '') {
- const moneyRequestID = `${iouType}${reportID}`;
+function navigateToNextPage(iou, iouType, report, path = '') {
+ const moneyRequestID = `${iouType}${report.reportID || ''}`;
const shouldReset = iou.id !== moneyRequestID;
// If the money request ID in Onyx does not match the ID from params, we want to start a new request
@@ -2128,8 +2276,8 @@ function navigateToNextPage(iou, iouType, reportID, report, path = '') {
}
// If we're adding a receipt, that means the user came from the confirmation page and we need to navigate back to it.
- if (path.slice(1) === ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, reportID)) {
- Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID));
+ if (path.slice(1) === ROUTES.MONEY_REQUEST_RECEIPT.getRoute(iouType, report.reportID)) {
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID));
return;
}
@@ -2148,7 +2296,7 @@ function navigateToNextPage(iou, iouType, reportID, report, path = '') {
.value();
setMoneyRequestParticipants(participants);
}
- Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, reportID));
+ Navigation.navigate(ROUTES.MONEY_REQUEST_CONFIRMATION.getRoute(iouType, report.reportID));
return;
}
Navigation.navigate(ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(iouType));
@@ -2182,5 +2330,6 @@ export {
setMoneyRequestReceipt,
createEmptyTransaction,
navigateToNextPage,
+ updateDistanceRequest,
replaceReceipt,
};
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index e42ef1ac4823..fcce909c5582 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -310,7 +310,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
workspaceMembersChats.onyxFailureData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${optimisticReport.reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${optimisticReport.reportID}`,
value: {
isLoadingReportActions: false,
},
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 296abc6a9cfa..b92862c5b5e1 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -450,43 +450,63 @@ function reportActionsExist(reportID) {
* @param {Array} participantAccountIDList The list of accountIDs that are included in a new chat, not including the user creating it
*/
function openReport(reportID, participantLoginList = [], newReportObject = {}, parentReportActionID = '0', isFromDeepLink = false, participantAccountIDList = []) {
- const optimisticReportData = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: reportActionsExist(reportID)
- ? {}
- : {
- isLoadingReportActions: true,
- isLoadingMoreReportActions: false,
- reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME),
- },
- };
- const reportSuccessData = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: {
- isLoadingReportActions: false,
- pendingFields: {
- createChat: null,
+ const optimisticReportData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: reportActionsExist(reportID)
+ ? {}
+ : {
+ reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME),
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
+ isLoadingReportActions: true,
+ isLoadingMoreReportActions: false,
},
- errorFields: {
- createChat: null,
+ },
+ ];
+
+ const reportSuccessData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ pendingFields: {
+ createChat: null,
+ },
+ errorFields: {
+ createChat: null,
+ },
+ isOptimisticReport: false,
},
- isOptimisticReport: false,
},
- };
- const reportFailureData = {
- onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
- value: {
- isLoadingReportActions: false,
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
+ isLoadingReportActions: false,
+ },
},
- };
+ ];
+
+ const reportFailureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
+ value: {
+ isLoadingReportActions: false,
+ },
+ },
+ ];
const onyxData = {
- optimisticData: [optimisticReportData],
- successData: [reportSuccessData],
- failureData: [reportFailureData],
+ optimisticData: optimisticReportData,
+ successData: reportSuccessData,
+ failureData: reportFailureData,
};
const params = {
@@ -503,17 +523,17 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
// If we open an exist report, but it is not present in Onyx yet, we should change the method to set for this report
// and we need data to be available when we navigate to the chat page
if (_.isEmpty(ReportUtils.getReport(reportID))) {
- optimisticReportData.onyxMethod = Onyx.METHOD.SET;
+ onyxData.optimisticData[0].onyxMethod = Onyx.METHOD.SET;
}
// If we are creating a new report, we need to add the optimistic report data and a report action
if (!_.isEmpty(newReportObject)) {
// Change the method to set for new reports because it doesn't exist yet, is faster,
// and we need the data to be available when we navigate to the chat page
- optimisticReportData.onyxMethod = Onyx.METHOD.SET;
- optimisticReportData.value = {
+ onyxData.optimisticData[0].onyxMethod = Onyx.METHOD.SET;
+ onyxData.optimisticData[0].value = {
reportName: CONST.REPORT.DEFAULT_REPORT_NAME,
- ...optimisticReportData.value,
+ ...onyxData.optimisticData[0].value,
...newReportObject,
pendingFields: {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
@@ -696,17 +716,23 @@ function reconnect(reportID) {
{
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ value: {
+ reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME),
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingReportActions: true,
isLoadingMoreReportActions: false,
- reportName: lodashGet(allReports, [reportID, 'reportName'], CONST.REPORT.DEFAULT_REPORT_NAME),
},
},
],
successData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingReportActions: false,
},
@@ -715,7 +741,7 @@ function reconnect(reportID) {
failureData: [
{
onyxMethod: Onyx.METHOD.MERGE,
- key: `${ONYXKEYS.COLLECTION.REPORT}${reportID}`,
+ key: `${ONYXKEYS.COLLECTION.REPORT_METADATA}${reportID}`,
value: {
isLoadingReportActions: false,
},
@@ -1249,11 +1275,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/libs/actions/Transaction.ts b/src/libs/actions/Transaction.ts
index cc26dccc25b6..fe1bc1621cfa 100644
--- a/src/libs/actions/Transaction.ts
+++ b/src/libs/actions/Transaction.ts
@@ -4,6 +4,7 @@ import lodashClone from 'lodash/clone';
import ONYXKEYS from '../../ONYXKEYS';
import * as CollectionUtils from '../CollectionUtils';
import * as API from '../API';
+import CONST from '../../CONST';
import {RecentWaypoint, Transaction} from '../../types/onyx';
import {WaypointCollection} from '../../types/onyx/Transaction';
import * as TransactionUtils from '../TransactionUtils';
@@ -54,11 +55,11 @@ function addStop(transactionID: string) {
});
}
-/**
- * Saves the selected waypoint to the transaction
- */
-function saveWaypoint(transactionID: string, index: string, waypoint: RecentWaypoint | null) {
+function saveWaypoint(transactionID: string, index: string, waypoint: RecentWaypoint | null, isEditingWaypoint = false) {
Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, {
+ pendingFields: {
+ comment: isEditingWaypoint ? CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE : CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
comment: {
waypoints: {
[`waypoint${index}`]: waypoint,
diff --git a/src/libs/actions/TransactionEdit.js b/src/libs/actions/TransactionEdit.js
new file mode 100644
index 000000000000..44b489b72c43
--- /dev/null
+++ b/src/libs/actions/TransactionEdit.js
@@ -0,0 +1,38 @@
+import Onyx from 'react-native-onyx';
+import ONYXKEYS from '../../ONYXKEYS';
+
+/**
+ * Makes a backup copy of a transaction object that can be restored when the user cancels editing a transaction.
+ *
+ * @param {Object} transaction
+ */
+function createBackupTransaction(transaction) {
+ const newTransaction = {
+ ...transaction,
+ };
+ // Use set so that it will always fully overwrite any backup transaction that could have existed before
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}-backup`, newTransaction);
+}
+
+/**
+ * Removes a transaction from Onyx that was only used temporary in the edit flow
+ * @param {String} transactionID
+ */
+function removeBackupTransaction(transactionID) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`, null);
+}
+
+function restoreOriginalTransactionFromBackup(transactionID) {
+ const connectionID = Onyx.connect({
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}-backup`,
+ callback: (backupTransaction) => {
+ Onyx.disconnect(connectionID);
+
+ // Use set to completely overwrite the original transaction
+ Onyx.set(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`, backupTransaction);
+ removeBackupTransaction(transactionID);
+ },
+ });
+}
+
+export {createBackupTransaction, removeBackupTransaction, restoreOriginalTransactionFromBackup};
diff --git a/src/pages/EditRequestDistancePage.js b/src/pages/EditRequestDistancePage.js
new file mode 100644
index 000000000000..84aa4de1acb0
--- /dev/null
+++ b/src/pages/EditRequestDistancePage.js
@@ -0,0 +1,127 @@
+import React, {useEffect, useRef} from 'react';
+import lodashGet from 'lodash/get';
+import PropTypes from 'prop-types';
+import _ from 'underscore';
+import {withOnyx} from 'react-native-onyx';
+import ONYXKEYS from '../ONYXKEYS';
+import CONST from '../CONST';
+import ScreenWrapper from '../components/ScreenWrapper';
+import HeaderWithBackButton from '../components/HeaderWithBackButton';
+import Navigation from '../libs/Navigation/Navigation';
+import useLocalize from '../hooks/useLocalize';
+import DistanceRequest from '../components/DistanceRequest';
+import reportPropTypes from './reportPropTypes';
+import * as IOU from '../libs/actions/IOU';
+import transactionPropTypes from '../components/transactionPropTypes';
+import * as TransactionEdit from '../libs/actions/TransactionEdit';
+import useNetwork from '../hooks/useNetwork';
+import usePrevious from '../hooks/usePrevious';
+
+const propTypes = {
+ /** The transactionID we're currently editing */
+ transactionID: PropTypes.string.isRequired,
+
+ /** The report to with which the distance request is associated */
+ report: reportPropTypes.isRequired,
+
+ /** Passed from the navigator */
+ route: PropTypes.shape({
+ /** Parameters the route gets */
+ params: PropTypes.shape({
+ /** Type of IOU */
+ iouType: PropTypes.oneOf(_.values(CONST.IOU.MONEY_REQUEST_TYPE)),
+
+ /** Id of the report on which the distance request is being created */
+ reportID: PropTypes.string,
+ }),
+ }).isRequired,
+
+ /* Onyx props */
+ /** The original transaction that is being edited */
+ transaction: transactionPropTypes,
+};
+
+const defaultProps = {
+ transaction: {},
+};
+
+function EditRequestDistancePage({report, route, transaction}) {
+ const {isOffline} = useNetwork();
+ const {translate} = useLocalize();
+ const transactionWasSaved = useRef(false);
+ const hasWaypointError = useRef(false);
+ const prevIsLoading = usePrevious(transaction.isLoading);
+
+ useEffect(() => {
+ hasWaypointError.current = Boolean(lodashGet(transaction, 'errorFields.route') || lodashGet(transaction, 'errorFields.waypoints'));
+
+ // When the loading goes from true to false, then we know the transaction has just been
+ // saved to the server. Check for errors. If there are no errors, then the modal can be closed.
+ if (prevIsLoading && !transaction.isLoading && !hasWaypointError.current) {
+ Navigation.dismissModal(report.reportID);
+ }
+ }, [transaction, prevIsLoading, report]);
+
+ useEffect(() => {
+ // This effect runs when the component is mounted and unmounted. It's purpose is to be able to properly
+ // discard changes if the user cancels out of making any changes. This is accomplished by backing up the
+ // original transaction, letting the user modify the current transaction, and then if the user ever
+ // cancels out of the modal without saving changes, the original transaction is restored from the backup.
+
+ // On mount, create the backup transaction.
+ TransactionEdit.createBackupTransaction(transaction);
+
+ return () => {
+ // If the user cancels out of the modal without without saving changes, then the original transaction
+ // needs to be restored from the backup so that all changes are removed.
+ if (transactionWasSaved.current) {
+ return;
+ }
+ TransactionEdit.restoreOriginalTransactionFromBackup(transaction.transactionID);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ /**
+ * Save the changes to the original transaction object
+ * @param {Object} waypoints
+ */
+ const saveTransaction = (waypoints) => {
+ transactionWasSaved.current = true;
+ IOU.updateDistanceRequest(transaction.transactionID, report.reportID, {waypoints});
+
+ // If the client is offline, then the modal can be closed as well (because there are no errors or other feedback to show them
+ // until they come online again and sync with the server).
+ if (isOffline) {
+ Navigation.dismissModal(report.reportID);
+ }
+ };
+
+ return (
+
+ Navigation.goBack()}
+ />
+
+
+ );
+}
+
+EditRequestDistancePage.propTypes = propTypes;
+EditRequestDistancePage.defaultProps = defaultProps;
+EditRequestDistancePage.displayName = 'EditRequestDistancePage';
+export default withOnyx({
+ transaction: {
+ key: (props) => `${ONYXKEYS.COLLECTION.TRANSACTION}${props.transactionID}`,
+ },
+})(EditRequestDistancePage);
diff --git a/src/pages/EditRequestPage.js b/src/pages/EditRequestPage.js
index 5e6e0dd3f17b..90a32ec453f5 100644
--- a/src/pages/EditRequestPage.js
+++ b/src/pages/EditRequestPage.js
@@ -21,9 +21,10 @@ import EditRequestMerchantPage from './EditRequestMerchantPage';
import EditRequestCreatedPage from './EditRequestCreatedPage';
import EditRequestAmountPage from './EditRequestAmountPage';
import EditRequestReceiptPage from './EditRequestReceiptPage';
+import reportPropTypes from './reportPropTypes';
+import EditRequestDistancePage from './EditRequestDistancePage';
import EditRequestCategoryPage from './EditRequestCategoryPage';
import EditRequestTagPage from './EditRequestTagPage';
-import reportPropTypes from './reportPropTypes';
const propTypes = {
/** Route from navigation */
@@ -116,7 +117,11 @@ function EditRequestPage({report, route, parentReport, policy, session, policyTa
// Update the transaction object and close the modal
function editMoneyRequest(transactionChanges) {
- IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges);
+ if (TransactionUtils.isDistanceRequest(transaction)) {
+ IOU.updateDistanceRequest(transaction.transactionID, report.reportID, transactionChanges);
+ } else {
+ IOU.editMoneyRequest(transaction.transactionID, report.reportID, transactionChanges);
+ }
Navigation.dismissModal(report.reportID);
}
@@ -236,6 +241,16 @@ function EditRequestPage({report, route, parentReport, policy, session, policyTa
);
}
+ if (fieldToEdit === CONST.EDIT_REQUEST_FIELD.DISTANCE) {
+ return (
+
+ );
+ }
+
return ;
}
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 (
-
-
-
-
+
);
}
+
+ return (
+
+
+
+
+ );
}
RequestorStep.propTypes = propTypes;
+RequestorStep.displayName = 'RequestorStep';
-export default withLocalize(RequestorStep);
+export default React.forwardRef(RequestorStep);
diff --git a/src/pages/home/HeaderView.js b/src/pages/home/HeaderView.js
index 477d063c1747..b7d9a923d0bf 100644
--- a/src/pages/home/HeaderView.js
+++ b/src/pages/home/HeaderView.js
@@ -25,7 +25,6 @@ import reportPropTypes from '../reportPropTypes';
import ONYXKEYS from '../../ONYXKEYS';
import ThreeDotsMenu from '../../components/ThreeDotsMenu';
import * as Task from '../../libs/actions/Task';
-import reportActionPropTypes from './report/reportActionPropTypes';
import PressableWithoutFeedback from '../../components/Pressable/PressableWithoutFeedback';
import PinButton from '../../components/PinButton';
import TaskHeaderActionButton from '../../components/TaskHeaderActionButton';
@@ -45,33 +44,22 @@ const propTypes = {
/** Onyx Props */
parentReport: reportPropTypes,
- /** The details about the account that the user is signing in with */
- account: PropTypes.shape({
- /** URL to the assigned guide's appointment booking calendar */
- guideCalendarLink: PropTypes.string,
- }),
+ /** URL to the assigned guide's appointment booking calendar */
+ guideCalendarLink: PropTypes.string,
/** Current user session */
session: PropTypes.shape({
accountID: PropTypes.number,
}),
- /** The report actions from the parent report */
- // TO DO: Replace with HOC https://github.com/Expensify/App/issues/18769.
- // eslint-disable-next-line react/no-unused-prop-types
- parentReportActions: PropTypes.objectOf(PropTypes.shape(reportActionPropTypes)),
-
...windowDimensionsPropTypes,
...withLocalizePropTypes,
};
const defaultProps = {
personalDetails: {},
- parentReportActions: {},
report: null,
- account: {
- guideCalendarLink: null,
- },
+ guideCalendarLink: null,
parentReport: {},
session: {
accountID: 0,
@@ -93,13 +81,12 @@ function HeaderView(props) {
const parentNavigationSubtitleData = ReportUtils.getParentNavigationSubtitle(reportHeaderData);
const isConcierge = ReportUtils.hasSingleParticipant(props.report) && _.contains(participants, CONST.ACCOUNT_ID.CONCIERGE);
const isAutomatedExpensifyAccount = ReportUtils.hasSingleParticipant(props.report) && ReportUtils.hasAutomatedExpensifyAccountIDs(participants);
- const guideCalendarLink = lodashGet(props.account, 'guideCalendarLink');
const parentReportAction = ReportActionsUtils.getParentReportAction(props.report);
const isCanceledTaskReport = ReportUtils.isCanceledTaskReport(props.report, parentReportAction);
// We hide the button when we are chatting with an automated Expensify account since it's not possible to contact
// these users via alternative means. It is possible to request a call with Concierge so we leave the option for them.
- const shouldShowCallButton = (isConcierge && guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport);
+ const shouldShowCallButton = (isConcierge && props.guideCalendarLink) || (!isAutomatedExpensifyAccount && !isTaskReport);
const threeDotMenuItems = [];
if (isTaskReport && !isCanceledTaskReport) {
const canModifyTask = Task.canModifyTask(props.report, props.session.accountID);
@@ -222,7 +209,7 @@ function HeaderView(props) {
{shouldShowCallButton && (
)}
@@ -247,17 +234,10 @@ export default compose(
withWindowDimensions,
withLocalize,
withOnyx({
- account: {
+ guideCalendarLink: {
key: ONYXKEYS.ACCOUNT,
- selector: (account) =>
- account && {
- guideCalendarLink: account.guideCalendarLink,
- primaryLogin: account.primaryLogin,
- },
- },
- parentReportActions: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.parentReportID}`,
- canEvict: false,
+ selector: (account) => (account && account.guideCalendarLink) || null,
+ initialValue: null,
},
parentReport: {
key: ({report}) => `${ONYXKEYS.COLLECTION.REPORT}${report.parentReportID || report.reportID}`,
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 15bf25695fd3..c30a8c7ed4a8 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -16,15 +16,15 @@ import * as ReportUtils from '../../libs/ReportUtils';
import ReportActionsView from './report/ReportActionsView';
import ReportActionsSkeletonView from '../../components/ReportActionsSkeletonView';
import reportActionPropTypes from './report/reportActionPropTypes';
-import useNetwork from '../../hooks/useNetwork';
-import useWindowDimensions from '../../hooks/useWindowDimensions';
-import useLocalize from '../../hooks/useLocalize';
import compose from '../../libs/compose';
import Visibility from '../../libs/Visibility';
+import useWindowDimensions from '../../hooks/useWindowDimensions';
+import useLocalize from '../../hooks/useLocalize';
import OfflineWithFeedback from '../../components/OfflineWithFeedback';
import ReportFooter from './report/ReportFooter';
import Banner from '../../components/Banner';
import reportPropTypes from '../reportPropTypes';
+import reportMetadataPropTypes from '../reportMetadataPropTypes';
import FullPageNotFoundView from '../../components/BlockingViews/FullPageNotFoundView';
import withViewportOffsetTop, {viewportOffsetTopPropTypes} from '../../components/withViewportOffsetTop';
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
@@ -33,7 +33,7 @@ import getIsReportFullyVisible from '../../libs/getIsReportFullyVisible';
import MoneyRequestHeader from '../../components/MoneyRequestHeader';
import MoneyReportHeader from '../../components/MoneyReportHeader';
import * as ComposerActions from '../../libs/actions/Composer';
-import ReportScreenContext from './ReportScreenContext';
+import {ActionListContext, ReactionListContext} from './ReportScreenContext';
import TaskHeaderActionButton from '../../components/TaskHeaderActionButton';
import DragAndDropProvider from '../../components/DragAndDrop/Provider';
import usePrevious from '../../hooks/usePrevious';
@@ -59,6 +59,9 @@ const propTypes = {
/** The report currently being looked at */
report: reportPropTypes,
+ /** The report metadata loading states */
+ reportMetadata: reportMetadataPropTypes,
+
/** Array of report actions for this report */
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
@@ -85,6 +88,9 @@ const propTypes = {
/** All of the personal details for everyone */
personalDetails: PropTypes.objectOf(personalDetailsPropType),
+ /** Onyx function that marks the component ready for hydration */
+ markReadyForHydration: PropTypes.func,
+
/** Whether user is leaving the current report */
userLeavingStatus: PropTypes.bool,
@@ -97,7 +103,10 @@ const defaultProps = {
reportActions: [],
report: {
hasOutstandingIOU: false,
+ },
+ reportMetadata: {
isLoadingReportActions: false,
+ isLoadingMoreReportActions: false,
},
isComposerFullSize: false,
betas: [],
@@ -105,6 +114,7 @@ const defaultProps = {
accountManagerReportID: null,
userLeavingStatus: false,
personalDetails: {},
+ markReadyForHydration: null,
...withCurrentReportIDDefaultProps,
};
@@ -133,9 +143,11 @@ function ReportScreen({
betas,
route,
report,
+ reportMetadata,
reportActions,
accountManagerReportID,
personalDetails,
+ markReadyForHydration,
policies,
isSidebarLoaded,
viewportOffsetTop,
@@ -145,7 +157,6 @@ function ReportScreen({
currentReportID,
}) {
const {translate} = useLocalize();
- const {isOffline} = useNetwork();
const {isSmallScreenWidth} = useWindowDimensions();
const firstRenderRef = useRef(true);
@@ -153,8 +164,6 @@ function ReportScreen({
const reactionListRef = useRef();
const prevReport = usePrevious(report);
const prevUserLeavingStatus = usePrevious(userLeavingStatus);
-
- const [skeletonViewContainerHeight, setSkeletonViewContainerHeight] = useState(0);
const [isBannerVisible, setIsBannerVisible] = useState(true);
const reportID = getReportID(route);
@@ -162,19 +171,19 @@ function ReportScreen({
const screenWrapperStyle = [styles.appContent, styles.flex1, {marginTop: viewportOffsetTop}];
// There are no reportActions at all to display and we are still in the process of loading the next set of actions.
- const isLoadingInitialReportActions = _.isEmpty(reportActions) && report.isLoadingReportActions;
+ const isLoadingInitialReportActions = _.isEmpty(reportActions) && reportMetadata.isLoadingReportActions;
const isOptimisticDelete = lodashGet(report, 'statusNum') === CONST.REPORT.STATUS.CLOSED;
const shouldHideReport = !ReportUtils.canAccessReport(report, policies, betas);
- const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails) || firstRenderRef.current;
+ const isLoading = !reportID || !isSidebarLoaded || _.isEmpty(personalDetails);
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
const isDeletedParentAction = ReportActionsUtils.isDeletedParentAction(parentReportAction);
const isSingleTransactionView = ReportUtils.isMoneyRequest(report);
- const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`];
+ const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] || {};
const isTopMostReportId = currentReportID === getReportID(route);
const didSubscribeToReportLeavingEvents = useRef(false);
@@ -344,112 +353,112 @@ function ReportScreen({
}
}, [report, didSubscribeToReportLeavingEvents, reportID]);
+ const onListLayout = useCallback(() => {
+ if (!markReadyForHydration) {
+ return;
+ }
+
+ markReadyForHydration();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = useMemo(
- () => (!_.isEmpty(report) && !isDefaultReport && !report.reportID && !isOptimisticDelete && !report.isLoadingReportActions && !isLoading && !userLeavingStatus) || shouldHideReport,
+ () =>
+ (!firstRenderRef.current &&
+ !_.isEmpty(report) &&
+ !isDefaultReport &&
+ !report.reportID &&
+ !isOptimisticDelete &&
+ !report.isLoadingReportActions &&
+ !isLoading &&
+ !userLeavingStatus) ||
+ shouldHideReport,
[report, isLoading, shouldHideReport, isDefaultReport, isOptimisticDelete, userLeavingStatus],
);
return (
-
-
-
+
+
-
- {headerView}
- {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
-
-
-
-
+
+ {headerView}
+ {ReportUtils.isTaskReport(report) && isSmallScreenWidth && ReportUtils.isOpenTaskReport(report, parentReportAction) && (
+
+
+
+
+
-
- )}
-
- {Boolean(accountManagerReportID) && ReportUtils.isConciergeChatReport(report) && isBannerVisible && (
-
- )}
-
- {
- // Rounding this value for comparison because they can look like this: 411.9999694824219
- const newSkeletonViewContainerHeight = Math.round(event.nativeEvent.layout.height);
-
- // The height can be 0 if the component unmounts - we are not interested in this value and want to know how much space it
- // takes up so we can set the skeleton view container height.
- if (newSkeletonViewContainerHeight === 0) {
- return;
- }
- setSkeletonViewContainerHeight(newSkeletonViewContainerHeight);
- }}
- >
- {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && (
-
)}
+
+ {!!accountManagerReportID && ReportUtils.isConciergeChatReport(report) && isBannerVisible && (
+
+ )}
+
+
+ {isReportReadyForDisplay && !isLoadingInitialReportActions && !isLoading && (
+
+ )}
- {/* Note: The report should be allowed to mount even if the initial report actions are not loaded. If we prevent rendering the report while they are loading then
- we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
- {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && }
+ {/* Note: The ReportActionsSkeletonView should be allowed to mount even if the initial report actions are not loaded.
+ If we prevent rendering the report while they are loading then
+ we'll unnecessarily unmount the ReportActionsView which will clear the new marker lines initial state. */}
+ {(!isReportReadyForDisplay || isLoadingInitialReportActions || isLoading) && }
- {isReportReadyForDisplay && (
- <>
+ {isReportReadyForDisplay ? (
- >
- )}
-
- {!isReportReadyForDisplay && (
-
- )}
-
-
-
-
-
+ ) : (
+
+ )}
+
+
+
+
+
+
);
}
@@ -460,35 +469,50 @@ ReportScreen.displayName = 'ReportScreen';
export default compose(
withViewportOffsetTop,
withCurrentReportID,
- withOnyx({
- isSidebarLoaded: {
- key: ONYXKEYS.IS_SIDEBAR_LOADED,
+ withOnyx(
+ {
+ isSidebarLoaded: {
+ key: ONYXKEYS.IS_SIDEBAR_LOADED,
+ },
+ reportActions: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
+ canEvict: false,
+ selector: ReportActionsUtils.getSortedReportActionsForDisplay,
+ },
+ report: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
+ allowStaleData: true,
+ },
+ reportMetadata: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${getReportID(route)}`,
+ initialValue: {
+ isLoadingReportActions: false,
+ isLoadingMoreReportActions: false,
+ },
+ },
+ isComposerFullSize: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`,
+ initialValue: false,
+ },
+ betas: {
+ key: ONYXKEYS.BETAS,
+ },
+ policies: {
+ key: ONYXKEYS.COLLECTION.POLICY,
+ allowStaleData: true,
+ },
+ accountManagerReportID: {
+ key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID,
+ initialValue: null,
+ },
+ personalDetails: {
+ key: ONYXKEYS.PERSONAL_DETAILS_LIST,
+ },
+ userLeavingStatus: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`,
+ initialValue: false,
+ },
},
- reportActions: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getReportID(route)}`,
- canEvict: false,
- selector: ReportActionsUtils.getSortedReportActionsForDisplay,
- },
- report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${getReportID(route)}`,
- },
- isComposerFullSize: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_IS_COMPOSER_FULL_SIZE}${getReportID(route)}`,
- },
- betas: {
- key: ONYXKEYS.BETAS,
- },
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- accountManagerReportID: {
- key: ONYXKEYS.ACCOUNT_MANAGER_REPORT_ID,
- },
- personalDetails: {
- key: ONYXKEYS.PERSONAL_DETAILS_LIST,
- },
- userLeavingStatus: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_LEAVING_ROOM}${getReportID(route)}`,
- },
- }),
+ true,
+ ),
)(ReportScreen);
diff --git a/src/pages/home/ReportScreenContext.js b/src/pages/home/ReportScreenContext.js
index 2f79d6ae9432..1e8d30cf7585 100644
--- a/src/pages/home/ReportScreenContext.js
+++ b/src/pages/home/ReportScreenContext.js
@@ -1,4 +1,6 @@
import {createContext} from 'react';
-const ReportScreenContext = createContext();
-export default ReportScreenContext;
+const ActionListContext = createContext();
+const ReactionListContext = createContext();
+
+export {ActionListContext, ReactionListContext};
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..d04983dc2f75 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -211,7 +211,7 @@ function ComposerWithSuggestions({
suggestionsRef.current.resetSuggestions();
}
- const remainder = ComposerUtils.getCommonSuffixLength(commentRef.current, newComment);
+ const remainder = ComposerUtils.getCommonSuffixLength(commentValue, newComment);
setSelection({
start: newComment.length - remainder,
end: newComment.length - remainder,
@@ -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/ReportActionCompose/SilentCommentUpdater.js b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js
index da5dc326d421..09f9d368bdcc 100644
--- a/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js
+++ b/src/pages/home/report/ReportActionCompose/SilentCommentUpdater.js
@@ -68,5 +68,6 @@ SilentCommentUpdater.displayName = 'SilentCommentUpdater';
export default withOnyx({
comment: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${reportID}`,
+ initialValue: '',
},
})(SilentCommentUpdater);
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 54d33919aafe..ed4ebd001937 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -64,8 +64,8 @@ import * as PersonalDetailsUtils from '../../../libs/PersonalDetailsUtils';
import ReportActionItemBasicMessage from './ReportActionItemBasicMessage';
import * as store from '../../../libs/actions/ReimbursementAccount/store';
import * as BankAccounts from '../../../libs/actions/BankAccounts';
+import {ReactionListContext} from '../ReportScreenContext';
import usePrevious from '../../../hooks/usePrevious';
-import ReportScreenContext from '../ReportScreenContext';
import Permissions from '../../../libs/Permissions';
import RenderHTML from '../../../components/RenderHTML';
import ReportAttachmentsContext from './ReportAttachmentsContext';
@@ -130,7 +130,7 @@ function ReportActionItem(props) {
const [isContextMenuActive, setIsContextMenuActive] = useState(ReportActionContextMenu.isActiveReportAction(props.action.reportActionID));
const [isHidden, setIsHidden] = useState(false);
const [moderationDecision, setModerationDecision] = useState(CONST.MODERATION.MODERATOR_DECISION_APPROVED);
- const {reactionListRef} = useContext(ReportScreenContext);
+ const reactionListRef = useContext(ReactionListContext);
const {updateHiddenAttachments} = useContext(ReportAttachmentsContext);
const textInputRef = useRef();
const popoverAnchorRef = useRef();
@@ -675,13 +675,15 @@ 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, '');
},
}),
withOnyx({
preferredSkinTone: {
key: ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE,
+ initialValue: CONST.EMOJI_DEFAULT_SKIN_TONE,
},
iouReport: {
key: ({action}) => {
@@ -692,6 +694,7 @@ export default compose(
},
emojiReactions: {
key: ({action}) => `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_REACTIONS}${action.reportActionID}`,
+ initialValue: {},
},
}),
)(
@@ -706,6 +709,7 @@ export default compose(
_.isEqual(prevProps.emojiReactions, nextProps.emojiReactions) &&
_.isEqual(prevProps.action, nextProps.action) &&
_.isEqual(prevProps.report.pendingFields, nextProps.report.pendingFields) &&
+ _.isEqual(prevProps.report.isDeletedParentAction, nextProps.report.isDeletedParentAction) &&
_.isEqual(prevProps.report.errorFields, nextProps.report.errorFields) &&
lodashGet(prevProps.report, 'statusNum') === lodashGet(nextProps.report, 'statusNum') &&
lodashGet(prevProps.report, 'stateNum') === lodashGet(nextProps.report, 'stateNum') &&
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/home/report/ReportActionsList.js b/src/pages/home/report/ReportActionsList.js
index f3f40d34a0f5..0163a7ff2b4f 100644
--- a/src/pages/home/report/ReportActionsList.js
+++ b/src/pages/home/report/ReportActionsList.js
@@ -32,6 +32,9 @@ const propTypes = {
/** The ID of the most recent IOU report action connected with the shown report */
mostRecentIOUReportActionID: PropTypes.string,
+ /** The report metadata loading states */
+ isLoadingReportActions: PropTypes.bool,
+
/** Are we loading more report actions? */
isLoadingMoreReportActions: PropTypes.bool,
@@ -61,6 +64,7 @@ const defaultProps = {
personalDetails: {},
onScroll: () => {},
mostRecentIOUReportActionID: '',
+ isLoadingReportActions: false,
isLoadingMoreReportActions: false,
...withCurrentUserPersonalDetailsDefaultProps,
};
@@ -96,6 +100,8 @@ function isMessageUnread(message, lastReadTime) {
function ReportActionsList({
report,
+ isLoadingReportActions,
+ isLoadingMoreReportActions,
sortedReportActions,
windowHeight,
onScroll,
@@ -117,10 +123,11 @@ function ReportActionsList({
const scrollingVerticalOffset = useRef(0);
const readActionSkipped = useRef(false);
const reportActionSize = useRef(sortedReportActions.length);
+ const firstRenderRef = useRef(true);
- // Considering that renderItem is enclosed within a useCallback, marking it as "read" twice will retain the value as "true," preventing the useCallback from re-executing.
- // However, if we create and listen to an object, it will lead to a new useCallback execution.
- const [messageManuallyMarked, setMessageManuallyMarked] = useState({read: false});
+ // This state is used to force a re-render when the user manually marks a message as unread
+ // by using a timestamp you can force re-renders without having to worry about if another message was marked as unread before
+ const [messageManuallyMarkedUnread, setMessageManuallyMarkedUnread] = useState(0);
const [isFloatingMessageCounterVisible, setIsFloatingMessageCounterVisible] = useState(false);
const animatedStyles = useAnimatedStyle(() => ({
opacity: opacity.value,
@@ -129,7 +136,6 @@ function ReportActionsList({
useEffect(() => {
opacity.value = withTiming(1, {duration: 100});
}, [opacity]);
- const [skeletonViewHeight, setSkeletonViewHeight] = useState(0);
useEffect(() => {
// If the reportID changes, we reset the userActiveSince to null, we need to do it because
@@ -167,14 +173,14 @@ function ReportActionsList({
useEffect(() => {
const didManuallyMarkReportAsUnread = report.lastReadTime < DateUtils.getDBTime() && ReportUtils.isUnread(report);
- if (!didManuallyMarkReportAsUnread) {
- setMessageManuallyMarked({read: false});
+ if (didManuallyMarkReportAsUnread) {
+ // Clearing the current unread marker so that it can be recalculated
+ setCurrentUnreadMarker(null);
+ setMessageManuallyMarkedUnread(new Date().getTime());
return;
}
- // Clearing the current unread marker so that it can be recalculated
- setCurrentUnreadMarker(null);
- setMessageManuallyMarked({read: true});
+ setMessageManuallyMarkedUnread(0);
// We only care when a new lastReadTime is set in the report
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -281,7 +287,7 @@ function ReportActionsList({
const isCurrentMessageUnread = isMessageUnread(reportAction, report.lastReadTime);
shouldDisplayNewMarker = isCurrentMessageUnread && !isMessageUnread(nextMessage, report.lastReadTime);
- if (!messageManuallyMarked.read) {
+ if (!messageManuallyMarkedUnread) {
shouldDisplayNewMarker = shouldDisplayNewMarker && reportAction.actorAccountID !== Report.getCurrentUserAccountID();
}
const canDisplayMarker = scrollingVerticalOffset.current < MSG_VISIBLE_THRESHOLD ? reportAction.created < userActiveSince.current : true;
@@ -305,7 +311,7 @@ function ReportActionsList({
/>
);
},
- [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarked, shouldHideThreadDividerLine, currentUnreadMarker],
+ [report, hasOutstandingIOU, sortedReportActions, mostRecentIOUReportActionID, messageManuallyMarkedUnread, shouldHideThreadDividerLine, currentUnreadMarker],
);
// Native mobile does not render updates flatlist the changes even though component did update called.
@@ -314,6 +320,36 @@ function ReportActionsList({
const hideComposer = ReportUtils.shouldDisableWriteActions(report);
const shouldShowReportRecipientLocalTime = ReportUtils.canShowReportRecipientLocalTime(personalDetailsList, report, currentUserPersonalDetails.accountID) && !isComposerFullSize;
+ const renderFooter = useCallback(() => {
+ // Skip this hook on the first render, as we are not sure if more actions are going to be loaded
+ // Therefore showing the skeleton on footer might be misleading
+ if (firstRenderRef.current) {
+ firstRenderRef.current = false;
+ return null;
+ }
+
+ if (isLoadingMoreReportActions) {
+ return ;
+ }
+
+ // Make sure the oldest report action loaded is not the first. This is so we do not show the
+ // skeleton view above the created action in a newly generated optimistic chat or one with not
+ // that many comments.
+ const lastReportAction = _.last(sortedReportActions) || {};
+ if (isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
+ return ;
+ }
+
+ return null;
+ }, [isLoadingMoreReportActions, isLoadingReportActions, sortedReportActions, isOffline]);
+
+ const onLayoutInner = useCallback(
+ (event) => {
+ onLayout(event);
+ },
+ [onLayout],
+ );
+
return (
<>
{
- if (report.isLoadingMoreReportActions) {
- return ;
- }
-
- // Make sure the oldest report action loaded is not the first. This is so we do not show the
- // skeleton view above the created action in a newly generated optimistic chat or one with not
- // that many comments.
- const lastReportAction = _.last(sortedReportActions) || {};
- if (report.isLoadingReportActions && lastReportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) {
- return (
-
- );
- }
-
- return null;
- }}
+ ListFooterComponent={renderFooter}
keyboardShouldPersistTaps="handled"
- onLayout={(event) => {
- setSkeletonViewHeight(event.nativeEvent.layout.height);
- onLayout(event);
- }}
+ onLayout={onLayoutInner}
onScroll={trackVerticalScrolling}
extraData={extraData}
/>
diff --git a/src/pages/home/report/ReportActionsView.js b/src/pages/home/report/ReportActionsView.js
index a694c4996438..f58c6644cd47 100755
--- a/src/pages/home/report/ReportActionsView.js
+++ b/src/pages/home/report/ReportActionsView.js
@@ -19,7 +19,7 @@ import * as ReportActionsUtils from '../../../libs/ReportActionsUtils';
import reportPropTypes from '../../reportPropTypes';
import PopoverReactionList from './ReactionList/PopoverReactionList';
import getIsReportFullyVisible from '../../../libs/getIsReportFullyVisible';
-import ReportScreenContext from '../ReportScreenContext';
+import {ReactionListContext} from '../ReportScreenContext';
const propTypes = {
/** The report currently being looked at */
@@ -28,6 +28,12 @@ const propTypes = {
/** Array of report actions for this report */
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
+ /** The report metadata loading states */
+ isLoadingReportActions: PropTypes.bool,
+
+ /** The report actions are loading more data */
+ isLoadingMoreReportActions: PropTypes.bool,
+
/** Whether the composer is full size */
/* eslint-disable-next-line react/no-unused-prop-types */
isComposerFullSize: PropTypes.bool.isRequired,
@@ -51,13 +57,13 @@ const propTypes = {
const defaultProps = {
reportActions: [],
policy: null,
+ isLoadingReportActions: false,
+ isLoadingMoreReportActions: false,
};
function ReportActionsView(props) {
- const context = useContext(ReportScreenContext);
-
useCopySelectionHelper();
-
+ const reactionListRef = useContext(ReactionListContext);
const didLayout = useRef(false);
const didSubscribeToReportTypingEvents = useRef(false);
const hasCachedActions = useRef(_.size(props.reportActions) > 0);
@@ -138,7 +144,7 @@ function ReportActionsView(props) {
*/
const loadMoreChats = () => {
// Only fetch more if we are not already fetching so that we don't initiate duplicate requests.
- if (props.report.isLoadingMoreReportActions) {
+ if (props.isLoadingMoreReportActions) {
return;
}
@@ -185,11 +191,12 @@ function ReportActionsView(props) {
onLayout={recordTimeToMeasureItemLayout}
sortedReportActions={props.reportActions}
mostRecentIOUReportActionID={mostRecentIOUReportActionID.current}
- isLoadingMoreReportActions={props.report.isLoadingMoreReportActions}
+ isLoadingReportActions={props.isLoadingReportActions}
+ isLoadingMoreReportActions={props.isLoadingMoreReportActions}
loadMoreChats={loadMoreChats}
policy={props.policy}
/>
-
+
>
);
}
@@ -215,11 +222,11 @@ function arePropsEqual(oldProps, newProps) {
return false;
}
- if (oldProps.report.isLoadingMoreReportActions !== newProps.report.isLoadingMoreReportActions) {
+ if (oldProps.isLoadingMoreReportActions !== newProps.isLoadingMoreReportActions) {
return false;
}
- if (oldProps.report.isLoadingReportActions !== newProps.report.isLoadingReportActions) {
+ if (oldProps.isLoadingReportActions !== newProps.isLoadingReportActions) {
return false;
}
diff --git a/src/pages/home/report/ReportFooter.js b/src/pages/home/report/ReportFooter.js
index 8d92c09b7a6e..51a8490162e5 100644
--- a/src/pages/home/report/ReportFooter.js
+++ b/src/pages/home/report/ReportFooter.js
@@ -11,6 +11,7 @@ import ArchivedReportFooter from '../../../components/ArchivedReportFooter';
import compose from '../../../libs/compose';
import ONYXKEYS from '../../../ONYXKEYS';
import withWindowDimensions, {windowDimensionsPropTypes} from '../../../components/withWindowDimensions';
+import useNetwork from '../../../hooks/useNetwork';
import styles from '../../../styles/styles';
import variables from '../../../styles/variables';
import reportActionPropTypes from './reportActionPropTypes';
@@ -25,9 +26,6 @@ const propTypes = {
/** Report actions for the current report */
reportActions: PropTypes.arrayOf(PropTypes.shape(reportActionPropTypes)),
- /** Offline status */
- isOffline: PropTypes.bool.isRequired,
-
/** Callback fired when the comment is submitted */
onSubmitComment: PropTypes.func,
@@ -53,7 +51,8 @@ const defaultProps = {
};
function ReportFooter(props) {
- const chatFooterStyles = {...styles.chatFooter, minHeight: !props.isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0};
+ const {isOffline} = useNetwork();
+ const chatFooterStyles = {...styles.chatFooter, minHeight: !isOffline ? CONST.CHAT_FOOTER_MIN_HEIGHT : 0};
const isArchivedRoom = ReportUtils.isArchivedRoom(props.report);
const isAnonymousUser = Session.isAnonymousUser();
@@ -102,5 +101,6 @@ export default compose(
withWindowDimensions,
withOnyx({
shouldShowComposeInput: {key: ONYXKEYS.SHOULD_SHOW_COMPOSE_INPUT},
+ initialValue: false,
}),
)(ReportFooter);
diff --git a/src/pages/home/report/ReportTypingIndicator.js b/src/pages/home/report/ReportTypingIndicator.js
index 4de649c7eb49..db97f712d65f 100755
--- a/src/pages/home/report/ReportTypingIndicator.js
+++ b/src/pages/home/report/ReportTypingIndicator.js
@@ -74,6 +74,7 @@ export default compose(
withOnyx({
userTypingStatuses: {
key: ({reportID}) => `${ONYXKEYS.COLLECTION.REPORT_USER_IS_TYPING}${reportID}`,
+ initialValue: {},
},
}),
)(ReportTypingIndicator);
diff --git a/src/pages/home/report/withReportAndReportActionOrNotFound.js b/src/pages/home/report/withReportAndReportActionOrNotFound.js
index 9bf3e73e761c..b4346504b327 100644
--- a/src/pages/home/report/withReportAndReportActionOrNotFound.js
+++ b/src/pages/home/report/withReportAndReportActionOrNotFound.js
@@ -6,6 +6,7 @@ import getComponentDisplayName from '../../../libs/getComponentDisplayName';
import NotFoundPage from '../../ErrorPage/NotFoundPage';
import ONYXKEYS from '../../../ONYXKEYS';
import reportPropTypes from '../../reportPropTypes';
+import reportMetadataPropTypes from '../../reportMetadataPropTypes';
import reportActionPropTypes from './reportActionPropTypes';
import FullscreenLoadingIndicator from '../../../components/FullscreenLoadingIndicator';
import * as ReportUtils from '../../../libs/ReportUtils';
@@ -23,6 +24,9 @@ export default function (WrappedComponent) {
/** The report currently being looked at */
report: reportPropTypes,
+ /** The report metadata */
+ reportMetadata: reportMetadataPropTypes,
+
/** Array of report actions for this report */
reportActions: PropTypes.shape(reportActionPropTypes),
@@ -62,6 +66,10 @@ export default function (WrappedComponent) {
forwardedRef: () => {},
reportActions: {},
report: {},
+ reportMetadata: {
+ isLoadingReportActions: false,
+ isLoadingMoreReportActions: false,
+ },
policies: {},
betas: [],
isLoadingReportData: true,
@@ -94,7 +102,7 @@ export default function (WrappedComponent) {
// Perform all the loading checks
const isLoadingReport = props.isLoadingReportData && (_.isEmpty(props.report) || !props.report.reportID);
- const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.report.isLoadingReportActions && _.isEmpty(getReportAction()));
+ const isLoadingReportAction = _.isEmpty(props.reportActions) || (props.reportMetadata.isLoadingReportActions && _.isEmpty(getReportAction()));
const shouldHideReport = !isLoadingReport && (_.isEmpty(props.report) || !props.report.reportID || !ReportUtils.canAccessReport(props.report, props.policies, props.betas));
if ((isLoadingReport || isLoadingReportAction) && !shouldHideReport) {
@@ -135,6 +143,9 @@ export default function (WrappedComponent) {
report: {
key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${route.params.reportID}`,
},
+ reportMetadata: {
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT_METADATA}${route.params.reportID}`,
+ },
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 9ff9cc261af4..984654c6b506 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -1,6 +1,6 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import React from 'react';
-import {View} from 'react-native';
+import {View, InteractionManager} from 'react-native';
import _ from 'underscore';
import PropTypes from 'prop-types';
import styles from '../../../styles/styles';
@@ -77,6 +77,13 @@ class SidebarLinks extends React.PureComponent {
SidebarUtils.setIsSidebarLoadedReady();
this.isSidebarLoaded = true;
+ // Eagerly set the locale on date-fns, it helps navigating to the report screen faster
+ InteractionManager.runAfterInteractions(() => {
+ requestAnimationFrame(() => {
+ this.props.updateLocale();
+ });
+ });
+
let modal = {};
this.unsubscribeOnyxModal = onyxSubscribe({
key: ONYXKEYS.MODAL,
@@ -134,11 +141,7 @@ class SidebarLinks extends React.PureComponent {
// or when clicking the active LHN row on large screens
// or when continuously clicking different LHNs, only apply to small screen
// since getTopmostReportId always returns on other devices
- if (
- this.props.isCreateMenuOpen ||
- (!this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID)) ||
- (this.props.isSmallScreenWidth && Navigation.getTopmostReportId())
- ) {
+ if (this.props.isCreateMenuOpen || option.reportID === Navigation.getTopmostReportId() || (this.props.isSmallScreenWidth && this.props.isActiveReport(option.reportID))) {
return;
}
Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(option.reportID));
@@ -195,4 +198,5 @@ class SidebarLinks extends React.PureComponent {
SidebarLinks.propTypes = propTypes;
SidebarLinks.defaultProps = defaultProps;
export default compose(withLocalize, withWindowDimensions)(SidebarLinks);
+
export {basePropTypes};
diff --git a/src/pages/iou/MoneyRequestEditWaypointPage.js b/src/pages/iou/MoneyRequestEditWaypointPage.js
new file mode 100644
index 000000000000..68f85848a69e
--- /dev/null
+++ b/src/pages/iou/MoneyRequestEditWaypointPage.js
@@ -0,0 +1,32 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import WaypointEditor from './WaypointEditor';
+
+const propTypes = {
+ /** Route params */
+ route: PropTypes.shape({
+ params: PropTypes.shape({
+ /** Thread reportID */
+ threadReportID: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+
+ /** ID of the transaction being edited */
+ transactionID: PropTypes.string,
+
+ /** Index of the waypoint being edited */
+ waypointIndex: PropTypes.string,
+ }),
+ }),
+};
+
+const defaultProps = {
+ route: {},
+};
+
+function MoneyRequestEditWaypointPage({route}) {
+ return ;
+}
+
+MoneyRequestEditWaypointPage.displayName = 'MoneyRequestEditWaypointPage';
+MoneyRequestEditWaypointPage.propTypes = propTypes;
+MoneyRequestEditWaypointPage.defaultProps = defaultProps;
+export default MoneyRequestEditWaypointPage;
diff --git a/src/pages/iou/MoneyRequestSelectorPage.js b/src/pages/iou/MoneyRequestSelectorPage.js
index c7a65215da58..9e46b1d2d7a2 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -15,7 +15,7 @@ import Navigation from '../../libs/Navigation/Navigation';
import styles from '../../styles/styles';
import ReceiptSelector from './ReceiptSelector';
import * as IOU from '../../libs/actions/IOU';
-import DistanceRequestPage from './DistanceRequestPage';
+import NewDistanceRequestPage from './NewDistanceRequestPage';
import DragAndDropProvider from '../../components/DragAndDrop/Provider';
import OnyxTabNavigator, {TopTab} from '../../libs/Navigation/OnyxTabNavigator';
import NewRequestAmountPage from './steps/NewRequestAmountPage';
@@ -133,7 +133,7 @@ function MoneyRequestSelectorPage(props) {
{shouldDisplayDistanceRequest && (
)}
diff --git a/src/pages/iou/DistanceRequestPage.js b/src/pages/iou/NewDistanceRequestPage.js
similarity index 79%
rename from src/pages/iou/DistanceRequestPage.js
rename to src/pages/iou/NewDistanceRequestPage.js
index 39b068975c77..562ea66453a1 100644
--- a/src/pages/iou/DistanceRequestPage.js
+++ b/src/pages/iou/NewDistanceRequestPage.js
@@ -42,8 +42,8 @@ const defaultProps = {
// This component is responsible for getting the transactionID from the IOU key, or creating the transaction if it doesn't exist yet, and then passing the transactionID.
// You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that DistanceRequest can subscribe to the transaction.
-function DistanceRequestPage({iou, report, route}) {
- const iouType = lodashGet(route, 'params.iouType', '');
+function NewDistanceRequestPage({iou, report, route}) {
+ const iouType = lodashGet(route, 'params.iouType', 'request');
useEffect(() => {
if (iou.transactionID) {
@@ -54,23 +54,20 @@ function DistanceRequestPage({iou, report, route}) {
return (
IOU.navigateToNextPage(iou, iouType, report)}
/>
);
}
-DistanceRequestPage.displayName = 'DistanceRequestPage';
-DistanceRequestPage.propTypes = propTypes;
-DistanceRequestPage.defaultProps = defaultProps;
+NewDistanceRequestPage.displayName = 'NewDistanceRequestPage';
+NewDistanceRequestPage.propTypes = propTypes;
+NewDistanceRequestPage.defaultProps = defaultProps;
export default withOnyx({
- // We must provide a default value for transactionID here, otherwise the component won't mount
- // because withOnyx returns null until all the keys are defined
iou: {key: ONYXKEYS.IOU},
report: {
- key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID', '')}`,
+ key: ({route}) => `${ONYXKEYS.COLLECTION.REPORT}${lodashGet(route, 'params.reportID')}`,
},
-})(DistanceRequestPage);
+})(NewDistanceRequestPage);
diff --git a/src/pages/iou/WaypointEditorPage.js b/src/pages/iou/NewDistanceRequestWaypointEditorPage.js
similarity index 63%
rename from src/pages/iou/WaypointEditorPage.js
rename to src/pages/iou/NewDistanceRequestWaypointEditorPage.js
index 51c03623fb50..47dcbc8e4139 100644
--- a/src/pages/iou/WaypointEditorPage.js
+++ b/src/pages/iou/NewDistanceRequestWaypointEditorPage.js
@@ -32,18 +32,24 @@ const defaultProps = {
// This component is responsible for grabbing the transactionID from the IOU key
// You can't use Onyx props in the withOnyx mapping, so we need to set up and access the transactionID here, and then pass it down so that WaypointEditor can subscribe to the transaction.
-function WaypointEditorPage({transactionID, route}) {
+function NewDistanceRequestWaypointEditorPage({transactionID, route}) {
return (
);
}
-WaypointEditorPage.displayName = 'WaypointEditorPage';
-WaypointEditorPage.propTypes = propTypes;
-WaypointEditorPage.defaultProps = defaultProps;
+NewDistanceRequestWaypointEditorPage.displayName = 'NewDistanceRequestWaypointEditorPage';
+NewDistanceRequestWaypointEditorPage.propTypes = propTypes;
+NewDistanceRequestWaypointEditorPage.defaultProps = defaultProps;
export default withOnyx({
transactionID: {key: ONYXKEYS.IOU, selector: (iou) => iou && iou.transactionID},
-})(WaypointEditorPage);
+})(NewDistanceRequestWaypointEditorPage);
diff --git a/src/pages/iou/ReceiptSelector/index.js b/src/pages/iou/ReceiptSelector/index.js
index 431fbcca05bd..b4cf75801a3f 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -60,7 +60,6 @@ const defaultProps = {
};
function ReceiptSelector(props) {
- const reportID = lodashGet(props.route, 'params.reportID', '');
const iouType = lodashGet(props.route, 'params.iouType', '');
const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState('');
@@ -126,7 +125,7 @@ function ReceiptSelector(props) {
return;
}
- IOU.navigateToNextPage(iou, iouType, reportID, report, props.route.path);
+ IOU.navigateToNextPage(iou, iouType, report, props.route.path);
};
const panResponder = useRef(
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index 0729db4b6043..4de4e9bb9148 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -294,7 +294,7 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
return;
}
- IOU.navigateToNextPage(iou, iouType, reportID, report, route.path);
+ IOU.navigateToNextPage(iou, iouType, report, route.path);
})
.catch(() => {
Log.info('User did not select an image from gallery');
diff --git a/src/pages/iou/WaypointEditor.js b/src/pages/iou/WaypointEditor.js
index d73e44746005..54269c197c1c 100644
--- a/src/pages/iou/WaypointEditor.js
+++ b/src/pages/iou/WaypointEditor.js
@@ -26,15 +26,18 @@ import transactionPropTypes from '../../components/transactionPropTypes';
import * as ErrorUtils from '../../libs/ErrorUtils';
const propTypes = {
- /** The transactionID of the IOU */
- transactionID: PropTypes.string.isRequired,
-
/** Route params */
route: PropTypes.shape({
params: PropTypes.shape({
/** IOU type */
iouType: PropTypes.string,
+ /** Thread reportID */
+ threadReportID: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+
+ /** ID of the transaction being edited */
+ transactionID: PropTypes.string,
+
/** Index of the waypoint being edited */
waypointIndex: PropTypes.string,
}),
@@ -59,21 +62,18 @@ const propTypes = {
}),
),
+ /* Onyx props */
/** The optimistic transaction for this request */
transaction: transactionPropTypes,
};
const defaultProps = {
- route: {
- params: {
- waypointIndex: '',
- },
- },
+ route: {},
recentWaypoints: [],
transaction: {},
};
-function WaypointEditor({transactionID, route: {params: {iouType = '', waypointIndex = ''} = {}} = {}, transaction, recentWaypoints}) {
+function WaypointEditor({route: {params: {iouType = '', transactionID = '', waypointIndex = '', threadReportID = 0}} = {}, transaction, recentWaypoints}) {
const {windowWidth} = useWindowDimensions();
const [isDeleteStopModalOpen, setIsDeleteStopModalOpen] = useState(false);
const navigation = useNavigation();
@@ -98,6 +98,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
}, [parsedWaypointIndex, waypointCount]);
const waypointAddress = lodashGet(currentWaypoint, 'address', '');
+ const isEditingWaypoint = Boolean(threadReportID);
const totalWaypoints = _.size(lodashGet(transaction, 'comment.waypoints', {}));
// Hide the menu when there is only start and finish waypoint
const shouldShowThreeDotsButton = totalWaypoints > 2;
@@ -128,7 +129,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
}
};
- const onSubmit = (values) => {
+ const submit = (values) => {
const waypointValue = values[`waypoint${waypointIndex}`] || '';
// Allows letting you set a waypoint to an empty value
@@ -163,7 +164,12 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
lng: values.lng,
address: values.address,
};
- saveWaypoint(waypoint);
+ Transaction.saveWaypoint(transactionID, waypointIndex, waypoint, isEditingWaypoint);
+
+ if (isEditingWaypoint) {
+ Navigation.goBack(ROUTES.REPORT_WITH_ID.getRoute(threadReportID));
+ return;
+ }
Navigation.goBack(ROUTES.MONEY_REQUEST_DISTANCE_TAB.getRoute(iouType));
};
@@ -217,7 +223,7 @@ function WaypointEditor({transactionID, route: {params: {iouType = '', waypointI
formID={ONYXKEYS.FORMS.WAYPOINT_FORM}
enabledWhenOffline
validate={validate}
- onSubmit={onSubmit}
+ onSubmit={submit}
shouldValidateOnChange={false}
shouldValidateOnBlur={false}
submitButtonText={translate('common.save')}
@@ -258,7 +264,7 @@ WaypointEditor.propTypes = propTypes;
WaypointEditor.defaultProps = defaultProps;
export default withOnyx({
transaction: {
- key: ({transactionID}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ key: ({route}) => `${ONYXKEYS.COLLECTION.TRANSACTION}${lodashGet(route, 'params.transactionID')}`,
selector: (transaction) => (transaction ? {transactionID: transaction.transactionID, comment: {waypoints: lodashGet(transaction, 'comment.waypoints')}} : null),
},
recentWaypoints: {
diff --git a/src/pages/iou/steps/NewRequestAmountPage.js b/src/pages/iou/steps/NewRequestAmountPage.js
index 21df38c8fb87..c9e2ca464303 100644
--- a/src/pages/iou/steps/NewRequestAmountPage.js
+++ b/src/pages/iou/steps/NewRequestAmountPage.js
@@ -147,7 +147,7 @@ function NewRequestAmountPage({route, iou, report, selectedTab}) {
return;
}
- IOU.navigateToNextPage(iou, iouType, reportID, report);
+ IOU.navigateToNextPage(iou, iouType, report);
};
const content = (
diff --git a/src/pages/reportMetadataPropTypes.js b/src/pages/reportMetadataPropTypes.js
new file mode 100644
index 000000000000..a75d71aef7b3
--- /dev/null
+++ b/src/pages/reportMetadataPropTypes.js
@@ -0,0 +1,9 @@
+import PropTypes from 'prop-types';
+
+export default PropTypes.shape({
+ /** Are we loading more report actions? */
+ isLoadingMoreReportActions: PropTypes.bool,
+
+ /** Flag to check if the report actions data are loading */
+ isLoadingReportActions: PropTypes.bool,
+});
diff --git a/src/pages/reportPropTypes.js b/src/pages/reportPropTypes.js
index da90e0a4ac5c..a2c41b5a8147 100644
--- a/src/pages/reportPropTypes.js
+++ b/src/pages/reportPropTypes.js
@@ -13,12 +13,6 @@ export default PropTypes.shape({
/** List of icons for report participants */
icons: PropTypes.arrayOf(avatarPropTypes),
- /** Are we loading more report actions? */
- isLoadingMoreReportActions: PropTypes.bool,
-
- /** Flag to check if the report actions data are loading */
- isLoadingReportActions: PropTypes.bool,
-
/** Whether the user is not an admin of policyExpenseChat chat */
isOwnPolicyExpenseChat: PropTypes.bool,
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}
/>
diff --git a/src/types/onyx/Report.ts b/src/types/onyx/Report.ts
index 88caa683305d..46e51fe41238 100644
--- a/src/types/onyx/Report.ts
+++ b/src/types/onyx/Report.ts
@@ -12,12 +12,6 @@ type Report = {
/** List of icons for report participants */
icons?: OnyxCommon.Icon[];
- /** Are we loading more report actions? */
- isLoadingMoreReportActions?: boolean;
-
- /** Flag to check if the report actions data are loading */
- isLoadingReportActions?: boolean;
-
/** Whether the user is not an admin of policyExpenseChat chat */
isOwnPolicyExpenseChat?: boolean;
diff --git a/src/types/onyx/ReportMetadata.ts b/src/types/onyx/ReportMetadata.ts
new file mode 100644
index 000000000000..3e389c8cff4f
--- /dev/null
+++ b/src/types/onyx/ReportMetadata.ts
@@ -0,0 +1,9 @@
+type ReportMetadata = {
+ /** Are we loading more report actions? */
+ isLoadingMoreReportActions?: boolean;
+
+ /** Flag to check if the report actions data are loading */
+ isLoadingReportActions?: boolean;
+};
+
+export default ReportMetadata;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 19a5d6105e9c..dd53024a5426 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -39,6 +39,9 @@ type Transaction = {
modifiedCreated?: string;
modifiedCurrency?: string;
pendingAction: OnyxCommon.PendingAction;
+ pendingFields: {
+ comment: string;
+ };
receipt: {
receiptID?: number;
source?: string;
diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts
index 815689efdaaf..e50925e7adf2 100644
--- a/src/types/onyx/index.ts
+++ b/src/types/onyx/index.ts
@@ -36,6 +36,7 @@ import PolicyMember from './PolicyMember';
import Policy from './Policy';
import PolicyCategory from './PolicyCategory';
import Report from './Report';
+import ReportMetadata from './ReportMetadata';
import ReportAction from './ReportAction';
import ReportActionReactions from './ReportActionReactions';
import SecurityGroup from './SecurityGroup';
@@ -84,6 +85,7 @@ export type {
Policy,
PolicyCategory,
Report,
+ ReportMetadata,
ReportAction,
ReportActionReactions,
SecurityGroup,