diff --git a/.well-known/apple-app-site-association b/.well-known/apple-app-site-association
index c871764117ed..a9e2b0383691 100644
--- a/.well-known/apple-app-site-association
+++ b/.well-known/apple-app-site-association
@@ -68,6 +68,10 @@
"/": "/workspace/*",
"comment": "Workspace Details"
},
+ {
+ "/": "/get-assistance/*",
+ "comment": "Get Assistance Pages"
+ },
{
"/": "/teachersunite/*",
"comment": "Teachers Unite!"
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/404.html b/docs/404.html
index 1773388c6923..4338293218cc 100644
--- a/docs/404.html
+++ b/docs/404.html
@@ -1,8 +1,8 @@
---
permalink: /404.html
---
-
-
+
+
Hmm it's not here...
That page is nowhere to be found.
diff --git a/docs/_sass/_main.scss b/docs/_sass/_main.scss
index 6e4095569a6d..3ad2276713da 100644
--- a/docs/_sass/_main.scss
+++ b/docs/_sass/_main.scss
@@ -641,30 +641,13 @@ button {
}
.centered-content {
- height: 240px;
+ width: 100%;
+ height: calc(100vh - 56px);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
text-align: center;
- font-size: larger;
- position: absolute;
- top: calc((100vh - 240px) / 2);
-
- width: 380px;
- right: calc((100vw - 380px) / 2);
- @include breakpoint($breakpoint-tablet) {
- width: 500px;
- right: calc((100vw - 500px) / 2);
- }
-
- &.with-lhn {
- right: calc((100vw - 380px) / 2);
-
- @include breakpoint($breakpoint-tablet) {
- right: calc((100vw - 320px - 500px ) / 2);
- }
-
- @include breakpoint($breakpoint-desktop) {
- right: calc((100vw - 420px - 500px) / 2);
- }
- }
div {
margin-top: 8px;
diff --git a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
index 8323be7b8e3f..e565e59dc754 100644
--- a/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
+++ b/docs/articles/expensify-classic/get-paid-back/expenses/Create-Expenses.md
@@ -1,5 +1,124 @@
---
-title: Create Expenses
-description: Create Expenses
+title: Create-Expenses.md
+description: This is an article that shows you all the ways that you can create Expenses in Expensify!
---
-## Resource Coming Soon!
+
+
+# About
+Whether you're using SmartScan for automatic expense creation, or manually creating, splitting, or duplicating expenses, you can rest assured your expenses will be correctly tracked in Expensify.
+
+# How-to Create Expenses
+## Using SmartScan
+Use the big green camera button within the Expensify mobile app to snap a photo of your physical receipt to have it SmartScanned.
+For digital or emailed receipts, simply forward them to receipts@expensify.com and it will be SmartScanned and added to your Expensify account.
+
+There’s no need to keep the app open and most SmartScans are finished within the hour. If more details are needed, Concierge will reach out to you with a friendly message.
+## Using the Mobile App
+Simply tap the **+** icon in the top-right corner
+Choose **Expense** and then select **Manually Create**.
+If you don't have a receipt handy or want to add it later, fill in your expense details and click the **Save** button.
+## Using the Expensify Website
+Log into the Expensify website
+Click on the **Expenses** page and find the **New Expense** dropdown.
+Select your expense type, hit the **Save** button and you're all set.
+You can then add details like the Merchant and Category, attach a receipt image, and even add a description.
+# How to Split an Expense
+Splitting an expense in Expensify allows you to break down a single expense into multiple expenses. Each split expense is treated as an individual expense which can be categorized and tagged separately. The same receipt image will be attached to all of the split expenses, allowing you to divide a single expense into smaller, more manageable expenses.
+To split an expense on the mobile app:
+
+1. Open an expense.
+2. At the bottom of the screen, tap **More Options**.
+3. Then, use the **Split** button to divide the expense.
+
+To split an expense on the Expensify website:
+
+1. Click on the expense you want to split.
+2. Click on the **Split** button.
+ - On the Expenses page, this button is at the top.
+ - Within an individual expense, you'll find it at the bottom.
+3. This will automatically be split in two, but you can decide how many expenses you want to split it into by clicking on the **Add Split** button.
+ - Remember, the total of all pieces must add up to the original expense amount, and no piece can have a $0.00 amount (or you won't be able to save the changes).
+
+# How to Create Bulk Expenses
+
+If you have multiple saved receipt images or PDFs to upload, you can drag and drop them onto your Expenses page in batches of ten - this will start the SmartScan process for all of them.
+
+You can also create a number of future 'placeholder' expenses for your recurring expenses (such as recurring bills or subscriptions) which you don't have receipts for by clicking *New Expense > Create Multiple* to quickly add multiple expenses in batches of up to ten.
+
+# How to Edit Bulk Expenses
+Editing expenses in bulk will allow you to apply the same coding across multiple expenses and is a web-only feature. To bulk edit expenses:
+Go to the Expenses page.
+To narrow down your selection, use the filters (e.g. "Merchant" and "Open") to find the specific expenses you want to edit.
+Select all the expenses you want to edit.
+Click on the **Edit Multiple** button at the top of the page.
+# How to Edit Expenses on a Report
+If you’d like to edit expenses within an Open report:
+
+1. Click on the Report containing all the expenses.
+2. Click on **Details**.
+3. Click on the Pencil icon.
+3. Select the **Edit Multiple** button.
+
+If you've already submitted your report, you'll need to Retract it or have it Unapproved first before you can edit the expenses.
+
+
+# FAQ
+## Does Expensify account for duplicates?
+
+Yes, Expensify will account for duplicates. Expensify works behind the scenes to identify duplicate expenses before they are submitted, warning employees when they exist. If a duplicate expense is submitted, the same warning will be shown to the approver responsible for reviewing the report.
+
+If two expenses are SmartScanned on the same day for the same amount, they will be flagged as duplicates unless:
+The expenses were split from a single expense,
+The expenses were imported from a credit card, or
+Matching email receipts sent to receipts@expensify.com were received with different timestamps.
+## How do I resolve a duplicate expense?
+
+If Concierge has let you know it's flagged a receipt as a duplicate, scanning the receipt again will trigger the same duplicate flagging.Users have the ability to resolve duplicates by either deleting the duplicated transactions, merging them, or ignoring them (if they are legitimately separate expenses of the same date and amount).
+
+## How do I recover a duplicate or undelete an expense?
+
+To recover a duplicate or undelete an expense:
+Log into your Expensify account on the website and navigate to the Expenses page
+Use the filters to search for deleted expenses by selecting the "Deleted" filter
+Select the checkbox next to the expenses you want to restore
+Click the **Undelete** button and you're all set. You’ll find the expense on your Expenses page again.
+
+# Deep Dive
+
+## What are the different Expense statuses?
+
+There are a number of different expense statuses in Expensify:
+1. **Unreported**: Unreported expenses are not yet part of a report (and therefore unsubmitted) and are not viewable by anyone but the expense creator/owner.
+2. **Open**: Open expenses are on a report that's still in progress, and are unsubmitted. Your Policy Admin will be able to view them, making it a collaborative step toward reimbursement.
+3. **Processing**: Processing expenses are submitted, but waiting for approval.
+4. **Approved**: If it's a non-reimbursable expense, the workflow is complete at this point. If it's a reimbursable expense, you're one step closer to getting paid.
+5. **Reimbursed**: Reimbursed expenses are fully settled. You can check the Report Comments to see when you'll get paid.
+6. **Closed**: Sometimes an expense accidentally ends up on your Individual Policy, falling into the Closed status. You’ll need to reopen the report and change the Policy by clicking on the **Details** tab in order to resubmit your report.
+## What are Violations?
+
+Violations represent errors or discrepancies that Expensify has picked up and need to be corrected before a report can be successfully submitted. The one exception is when an expense comment is added, it will override the violation - as the user is providing a valid reason for submission.
+
+To enable or configure violations according to your policy, go to **Settings > Policies > _Policy Name_ > Expenses > Expense Violations**. Keep in mind that Expensify includes certain system mandatory violations that can't be disabled, even if your policy has violations turned off.
+
+You can spot violations by the exclamation marks (!) attached to expenses. Hovering over the symbol will provide a brief description and you can find more detailed information below the list of expenses. The two types of violations are:
+**Red**: These indicate violations directly tied to your report's Policy settings. They are clear rule violations that must be addressed before submission.
+**Yellow**: Concierge will highlight items that require attention but may not necessarily need corrective action. For example, if a receipt was SmartScanned and then the amount was modified, we’ll bring it to your attention so that it can be manually reviewed.
+## How to Track Attendees
+
+Attendee tracking makes it easy to track shared expenses and maintain transparency in your group spending.
+
+Internal attendees are considered users within your policies or domain. To add internal attendees on mobile or web:
+1. Click or tap the **Attendee** field within your expense.
+2. Select the internal attendees you'd like to add from the list of searchable users.
+3. You can continue adding more attendees or save the Expense.
+
+External attendees are considered users outside your group policy or domain. To add external attendees:
+1. Click or tap the **Attendee** field within your expense.
+2. Type in the individual's name or email address.
+3. Tap **Add** to include the attendee.
+You can continue adding more attendees or save the Expense.
+To remove an attendee from an expense:
+Open the expense.
+Click or tap the **Attendees** field to display the list of attendees.
+From the list, de-select the attendees you'd like to remove from the expense.
+
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..d8cba15c32af 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.3.74-2",
+ "version": "1.3.74-3",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.3.74-2",
+ "version": "1.3.74-3",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index cd93f718679e..24fdeaaed66d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.3.74-2",
+ "version": "1.3.74-3",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
diff --git a/src/CONST.ts b/src/CONST.ts
index 4d216285bc50..4f34e7cb2136 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -440,6 +440,12 @@ const CONST = {
INTERNAL_DEV_EXPENSIFY_URL: 'https://www.expensify.com.dev',
STAGING_EXPENSIFY_URL: 'https://staging.expensify.com',
EXPENSIFY_URL: 'https://www.expensify.com',
+ BANK_ACCOUNT_PERSONAL_DOCUMENTATION_INFO_URL:
+ 'https://community.expensify.com/discussion/6983/faq-why-do-i-need-to-provide-personal-documentation-when-setting-up-updating-my-bank-account',
+ PERSONAL_DATA_PROTECTION_INFO_URL: 'https://community.expensify.com/discussion/5677/deep-dive-security-how-expensify-protects-your-information',
+ ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/',
+ ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/',
+ ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/',
// Use Environment.getEnvironmentURL to get the complete URL with port number
DEV_NEW_EXPENSIFY_URL: 'http://localhost:',
@@ -982,6 +988,10 @@ const CONST = {
GOLD: 'GOLD',
SILVER: 'SILVER',
},
+ WEB_MESSAGE_TYPE: {
+ STATEMENT: 'STATEMENT_NAVIGATE',
+ CONCIERGE: 'CONCIERGE_NAVIGATE',
+ },
},
PLAID: {
@@ -1359,6 +1369,7 @@ const CONST = {
MERCHANT: 'merchant',
CATEGORY: 'category',
RECEIPT: 'receipt',
+ DISTANCE: 'distance',
TAG: 'tag',
},
FOOTER: {
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/AttachmentModal.js b/src/components/AttachmentModal.js
index c0fe0e2d26f8..23545de26cfd 100755
--- a/src/components/AttachmentModal.js
+++ b/src/components/AttachmentModal.js
@@ -30,6 +30,7 @@ import useWindowDimensions from '../hooks/useWindowDimensions';
import Navigation from '../libs/Navigation/Navigation';
import ROUTES from '../ROUTES';
import useNativeDriver from '../libs/useNativeDriver';
+import useNetwork from '../hooks/useNetwork';
/**
* Modal render prop component that exposes modal launching triggers that can be used
@@ -121,6 +122,7 @@ function AttachmentModal(props) {
: undefined,
);
const {translate} = useLocalize();
+ const {isOffline} = useNetwork();
const onCarouselAttachmentChange = props.onCarouselAttachmentChange;
@@ -350,7 +352,7 @@ function AttachmentModal(props) {
downloadAttachment(source)}
shouldShowCloseButton={!props.isSmallScreenWidth}
shouldShowBackButton={props.isSmallScreenWidth}
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/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 91b582221171..fddcede3a4b0 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -177,6 +177,7 @@ function OptionRowLHN(props) {
]}
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
accessibilityLabel={translate('accessibilityHints.navigatesToChat')}
+ needsOffscreenAlphaCompositing={props.optionItem.icons.length >= 2}
>
diff --git a/src/components/MagicCodeInput.js b/src/components/MagicCodeInput.js
index 454aacc8a03b..1db1acddc5d7 100644
--- a/src/components/MagicCodeInput.js
+++ b/src/components/MagicCodeInput.js
@@ -2,6 +2,7 @@ import React, {useEffect, useImperativeHandle, useRef, useState, forwardRef} fro
import {StyleSheet, View} from 'react-native';
import PropTypes from 'prop-types';
import _ from 'underscore';
+import {TapGestureHandler} from 'react-native-gesture-handler';
import styles from '../styles/styles';
import * as StyleUtils from '../styles/StyleUtils';
import * as ValidationUtils from '../libs/ValidationUtils';
@@ -12,6 +13,9 @@ import FormHelpMessage from './FormHelpMessage';
import {withNetwork} from './OnyxProvider';
import networkPropTypes from './networkPropTypes';
import useNetwork from '../hooks/useNetwork';
+import * as Browser from '../libs/Browser';
+
+const TEXT_INPUT_EMPTY_STATE = '';
const propTypes = {
/** Information about the network */
@@ -91,22 +95,40 @@ const composeToString = (value) => _.map(value, (v) => (v === undefined || v ===
const getInputPlaceholderSlots = (length) => Array.from(Array(length).keys());
function MagicCodeInput(props) {
- const inputRefs = useRef([]);
- const [input, setInput] = useState('');
+ const inputRefs = useRef();
+ const [input, setInput] = useState(TEXT_INPUT_EMPTY_STATE);
const [focusedIndex, setFocusedIndex] = useState(0);
const [editIndex, setEditIndex] = useState(0);
+ const shouldFocusLast = useRef(false);
+ const inputWidth = useRef(0);
+ const lastFocusedIndex = useRef(0);
const blurMagicCodeInput = () => {
- inputRefs.current[editIndex].blur();
+ inputRefs.current.blur();
setFocusedIndex(undefined);
};
+ const focusMagicCodeInput = () => {
+ setFocusedIndex(0);
+ lastFocusedIndex.current = 0;
+ setEditIndex(0);
+ inputRefs.current.focus();
+ };
+
useImperativeHandle(props.innerRef, () => ({
focus() {
- inputRefs.current[0].focus();
+ focusMagicCodeInput();
+ },
+ resetFocus() {
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ focusMagicCodeInput();
},
clear() {
- inputRefs.current[0].focus();
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(0);
+ lastFocusedIndex.current = 0;
+ setEditIndex(0);
+ inputRefs.current.focus();
props.onChangeText('');
},
blur() {
@@ -137,17 +159,37 @@ function MagicCodeInput(props) {
}, [props.value, props.shouldSubmitOnComplete]);
/**
- * Callback for the onFocus event, updates the indexes
- * of the currently focused input.
+ * Focuses on the input when it is pressed.
*
* @param {Object} event
* @param {Number} index
*/
- const onFocus = (event, index) => {
+ const onFocus = (event) => {
+ if (shouldFocusLast.current) {
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(lastFocusedIndex.current);
+ setEditIndex(lastFocusedIndex.current);
+ }
event.preventDefault();
- setInput('');
+ };
+
+ /**
+ * Callback for the onPress event, updates the indexes
+ * of the currently focused input.
+ *
+ * @param {Number} index
+ */
+ const onPress = (index) => {
+ shouldFocusLast.current = false;
+ // TapGestureHandler works differently on mobile web and native app
+ // On web gesture handler doesn't block interactions with textInput below so there is no need to run `focus()` manually
+ if (!Browser.isMobileChrome() && !Browser.isMobileSafari()) {
+ inputRefs.current.focus();
+ }
+ setInput(TEXT_INPUT_EMPTY_STATE);
setFocusedIndex(index);
setEditIndex(index);
+ lastFocusedIndex.current = index;
};
/**
@@ -175,7 +217,9 @@ function MagicCodeInput(props) {
let numbers = decomposeString(props.value, props.maxLength);
numbers = [...numbers.slice(0, editIndex), ...numbersArr, ...numbers.slice(numbersArr.length + editIndex, props.maxLength)];
- inputRefs.current[updatedFocusedIndex].focus();
+ setFocusedIndex(updatedFocusedIndex);
+ setEditIndex(updatedFocusedIndex);
+ setInput(TEXT_INPUT_EMPTY_STATE);
const finalInput = composeToString(numbers);
props.onChangeText(finalInput);
@@ -196,7 +240,7 @@ function MagicCodeInput(props) {
// If the currently focused index already has a value, it will delete
// that value but maintain the focus on the same input.
if (numbers[focusedIndex] !== CONST.MAGIC_CODE_EMPTY_CHAR) {
- setInput('');
+ setInput(TEXT_INPUT_EMPTY_STATE);
numbers = [...numbers.slice(0, focusedIndex), CONST.MAGIC_CODE_EMPTY_CHAR, ...numbers.slice(focusedIndex + 1, props.maxLength)];
setEditIndex(focusedIndex);
props.onChangeText(composeToString(numbers));
@@ -215,24 +259,37 @@ function MagicCodeInput(props) {
}
const newFocusedIndex = Math.max(0, focusedIndex - 1);
+
+ // Saves the input string so that it can compare to the change text
+ // event that will be triggered, this is a workaround for mobile that
+ // triggers the change text on the event after the key press.
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(newFocusedIndex);
+ setEditIndex(newFocusedIndex);
props.onChangeText(composeToString(numbers));
if (!_.isUndefined(newFocusedIndex)) {
- inputRefs.current[newFocusedIndex].focus();
+ inputRefs.current.focus();
}
}
if (keyValue === 'ArrowLeft' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.max(0, focusedIndex - 1);
- inputRefs.current[newFocusedIndex].focus();
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(newFocusedIndex);
+ setEditIndex(newFocusedIndex);
+ inputRefs.current.focus();
} else if (keyValue === 'ArrowRight' && !_.isUndefined(focusedIndex)) {
const newFocusedIndex = Math.min(focusedIndex + 1, props.maxLength - 1);
- inputRefs.current[newFocusedIndex].focus();
+ setInput(TEXT_INPUT_EMPTY_STATE);
+ setFocusedIndex(newFocusedIndex);
+ setEditIndex(newFocusedIndex);
+ inputRefs.current.focus();
} else if (keyValue === 'Enter') {
// We should prevent users from submitting when it's offline.
if (props.network.isOffline) {
return;
}
- setInput('');
+ setInput(TEXT_INPUT_EMPTY_STATE);
props.onFulfill(props.value);
}
};
@@ -240,6 +297,48 @@ function MagicCodeInput(props) {
return (
<>
+ {
+ onPress(Math.floor(e.nativeEvent.x / (inputWidth.current / props.maxLength)));
+ }}
+ >
+ {/* Android does not handle touch on invisible Views so I created a wrapper around invisible TextInput just to handle taps */}
+
+ {
+ inputWidth.current = e.nativeEvent.layout.width;
+ }}
+ ref={(ref) => (inputRefs.current = ref)}
+ autoFocus={props.autoFocus}
+ inputMode="numeric"
+ textContentType="oneTimeCode"
+ name={props.name}
+ maxLength={props.maxLength}
+ value={input}
+ hideFocusedState
+ autoComplete={props.autoComplete}
+ keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
+ onChangeText={(value) => {
+ onChangeText(value);
+ }}
+ onKeyPress={onKeyPress}
+ onFocus={onFocus}
+ onBlur={() => {
+ shouldFocusLast.current = true;
+ lastFocusedIndex.current = focusedIndex;
+ setFocusedIndex(undefined);
+ }}
+ selectionColor="transparent"
+ inputStyle={[styles.inputTransparent]}
+ accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
+ style={[styles.inputTransparent]}
+ textInputContainerStyles={[styles.borderNone]}
+ />
+
+
{_.map(getInputPlaceholderSlots(props.maxLength), (index) => (
{decomposeString(props.value, props.maxLength)[index] || ''}
- {/* Hide the input above the text. Cannot set opacity to 0 as it would break pasting on iOS Safari. */}
-
- {
- inputRefs.current[index] = ref;
- // Setting attribute type to "search" to prevent Password Manager from appearing in Mobile Chrome
- if (ref && ref.setAttribute) {
- ref.setAttribute('type', 'search');
- }
- }}
- autoFocus={index === 0 && props.autoFocus}
- inputMode="numeric"
- textContentType="oneTimeCode"
- name={props.name}
- maxLength={props.maxLength}
- value={input}
- hideFocusedState
- autoComplete={index === 0 ? props.autoComplete : 'off'}
- keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD}
- onChangeText={(value) => {
- // Do not run when the event comes from an input that is
- // not currently being responsible for the input, this is
- // necessary to avoid calls when the input changes due to
- // deleted characters. Only happens in mobile.
- if (index !== editIndex || _.isUndefined(focusedIndex)) {
- return;
- }
- onChangeText(value);
- }}
- onKeyPress={onKeyPress}
- onFocus={(event) => onFocus(event, index)}
- // Manually set selectionColor to make caret transparent.
- // We cannot use caretHidden as it breaks the pasting function on Android.
- selectionColor="transparent"
- textInputContainerStyles={[styles.borderNone]}
- inputStyle={[styles.inputTransparent]}
- accessibilityRole={CONST.ACCESSIBILITY_ROLE.TEXT}
- />
-
))}
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/OpacityView.js b/src/components/OpacityView.js
index 2d09da744267..daef93cdc09b 100644
--- a/src/components/OpacityView.js
+++ b/src/components/OpacityView.js
@@ -3,6 +3,7 @@ import Animated, {useAnimatedStyle, useSharedValue, withTiming} from 'react-nati
import PropTypes from 'prop-types';
import variables from '../styles/variables';
import * as StyleUtils from '../styles/StyleUtils';
+import shouldRenderOffscreen from '../libs/shouldRenderOffscreen';
const propTypes = {
/**
@@ -27,11 +28,15 @@ const propTypes = {
* @default 0.5
*/
dimmingValue: PropTypes.number,
+
+ /** Whether the view needs to be rendered offscreen (for Android only) */
+ needsOffscreenAlphaCompositing: PropTypes.bool,
};
const defaultProps = {
style: [],
dimmingValue: variables.hoverDimValue,
+ needsOffscreenAlphaCompositing: false,
};
function OpacityView(props) {
@@ -48,7 +53,14 @@ function OpacityView(props) {
}
}, [props.shouldDim, props.dimmingValue, opacity]);
- return {props.children} ;
+ return (
+
+ {props.children}
+
+ );
}
OpacityView.displayName = 'OpacityView';
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index 7f6eb0a490b7..8bc016faa6b5 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -212,6 +212,7 @@ class OptionRow extends Component {
accessibilityRole={CONST.ACCESSIBILITY_ROLE.BUTTON}
hoverDimmingValue={1}
hoverStyle={this.props.hoverStyle}
+ needsOffscreenAlphaCompositing={this.props.option.icons.length >= 2}
>
diff --git a/src/components/Pressable/PressableWithFeedback.js b/src/components/Pressable/PressableWithFeedback.js
index 40be99823ceb..a80e2109ebd7 100644
--- a/src/components/Pressable/PressableWithFeedback.js
+++ b/src/components/Pressable/PressableWithFeedback.js
@@ -7,7 +7,7 @@ import OpacityView from '../OpacityView';
import variables from '../../styles/variables';
import useSingleExecution from '../../hooks/useSingleExecution';
-const omittedProps = ['wrapperStyle'];
+const omittedProps = ['wrapperStyle', 'needsOffscreenAlphaCompositing'];
const PressableWithFeedbackPropTypes = {
...GenericPressablePropTypes.pressablePropTypes,
@@ -27,6 +27,9 @@ const PressableWithFeedbackPropTypes = {
* Used to locate this view from native classes.
*/
nativeID: propTypes.string,
+
+ /** Whether the view needs to be rendered offscreen (for Android only) */
+ needsOffscreenAlphaCompositing: propTypes.bool,
};
const PressableWithFeedbackDefaultProps = {
@@ -35,10 +38,11 @@ const PressableWithFeedbackDefaultProps = {
hoverDimmingValue: variables.hoverDimValue,
nativeID: '',
wrapperStyle: [],
+ needsOffscreenAlphaCompositing: false,
};
const PressableWithFeedback = forwardRef((props, ref) => {
- const propsWithoutWrapperStyles = _.omit(props, omittedProps);
+ const propsWithoutWrapperProps = _.omit(props, omittedProps);
const {isExecuting, singleExecution} = useSingleExecution();
const [isPressed, setIsPressed] = useState(false);
const [isHovered, setIsHovered] = useState(false);
@@ -49,11 +53,12 @@ const PressableWithFeedback = forwardRef((props, ref) => {
shouldDim={Boolean(!isDisabled && (isPressed || isHovered))}
dimmingValue={isPressed ? props.pressDimmingValue : props.hoverDimmingValue}
style={props.wrapperStyle}
+ needsOffscreenAlphaCompositing={props.needsOffscreenAlphaCompositing}
>
{
diff --git a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js
index f521a57957f3..0a4f7949643a 100644
--- a/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js
+++ b/src/components/PressableWithSecondaryInteraction/pressableWithSecondaryInteractionPropTypes.js
@@ -48,6 +48,9 @@ const propTypes = {
/** Used to apply styles to the Pressable */
style: stylePropTypes,
+
+ /** Whether the view needs to be rendered offscreen (for Android only) */
+ needsOffscreenAlphaCompositing: PropTypes.bool,
};
const defaultProps = {
@@ -59,6 +62,7 @@ const defaultProps = {
withoutFocusOnSecondaryInteraction: false,
activeOpacity: 1,
enableLongPressWithHover: false,
+ needsOffscreenAlphaCompositing: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 16d5cb57d592..808babdec779 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -7,13 +7,15 @@ import PropTypes from 'prop-types';
import reportPropTypes from '../../pages/reportPropTypes';
import ONYXKEYS from '../../ONYXKEYS';
import ROUTES from '../../ROUTES';
+import Permissions from '../../libs/Permissions';
import Navigation from '../../libs/Navigation/Navigation';
import withCurrentUserPersonalDetails, {withCurrentUserPersonalDetailsPropTypes} from '../withCurrentUserPersonalDetails';
import compose from '../../libs/compose';
-import Permissions from '../../libs/Permissions';
import MenuItemWithTopDescription from '../MenuItemWithTopDescription';
import styles from '../../styles/styles';
+import themeColors from '../../styles/themes/default';
import * as ReportUtils from '../../libs/ReportUtils';
+import * as IOU from '../../libs/actions/IOU';
import * as OptionsListUtils from '../../libs/OptionsListUtils';
import * as ReportActionsUtils from '../../libs/ReportActionsUtils';
import * as StyleUtils from '../../styles/StyleUtils';
@@ -28,6 +30,8 @@ import * as ReceiptUtils from '../../libs/ReceiptUtils';
import useWindowDimensions from '../../hooks/useWindowDimensions';
import transactionPropTypes from '../transactionPropTypes';
import Image from '../Image';
+import Text from '../Text';
+import Switch from '../Switch';
import ReportActionItemImage from './ReportActionItemImage';
import * as TransactionUtils from '../../libs/TransactionUtils';
import OfflineWithFeedback from '../OfflineWithFeedback';
@@ -73,10 +77,9 @@ const defaultProps = {
policyTags: {},
};
-function MoneyRequestView({report, betas, parentReport, policyCategories, shouldShowHorizontalRule, transaction, policyTags}) {
+function MoneyRequestView({report, betas, parentReport, policyCategories, shouldShowHorizontalRule, transaction, policyTags, policy}) {
const {isSmallScreenWidth} = useWindowDimensions();
const {translate} = useLocalize();
-
const parentReportAction = ReportActionsUtils.getParentReportAction(report);
const moneyRequestReport = parentReport;
const {
@@ -85,6 +88,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
currency: transactionCurrency,
comment: transactionDescription,
merchant: transactionMerchant,
+ billable: transactionBillable,
category: transactionCategory,
tag: transactionTag,
} = ReportUtils.getTransactionDetails(transaction);
@@ -104,6 +108,7 @@ function MoneyRequestView({report, betas, parentReport, policyCategories, should
// Flags for showing categories and tags
const shouldShowCategory = isPolicyExpenseChat && Permissions.canUseCategories(betas) && (transactionCategory || OptionsListUtils.hasEnabledOptions(lodashValues(policyCategories)));
const shouldShowTag = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionTag || OptionsListUtils.hasEnabledOptions(lodashValues(policyTagsList)));
+ const shouldShowBillable = isPolicyExpenseChat && Permissions.canUseTags(betas) && (transactionBillable || !lodashGet(policy, 'disabledFields.defaultBillable', true));
let description = `${translate('iou.amount')} • ${translate('iou.cash')}`;
if (isSettled) {
@@ -162,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') : ''}
/>
@@ -188,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 && (
)}
+ {shouldShowBillable && (
+
+ {translate('common.billable')}
+ IOU.editMoneyRequest(transaction.transactionID, report.reportID, {billable: value})}
+ />
+
+ )}
{
- if (!event.data || !event.data.type || (event.data.type !== 'STATEMENT_NAVIGATE' && event.data.type !== 'CONCIERGE_NAVIGATE')) {
+ if (!event.data || !event.data.type || (event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) {
return;
}
- if (event.data.type === 'CONCIERGE_NAVIGATE') {
+ if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) {
Report.navigateToConciergeChat();
}
- if (event.data.type === 'STATEMENT_NAVIGATE' && event.data.url) {
+ if (event.data.type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && event.data.url) {
const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND];
const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => event.data.url.includes(iouRoute));
if (navigateToIOURoute) {
diff --git a/src/components/WalletStatementModal/index.native.js b/src/components/WalletStatementModal/index.native.js
index 590431274da5..38d1f90af00d 100644
--- a/src/components/WalletStatementModal/index.native.js
+++ b/src/components/WalletStatementModal/index.native.js
@@ -1,24 +1,22 @@
-import React from 'react';
+import React, {useCallback, useRef} from 'react';
import {WebView} from 'react-native-webview';
import lodashGet from 'lodash/get';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
-import withLocalize from '../withLocalize';
-import ONYXKEYS from '../../ONYXKEYS';
-import compose from '../../libs/compose';
import {walletStatementPropTypes, walletStatementDefaultProps} from './WalletStatementModalPropTypes';
import FullScreenLoadingIndicator from '../FullscreenLoadingIndicator';
import * as Report from '../../libs/actions/Report';
import Navigation from '../../libs/Navigation/Navigation';
import ROUTES from '../../ROUTES';
+import ONYXKEYS from '../../ONYXKEYS';
+import CONST from '../../CONST';
-class WalletStatementModal extends React.Component {
- constructor(props) {
- super(props);
+const IOU_ROUTES = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND];
+const renderLoading = () => ;
- this.authToken = lodashGet(props, 'session.authToken', null);
- this.navigate = this.navigate.bind(this);
- }
+function WalletStatementModal({statementPageURL, session}) {
+ const webViewRef = useRef();
+ const authToken = lodashGet(session, 'authToken', null);
/**
* Handles in-app navigation for webview links
@@ -26,54 +24,53 @@ class WalletStatementModal extends React.Component {
* @param {String} params.type
* @param {String} params.url
*/
- navigate({type, url}) {
- if (!this.webview || (type !== 'STATEMENT_NAVIGATE' && type !== 'CONCIERGE_NAVIGATE')) {
- return;
- }
+ const handleNavigationStateChange = useCallback(
+ ({type, url}) => {
+ if (!webViewRef.current || (type !== CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && type !== CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE)) {
+ return;
+ }
- if (type === 'CONCIERGE_NAVIGATE') {
- this.webview.stopLoading();
- Report.navigateToConciergeChat();
- }
+ if (type === CONST.WALLET.WEB_MESSAGE_TYPE.CONCIERGE) {
+ webViewRef.current.stopLoading();
+ Report.navigateToConciergeChat();
+ }
- if (type === 'STATEMENT_NAVIGATE' && url) {
- const iouRoutes = [ROUTES.IOU_REQUEST, ROUTES.IOU_SEND];
- const navigateToIOURoute = _.find(iouRoutes, (iouRoute) => url.includes(iouRoute));
- if (navigateToIOURoute) {
- this.webview.stopLoading();
- Navigation.navigate(navigateToIOURoute);
+ if (type === CONST.WALLET.WEB_MESSAGE_TYPE.STATEMENT && url) {
+ const iouRoute = _.find(IOU_ROUTES, (item) => url.includes(item));
+
+ if (iouRoute) {
+ webViewRef.current.stopLoading();
+ Navigation.navigate(iouRoute);
+ }
}
- }
- }
+ },
+ [webViewRef],
+ );
- render() {
- return (
- (this.webview = node)}
- originWhitelist={['https://*']}
- source={{
- uri: this.props.statementPageURL,
- headers: {
- Cookie: `authToken=${this.authToken}`,
- },
- }}
- incognito // 'incognito' prop required for Android, issue here https://github.com/react-native-webview/react-native-webview/issues/1352
- startInLoadingState
- renderLoading={() => }
- onNavigationStateChange={this.navigate}
- />
- );
- }
+ return (
+
+ );
}
+WalletStatementModal.displayName = 'WalletStatementModal';
WalletStatementModal.propTypes = walletStatementPropTypes;
WalletStatementModal.defaultProps = walletStatementDefaultProps;
-export default compose(
- withLocalize,
- withOnyx({
- session: {
- key: ONYXKEYS.SESSION,
- },
- }),
-)(WalletStatementModal);
+export default withOnyx({
+ session: {
+ key: ONYXKEYS.SESSION,
+ },
+})(WalletStatementModal);
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/languages/en.ts b/src/languages/en.ts
index 403931d542f3..43d93e093d69 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -244,6 +244,7 @@ export default {
merchant: 'Merchant',
category: 'Category',
billable: 'Billable',
+ nonBillable: 'Non-billable',
tag: 'Tag',
receipt: 'Receipt',
replace: 'Replace',
@@ -1071,7 +1072,7 @@ export default {
noBankAccountSelected: 'Please choose an account',
taxID: 'Please enter a valid tax ID number',
website: 'Please enter a valid website',
- zipCode: 'Please enter a valid zip code',
+ zipCode: `Incorrect zip code format. Acceptable format: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`,
phoneNumber: 'Please enter a valid phone number',
companyName: 'Please enter a valid legal business name',
addressCity: 'Please enter a valid city',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index ffe334f4a807..582134c32896 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -234,6 +234,7 @@ export default {
merchant: 'Comerciante',
category: 'Categoría',
billable: 'Facturable',
+ nonBillable: 'No facturable',
tag: 'Etiqueta',
receipt: 'Recibo',
replace: 'Sustituir',
@@ -1086,7 +1087,7 @@ export default {
noBankAccountSelected: 'Por favor, elige una cuenta bancaria',
taxID: 'Por favor, introduce un número de identificación fiscal válido',
website: 'Por favor, introduce un sitio web válido',
- zipCode: 'Por favor, introduce un código postal válido',
+ zipCode: `Formato de código postal incorrecto. Formato aceptable: ${CONST.COUNTRY_ZIP_REGEX_DATA.US.samples}`,
phoneNumber: 'Por favor, introduce un teléfono válido',
companyName: 'Por favor, introduce un nombre comercial legal válido',
addressCity: 'Por favor, introduce una ciudad válida',
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/ErrorUtils.js b/src/libs/ErrorUtils.js
deleted file mode 100644
index 95bbad5f5409..000000000000
--- a/src/libs/ErrorUtils.js
+++ /dev/null
@@ -1,135 +0,0 @@
-import _ from 'underscore';
-import lodashGet from 'lodash/get';
-import CONST from '../CONST';
-import DateUtils from './DateUtils';
-import * as Localize from './Localize';
-
-/**
- * @param {Object} response
- * @param {Number} response.jsonCode
- * @param {String} response.message
- * @returns {String}
- */
-function getAuthenticateErrorMessage(response) {
- switch (response.jsonCode) {
- case CONST.JSON_CODE.UNABLE_TO_RETRY:
- return 'session.offlineMessageRetry';
- case 401:
- return 'passwordForm.error.incorrectLoginOrPassword';
- case 402:
- // If too few characters are passed as the password, the WAF will pass it to the API as an empty
- // string, which results in a 402 error from Auth.
- if (response.message === '402 Missing partnerUserSecret') {
- return 'passwordForm.error.incorrectLoginOrPassword';
- }
- return 'passwordForm.error.twoFactorAuthenticationEnabled';
- case 403:
- if (response.message === 'Invalid code') {
- return 'passwordForm.error.incorrect2fa';
- }
- return 'passwordForm.error.invalidLoginOrPassword';
- case 404:
- return 'passwordForm.error.unableToResetPassword';
- case 405:
- return 'passwordForm.error.noAccess';
- case 413:
- return 'passwordForm.error.accountLocked';
- default:
- return 'passwordForm.error.fallback';
- }
-}
-
-/**
- * Method used to get an error object with microsecond as the key.
- * @param {String} error - error key or message to be saved
- * @return {Object}
- *
- */
-function getMicroSecondOnyxError(error) {
- return {[DateUtils.getMicroseconds()]: error};
-}
-
-/**
- * @param {Object} onyxData
- * @param {Object} onyxData.errors
- * @returns {String}
- */
-function getLatestErrorMessage(onyxData) {
- if (_.isEmpty(onyxData.errors)) {
- return '';
- }
- return _.chain(onyxData.errors || [])
- .keys()
- .sortBy()
- .reverse()
- .map((key) => onyxData.errors[key])
- .first()
- .value();
-}
-
-/**
- * @param {Object} onyxData
- * @param {Object} onyxData.errorFields
- * @param {String} fieldName
- * @returns {Object}
- */
-function getLatestErrorField(onyxData, fieldName) {
- const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {});
-
- if (_.isEmpty(errorsForField)) {
- return {};
- }
- return _.chain(errorsForField)
- .keys()
- .sortBy()
- .reverse()
- .map((key) => ({[key]: errorsForField[key]}))
- .first()
- .value();
-}
-
-/**
- * @param {Object} onyxData
- * @param {Object} onyxData.errorFields
- * @param {String} fieldName
- * @returns {Object}
- */
-function getEarliestErrorField(onyxData, fieldName) {
- const errorsForField = lodashGet(onyxData, ['errorFields', fieldName], {});
-
- if (_.isEmpty(errorsForField)) {
- return {};
- }
- return _.chain(errorsForField)
- .keys()
- .sortBy()
- .map((key) => ({[key]: errorsForField[key]}))
- .first()
- .value();
-}
-
-/**
- * Method used to generate error message for given inputID
- * @param {Object} errors - An object containing current errors in the form
- * @param {String} inputID
- * @param {String|Array} message - Message to assign to the inputID errors
- *
- */
-function addErrorMessage(errors, inputID, message) {
- if (!message || !inputID) {
- return;
- }
-
- const errorList = errors;
- const translatedMessage = Localize.translateIfPhraseKey(message);
-
- if (_.isEmpty(errorList[inputID])) {
- errorList[inputID] = [translatedMessage, {isTranslated: true}];
- } else if (_.isString(errorList[inputID])) {
- errorList[inputID] = [`${errorList[inputID]}\n${translatedMessage}`, {isTranslated: true}];
- } else {
- errorList[inputID][0] = `${errorList[inputID][0]}\n${translatedMessage}`;
- }
-}
-
-export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
new file mode 100644
index 000000000000..bf4fc0d810a4
--- /dev/null
+++ b/src/libs/ErrorUtils.ts
@@ -0,0 +1,114 @@
+import CONST from '../CONST';
+import DateUtils from './DateUtils';
+import * as Localize from './Localize';
+import Response from '../types/onyx/Response';
+import {ErrorFields, Errors} from '../types/onyx/OnyxCommon';
+import {TranslationFlatObject} from '../languages/types';
+
+function getAuthenticateErrorMessage(response: Response): keyof TranslationFlatObject {
+ switch (response.jsonCode) {
+ case CONST.JSON_CODE.UNABLE_TO_RETRY:
+ return 'session.offlineMessageRetry';
+ case 401:
+ return 'passwordForm.error.incorrectLoginOrPassword';
+ case 402:
+ // If too few characters are passed as the password, the WAF will pass it to the API as an empty
+ // string, which results in a 402 error from Auth.
+ if (response.message === '402 Missing partnerUserSecret') {
+ return 'passwordForm.error.incorrectLoginOrPassword';
+ }
+ return 'passwordForm.error.twoFactorAuthenticationEnabled';
+ case 403:
+ if (response.message === 'Invalid code') {
+ return 'passwordForm.error.incorrect2fa';
+ }
+ return 'passwordForm.error.invalidLoginOrPassword';
+ case 404:
+ return 'passwordForm.error.unableToResetPassword';
+ case 405:
+ return 'passwordForm.error.noAccess';
+ case 413:
+ return 'passwordForm.error.accountLocked';
+ default:
+ return 'passwordForm.error.fallback';
+ }
+}
+
+/**
+ * Method used to get an error object with microsecond as the key.
+ * @param error - error key or message to be saved
+ */
+function getMicroSecondOnyxError(error: string): Record {
+ return {[DateUtils.getMicroseconds()]: error};
+}
+
+type OnyxDataWithErrors = {
+ errors?: Errors;
+};
+
+function getLatestErrorMessage(onyxData: TOnyxData): string {
+ const errors = onyxData.errors ?? {};
+
+ if (Object.keys(errors).length === 0) {
+ return '';
+ }
+
+ const key = Object.keys(errors).sort().reverse()[0];
+
+ return errors[key];
+}
+
+type OnyxDataWithErrorFields = {
+ errorFields?: ErrorFields;
+};
+
+function getLatestErrorField(onyxData: TOnyxData, fieldName: string): Record {
+ const errorsForField = onyxData.errorFields?.[fieldName] ?? {};
+
+ if (Object.keys(errorsForField).length === 0) {
+ return {};
+ }
+
+ const key = Object.keys(errorsForField).sort().reverse()[0];
+
+ return {[key]: errorsForField[key]};
+}
+
+function getEarliestErrorField(onyxData: TOnyxData, fieldName: string): Record {
+ const errorsForField = onyxData.errorFields?.[fieldName] ?? {};
+
+ if (Object.keys(errorsForField).length === 0) {
+ return {};
+ }
+
+ const key = Object.keys(errorsForField).sort()[0];
+
+ return {[key]: errorsForField[key]};
+}
+
+type ErrorsList = Record;
+
+/**
+ * Method used to generate error message for given inputID
+ * @param errorList - An object containing current errors in the form
+ * @param message - Message to assign to the inputID errors
+ */
+function addErrorMessage(errors: ErrorsList, inputID?: string, message?: string) {
+ if (!message || !inputID) {
+ return;
+ }
+
+ const errorList = errors;
+ const error = errorList[inputID];
+ const translatedMessage = Localize.translateIfPhraseKey(message);
+
+ if (!error) {
+ errorList[inputID] = [translatedMessage, {isTranslated: true}];
+ } else if (typeof error === 'string') {
+ errorList[inputID] = [`${error}\n${translatedMessage}`, {isTranslated: true}];
+ } else if (Array.isArray(error)) {
+ error[0] = `${error[0]}\n${translatedMessage}`;
+ }
+}
+
+export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
diff --git a/src/libs/KeyboardShortcut/index.js b/src/libs/KeyboardShortcut/index.js
index 37d85c7bfbfc..f91c81a1b856 100644
--- a/src/libs/KeyboardShortcut/index.js
+++ b/src/libs/KeyboardShortcut/index.js
@@ -83,6 +83,9 @@ _.each(CONST.KEYBOARD_SHORTCUTS, (shortcut) => {
*/
function unsubscribe(displayName, callbackID) {
eventHandlers[displayName] = _.reject(eventHandlers[displayName], (callback) => callback.id === callbackID);
+ if (_.has(documentedShortcuts, displayName) && _.size(eventHandlers[displayName]) === 0) {
+ delete documentedShortcuts[displayName];
+ }
}
/**
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/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..56411be6cc04 100644
--- a/src/libs/OptionsListUtils.js
+++ b/src/libs/OptionsListUtils.js
@@ -814,6 +814,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 475c1a8bcb8a..47d2f9ba2217 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -1341,7 +1341,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,7 +1355,9 @@ 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),
};
}
@@ -1615,6 +1618,11 @@ function getModifiedExpenseMessage(reportAction) {
if (hasModifiedTag) {
return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.tag, reportActionOriginalMessage.oldTag, Localize.translateLocal('common.tag'), true);
}
+
+ const hasModifiedBillable = _.has(reportActionOriginalMessage, 'oldBillable') && _.has(reportActionOriginalMessage, 'billable');
+ if (hasModifiedBillable) {
+ return getProperSchemaForModifiedExpenseMessage(reportActionOriginalMessage.billable, reportActionOriginalMessage.oldBillable, Localize.translateLocal('iou.request'), true);
+ }
}
/**
@@ -1665,6 +1673,12 @@ function getModifiedExpenseOriginalMessage(oldTransaction, transactionChanges, i
originalMessage.tag = transactionChanges.tag;
}
+ if (_.has(transactionChanges, 'billable')) {
+ const oldBillable = TransactionUtils.getBillable(oldTransaction);
+ originalMessage.oldBillable = oldBillable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase();
+ originalMessage.billable = transactionChanges.billable ? Localize.translateLocal('common.billable').toLowerCase() : Localize.translateLocal('common.nonBillable').toLowerCase();
+ }
+
return originalMessage;
}
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 58fb23a8811a..aff1068546d1 100644
--- a/src/libs/TransactionUtils.js
+++ b/src/libs/TransactionUtils.js
@@ -147,6 +147,15 @@ 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;
+ }
+
if (_.has(transactionChanges, 'category')) {
updatedTransaction.category = transactionChanges.category;
}
@@ -165,6 +174,8 @@ 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}),
};
@@ -248,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.
*
@@ -258,6 +279,16 @@ function getCategory(transaction) {
return lodashGet(transaction, 'category', '');
}
+/**
+ * Return the billable field from the transaction. This "billable" field has no "modified" complement.
+ *
+ * @param {Object} transaction
+ * @return {Boolean}
+ */
+function getBillable(transaction) {
+ return lodashGet(transaction, 'billable', false);
+}
+
/**
* Return the tag from the transaction. This "tag" field has no "modified" complement.
*
@@ -414,6 +445,7 @@ export {
getMerchant,
getCreated,
getCategory,
+ getBillable,
getTag,
getLinkedTransaction,
getAllReportTransactions,
@@ -422,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/Chronos.js b/src/libs/actions/Chronos.ts
similarity index 82%
rename from src/libs/actions/Chronos.js
rename to src/libs/actions/Chronos.ts
index b9c0eed7b354..1b46a68a1afe 100644
--- a/src/libs/actions/Chronos.js
+++ b/src/libs/actions/Chronos.ts
@@ -1,16 +1,10 @@
-import _ from 'underscore';
import Onyx from 'react-native-onyx';
import CONST from '../../CONST';
import ONYXKEYS from '../../ONYXKEYS';
import * as API from '../API';
+import {ChronosOOOEvent} from '../../types/onyx/OriginalMessage';
-/**
- * @param {String} reportID
- * @param {String} reportActionID
- * @param {String} eventID
- * @param {Object[]} events
- */
-const removeEvent = (reportID, reportActionID, eventID, events) => {
+const removeEvent = (reportID: string, reportActionID: string, eventID: string, events: ChronosOOOEvent[]) => {
const optimisticData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -19,7 +13,7 @@ const removeEvent = (reportID, reportActionID, eventID, events) => {
[reportActionID]: {
pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE,
originalMessage: {
- events: _.reject(events, (event) => event.id === eventID),
+ events: events.filter((event) => event.id !== eventID),
},
},
},
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 bda357b14789..198ceb2b8172 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -604,8 +604,9 @@ function getMoneyRequestInformation(
* @param {Number} amount
* @param {String} currency
* @param {String} merchant
+ * @param {Boolean} [billable]
*/
-function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant) {
+function createDistanceRequest(report, participant, comment, created, transactionID, category, tag, amount, currency, merchant, billable) {
const optimisticReceipt = {
source: ReceiptGeneric,
state: CONST.IOU.RECEIPT_STATE.OPEN,
@@ -624,6 +625,7 @@ function createDistanceRequest(report, participant, comment, created, transactio
transactionID,
category,
tag,
+ billable,
);
API.write(
'CreateDistanceRequest',
@@ -640,6 +642,7 @@ function createDistanceRequest(report, participant, comment, created, transactio
created,
category,
tag,
+ billable,
},
onyxData,
);
@@ -647,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
*
@@ -1224,6 +1375,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
created: null,
currency: null,
merchant: null,
+ billable: null,
category: null,
tag: null,
},
@@ -1272,7 +1424,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
];
// STEP 6: Call the API endpoint
- const {created, amount, currency, comment, merchant, category, tag} = ReportUtils.getTransactionDetails(updatedTransaction);
+ const {created, amount, currency, comment, merchant, category, billable, tag} = ReportUtils.getTransactionDetails(updatedTransaction);
API.write(
'EditMoneyRequest',
{
@@ -1284,6 +1436,7 @@ function editMoneyRequest(transactionID, transactionThreadReportID, transactionC
comment,
merchant,
category,
+ billable,
tag,
},
{optimisticData, successData, failureData},
@@ -2108,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
@@ -2123,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;
}
@@ -2143,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));
@@ -2177,5 +2330,6 @@ export {
setMoneyRequestReceipt,
createEmptyTransaction,
navigateToNextPage,
+ updateDistanceRequest,
replaceReceipt,
};
diff --git a/src/libs/actions/OnyxUpdateManager.js b/src/libs/actions/OnyxUpdateManager.js
index e0f3f8fd4622..b6318b784439 100644
--- a/src/libs/actions/OnyxUpdateManager.js
+++ b/src/libs/actions/OnyxUpdateManager.js
@@ -88,6 +88,7 @@ export default () => {
canUnpauseQueuePromise.finally(() => {
OnyxUpdates.apply(updateParams).finally(() => {
console.debug('[OnyxUpdateManager] Done applying all updates');
+ Onyx.set(ONYXKEYS.ONYX_UPDATES_FROM_SERVER, null);
SequentialQueue.unpause();
});
});
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index 296abc6a9cfa..2de53293853a 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -1249,11 +1249,12 @@ function editReportComment(reportID, originalReportAction, textForNewComment) {
* Saves the draft for a comment report action. This will put the comment into "edit mode"
*
* @param {String} reportID
- * @param {Number} reportActionID
+ * @param {Object} reportAction
* @param {String} draftMessage
*/
-function saveReportActionDraft(reportID, reportActionID, draftMessage) {
- Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${reportID}_${reportActionID}`, draftMessage);
+function saveReportActionDraft(reportID, reportAction, draftMessage) {
+ const originalReportID = ReportUtils.getOriginalReportID(reportID, reportAction);
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${reportAction.reportActionID}`, draftMessage);
}
/**
diff --git a/src/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/ReportScreen.js b/src/pages/home/ReportScreen.js
index 15bf25695fd3..999622b9a22e 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -174,7 +174,7 @@ function ReportScreen({
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);
diff --git a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
index fc56b3b1fac9..91fe38784e9c 100755
--- a/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/BaseReportActionContextMenu.js
@@ -18,6 +18,7 @@ import ONYXKEYS from '../../../../ONYXKEYS';
import CONST from '../../../../CONST';
import useArrowKeyFocusManager from '../../../../hooks/useArrowKeyFocusManager';
import useKeyboardShortcut from '../../../../hooks/useKeyboardShortcut';
+import useNetwork from '../../../../hooks/useNetwork';
const propTypes = {
/** String representing the context menu type [LINK, REPORT_ACTION] which controls context menu choices */
@@ -51,6 +52,7 @@ function BaseReportActionContextMenu(props) {
const menuItemRefs = useRef({});
const [shouldKeepOpen, setShouldKeepOpen] = useState(false);
const wrapperStyle = getReportActionContextMenuStyles(props.isMini, props.isSmallScreenWidth);
+ const {isOffline} = useNetwork();
const reportAction = useMemo(() => {
if (_.isEmpty(props.reportActions) || props.reportActionID === '0') {
@@ -60,7 +62,18 @@ function BaseReportActionContextMenu(props) {
}, [props.reportActions, props.reportActionID]);
const shouldShowFilter = (contextAction) =>
- contextAction.shouldShow(props.type, reportAction, props.isArchivedRoom, props.betas, props.anchor, props.isChronosReport, props.reportID, props.isPinnedChat, props.isUnreadChat);
+ contextAction.shouldShow(
+ props.type,
+ reportAction,
+ props.isArchivedRoom,
+ props.betas,
+ props.anchor,
+ props.isChronosReport,
+ props.reportID,
+ props.isPinnedChat,
+ props.isUnreadChat,
+ isOffline,
+ );
const shouldEnableArrowNavigation = !props.isMini && (props.isVisible || shouldKeepOpen);
const filteredContextMenuActions = _.filter(ContextMenuActions, shouldShowFilter);
diff --git a/src/pages/home/report/ContextMenu/ContextMenuActions.js b/src/pages/home/report/ContextMenu/ContextMenuActions.js
index 964a10a9ad66..157ae66dc918 100644
--- a/src/pages/home/report/ContextMenu/ContextMenuActions.js
+++ b/src/pages/home/report/ContextMenu/ContextMenuActions.js
@@ -95,10 +95,10 @@ export default [
icon: Expensicons.Download,
successTextTranslateKey: 'common.download',
successIcon: Expensicons.Download,
- shouldShow: (type, reportAction) => {
+ shouldShow: (type, reportAction, isArchivedRoom, betas, anchor, isChronosReport, reportID, isPinnedChat, isUnreadChat, isOffline) => {
const isAttachment = ReportActionsUtils.isReportActionAttachment(reportAction);
const messageHtml = lodashGet(reportAction, ['message', 0, 'html']);
- return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction);
+ return isAttachment && messageHtml !== CONST.ATTACHMENT_UPLOADING_MESSAGE_HTML && reportAction.reportActionID && !ReportActionsUtils.isMessageDeleted(reportAction) && !isOffline;
},
onPress: (closePopover, {reportAction}) => {
const message = _.last(lodashGet(reportAction, 'message', [{}]));
@@ -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/ContextMenu/PopoverReportActionContextMenu.js b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
index dd0813132a8e..4f09df7330ff 100644
--- a/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
+++ b/src/pages/home/report/ContextMenu/PopoverReportActionContextMenu.js
@@ -1,96 +1,59 @@
-import React from 'react';
+import React, {forwardRef, useEffect, useState, useRef, useImperativeHandle, useCallback} from 'react';
import {Dimensions} from 'react-native';
import _ from 'underscore';
-import lodashGet from 'lodash/get';
import * as Report from '../../../../libs/actions/Report';
-import withLocalize, {withLocalizePropTypes} from '../../../../components/withLocalize';
import PopoverWithMeasuredContent from '../../../../components/PopoverWithMeasuredContent';
import BaseReportActionContextMenu from './BaseReportActionContextMenu';
import ConfirmModal from '../../../../components/ConfirmModal';
-import CONST from '../../../../CONST';
import * as ReportActionsUtils from '../../../../libs/ReportActionsUtils';
import * as IOU from '../../../../libs/actions/IOU';
+import useLocalize from '../../../../hooks/useLocalize';
-const propTypes = {
- ...withLocalizePropTypes,
-};
-
-class PopoverReportActionContextMenu extends React.Component {
- constructor(props) {
- super(props);
-
- this.state = {
- reportID: '0',
- reportActionID: '0',
- originalReportID: '0',
- reportAction: {},
- selection: '',
- reportActionDraftMessage: '',
- isPopoverVisible: false,
- isDeleteCommentConfirmModalVisible: false,
- shouldSetModalVisibilityForDeleteConfirmation: true,
- cursorRelativePosition: {
- horizontal: 0,
- vertical: 0,
- },
-
- // The horizontal and vertical position (relative to the screen) where the popover will display.
- popoverAnchorPosition: {
- horizontal: 0,
- vertical: 0,
- },
- isArchivedRoom: false,
- isChronosReport: false,
- isPinnedChat: false,
- isUnreadChat: false,
- };
- this.onPopoverShow = () => {};
- this.onPopoverHide = () => {};
- this.onPopoverHideActionCallback = () => {};
- this.contextMenuAnchor = undefined;
- this.showContextMenu = this.showContextMenu.bind(this);
- this.hideContextMenu = this.hideContextMenu.bind(this);
- this.measureContextMenuAnchorPosition = this.measureContextMenuAnchorPosition.bind(this);
- this.confirmDeleteAndHideModal = this.confirmDeleteAndHideModal.bind(this);
- this.hideDeleteModal = this.hideDeleteModal.bind(this);
- this.showDeleteModal = this.showDeleteModal.bind(this);
- this.runAndResetOnPopoverShow = this.runAndResetOnPopoverShow.bind(this);
- this.runAndResetOnPopoverHide = this.runAndResetOnPopoverHide.bind(this);
- this.getContextMenuMeasuredLocation = this.getContextMenuMeasuredLocation.bind(this);
- this.isActiveReportAction = this.isActiveReportAction.bind(this);
- this.clearActiveReportAction = this.clearActiveReportAction.bind(this);
-
- this.dimensionsEventListener = null;
-
- this.contentRef = React.createRef();
- this.setContentRef = (ref) => {
- this.contentRef.current = ref;
- };
- this.setContentRef = this.setContentRef.bind(this);
- this.anchorRef = React.createRef();
- }
-
- componentDidMount() {
- this.dimensionsEventListener = Dimensions.addEventListener('change', this.measureContextMenuAnchorPosition);
- }
-
- shouldComponentUpdate(nextProps, nextState) {
- const previousLocale = lodashGet(this.props, 'preferredLocale', CONST.LOCALES.DEFAULT);
- const nextLocale = lodashGet(nextProps, 'preferredLocale', CONST.LOCALES.DEFAULT);
- return (
- this.state.isPopoverVisible !== nextState.isPopoverVisible ||
- this.state.popoverAnchorPosition !== nextState.popoverAnchorPosition ||
- this.state.isDeleteCommentConfirmModalVisible !== nextState.isDeleteCommentConfirmModalVisible ||
- previousLocale !== nextLocale
- );
- }
-
- componentWillUnmount() {
- if (!this.dimensionsEventListener) {
- return;
- }
- this.dimensionsEventListener.remove();
- }
+function PopoverReportActionContextMenu(_props, ref) {
+ const {translate} = useLocalize();
+ const reportIDRef = useRef('0');
+ const typeRef = useRef('');
+ const reportActionRef = useRef({});
+ const reportActionIDRef = useRef('0');
+ const originalReportIDRef = useRef('0');
+ const selectionRef = useRef('');
+ const reportActionDraftMessageRef = useRef('');
+
+ const cursorRelativePosition = useRef({
+ horizontal: 0,
+ vertical: 0,
+ });
+
+ // The horizontal and vertical position (relative to the screen) where the popover will display.
+ const popoverAnchorPosition = useRef({
+ horizontal: 0,
+ vertical: 0,
+ });
+
+ const [instanceID, setInstanceID] = useState('');
+
+ const [isPopoverVisible, setIsPopoverVisible] = useState(false);
+ const [isDeleteCommentConfirmModalVisible, setIsDeleteCommentConfirmModalVisible] = useState(false);
+ const [shouldSetModalVisibilityForDeleteConfirmation, setShouldSetModalVisibilityForDeleteConfirmation] = useState(true);
+
+ const [isRoomArchived, setIsRoomArchived] = useState(false);
+ const [isChronosReportEnabled, setIsChronosReportEnabled] = useState(false);
+ const [isChatPinned, setIsChatPinned] = useState(false);
+ const [hasUnreadMessages, setHasUnreadMessages] = useState(false);
+
+ const contentRef = useRef(null);
+ const anchorRef = useRef(null);
+ const dimensionsEventListener = useRef(null);
+ const contextMenuAnchorRef = useRef(null);
+ const contextMenuTargetNode = useRef(null);
+
+ const onPopoverShow = useRef(() => {});
+ const onPopoverHide = useRef(() => {});
+ const onCancelDeleteModal = useRef(() => {});
+ const onComfirmDeleteModal = useRef(() => {});
+
+ const onPopoverHideActionCallback = useRef(() => {});
+ const callbackWhenDeleteModalHide = useRef(() => {});
/**
* Get the Context menu anchor position
@@ -98,15 +61,48 @@ class PopoverReportActionContextMenu extends React.Component {
*
* @returns {Promise}
*/
- getContextMenuMeasuredLocation() {
- return new Promise((resolve) => {
- if (this.contextMenuAnchor) {
- (this.contextMenuAnchor.current || this.contextMenuAnchor).measureInWindow((x, y) => resolve({x, y}));
- } else {
- resolve({x: 0, y: 0});
+ const getContextMenuMeasuredLocation = useCallback(
+ () =>
+ new Promise((resolve) => {
+ if (contextMenuAnchorRef.current && _.isFunction(contextMenuAnchorRef.current.measureInWindow)) {
+ contextMenuAnchorRef.current.measureInWindow((x, y) => resolve({x, y}));
+ } else {
+ resolve({x: 0, y: 0});
+ }
+ }),
+ [],
+ );
+
+ /**
+ * This gets called on Dimensions change to find the anchor coordinates for the action context menu.
+ */
+ const measureContextMenuAnchorPosition = useCallback(() => {
+ if (!isPopoverVisible) {
+ return;
+ }
+
+ getContextMenuMeasuredLocation().then(({x, y}) => {
+ if (!x || !y) {
+ return;
}
+
+ popoverAnchorPosition.current = {
+ horizontal: cursorRelativePosition.horizontal + x,
+ vertical: cursorRelativePosition.vertical + y,
+ };
});
- }
+ }, [isPopoverVisible, getContextMenuMeasuredLocation]);
+
+ useEffect(() => {
+ dimensionsEventListener.current = Dimensions.addEventListener('change', measureContextMenuAnchorPosition);
+
+ return () => {
+ if (!dimensionsEventListener.current) {
+ return;
+ }
+ dimensionsEventListener.current.remove();
+ };
+ }, [measureContextMenuAnchorPosition]);
/**
* Whether Context Menu is active for the Report Action.
@@ -114,13 +110,12 @@ class PopoverReportActionContextMenu extends React.Component {
* @param {Number|String} actionID
* @return {Boolean}
*/
- isActiveReportAction(actionID) {
- return Boolean(actionID) && (this.state.reportActionID === actionID || this.state.reportAction.reportActionID === actionID);
- }
+ const isActiveReportAction = (actionID) => Boolean(actionID) && (reportActionIDRef.current === actionID || reportActionRef.current.reportActionID === actionID);
- clearActiveReportAction() {
- this.setState({reportID: '0', reportAction: {}});
- }
+ const clearActiveReportAction = () => {
+ reportActionIDRef.current = '0';
+ reportActionRef.current = {};
+ };
/**
* Show the ReportActionContextMenu modal popover.
@@ -140,7 +135,7 @@ class PopoverReportActionContextMenu extends React.Component {
* @param {Boolean} isPinnedChat - Flag to check if the chat is pinned in the LHN. Used for the Pin/Unpin action
* @param {Boolean} isUnreadChat - Flag to check if the chat is unread in the LHN. Used for the Mark as Read/Unread action
*/
- showContextMenu(
+ const showContextMenu = (
type,
event,
selection,
@@ -155,130 +150,105 @@ class PopoverReportActionContextMenu extends React.Component {
isChronosReport = false,
isPinnedChat = false,
isUnreadChat = false,
- ) {
+ ) => {
const nativeEvent = event.nativeEvent || {};
- this.contextMenuAnchor = contextMenuAnchor;
- this.contextMenuTargetNode = nativeEvent.target;
-
- // Singleton behaviour of ContextMenu creates race conditions when user requests multiple contextMenus.
- // But it is possible that every new request registers new callbacks thus instanceID is used to corelate those callbacks
- this.instanceID = Math.random().toString(36).substr(2, 5);
-
- this.onPopoverShow = onShow;
- this.onPopoverHide = onHide;
-
- this.getContextMenuMeasuredLocation().then(({x, y}) => {
- this.setState({
- cursorRelativePosition: {
- horizontal: nativeEvent.pageX - x,
- vertical: nativeEvent.pageY - y,
- },
- popoverAnchorPosition: {
- horizontal: nativeEvent.pageX,
- vertical: nativeEvent.pageY,
- },
- type,
- reportID,
- reportActionID,
- originalReportID,
- selection,
- isPopoverVisible: true,
- reportActionDraftMessage: draftMessage,
- isArchivedRoom,
- isChronosReport,
- isPinnedChat,
- isUnreadChat,
- });
- });
- }
+ contextMenuAnchorRef.current = contextMenuAnchor;
+ contextMenuTargetNode.current = nativeEvent.target;
- /**
- * This gets called on Dimensions change to find the anchor coordinates for the action context menu.
- */
- measureContextMenuAnchorPosition() {
- if (!this.state.isPopoverVisible) {
- return;
- }
- this.getContextMenuMeasuredLocation().then(({x, y}) => {
- if (!x || !y) {
- return;
- }
- this.setState((prev) => ({
- popoverAnchorPosition: {
- horizontal: prev.cursorRelativePosition.horizontal + x,
- vertical: prev.cursorRelativePosition.vertical + y,
- },
- }));
+ setInstanceID(Math.random().toString(36).substr(2, 5));
+
+ onPopoverShow.current = onShow;
+ onPopoverHide.current = onHide;
+
+ getContextMenuMeasuredLocation().then(({x, y}) => {
+ popoverAnchorPosition.current = {
+ horizontal: nativeEvent.pageX - x,
+ vertical: nativeEvent.pageY - y,
+ };
+
+ popoverAnchorPosition.current = {
+ horizontal: nativeEvent.pageX,
+ vertical: nativeEvent.pageY,
+ };
+ typeRef.current = type;
+ reportIDRef.current = reportID;
+ reportActionIDRef.current = reportActionID;
+ originalReportIDRef.current = originalReportID;
+ selectionRef.current = selection;
+ setIsPopoverVisible(true);
+ reportActionDraftMessageRef.current = draftMessage;
+ setIsRoomArchived(isArchivedRoom);
+ setIsChronosReportEnabled(isChronosReport);
+ setIsChatPinned(isPinnedChat);
+ setHasUnreadMessages(isUnreadChat);
});
- }
+ };
/**
* After Popover shows, call the registered onPopoverShow callback and reset it
*/
- runAndResetOnPopoverShow() {
- this.onPopoverShow();
+ const runAndResetOnPopoverShow = () => {
+ onPopoverShow.current();
// After we have called the action, reset it.
- this.onPopoverShow = () => {};
- }
+ onPopoverShow.current = () => {};
+ };
+
+ /**
+ * Run the callback and return a noop function to reset it
+ * @param {Function} callback
+ * @returns {Function}
+ */
+ const runAndResetCallback = (callback) => {
+ callback();
+ return () => {};
+ };
/**
* After Popover hides, call the registered onPopoverHide & onPopoverHideActionCallback callback and reset it
*/
- runAndResetOnPopoverHide() {
- this.setState({reportID: '0', reportActionID: '0', originalReportID: '0'}, () => {
- this.onPopoverHide = this.runAndResetCallback(this.onPopoverHide);
- this.onPopoverHideActionCallback = this.runAndResetCallback(this.onPopoverHideActionCallback);
- });
- }
+ const runAndResetOnPopoverHide = () => {
+ reportIDRef.current = '0';
+ reportActionIDRef.current = '0';
+ originalReportIDRef.current = '0';
+
+ onPopoverHide.current = runAndResetCallback(onPopoverHide.current);
+ onPopoverHideActionCallback.current = runAndResetCallback(onPopoverHideActionCallback.current);
+ };
/**
* Hide the ReportActionContextMenu modal popover.
* @param {Function} onHideActionCallback Callback to be called after popover is completely hidden
*/
- hideContextMenu(onHideActionCallback) {
+ const hideContextMenu = (onHideActionCallback) => {
if (_.isFunction(onHideActionCallback)) {
- this.onPopoverHideActionCallback = onHideActionCallback;
+ onPopoverHideActionCallback.current = onHideActionCallback;
}
- this.setState({
- selection: '',
- reportActionDraftMessage: '',
- isPopoverVisible: false,
- });
- }
- /**
- * Run the callback and return a noop function to reset it
- * @param {Function} callback
- * @returns {Function}
- */
- runAndResetCallback(callback) {
- callback();
- return () => {};
- }
-
- confirmDeleteAndHideModal() {
- this.callbackWhenDeleteModalHide = () => (this.onComfirmDeleteModal = this.runAndResetCallback(this.onComfirmDeleteModal));
+ selectionRef.current = '';
+ reportActionDraftMessageRef.current = '';
+ setIsPopoverVisible(false);
+ };
- if (ReportActionsUtils.isMoneyRequestAction(this.state.reportAction)) {
- IOU.deleteMoneyRequest(this.state.reportAction.originalMessage.IOUTransactionID, this.state.reportAction);
+ const confirmDeleteAndHideModal = useCallback(() => {
+ callbackWhenDeleteModalHide.current = () => (onComfirmDeleteModal.current = runAndResetCallback(onComfirmDeleteModal.current));
+ if (ReportActionsUtils.isMoneyRequestAction(reportActionRef.current)) {
+ IOU.deleteMoneyRequest(reportActionRef.current.originalMessage.IOUTransactionID, reportActionRef.current);
} else {
- Report.deleteReportComment(this.state.reportID, this.state.reportAction);
+ Report.deleteReportComment(reportIDRef.current, reportActionRef.current);
}
- this.setState({isDeleteCommentConfirmModalVisible: false});
- }
-
- hideDeleteModal() {
- this.callbackWhenDeleteModalHide = () => (this.onCancelDeleteModal = this.runAndResetCallback(this.onCancelDeleteModal));
- this.setState({
- isDeleteCommentConfirmModalVisible: false,
- shouldSetModalVisibilityForDeleteConfirmation: true,
- isArchivedRoom: false,
- isChronosReport: false,
- isPinnedChat: false,
- isUnreadChat: false,
- });
- }
+ setIsDeleteCommentConfirmModalVisible(false);
+ }, [reportActionRef]);
+
+ const hideDeleteModal = () => {
+ callbackWhenDeleteModalHide.current = () => (onCancelDeleteModal.current = runAndResetCallback(onCancelDeleteModal.current));
+ setIsDeleteCommentConfirmModalVisible(false);
+ setShouldSetModalVisibilityForDeleteConfirmation(true);
+ setIsRoomArchived(false);
+ setIsChronosReportEnabled(false);
+ setIsChatPinned(false);
+ setHasUnreadMessages(false);
+ };
/**
* Opens the Confirm delete action modal
@@ -288,67 +258,82 @@ class PopoverReportActionContextMenu extends React.Component {
* @param {Function} [onConfirm]
* @param {Function} [onCancel]
*/
- showDeleteModal(reportID, reportAction, shouldSetModalVisibility = true, onConfirm = () => {}, onCancel = () => {}) {
- this.onCancelDeleteModal = onCancel;
- this.onComfirmDeleteModal = onConfirm;
- this.setState({
- reportID,
- reportAction,
- shouldSetModalVisibilityForDeleteConfirmation: shouldSetModalVisibility,
- isDeleteCommentConfirmModalVisible: true,
- });
- }
-
- render() {
- return (
- <>
-
-
-
- {}, onCancel = () => {}) => {
+ onCancelDeleteModal.current = onCancel;
+ onComfirmDeleteModal.current = onConfirm;
+
+ reportIDRef.current = reportID;
+ reportActionRef.current = reportAction;
+
+ setShouldSetModalVisibilityForDeleteConfirmation(shouldSetModalVisibility);
+ setIsDeleteCommentConfirmModalVisible(true);
+ };
+
+ useImperativeHandle(ref, () => ({
+ showContextMenu,
+ hideContextMenu,
+ showDeleteModal,
+ hideDeleteModal,
+ isActiveReportAction,
+ instanceID,
+ runAndResetOnPopoverHide,
+ clearActiveReportAction,
+ }));
+
+ const reportAction = reportActionRef.current;
+
+ return (
+ <>
+
+
- >
- );
- }
+
+ {
+ reportIDRef.current = '0';
+ reportActionRef.current = {};
+ callbackWhenDeleteModalHide.current();
+ }}
+ prompt={translate('reportActionContextMenu.deleteConfirmation', {action: reportAction})}
+ confirmText={translate('common.delete')}
+ cancelText={translate('common.cancel')}
+ danger
+ />
+ >
+ );
}
-PopoverReportActionContextMenu.propTypes = propTypes;
+PopoverReportActionContextMenu.displayName = 'PopoverReportActionContextMenu';
-export default withLocalize(PopoverReportActionContextMenu);
+export default forwardRef(PopoverReportActionContextMenu);
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
index 95a33fe6b721..1c537f9b4e77 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions.js
@@ -327,7 +327,7 @@ function ComposerWithSuggestions({
(action) => ReportUtils.canEditReportAction(action) && !ReportActionsUtils.isMoneyRequestAction(action),
);
if (lastReportAction) {
- Report.saveReportActionDraft(reportID, lastReportAction.reportActionID, _.last(lastReportAction.message).html);
+ Report.saveReportActionDraft(reportID, lastReportAction, _.last(lastReportAction.message).html);
}
}
},
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 3ff7972e06a5..645f7d32abdb 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -668,7 +668,8 @@ export default compose(
withReportActionsDrafts({
propName: 'draftMessage',
transformValue: (drafts, props) => {
- const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${props.report.reportID}_${props.action.reportActionID}`;
+ const originalReportID = ReportUtils.getOriginalReportID(props.report.reportID, props.action);
+ const draftKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS_DRAFTS}${originalReportID}_${props.action.reportActionID}`;
return lodashGet(drafts, draftKey, '');
},
}),
diff --git a/src/pages/home/report/ReportActionItemMessageEdit.js b/src/pages/home/report/ReportActionItemMessageEdit.js
index 954af7a8d8a1..24ea8c59f545 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];
@@ -226,7 +223,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],
);
/**
@@ -234,7 +231,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();
@@ -248,7 +245,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
@@ -266,21 +263,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();
@@ -289,7 +271,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
@@ -451,17 +433,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/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 990ffd4bb590..9e46b1d2d7a2 100644
--- a/src/pages/iou/MoneyRequestSelectorPage.js
+++ b/src/pages/iou/MoneyRequestSelectorPage.js
@@ -1,6 +1,6 @@
import {withOnyx} from 'react-native-onyx';
import {View} from 'react-native';
-import React, {useState} from 'react';
+import React, {useEffect, useState} from 'react';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import ONYXKEYS from '../../ONYXKEYS';
@@ -15,13 +15,14 @@ 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';
import reportPropTypes from '../reportPropTypes';
import * as ReportUtils from '../../libs/ReportUtils';
import themeColors from '../../styles/themes/default';
+import usePrevious from '../../hooks/usePrevious';
const propTypes = {
/** React Navigation route */
@@ -69,6 +70,18 @@ function MoneyRequestSelectorPage(props) {
IOU.resetMoneyRequestInfo(moneyRequestID);
};
+ const prevSelectedTab = usePrevious(props.selectedTab);
+
+ useEffect(() => {
+ if (prevSelectedTab === props.selectedTab) {
+ return;
+ }
+
+ resetMoneyRequestInfo();
+ // resetMoneyRequestInfo function is not added as dependencies since they don't change between renders
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [props.selectedTab, prevSelectedTab]);
+
return (
)}
@@ -121,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 a817195fe8a3..b4cf75801a3f 100644
--- a/src/pages/iou/ReceiptSelector/index.js
+++ b/src/pages/iou/ReceiptSelector/index.js
@@ -1,5 +1,5 @@
-import {View, Text, PixelRatio} from 'react-native';
-import React, {useContext, useState} from 'react';
+import {View, Text, PanResponder, PixelRatio} from 'react-native';
+import React, {useContext, useRef, useState} from 'react';
import lodashGet from 'lodash/get';
import _ from 'underscore';
import PropTypes from 'prop-types';
@@ -8,7 +8,6 @@ import * as IOU from '../../../libs/actions/IOU';
import reportPropTypes from '../../reportPropTypes';
import CONST from '../../../CONST';
import ReceiptUpload from '../../../../assets/images/receipt-upload.svg';
-import PressableWithFeedback from '../../../components/Pressable/PressableWithFeedback';
import Button from '../../../components/Button';
import styles from '../../../styles/styles';
import CopyTextToClipboard from '../../../components/CopyTextToClipboard';
@@ -61,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('');
@@ -127,9 +125,16 @@ function ReceiptSelector(props) {
return;
}
- IOU.navigateToNextPage(iou, iouType, reportID, report, props.route.path);
+ IOU.navigateToNextPage(iou, iouType, report, props.route.path);
};
+ const panResponder = useRef(
+ PanResponder.create({
+ onMoveShouldSetPanResponder: () => true,
+ onPanResponderTerminationRequest: () => false,
+ }),
+ ).current;
+
return (
{!isDraggingOver ? (
@@ -144,35 +149,37 @@ function ReceiptSelector(props) {
height={CONST.RECEIPT.ICON_SIZE}
/>
- {translate('receipt.upload')}
-
- {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')}
-
- {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')}
-
+
+ {translate('receipt.upload')}
+
+ {isSmallScreenWidth ? translate('receipt.chooseReceipt') : translate('receipt.dragReceiptBeforeEmail')}
+
+ {isSmallScreenWidth ? null : translate('receipt.dragReceiptAfterEmail')}
+
+
{({openPicker}) => (
-
- {
- openPicker({
- onPicked: (file) => {
- setReceiptAndNavigate(file, props.iou, props.report);
- },
- });
- }}
- />
-
+ style={[styles.p9]}
+ onPress={() => {
+ openPicker({
+ onPicked: (file) => {
+ setReceiptAndNavigate(file, props.iou, props.report);
+ },
+ });
+ }}
+ />
)}
>
diff --git a/src/pages/iou/ReceiptSelector/index.native.js b/src/pages/iou/ReceiptSelector/index.native.js
index a9d02a52411e..4de4e9bb9148 100644
--- a/src/pages/iou/ReceiptSelector/index.native.js
+++ b/src/pages/iou/ReceiptSelector/index.native.js
@@ -246,18 +246,14 @@ function ReceiptSelector({route, report, iou, transactionID, isInTabNavigator})
/>
{translate('receipt.takePhoto')}
{translate('receipt.cameraAccess')}
-
-
-
+ style={[styles.p9, styles.pt5]}
+ onPress={askForPermissions}
+ />
)}
{permissions === RESULTS.GRANTED && device == null && (
@@ -298,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/MoneyRequestAmountForm.js b/src/pages/iou/steps/MoneyRequestAmountForm.js
index e08fd5bde881..c4fc29957179 100644
--- a/src/pages/iou/steps/MoneyRequestAmountForm.js
+++ b/src/pages/iou/steps/MoneyRequestAmountForm.js
@@ -106,7 +106,7 @@ function MoneyRequestAmountForm({amount, currency, isEditing, forwardedRef, onCu
};
useEffect(() => {
- if (!currency || !amount) {
+ if (!currency || !_.isNumber(amount)) {
return;
}
const amountAsStringForState = CurrencyUtils.convertToFrontendAmount(amount).toString();
diff --git a/src/pages/iou/steps/MoneyRequestConfirmPage.js b/src/pages/iou/steps/MoneyRequestConfirmPage.js
index 015707db71f2..3881221d5c52 100644
--- a/src/pages/iou/steps/MoneyRequestConfirmPage.js
+++ b/src/pages/iou/steps/MoneyRequestConfirmPage.js
@@ -178,9 +178,10 @@ function MoneyRequestConfirmPage(props) {
props.iou.amount,
props.iou.currency,
props.iou.merchant,
+ props.iou.billable,
);
},
- [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant],
+ [props.report, props.iou.created, props.iou.transactionID, props.iou.category, props.iou.tag, props.iou.amount, props.iou.currency, props.iou.merchant, props.iou.billable],
);
const createTransaction = useCallback(
@@ -393,7 +394,7 @@ export default compose(
// eslint-disable-next-line rulesdir/no-multiple-onyx-in-file
withOnyx({
policy: {
- key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`,
+ key: ({report}) => `${ONYXKEYS.COLLECTION.POLICY}${report ? report.policyID : '0'}`,
},
}),
)(MoneyRequestConfirmPage);
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/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
index bcea33d9c366..300bd23cc2e5 100644
--- a/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
+++ b/src/pages/settings/Profile/Contacts/ValidateCodeForm/BaseValidateCodeForm.js
@@ -111,6 +111,7 @@ function BaseValidateCodeForm(props) {
const resendValidateCode = () => {
User.requestContactMethodValidateCode(props.contactMethod);
setValidateCode('');
+ inputValidateCodeRef.current.clear();
inputValidateCodeRef.current.focus();
};
diff --git a/src/pages/settings/Profile/CustomStatus/StatusPage.js b/src/pages/settings/Profile/CustomStatus/StatusPage.js
index 807bd73cecc1..0fa231aec6f1 100644
--- a/src/pages/settings/Profile/CustomStatus/StatusPage.js
+++ b/src/pages/settings/Profile/CustomStatus/StatusPage.js
@@ -7,9 +7,9 @@ import MenuItemWithTopDescription from '../../../../components/MenuItemWithTopDe
import HeaderPageLayout from '../../../../components/HeaderPageLayout';
import * as Expensicons from '../../../../components/Icon/Expensicons';
import withLocalize from '../../../../components/withLocalize';
-import MenuItem from '../../../../components/MenuItem';
import Button from '../../../../components/Button';
import Text from '../../../../components/Text';
+import MenuItem from '../../../../components/MenuItem';
import Navigation from '../../../../libs/Navigation/Navigation';
import * as User from '../../../../libs/actions/User';
import MobileBackgroundImage from '../../../../../assets/images/money-stack.svg';
@@ -34,8 +34,16 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
const defaultEmoji = draftEmojiCode || currentUserEmojiCode;
const defaultText = draftEmojiCode ? draftText : currentUserStatusText;
- const customStatus = draftEmojiCode ? `${draftEmojiCode} ${draftText}` : `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`;
const hasDraftStatus = !!draftEmojiCode || !!draftText;
+ const customStatus = useMemo(() => {
+ if (draftEmojiCode) {
+ return `${draftEmojiCode} ${draftText}`;
+ }
+ if (currentUserEmojiCode || currentUserStatusText) {
+ return `${currentUserEmojiCode || ''} ${currentUserStatusText || ''}`;
+ }
+ return '';
+ }, [draftEmojiCode, draftText, currentUserEmojiCode, currentUserStatusText]);
const clearStatus = () => {
User.clearCustomStatus();
@@ -77,7 +85,7 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
backgroundColor={themeColors.PAGE_BACKGROUND_COLORS[SCREENS.SETTINGS.STATUS]}
footer={footerComponent}
>
-
+
{localize.translate('statusPage.setStatusTitle')}
{localize.translate('statusPage.statusExplanation')}
@@ -92,10 +100,11 @@ function StatusPage({draftStatus, currentUserPersonalDetails}) {
{(!!currentUserEmojiCode || !!currentUserStatusText) && (
)}
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/styles/styles.js b/src/styles/styles.js
index 7bba63c8f09f..8e90af0f027c 100644
--- a/src/styles/styles.js
+++ b/src/styles/styles.js
@@ -767,11 +767,16 @@ const styles = (theme) => ({
marginRight: 20,
justifyContent: 'center',
alignItems: 'center',
- padding: 40,
+ paddingVertical: 40,
gap: 4,
flex: 1,
}),
+ receiptViewTextContainer: {
+ paddingHorizontal: 40,
+ ...sizing.w100,
+ },
+
cameraView: {
flex: 1,
overflow: 'hidden',
@@ -3390,7 +3395,7 @@ const styles = (theme) => ({
},
signInIconButton: {
- padding: 2,
+ paddingVertical: 2,
},
googleButtonContainer: {
diff --git a/src/types/onyx/OriginalMessage.ts b/src/types/onyx/OriginalMessage.ts
index 8ed25cb286b0..369ff44773ab 100644
--- a/src/types/onyx/OriginalMessage.ts
+++ b/src/types/onyx/OriginalMessage.ts
@@ -137,4 +137,4 @@ type OriginalMessage =
| OriginalMessagePolicyTask;
export default OriginalMessage;
-export type {Reaction};
+export type {Reaction, ChronosOOOEvent};
diff --git a/src/types/onyx/Response.ts b/src/types/onyx/Response.ts
index c501034e971c..255ac6d9bae4 100644
--- a/src/types/onyx/Response.ts
+++ b/src/types/onyx/Response.ts
@@ -3,9 +3,10 @@ import {OnyxUpdate} from 'react-native-onyx';
type Response = {
previousUpdateID?: number | string;
lastUpdateID?: number | string;
- jsonCode?: number;
+ jsonCode?: number | string;
onyxData?: OnyxUpdate[];
requestID?: string;
+ message?: string;
};
export default Response;
diff --git a/src/types/onyx/Transaction.ts b/src/types/onyx/Transaction.ts
index 8327c866df0f..dd53024a5426 100644
--- a/src/types/onyx/Transaction.ts
+++ b/src/types/onyx/Transaction.ts
@@ -25,6 +25,7 @@ type Routes = Record;
type Transaction = {
amount: number;
+ billable: boolean;
category: string;
comment: Comment;
created: string;
@@ -38,6 +39,9 @@ type Transaction = {
modifiedCreated?: string;
modifiedCurrency?: string;
pendingAction: OnyxCommon.PendingAction;
+ pendingFields: {
+ comment: string;
+ };
receipt: {
receiptID?: number;
source?: string;