diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 1f80908b02b5..0951b194430b 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -24,8 +24,16 @@ jobs:
- name: Check for new JavaScript files
run: |
git fetch origin main --no-tags --depth=1
- count_new_js=$(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js' | wc -l)
+
+ # Explanation:
+ # - comm is used to get the intersection between two bash arrays
+ # - git diff is used to see the files that were added on this branch
+ # - gh pr view is used to list files touched by this PR. Git diff may give false positives if the branch isn't up-to-date with main
+ # - wc counts the words in the result of the intersection
+ count_new_js=$(comm -1 -2 <(git diff --name-only --diff-filter=A origin/main HEAD -- 'src/libs/*.js' 'src/hooks/*.js' 'src/styles/*.js' 'src/languages/*.js') <(gh pr view ${{ github.event.pull_request.number }} --json files | jq -r '.files | map(.path) | .[]') | wc -l)
if [ "$count_new_js" -gt "0" ]; then
echo "ERROR: Found new JavaScript files in the /src/libs, /src/hooks, /src/styles, or /src/languages directories; use TypeScript instead."
exit 1
fi
+ env:
+ GITHUB_TOKEN: ${{ github.token }}
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 05a934db528a..e11f938a9a8e 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -91,8 +91,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1001040110
- versionName "1.4.1-10"
+ versionCode 1001040306
+ versionName "1.4.3-6"
}
flavorDimensions "default"
diff --git a/docs/articles/expensify-classic/account-settings/Close-Account.md b/docs/articles/expensify-classic/account-settings/Close-Account.md
index 5e18490fc357..c25c22de9704 100644
--- a/docs/articles/expensify-classic/account-settings/Close-Account.md
+++ b/docs/articles/expensify-classic/account-settings/Close-Account.md
@@ -1,5 +1,123 @@
---
-title: Close Account
+title: Close Account
description: Close Account
----
-## Resource Coming Soon!
+---
+# Overview
+
+Here is a walk through of how to close an Expensify account through the website or mobile app.
+
+# How to close your account
+On the Web:
+
+1. Go to **Settings** in the left hand menu. Click **Account**.
+2. Click “Close Account”
+3. Follow the prompts to verify your email or phone number.
+4. Your account will be closed, and all data will be deleted.*
+
+![Close Account via Website]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Desktop.png){:width="100%"}
+
+On the Mobile App:
+
+Open the app and tap the three horizontal lines in the upper left corner.
+Select Settings from the menu.
+Look for the "Close Account" option in the "Others" section. (If you don’t see this option, you have a Domain Controlled account and will need to ask your Domain Admin to delete your account.)
+Complete the verification process using your email or phone number.
+Your account will be closed, and all data will be deleted.*
+
+![Close Account on Mobile Application]({{site.url}}/assets/images/ExpensifyHelp_CloseAccount_Mobile.png){:width="100%"}
+
+These instructions may vary depending on the specific device you are using, so be sure to follow the steps as they appear on your screen.
+
+*Note: Transactions shared with other accounts will still be visible to those accounts. (Example: A report submitted to your company and reimbursed will not be deleted from your company’s account.) Additionally, we are required to retain certain records of transactions in compliance with laws in various jurisdictions.
+
+# How to reopen your account
+
+If your Expensify account is closed and not associated with a verified domain, you can reopen it with the following steps:
+
+1. Visit [expensify.com](https://www.expensify.com/).
+2. Attempt to sign in using your email address or phone number associated with the closed account.
+3. After entering your user name, you will see a prompt to reopen your account.
+4. Click on **Reopen Account**.
+5. A magic link will be sent to your email address.
+6. Access your email and click on the magic link. This link will take you to Expensify and reopen your account.
+7. Follow the prompts to create a new password associated with your account.
+8. Your account is now reopened. Any previously approved expense data will still be visible in the account.
+
+Note: Reports submitted and closed on an Individual workspace will not be retained since they have not been approved or shared with anyone else in Expensify.
+
+That's it! Your account is successfully reopened, and you can access your historical data that was shared with other accounts. Remember to recreate any workspaces and adjust settings if needed.
+
+# How to Reopen a Domain-controlled account
+Once an account has been **Closed** by a Domain Admin, it can be reopened by any Domain Admin on that domain.
+
+The Domain Admin will simply need to invite the previously closed account in the same manner that new accounts are invited to the Domain. The user will receive a magic link to their email account which they can use to Reopen the account.
+
+# How to retain a free account to keep historical expenses
+If you no longer need a group workspace or have a more advanced workspace than necessary in Expensify, and you want to downgrade while retaining your historical data, here's what you should do:
+
+1. If you're part of a group workspace, request the Workspace Admin to remove you, or if you own the workspace, delete it to downgrade to a free version.
+2. Once you've removed or been removed from a workspace, start using your free Expensify account. Your submitted expenses will still be saved, allowing you to access the historical data.
+3. Domain Admins in the company will still retain access to approved and reimbursed expenses.
+4. To keep your data, avoid closing your account. Account closures are irreversible and will result in the deletion of all your unapproved data.
+
+# Deep Dive
+
+## I’m unable to close my account
+
+If you're encountering an error message while trying to close your Expensify account, it's important to pinpoint the specific error. Encountering an error when trying to close your account is typically only experienced if the account has been an admin on a company’s Expensify workspace. (Especially if the account was involved in responsibilities like reimbursing employees or exporting expense reports.)
+
+In order to avoid users accidentally creating issues for their company, Expensify prevents account closure if you still have any individual responsibilities related to a Workspace within the platform. To successfully close your account, you need to ensure that your workspace no longer relies on your account to operate.
+
+Here are the reasons why you might encounter an error when trying to close your Expensify account, along with the actions required to address each of these issues:
+
+- **Account Under a Validated Domain**: If your account is associated with a validated domain, only a Domain Admin can close it. You can find this option in your Domain Admin's settings under Settings > Domains > Domain Members. Afterward, if you have a secondary personal login, you can delete it by following the instructions mentioned above.
+- **Sole Domain Admin for Your Company**: If you are the only Domain Admin for your company's domain, you must appoint another Domain Admin before you can close your account. This is to avoid accidentally prohibiting your entire company from using Expensify. You can do this by going to Settings > Domains > [Domain Name] > Domain Admins and making the necessary changes, or you can reset the entire domain.
+- **Workspace Billing Owner with an Annual Subscription**: If you are the Workspace Billing Owner with an Annual Subscription, you need to downgrade from the Annual subscription before closing the account. Alternatively, you can have another user take over billing for your workspaces.
+- **Ownership of a Company Workspace or Outstanding Balance**: If you own a company workspace or there is an outstanding balance owed to Expensify, you must take one of the following actions before closing your account:
+
+ - Make the payment for the outstanding balance.
+ - Have another user take over billing for the workspace.
+ - Request a refund for your initial bill.
+ - Delete the workspace.
+
+- **Preferred Exporter for Your Workspace Integration**: If you are the "Preferred Exporter" for a workspace Integration, you must update the Preferred Exporter before closing your account. You can do this by navigating to **Settings** > **Workspaces** > **Group** > [Workspace name] > **Connections** > **Configure** and selecting any Workspace Admin from the dropdown menu as the Preferred Exporter.
+- **Verified Business Account with Outstanding Balance or Locked Status**: If you have a Verified Business Account with an outstanding balance or if the account is locked, you should wait for all payments to settle or unlock the account. To settle the amount owed, go to **Settings** > **Account** > **Payments** > **Bank Accounts** and take the necessary steps.
+
+## Validate the account to close it
+
+Sometimes, you may find yourself with an Expensify account that you don't need. This could be due to various reasons like a company inviting you for reimbursement, a vendor inviting you to pay, or a mistaken sign-up.
+
+In such cases, you have two options:
+
+**Option 1**: Retain the Account for Future Use
+You can keep the Expensify account just in case you need it later.
+
+**Option 2**: Close the Account
+
+Start by verifying your email or phone number
+
+Before closing the account, you need to verify that you have access to the email or phone number associated with it. This ensures that only you can close the account.
+
+Here's how to do it:
+
+1. Go to [www.expensify.com](http://www.expensify.com/).
+2. Enter your email address or phone number (whichever is associated with the unwanted account).
+3. Click the **Resend Link** button.
+4. Check your Home Page for the most recent email with the subject line "Please validate your Expensify login." Click the link provided in the email to validate your email address.
+ - If it's an account linked to a phone number, tap the link sent to your phone.
+5. After clicking the validation link, you'll be directed to an Expensify Home Page.
+6. Navigate to **Settings** > **Account** > **Account Details** > **Close Account**.
+7. Click the **Close My Account** button.
+ - Re-enter the email address or phone number of the account when prompted.
+ - Check the box that says, "I understand all of my unsubmitted expense data will be deleted."
+ - Click the **Close My Account** button.
+
+By following these steps, you can easily verify your email or phone number and close an unwanted Expensify account.
+
+# FAQ
+
+## What should I do if I'm not directed to my account when clicking the validate option from my phone or email?
+It's possible your browser has blocked this, either because of some existing cache or extension. In this case, you should follow the Reset Password flow to reset the password and manually gain access with the new password, along with your email address.
+
+## Why don't I see the Close Account option?
+It's possible your account is on a managed company domain. In this case, only the admins from that company can close it.
diff --git a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
index 3ee1c8656b4b..32ce41d3cbf3 100644
--- a/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
+++ b/docs/articles/expensify-classic/manage-employees-and-report-approvals/Approving-Reports.md
@@ -1,5 +1,189 @@
---
-title: Coming Soon
-description: Coming Soon
+title: How To Manage Employees and Reports > Approving Reports
+description: This page will help you understand the lifecycle of a report and how to approve reports that are submitted to you.
---
-## Resource Coming Soon!
+# About
+This article provides a comprehensive guide on report management within our platform. From viewing, editing, and submitting your employees' Open reports to handling rejections and unapproving, as well as harnessing the power of our "Guided Review" feature. Additionally, we'll delve into best practices for Concierge, offering insights on how to streamline and automate your report approval processes for maximum efficiency.
+Let's dive in!
+
+
+# How-to manage reports
+This section covers the most essential information a user needs to operate a feature i.e. what to click on. We’ll go over any action the user might take when configuring or using the feature, starting from configuration and moving to usage.
+
+
+What options does a user have when configuring this feature?
+
+
+What options does a user have then interacting with this feature?
+
+
+What elements of this feature are pay-walled vs. free?
+
+
+As a Workspace admin, you have the ability to view, edit, and submit your employees' Open reports.
+
+
+We recommend beginning this process from the web version of Expensify because it offers more functionality compared to the mobile app. Here's how to get started:
+Click on the "Reports" tab.
+Select the "All Submitters" and "Open" filters.
+This will display all employee reports on your Workspaces that have not yet been submitted.
+
+## Viewing Employee Reports
+Viewing employee reports can vary depending on whether you're using the web or mobile versions of Expensify. We generally recommend using the web version for this purpose, as it offers the following advantages:
+
+
+You will only receive reports directly submitted to you when using the mobile app.
+
+
+The option to filter reports via the Reports page is exclusively available in the web version, making it more convenient when reviewing multiple reports during a session.
+
+## Viewing employee reports on the mobile app
+When using the mobile app to view reports, please note the following:
+
+
+Tapping on the Reports list will only display your own reports; you won't see reports from other Workspace members.
+
+
+To view another Workspace member's report in the Expensify app, it must be submitted directly to you, and you must access it through a link from an email or via Home.
+
+
+When you access a report in this manner, you will have the option to approve/reject it or go through the review process if there are expenses that require your attention.
+
+
+Once you've approved or rejected the report, it won't be accessible in the app anymore. To view it again, please visit the website and follow the steps mentioned above.
+
+## Editing employee reports
+If a report has been submitted directly to you, follow these steps to edit the expense details. Please note that you cannot change the expense amount; to make changes affecting the report total, you must reject it and return it to the employee.
+
+
+Here's what to do:
+- Click on any expense within the report to edit the expense details.
+- Remember that you cannot modify the expense amount directly. To make changes affecting the report total, reject the report, and it will be sent back to the employee for revisions.
+- If you're a Workspace admin and need to edit a report that wasn't submitted directly to you, use the "Take Control" button at the top of the report. Keep in mind that taking control of a report will disrupt the approval workflow.
+
+Additionally, here are some other editing options for Admins:
+- Undelete deleted company card expenses via the Reconciliation Dashboard (requires Domain Admin privileges).
+- Add unreported company card expenses to an existing Open (unsubmitted) report or create a new report via the Reconciliation Dashboard (requires Domain Admin privileges).
+- Add or modify expense coding, including Category, Tag(s), and Attendees.
+- Attach a receipt to an expense that doesn't have one.
+- Move card expenses between two Open (unsubmitted) reports.
+- Merge duplicate expenses (only applicable if they are not card transactions).
+- Change the Workspace associated with a report.
+
+
+## Submitting Employee Reports
+As a Workspace Admin, you have the option to submit any of your employee's Open reports. If an employee is unable to do it themselves, a Workspace admin can submit an expense report on their behalf to initiate the approval process. Follow these steps:
+Click the "Submit" button located at the top of the report.
+
+
+## Report History and Comments
+Please keep in mind that any changes made by the admin are tracked under "Report History and Comments." If you change the reimbursable status of an expense (e.g., from Reimbursable to Non-Reimbursable), an email notification will be sent to the employee to notify them of this change.
+
+
+## Rejecting or Unapproving a Report
+If you need to reject a report that has been submitted to you or Unapprove a report that has already been approved.
+
+
+To reject the report, click Reject rather than beginning the Review process. If there are multiple approvers involved, you can choose how far back to reject the report.
+
+
+Rejecting a report will return the report back to the submitter in an Open status or, in the case of a multi-level approval workflow, back to the previous approver in a Processing status (awaiting their approval). You may need to do this if the submitter is not ready to submit the report, or perhaps the report as a whole needs to be rejected based on the configuration of your organization's expense Workspace.
+
+
+## Unapprove a Report
+You can click the red Unapprove button at the top of the report to undo approving a report. Keep in mind that you'll only see the Unapprove button if you're a report approver on an admin that has taken control of the report.
+
+
+## Marking a Report as Reimbursed Outside of Expensify
+If you are reimbursing reports via paper check, through payroll or any other method that takes place outside of Expensify, you'll want to keep track of which reports have been taken care of by marking reports as reimbursed.
+
+
+1. Log into your Expensify account using your preferred web browser, (ie: Chrome or Safari)
+2. Head to your Reports page and locate the report
+3. Click the report name to open it
+4. Click on Reimburse
+5. Choose "I'll do it manually - just mark it as reimbursed". This will change the report status to Reimbursed
+6. The submitter can then go into the report and confirm that they received the reimbursement by clicking the button at the top of the report.
+7. This will change the report status to Reimbursed: CONFIRMED
+
+
+# How to Use Guided Review to Approve Reports
+Guided Review helps alert you to what might be out-of-Workspace for an Expense Report. You'll be guided through all report violations and warnings and given the option to Reject or Edit items that need review prior to approving a report.
+
+
+Guided Review helps approvers quickly identify reports that need more attention so they can pass over reports that can be quickly approved. Both Submitters and Approvers have actionable notifications for the following: violations, warnings, and notices. These notifications are important since they will be included in “review mode” for the approver to make clear approve or reject decisions.
+
+
+Via the Website:
+1. Simply click Review at the top left of the report and the system will begin to walk you through the entire report.
+2. Choose to Reject, View, or skip over an item needing review. If you wish to stop the process at any time, click the X in the progress bar in the top right corner.
+Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected.
+View: This will allow you to open the expense so you can view and fix any incorrect data.
+Next: This will allow you to skip over the current item and move forward to review the rest of the report.
+Finish: Click this to finish reviewing the report!
+3. Click the Finish button if you are done reviewing, or reject/edit the last item to finish the review process.
+4. Approve the report! Approve and Forward the report if there is another person who needs to review the report in your approval workflow, or you can Final Approve if you are the final approver. Note: When in Guided Review, you'll automatically Approve the report adhering to your Company's Approval Workflow once you Approve the final expense on the report. You'll then be immediately taken to the next report requiring your attention - making Approving multiple expenses a quick and painless process!
+
+
+
+
+Via the Mobile App:
+1. From Home, under Reports that need your attention, click Begin Review, and the system will bring you to the first expense on the oldest report in Home.
+2. Edit the expense: Make any necessary edits to the expense by tapping the corresponding field. Be sure to address any Violations and Notes on the expense! Notes are indicated at the top of the expense with a yellow exclamation point, while violations appear on the expense with a red exclamation point:
+3. Choose Reject or Accept at the top of the expense.
+
+
+Reject: This will remove the expense from the report and send it back to the submitter. An email will be sent to the submitter explaining this expense has been rejected, and a comment will be added to the report it was rejected from. If this is the only expense on the report, the entire report will be rejected (and the expense will remain on the report).
+
+
+If Scheduled Submit is being used, rejected expenses will auto-report to the next Open report on the Workspace (as if it were a new expense). If an open report doesn't exist, Concierge will create a new one.
+
+
+
+If Scheduled Submit is not being used, any rejected expenses will be Unreported in the submitter's account and need to be manually applied to a new report.
+
+Accept: This will move to the next expense on the report, leaving behind any outstanding violations or notes. If this is the last expense on the report, you'll be all done!
+Once you've made it through all of the expenses on the report, you'll be all set!
+
+
+# Deep Dive
+## Concierge Report Management
+
+
+Concierge report approval removes the need for you to manually click "Approve" on endless reports! Instead, you can set up your group Workspace to capture all the requirements you have for your team's expenses. As long as all the rules have been followed and Concierge's additional audit is passed (more below), we will automatically approve such reports on behalf of the approver after submission.
+
+Before you start:
+Ensure are a Workspace admin on a group Workspace
+Set your workflow to Submit-and-Approve or Advanced Approval workflow
+
+
+## Then follow these steps:
+Set up your group Workspace so that all of your expense requirements are defined. Setting automatic categories for employees and category rules (e.g., maximum amounts, receipt requirements, etc.) are great examples!
+
+
+Navigate to Settings > Workspaces > Group > [Workspace Name] > Members.
+
+
+Scroll down to Approval Mode and select either Submit-and-Approve or Advanced Approval.
+
+
+Under Expense Approvals, select a Manual Approval Threshold greater than $0.
+
+
+With this setup, manual approval will only be required:
+- For reports that fail audit (i.e. there is at least one Workspace violation on the report)
+- For reports that contain at least one expense over the Manual Approval Threshold
+- For any percentage of reports that you'd like to spot-check (this is set at 5% or 1 in 20 by default).
+- If the report meets all of the requirements you specify in your Workspace settings and all expenses are under the Manual Approval Threshold, then Concierge will automatically move your report through each step of your designated approval workflow (unless it's routed for a spot-check).
+
+
+
+
+## Concierge Receipt Audit
+Concierge Receipt Audit is a real-time audit and compliance of receipts submitted by employees and Workspace users. Concierge checks every receipt for accuracy and compliance, flagging any expenses that seem fishy before expense reports are even submitted for approval. All risky expenses are highlighted for manual review, leaving you with more control over and visibility into employee expenses.
+
+
+1. Concierge will SmartScan every receipt to verify the data input by the user matches the currency, date, and amount on the physical receipt.
+2. After the report is submitted for approval, Concierge highlights any differences between the SmartScanned values and the employee's chosen input.
+3. Each receipt that has been verified will show the "Verified" logo.
+
diff --git a/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png
new file mode 100644
index 000000000000..8a102375d1c8
Binary files /dev/null and b/docs/assets/images/ExpensifyHelp_CloseAccount_Desktop.png differ
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 8779ef2ee5d3..2d89178b271b 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -19,7 +19,7 @@
CFBundlePackageTypeAPPLCFBundleShortVersionString
- 1.4.1
+ 1.4.3CFBundleSignature????CFBundleURLTypes
@@ -40,7 +40,7 @@
CFBundleVersion
- 1.4.1.10
+ 1.4.3.6ITSAppUsesNonExemptEncryptionLSApplicationQueriesSchemes
diff --git a/ios/NewExpensifyTests/Info.plist b/ios/NewExpensifyTests/Info.plist
index 6f2a9941b1cc..2f053575ff3b 100644
--- a/ios/NewExpensifyTests/Info.plist
+++ b/ios/NewExpensifyTests/Info.plist
@@ -15,10 +15,10 @@
CFBundlePackageTypeBNDLCFBundleShortVersionString
- 1.4.1
+ 1.4.3CFBundleSignature????CFBundleVersion
- 1.4.1.10
+ 1.4.3.6
diff --git a/jest.config.js b/jest.config.js
index efd72d20694f..611a0248b491 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -24,7 +24,7 @@ module.exports = {
},
testEnvironment: 'jsdom',
setupFiles: ['/jest/setup.js', './node_modules/@react-native-google-signin/google-signin/jest/build/setup.js'],
- setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js'],
+ setupFilesAfterEnv: ['@testing-library/jest-native/extend-expect', '/jest/setupAfterEnv.js', '/tests/perf-test/setupAfterEnv.js'],
cacheDirectory: '/.jest-cache',
moduleNameMapper: {
'\\.(lottie)$': '/__mocks__/fileMock.js',
diff --git a/package-lock.json b/package-lock.json
index cfda0135fbac..9dd6974b12c5 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "1.4.1-10",
+ "version": "1.4.3-6",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "1.4.1-10",
+ "version": "1.4.3-6",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
@@ -51,9 +51,8 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
@@ -29906,8 +29905,8 @@
},
"node_modules/expensify-common": {
"version": "1.0.0",
- "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
- "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==",
+ "resolved": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
+ "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==",
"license": "MIT",
"dependencies": {
"classnames": "2.3.1",
@@ -30788,28 +30787,6 @@
"readable-stream": "^2.3.6"
}
},
- "node_modules/focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "dependencies": {
- "tabbable": "^6.2.0"
- }
- },
- "node_modules/focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "dependencies": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- },
- "peerDependencies": {
- "prop-types": "^15.8.1",
- "react": ">=16.3.0",
- "react-dom": ">=16.3.0"
- }
- },
"node_modules/follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -49067,11 +49044,6 @@
"dev": true,
"license": "BSD-3-Clause"
},
- "node_modules/tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"node_modules/table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
@@ -74445,9 +74417,9 @@
}
},
"expensify-common": {
- "version": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
- "integrity": "sha512-gxW5C5LHt1yYLWik1X1aipE05gcVZZJxyp9MC8Kxr5jKtuVm4OeNeYCFWhSgfIrfszfY7VAdofzALcWLAMjxqg==",
- "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "version": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
+ "integrity": "sha512-u5Is3a/jD9KJ/LtSofeevauDlxZX5++w+VENP2cNhT1lm1GSwRM5FlTT7bIVSyrHr0cppAgl7cLiW2aPDr2hKA==",
+ "from": "expensify-common@git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"requires": {
"classnames": "2.3.1",
"clipboard": "2.0.4",
@@ -75103,23 +75075,6 @@
"readable-stream": "^2.3.6"
}
},
- "focus-trap": {
- "version": "7.5.2",
- "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.5.2.tgz",
- "integrity": "sha512-p6vGNNWLDGwJCiEjkSK6oERj/hEyI9ITsSwIUICBoKLlWiTWXJRfQibCwcoi50rTZdbi87qDtUlMCmQwsGSgPw==",
- "requires": {
- "tabbable": "^6.2.0"
- }
- },
- "focus-trap-react": {
- "version": "10.2.1",
- "resolved": "https://registry.npmjs.org/focus-trap-react/-/focus-trap-react-10.2.1.tgz",
- "integrity": "sha512-UrAKOn52lvfHF6lkUMfFhlQxFgahyNW5i6FpHWkDxAeD4FSk3iwx9n4UEA4Sims0G5WiGIi0fAyoq3/UVeNCYA==",
- "requires": {
- "focus-trap": "^7.5.2",
- "tabbable": "^6.2.0"
- }
- },
"follow-redirects": {
"version": "1.15.3",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.3.tgz",
@@ -88110,11 +88065,6 @@
"version": "2.0.15",
"dev": true
},
- "tabbable": {
- "version": "6.2.0",
- "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.2.0.tgz",
- "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew=="
- },
"table": {
"version": "6.8.1",
"resolved": "https://registry.npmjs.org/table/-/table-6.8.1.tgz",
diff --git a/package.json b/package.json
index 8da100275b4e..eb1b725eea10 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "1.4.1-10",
+ "version": "1.4.3-6",
"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.",
@@ -98,9 +98,8 @@
"date-fns-tz": "^2.0.0",
"dom-serializer": "^0.2.2",
"domhandler": "^4.3.0",
- "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#e9daa1c475ba047fd13ad50079cd64f730e58c29",
+ "expensify-common": "git+ssh://git@github.com/Expensify/expensify-common.git#ee14b3255da33d2b6924c357f43393251b6dc6d2",
"fbjs": "^3.0.2",
- "focus-trap-react": "^10.2.1",
"htmlparser2": "^7.2.0",
"idb-keyval": "^6.2.1",
"jest-when": "^3.5.2",
diff --git a/src/CONST.ts b/src/CONST.ts
index 4024158d0805..f1364ebbb5bf 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -248,88 +248,9 @@ const CONST = {
BETAS: {
ALL: 'all',
CHRONOS_IN_CASH: 'chronosInCash',
- PAY_WITH_EXPENSIFY: 'payWithExpensify',
- FREE_PLAN: 'freePlan',
DEFAULT_ROOMS: 'defaultRooms',
- BETA_EXPENSIFY_WALLET: 'expensifyWallet',
BETA_COMMENT_LINKING: 'commentLinking',
- INTERNATIONALIZATION: 'internationalization',
POLICY_ROOMS: 'policyRooms',
- PASSWORDLESS: 'passwordless',
- TASKS: 'tasks',
- THREADS: 'threads',
- CUSTOM_STATUS: 'customStatus',
- NEW_DOT_SAML: 'newDotSAML',
- PDF_META_STORE: 'pdfMetaStore',
- REPORT_ACTION_CONTEXT_MENU: 'reportActionContextMenu',
- SUBMIT_POLICY: 'submitPolicy',
- ATTENDEES: 'attendees',
- AUTO_EXPORT: 'autoExport',
- AUTO_EXPORT_INTACCT: 'autoExportIntacct',
- AUTO_EXPORT_QBO: 'autoExportQbo',
- AUTO_EXPORT_XERO: 'autoExportXero',
- AUTO_JOIN_POLICY: 'autoJoinPolicy',
- AUTOMATED_TAX_EXEMPTION: 'automatedTaxExemption',
- BILL_PAY: 'billPay',
- CATEGORY_DEFAULT_TAX: 'categoryDefaultTax',
- COLLECTABLE_DEPOSIT_ACCOUNTS: 'collectableDepositAccounts',
- CONCIERGE_TRAVEL: 'conciergeTravel',
- CONNECTED_CARDS: 'connectedCards',
- DISCREPANCY: 'discrepancy',
- DOMAIN_CONTACT_BILLING: 'domainContactBilling',
- DOMAIN_TWO_FACTOR_AUTH: 'domainTwoFactorAuth',
- DUPLICATE_DETECTION: 'duplicateDetection',
- EMAIL_SUPPRESSION_BETA: 'emailSuppressionBeta',
- EXPENSES_V2: 'expensesV2',
- EXPENSIFY_CARD: 'expensifyCard',
- EXPENSIFY_CARD_INTACCT_RECONCILIATION: 'expensifyCardIntacctReconciliation',
- EXPENSIFY_CARD_NETSUITE_RECONCILIATION: 'expensifyCardNetSuiteReconciliation',
- EXPENSIFY_CARD_QBO_RECONCILIATION: 'expensifyCardQBOReconciliation',
- EXPENSIFY_CARD_RAPID_INCREASE_FRAUD: 'expensifyCardRapidIncreaseFraud',
- EXPENSIFY_CARD_XERO_RECONCILIATION: 'expensifyCardXeroReconciliation',
- EXPENSIFY_ORG: 'expensifyOrg',
- FIX_VIOLATION_PUSH_NOTIFICATION: 'fixViolationPushNotification',
- FREE_PLAN_FULL_LAUNCH: 'freePlanFullLaunch',
- FREE_PLAN_SOFT_LAUNCH: 'freePlanSoftLaunch',
- GUSTO: 'gusto',
- INBOX_CACHE: 'inboxCache',
- INBOX_HIDDEN_TASKS: 'inboxHiddenTasks',
- INDIRECT_INTEGRATION_SETUP: 'indirectIntegrationSetup',
- IOU: 'IOU',
- JOIN_POLICY: 'joinPolicy',
- LOAD_POLICY_ASYNC: 'loadPolicyAsync',
- MAP_RECEIPT: 'mapReceipt',
- MERGE_API: 'mergeAPI',
- MOBILE_REALTIME_REPORT_COMMENTS: 'mobileRealtimeReportComments',
- MOBILE_SECURE_RECEIPTS: 'mobileSecureReceipts',
- MONTHLY_SETTLEMENT: 'monthlySettlement',
- NAMES_AND_AVATARS: 'namesAndAvatars',
- NATIVE_CHAT: 'nativeChat',
- NEW_PRICING: 'newPricing',
- NEWSLETTER_THREE: 'newsletterThree',
- NEXT_STEPS: 'nextSteps',
- OPEN_FACE_HAMBURGER: 'openFaceHamburger',
- PER_DIEM: 'perDiem',
- PER_DIEM_INTERNATIONAL: 'perDiemInternational',
- PRICING_COPY_CHANGES: 'pricingCopyChanges',
- QBO_INVOICES: 'qboInvoices',
- QUICKBOOKS_DESKTOP_V2: 'quickbooksDesktopV2',
- REALTIME_REPORT_COMMENTS: 'realtimeReportComments',
- S2W_ANNOUNCEMENT: 's2wAnnouncement',
- SCHEDULED_AUTO_REPORTING: 'scheduledAutoReporting',
- SECURE_RECEIPTS: 'secureReceipts',
- SECURE_RECEIPTS_REPORTS: 'secureReceiptsReports',
- SELF_SERVICE_HARD_LAUNCH: 'selfServiceHardLaunch',
- SEND_MONEY: 'sendMoney',
- SMART_SCAN_USER_DISPUTES: 'smartScanUserDisputes',
- SMS_SIGN_UP: 'smsSignUp',
- STRIPE_CONNECT: 'stripeConnect',
- SUMMARY_EMAIL: 'summaryEmail',
- SWIPE_TO_WIN: 'swipeToWin',
- TAX_FOR_MILEAGE: 'taxForMileage',
- TWO_FACTOR_AUTH: 'twoFactorAuth',
- VENMO_INTEGRATION: 'venmoIntegration',
- ZENEFITS_INTEGRATION: 'zenefitsIntegration',
VIOLATIONS: 'violations',
},
BUTTON_STATES: {
@@ -574,6 +495,7 @@ const CONST = {
CREATED: 'CREATED',
IOU: 'IOU',
MODIFIEDEXPENSE: 'MODIFIEDEXPENSE',
+ MOVED: 'MOVED',
REIMBURSEMENTQUEUED: 'REIMBURSEMENTQUEUED',
RENAMED: 'RENAMED',
REPORTPREVIEW: 'REPORTPREVIEW',
@@ -1188,7 +1110,8 @@ const CONST = {
PAYMENT_METHODS: {
DEBIT_CARD: 'debitCard',
- BANK_ACCOUNT: 'bankAccount',
+ PERSONAL_BANK_ACCOUNT: 'bankAccount',
+ BUSINESS_BANK_ACCOUNT: 'businessBankAccount',
},
PAYMENT_METHOD_ID_KEYS: {
@@ -1231,6 +1154,7 @@ const CONST = {
DOCX: 'docx',
SVG: 'svg',
},
+ RECEIPT_ERROR: 'receiptError',
},
GROWL: {
@@ -1277,7 +1201,11 @@ const CONST = {
TYPE: {
FREE: 'free',
PERSONAL: 'personal',
+
+ // Often referred to as "control" workspaces
CORPORATE: 'corporate',
+
+ // Often referred to as "collect" workspaces
TEAM: 'team',
},
ROLE: {
@@ -2890,6 +2818,23 @@ const CONST = {
LEARN_MORE_LINK: 'https://help.expensify.com/articles/new-expensify/billing-and-plan-types/Referral-Program',
LINK: 'https://join.my.expensify.com',
},
+
+ /**
+ * native IDs for close buttons in Overlay component
+ */
+ OVERLAY: {
+ TOP_BUTTON_NATIVE_ID: 'overLayTopButton',
+ BOTTOM_BUTTON_NATIVE_ID: 'overLayBottomButton',
+ },
+
+ BACK_BUTTON_NATIVE_ID: 'backButton',
+
+ /**
+ * Performance test setup - run the same test multiple times to get a more accurate result
+ */
+ PERFORMANCE_TESTS: {
+ RUNS: 20,
+ },
} as const;
export default CONST;
diff --git a/src/components/AddPaymentMethodMenu.js b/src/components/AddPaymentMethodMenu.js
index 252c8380b062..ce26985932d6 100644
--- a/src/components/AddPaymentMethodMenu.js
+++ b/src/components/AddPaymentMethodMenu.js
@@ -1,15 +1,18 @@
+import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
+import useLocalize from '@hooks/useLocalize';
import compose from '@libs/compose';
-import Permissions from '@libs/Permissions';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
+import * as ReportUtils from '@libs/ReportUtils';
+import iouReportPropTypes from '@pages/iouReportPropTypes';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import * as Expensicons from './Icon/Expensicons';
import PopoverMenu from './PopoverMenu';
import refPropTypes from './refPropTypes';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
import withWindowDimensions from './withWindowDimensions';
const propTypes = {
@@ -19,6 +22,12 @@ const propTypes = {
/** Callback to execute when the component closes. */
onClose: PropTypes.func.isRequired,
+ /** Callback to execute when the payment method is selected. */
+ onItemSelected: PropTypes.func.isRequired,
+
+ /** The IOU/Expense report we are paying */
+ iouReport: iouReportPropTypes,
+
/** Anchor position for the AddPaymentMenu. */
anchorPosition: PropTypes.shape({
horizontal: PropTypes.number,
@@ -31,51 +40,66 @@ const propTypes = {
vertical: PropTypes.oneOf(_.values(CONST.MODAL.ANCHOR_ORIGIN_VERTICAL)),
}),
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** Popover anchor ref */
anchorRef: refPropTypes,
- ...withLocalizePropTypes,
+ /** Session info for the currently logged in user. */
+ session: PropTypes.shape({
+ /** Currently logged in user accountID */
+ accountID: PropTypes.number,
+ }),
};
const defaultProps = {
+ iouReport: {},
anchorPosition: {},
anchorAlignment: {
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
- betas: [],
anchorRef: () => {},
+ session: {},
};
-function AddPaymentMethodMenu(props) {
+function AddPaymentMethodMenu({isVisible, onClose, anchorPosition, anchorAlignment, anchorRef, iouReport, onItemSelected, session}) {
+ const {translate} = useLocalize();
+
return (
{
- props.onItemSelected(CONST.PAYMENT_METHODS.BANK_ACCOUNT);
- },
- },
- ...(Permissions.canUseWallet(props.betas)
+ ...(ReportUtils.isIOUReport(iouReport)
? [
{
- text: props.translate('common.debitCard'),
- icon: Expensicons.CreditCard,
- onSelected: () => props.onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
+ text: translate('common.personalBankAccount'),
+ icon: Expensicons.Bank,
+ onSelected: () => {
+ onItemSelected(CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT);
+ },
},
]
: []),
+ ...(!ReportActionsUtils.hasRequestFromCurrentAccount(lodashGet(iouReport, 'reportID', 0), lodashGet(session, 'accountID', 0))
+ ? [
+ {
+ text: translate('common.businessBankAccount'),
+ icon: Expensicons.Building,
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT),
+ },
+ ]
+ : []),
+ ...[
+ {
+ text: translate('common.debitCard'),
+ icon: Expensicons.CreditCard,
+ onSelected: () => onItemSelected(CONST.PAYMENT_METHODS.DEBIT_CARD),
+ },
+ ],
]}
withoutOverlay
/>
@@ -88,10 +112,9 @@ AddPaymentMethodMenu.displayName = 'AddPaymentMethodMenu';
export default compose(
withWindowDimensions,
- withLocalize,
withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
+ session: {
+ key: ONYXKEYS.SESSION,
},
}),
)(AddPaymentMethodMenu);
diff --git a/src/components/Alert/index.js b/src/components/Alert/index.js
index dc3a722c2331..3fc90433f13e 100644
--- a/src/components/Alert/index.js
+++ b/src/components/Alert/index.js
@@ -5,7 +5,7 @@ import _ from 'underscore';
*
* @param {String} title The title of the alert
* @param {String} description The description of the alert
- * @param {Object[]} options An array of objects with `style` and `onPress` properties
+ * @param {Object[]} [options] An array of objects with `style` and `onPress` properties
*/
export default (title, description, options) => {
const result = _.filter(window.confirm([title, description], Boolean)).join('\n');
diff --git a/src/components/Banner.js b/src/components/Banner.tsx
similarity index 61%
rename from src/components/Banner.js
rename to src/components/Banner.tsx
index 2fcb866334e0..1c92208a7aa2 100644
--- a/src/components/Banner.js
+++ b/src/components/Banner.tsx
@@ -1,7 +1,6 @@
-import PropTypes from 'prop-types';
import React, {memo} from 'react';
-import {View} from 'react-native';
-import compose from '@libs/compose';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import getButtonState from '@libs/getButtonState';
import * as StyleUtils from '@styles/StyleUtils';
import useThemeStyles from '@styles/useThemeStyles';
@@ -13,54 +12,41 @@ import PressableWithFeedback from './Pressable/PressableWithFeedback';
import RenderHTML from './RenderHTML';
import Text from './Text';
import Tooltip from './Tooltip';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-const propTypes = {
+type BannerProps = {
/** Text to display in the banner. */
- text: PropTypes.string.isRequired,
+ text: string;
/** Should this component render the left-aligned exclamation icon? */
- shouldShowIcon: PropTypes.bool,
+ shouldShowIcon?: boolean;
/** Should this component render a close button? */
- shouldShowCloseButton: PropTypes.bool,
+ shouldShowCloseButton?: boolean;
/** Should this component render the text as HTML? */
- shouldRenderHTML: PropTypes.bool,
+ shouldRenderHTML?: boolean;
/** Callback called when the close button is pressed */
- onClose: PropTypes.func,
+ onClose?: () => void;
/** Callback called when the message is pressed */
- onPress: PropTypes.func,
+ onPress?: () => void;
/** Styles to be assigned to the Banner container */
- // eslint-disable-next-line react/forbid-prop-types
- containerStyles: PropTypes.arrayOf(PropTypes.object),
+ containerStyles?: StyleProp;
/** Styles to be assigned to the Banner text */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
-
- ...withLocalizePropTypes,
-};
-
-const defaultProps = {
- shouldRenderHTML: false,
- shouldShowIcon: false,
- shouldShowCloseButton: false,
- onClose: undefined,
- onPress: undefined,
- containerStyles: [],
- textStyles: [],
+ textStyles?: StyleProp;
};
-function Banner(props) {
+function Banner({text, onClose, onPress, containerStyles, textStyles, shouldRenderHTML = false, shouldShowIcon = false, shouldShowCloseButton = false}: BannerProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
{(isHovered) => {
- const isClickable = props.onClose || props.onPress;
+ const isClickable = onClose ?? onPress;
const shouldHighlight = isClickable && isHovered;
return (
- {props.shouldShowIcon && (
+ {shouldShowIcon && (
)}
- {props.shouldRenderHTML ? (
-
+ {shouldRenderHTML ? (
+
) : (
- {props.text}
+ {text}
)}
- {props.shouldShowCloseButton && (
-
+ {shouldShowCloseButton && !!onClose && (
+
@@ -113,8 +99,6 @@ function Banner(props) {
);
}
-Banner.propTypes = propTypes;
-Banner.defaultProps = defaultProps;
Banner.displayName = 'Banner';
-export default compose(withLocalize, memo)(Banner);
+export default memo(Banner);
diff --git a/src/components/Button/index.js b/src/components/Button/index.tsx
similarity index 63%
rename from src/components/Button/index.js
rename to src/components/Button/index.tsx
index b9aaf8868924..02f743b6a1b6 100644
--- a/src/components/Button/index.js
+++ b/src/components/Button/index.tsx
@@ -1,212 +1,167 @@
-import {useIsFocused} from '@react-navigation/native';
-import PropTypes from 'prop-types';
-import React, {useCallback} from 'react';
-import {ActivityIndicator, View} from 'react-native';
+import React, {ForwardedRef, useCallback} from 'react';
+import {ActivityIndicator, GestureResponderEvent, StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import {SvgProps} from 'react-native-svg';
import Icon from '@components/Icon';
import * as Expensicons from '@components/Icon/Expensicons';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
-import refPropTypes from '@components/refPropTypes';
import Text from '@components/Text';
import withNavigationFallback from '@components/withNavigationFallback';
import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import HapticFeedback from '@libs/HapticFeedback';
-import * as StyleUtils from '@styles/StyleUtils';
+import themeColors from '@styles/themes/default';
import useTheme from '@styles/themes/useTheme';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
import validateSubmitShortcut from './validateSubmitShortcut';
-const propTypes = {
- /** Should the press event bubble across multiple instances when Enter key triggers it. */
- allowBubble: PropTypes.bool,
-
+type ButtonWithText = {
/** The text for the button label */
- text: PropTypes.string,
+ text: string;
/** Boolean whether to display the right icon */
- shouldShowRightIcon: PropTypes.bool,
+ shouldShowRightIcon?: boolean;
/** The icon asset to display to the left of the text */
- icon: PropTypes.func,
+ icon?: React.FC | null;
+};
+
+type ButtonProps = (ButtonWithText | ChildrenProps) & {
+ /** Should the press event bubble across multiple instances when Enter key triggers it. */
+ allowBubble?: boolean;
/** The icon asset to display to the right of the text */
- iconRight: PropTypes.func,
+ iconRight?: React.FC;
/** The fill color to pass into the icon. */
- iconFill: PropTypes.string,
+ iconFill?: string;
/** Any additional styles to pass to the left icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconStyles: PropTypes.arrayOf(PropTypes.object),
+ iconStyles?: StyleProp;
/** Any additional styles to pass to the right icon container. */
- // eslint-disable-next-line react/forbid-prop-types
- iconRightStyles: PropTypes.arrayOf(PropTypes.object),
+ iconRightStyles?: StyleProp;
/** Small sized button */
- small: PropTypes.bool,
+ small?: boolean;
/** Large sized button */
- large: PropTypes.bool,
+ large?: boolean;
- /** medium sized button */
- medium: PropTypes.bool,
+ /** Medium sized button */
+ medium?: boolean;
/** Indicates whether the button should be disabled and in the loading state */
- isLoading: PropTypes.bool,
+ isLoading?: boolean;
/** Indicates whether the button should be disabled */
- isDisabled: PropTypes.bool,
+ isDisabled?: boolean;
/** A function that is called when the button is clicked on */
- onPress: PropTypes.func,
+ onPress?: (event?: GestureResponderEvent | KeyboardEvent) => void;
/** A function that is called when the button is long pressed */
- onLongPress: PropTypes.func,
+ onLongPress?: (event?: GestureResponderEvent) => void;
/** A function that is called when the button is pressed */
- onPressIn: PropTypes.func,
+ onPressIn?: () => void;
/** A function that is called when the button is released */
- onPressOut: PropTypes.func,
+ onPressOut?: () => void;
/** Callback that is called when mousedown is triggered. */
- onMouseDown: PropTypes.func,
+ onMouseDown?: () => void;
/** Call the onPress function when Enter key is pressed */
- pressOnEnter: PropTypes.bool,
+ pressOnEnter?: boolean;
/** The priority to assign the enter key event listener. 0 is the highest priority. */
- enterKeyEventListenerPriority: PropTypes.number,
+ enterKeyEventListenerPriority?: number;
/** Additional styles to add after local styles. Applied to Pressable portion of button */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
- /** Additional button styles. Specific to the OpacityView of button */
- // eslint-disable-next-line react/forbid-prop-types
- innerStyles: PropTypes.arrayOf(PropTypes.object),
+ /** Additional button styles. Specific to the OpacityView of the button */
+ innerStyles?: StyleProp;
/** Additional text styles */
- // eslint-disable-next-line react/forbid-prop-types
- textStyles: PropTypes.arrayOf(PropTypes.object),
+ textStyles?: StyleProp;
/** Whether we should use the default hover style */
- shouldUseDefaultHover: PropTypes.bool,
+ shouldUseDefaultHover?: boolean;
/** Whether we should use the success theme color */
- success: PropTypes.bool,
+ success?: boolean;
/** Whether we should use the danger theme color */
- danger: PropTypes.bool,
-
- /** Children to replace all inner contents of button */
- children: PropTypes.node,
+ danger?: boolean;
/** Should we remove the right border radius top + bottom? */
- shouldRemoveRightBorderRadius: PropTypes.bool,
+ shouldRemoveRightBorderRadius?: boolean;
/** Should we remove the left border radius top + bottom? */
- shouldRemoveLeftBorderRadius: PropTypes.bool,
+ shouldRemoveLeftBorderRadius?: boolean;
/** Should enable the haptic feedback? */
- shouldEnableHapticFeedback: PropTypes.bool,
+ shouldEnableHapticFeedback?: boolean;
/** Id to use for this button */
- id: PropTypes.string,
+ id?: string;
/** Accessibility label for the component */
- accessibilityLabel: PropTypes.string,
-
- /** A ref to forward the button */
- forwardedRef: refPropTypes,
-};
-
-const defaultProps = {
- allowBubble: false,
- text: '',
- shouldShowRightIcon: false,
- icon: null,
- iconRight: Expensicons.ArrowRight,
- iconFill: undefined,
- iconStyles: [],
- iconRightStyles: [],
- isLoading: false,
- isDisabled: false,
- small: false,
- large: false,
- medium: false,
- onPress: () => {},
- onLongPress: () => {},
- onPressIn: () => {},
- onPressOut: () => {},
- onMouseDown: undefined,
- pressOnEnter: false,
- enterKeyEventListenerPriority: 0,
- style: [],
- innerStyles: [],
- textStyles: [],
- shouldUseDefaultHover: true,
- success: false,
- danger: false,
- children: null,
- shouldRemoveRightBorderRadius: false,
- shouldRemoveLeftBorderRadius: false,
- shouldEnableHapticFeedback: false,
- id: '',
- accessibilityLabel: '',
- forwardedRef: undefined,
+ accessibilityLabel?: string;
+ isFocused: boolean;
};
-function Button({
- allowBubble,
- text,
- shouldShowRightIcon,
-
- icon,
- iconRight,
- iconFill,
- iconStyles,
- iconRightStyles,
-
- small,
- large,
- medium,
-
- isLoading,
- isDisabled,
-
- onPress,
- onLongPress,
- onPressIn,
- onPressOut,
- onMouseDown,
-
- pressOnEnter,
- enterKeyEventListenerPriority,
-
- style,
- innerStyles,
- textStyles,
-
- shouldUseDefaultHover,
- success,
- danger,
- children,
-
- shouldRemoveRightBorderRadius,
- shouldRemoveLeftBorderRadius,
- shouldEnableHapticFeedback,
-
- id,
- accessibilityLabel,
- forwardedRef,
-}) {
+function Button(
+ {
+ allowBubble = false,
+
+ iconRight = Expensicons.ArrowRight,
+ iconFill = themeColors.textLight,
+ iconStyles = [],
+ iconRightStyles = [],
+
+ small = false,
+ large = false,
+ medium = false,
+
+ isLoading = false,
+ isDisabled = false,
+
+ onPress = () => {},
+ onLongPress = () => {},
+ onPressIn = () => {},
+ onPressOut = () => {},
+ onMouseDown = undefined,
+
+ pressOnEnter = false,
+ enterKeyEventListenerPriority = 0,
+
+ style = [],
+ innerStyles = [],
+ textStyles = [],
+
+ shouldUseDefaultHover = true,
+ success = false,
+ danger = false,
+
+ shouldRemoveRightBorderRadius = false,
+ shouldRemoveLeftBorderRadius = false,
+ shouldEnableHapticFeedback = false,
+ isFocused,
+
+ id = '',
+ accessibilityLabel = '',
+ ...rest
+ }: ButtonProps,
+ ref: ForwardedRef,
+) {
const theme = useTheme();
const styles = useThemeStyles();
- const isFocused = useIsFocused();
const keyboardShortcutCallback = useCallback(
- (event) => {
+ (event?: GestureResponderEvent | KeyboardEvent) => {
if (!validateSubmitShortcut(isFocused, isDisabled, isLoading, event)) {
return;
}
@@ -223,10 +178,12 @@ function Button({
});
const renderContent = () => {
- if (children) {
- return children;
+ if ('children' in rest) {
+ return rest.children;
}
+ const {text = '', icon = null, shouldShowRightIcon = false} = rest;
+
const textComponent = (
@@ -248,12 +205,13 @@ function Button({
);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
if (icon || shouldShowRightIcon) {
return (
{icon && (
-
+
{shouldShowRightIcon && (
-
+ {
- if (event && event.type === 'click') {
- event.currentTarget.blur();
+ if (event?.type === 'click') {
+ const currentTarget = event?.currentTarget as HTMLElement;
+ currentTarget?.blur();
}
if (shouldEnableHapticFeedback) {
@@ -307,7 +266,7 @@ function Button({
styles.buttonContainer,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- ...StyleUtils.parseStyleAsArray(style),
+ style,
]}
style={[
styles.button,
@@ -320,8 +279,9 @@ function Button({
isDisabled && !danger && !success ? styles.buttonDisabled : undefined,
shouldRemoveRightBorderRadius ? styles.noRightBorderRadius : undefined,
shouldRemoveLeftBorderRadius ? styles.noLeftBorderRadius : undefined,
- icon || shouldShowRightIcon ? styles.alignItemsStretch : undefined,
- ...innerStyles,
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ 'text' in rest && (rest?.icon || rest?.shouldShowRightIcon) ? styles.alignItemsStretch : undefined,
+ innerStyles,
]}
hoverStyle={[
shouldUseDefaultHover && !isDisabled ? styles.buttonDefaultHovered : undefined,
@@ -344,18 +304,6 @@ function Button({
);
}
-Button.propTypes = propTypes;
-Button.defaultProps = defaultProps;
Button.displayName = 'Button';
-const ButtonWithRef = React.forwardRef((props, ref) => (
-
-));
-
-ButtonWithRef.displayName = 'ButtonWithRef';
-
-export default withNavigationFallback(ButtonWithRef);
+export default withNavigationFallback(React.forwardRef(Button));
diff --git a/src/components/Button/validateSubmitShortcut/index.js b/src/components/Button/validateSubmitShortcut/index.js
deleted file mode 100644
index bfe5c79483fa..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.js
+++ /dev/null
@@ -1,19 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @param {Object} event Focused input event
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading, event) {
- if (!isFocused || isDisabled || isLoading || (event && event.target.nodeName === 'TEXTAREA')) {
- return false;
- }
-
- event.preventDefault();
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.js b/src/components/Button/validateSubmitShortcut/index.native.js
deleted file mode 100644
index 2822fa56d590..000000000000
--- a/src/components/Button/validateSubmitShortcut/index.native.js
+++ /dev/null
@@ -1,17 +0,0 @@
-/**
- * Validate if the submit shortcut should be triggered depending on the button state
- *
- * @param {boolean} isFocused Whether Button is on active screen
- * @param {boolean} isDisabled Indicates whether the button should be disabled
- * @param {boolean} isLoading Indicates whether the button should be disabled and in the loading state
- * @returns {boolean} Returns `true` if the shortcut should be triggered
- */
-function validateSubmitShortcut(isFocused, isDisabled, isLoading) {
- if (!isFocused || isDisabled || isLoading) {
- return false;
- }
-
- return true;
-}
-
-export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.native.ts b/src/components/Button/validateSubmitShortcut/index.native.ts
new file mode 100644
index 000000000000..7687855f109b
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.native.ts
@@ -0,0 +1,20 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @return Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading) => {
+ if (!isFocused || isDisabled || isLoading) {
+ return false;
+ }
+
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/index.ts b/src/components/Button/validateSubmitShortcut/index.ts
new file mode 100644
index 000000000000..55b3e44192e4
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/index.ts
@@ -0,0 +1,23 @@
+import ValidateSubmitShortcut from './types';
+
+/**
+ * Validate if the submit shortcut should be triggered depending on the button state
+ *
+ * @param isFocused Whether Button is on active screen
+ * @param isDisabled Indicates whether the button should be disabled
+ * @param isLoading Indicates whether the button should be disabled and in the loading state
+ * @param event Focused input event
+ * @returns Returns `true` if the shortcut should be triggered
+ */
+
+const validateSubmitShortcut: ValidateSubmitShortcut = (isFocused, isDisabled, isLoading, event) => {
+ const eventTarget = event?.target as HTMLElement;
+ if (!isFocused || isDisabled || isLoading || eventTarget.nodeName === 'TEXTAREA') {
+ return false;
+ }
+
+ event?.preventDefault();
+ return true;
+};
+
+export default validateSubmitShortcut;
diff --git a/src/components/Button/validateSubmitShortcut/types.ts b/src/components/Button/validateSubmitShortcut/types.ts
new file mode 100644
index 000000000000..9970e1478a4c
--- /dev/null
+++ b/src/components/Button/validateSubmitShortcut/types.ts
@@ -0,0 +1,5 @@
+import {GestureResponderEvent} from 'react-native';
+
+type ValidateSubmitShortcut = (isFocused: boolean, isDisabled: boolean, isLoading: boolean, event?: GestureResponderEvent | KeyboardEvent) => boolean;
+
+export default ValidateSubmitShortcut;
diff --git a/src/components/CategoryPicker/index.js b/src/components/CategoryPicker/index.js
index ff7087df91dd..36cf9b1deadc 100644
--- a/src/components/CategoryPicker/index.js
+++ b/src/components/CategoryPicker/index.js
@@ -52,20 +52,9 @@ function CategoryPicker({selectedCategory, policyCategories, policyRecentlyUsedC
return categoryOptions;
}, [policyCategories, policyRecentlyUsedCategories, searchValue, selectedOptions]);
- const initialFocusedIndex = useMemo(() => {
- let categoryInitialFocusedIndex = 0;
-
- if (!_.isEmpty(searchValue) || isCategoriesCountBelowThreshold) {
- const index = _.findIndex(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory);
-
- categoryInitialFocusedIndex = index === -1 ? 0 : index;
- }
-
- return categoryInitialFocusedIndex;
- }, [selectedCategory, searchValue, isCategoriesCountBelowThreshold, sections]);
-
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
const shouldShowTextInput = !isCategoriesCountBelowThreshold;
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (category) => category.searchText === selectedCategory)[0], 'keyForList');
return (
{
- if (event.code !== 'Space') {
- return;
- }
-
- props.onPress();
- };
-
- const firePressHandlerOnClick = (event) => {
- // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
- // We do not want to toggle it, when Enter key is pressed.
- if (event.type && event.type !== 'click') {
- return;
- }
-
- props.onPress();
- };
-
- return (
-
- {props.children ? (
- props.children
- ) : (
-
- {props.isChecked && (
-
- )}
-
- )}
-
- );
-}
-
-Checkbox.propTypes = propTypes;
-Checkbox.defaultProps = defaultProps;
-Checkbox.displayName = 'Checkbox';
-
-export default Checkbox;
diff --git a/src/components/Checkbox.tsx b/src/components/Checkbox.tsx
new file mode 100644
index 000000000000..6ee5ed1c558f
--- /dev/null
+++ b/src/components/Checkbox.tsx
@@ -0,0 +1,126 @@
+import React, {ForwardedRef, forwardRef, KeyboardEvent as ReactKeyboardEvent} from 'react';
+import {GestureResponderEvent, StyleProp, View, ViewStyle} from 'react-native';
+import * as StyleUtils from '@styles/StyleUtils';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import ChildrenProps from '@src/types/utils/ChildrenProps';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import PressableWithFeedback from './Pressable/PressableWithFeedback';
+
+type CheckboxProps = ChildrenProps & {
+ /** Whether checkbox is checked */
+ isChecked?: boolean;
+
+ /** A function that is called when the box/label is pressed */
+ onPress: () => void;
+
+ /** Should the input be styled for errors */
+ hasError?: boolean;
+
+ /** Should the input be disabled */
+ disabled?: boolean;
+
+ /** Additional styles to add to checkbox button */
+ style?: StyleProp;
+
+ /** Additional styles to add to checkbox container */
+ containerStyle?: StyleProp;
+
+ /** Callback that is called when mousedown is triggered. */
+ onMouseDown?: () => void;
+
+ /** The size of the checkbox container */
+ containerSize?: number;
+
+ /** The border radius of the checkbox container */
+ containerBorderRadius?: number;
+
+ /** The size of the caret (checkmark) */
+ caretSize?: number;
+
+ /** An accessibility label for the checkbox */
+ accessibilityLabel: string;
+};
+
+function Checkbox(
+ {
+ isChecked = false,
+ hasError = false,
+ disabled = false,
+ style,
+ containerStyle,
+ children = null,
+ onMouseDown,
+ containerSize = 20,
+ containerBorderRadius = 4,
+ caretSize = 14,
+ onPress,
+ accessibilityLabel,
+ }: CheckboxProps,
+ ref: ForwardedRef,
+) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ const handleSpaceKey = (event?: ReactKeyboardEvent) => {
+ if (event?.code !== 'Space') {
+ return;
+ }
+
+ onPress();
+ };
+
+ const firePressHandlerOnClick = (event?: GestureResponderEvent | KeyboardEvent) => {
+ // Pressable can be triggered with Enter key and by a click. As this is a checkbox,
+ // We do not want to toggle it, when Enter key is pressed.
+ if (event?.type && event.type !== 'click') {
+ return;
+ }
+
+ onPress();
+ };
+
+ return (
+
+ {children ?? (
+
+ {isChecked && (
+
+ )}
+
+ )}
+
+ );
+}
+
+Checkbox.displayName = 'Checkbox';
+
+export default forwardRef(Checkbox);
diff --git a/src/components/CheckboxWithLabel.js b/src/components/CheckboxWithLabel.js
index 146e37ceb730..b78a7a136158 100644
--- a/src/components/CheckboxWithLabel.js
+++ b/src/components/CheckboxWithLabel.js
@@ -7,7 +7,6 @@ import variables from '@styles/variables';
import Checkbox from './Checkbox';
import FormHelpMessage from './FormHelpMessage';
import PressableWithFeedback from './Pressable/PressableWithFeedback';
-import refPropTypes from './refPropTypes';
import Text from './Text';
/**
@@ -54,9 +53,6 @@ const propTypes = {
/** The default value for the checkbox */
defaultValue: PropTypes.bool,
- /** React ref being forwarded to the Checkbox input */
- forwardedRef: refPropTypes,
-
/** The ID used to uniquely identify the input in a Form */
/* eslint-disable-next-line react/no-unused-prop-types */
inputID: PropTypes.string,
@@ -79,11 +75,10 @@ const defaultProps = {
isChecked: false,
value: false,
defaultValue: false,
- forwardedRef: () => {},
accessibilityLabel: undefined,
};
-function CheckboxWithLabel(props) {
+const CheckboxWithLabel = React.forwardRef((props, ref) => {
const styles = useThemeStyles();
// We need to pick the first value that is strictly a boolean
// https://github.com/Expensify/App/issues/16885#issuecomment-1520846065
@@ -106,7 +101,7 @@ function CheckboxWithLabel(props) {
label={props.label}
style={[styles.checkboxWithLabelCheckboxStyle]}
hasError={Boolean(props.errorText)}
- forwardedRef={props.forwardedRef}
+ ref={ref}
accessibilityLabel={props.accessibilityLabel || props.label}
/>
);
-}
+});
CheckboxWithLabel.propTypes = propTypes;
CheckboxWithLabel.defaultProps = defaultProps;
CheckboxWithLabel.displayName = 'CheckboxWithLabel';
-const CheckboxWithLabelWithRef = React.forwardRef((props, ref) => (
-
-));
-
-CheckboxWithLabelWithRef.displayName = 'CheckboxWithLabelWithRef';
-
-export default CheckboxWithLabelWithRef;
+export default CheckboxWithLabel;
diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js
index 7c720c4bd681..3fe3838c8c81 100755
--- a/src/components/ConfirmModal.js
+++ b/src/components/ConfirmModal.js
@@ -98,7 +98,6 @@ function ConfirmModal(props) {
shouldSetModalVisibility={props.shouldSetModalVisibility}
onModalHide={props.onModalHide}
type={props.isSmallScreenWidth ? CONST.MODAL.MODAL_TYPE.BOTTOM_DOCKED : CONST.MODAL.MODAL_TYPE.CONFIRM}
- shouldEnableFocusTrap
>
props.messages[key])
-
- // Using uniq here since some fields are wrapped by the same OfflineWithFeedback component (e.g. WorkspaceReimburseView)
- // and can potentially pass the same error.
- .uniq()
- .map((message) => Localize.translateIfPhraseKey(message))
- .value();
-
- const isErrorMessage = props.type === 'error';
-
- return (
-
-
-
-
-
- {_.map(sortedMessages, (message, i) => (
-
- {message}
-
- ))}
-
-
- );
-}
-
-DotIndicatorMessage.propTypes = propTypes;
-DotIndicatorMessage.defaultProps = defaultProps;
-DotIndicatorMessage.displayName = 'DotIndicatorMessage';
-
-export default DotIndicatorMessage;
diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx
new file mode 100644
index 000000000000..112feab166cf
--- /dev/null
+++ b/src/components/DotIndicatorMessage.tsx
@@ -0,0 +1,108 @@
+/* eslint-disable react/no-array-index-key */
+import React from 'react';
+import {StyleProp, TextStyle, View, ViewStyle} from 'react-native';
+import fileDownload from '@libs/fileDownload';
+import * as Localize from '@libs/Localize';
+import * as StyleUtils from '@styles/StyleUtils';
+import useTheme from '@styles/themes/useTheme';
+import useThemeStyles from '@styles/useThemeStyles';
+import CONST from '@src/CONST';
+import Icon from './Icon';
+import * as Expensicons from './Icon/Expensicons';
+import {PressableWithoutFeedback} from './Pressable';
+import Text from './Text';
+
+type ReceiptError = {error?: string; source: string; filename: string};
+
+type DotIndicatorMessageProps = {
+ /**
+ * In most cases this should just be errors from onxyData
+ * if you are not passing that data then this needs to be in a similar shape like
+ * {
+ * timestamp: 'message',
+ * }
+ */
+ messages: Record;
+
+ /** The type of message, 'error' shows a red dot, 'success' shows a green dot */
+ type: 'error' | 'success';
+
+ /** Additional styles to apply to the container */
+ style?: StyleProp;
+
+ /** Additional styles to apply to the text */
+ textStyles?: StyleProp;
+};
+
+/** Check if the error includes a receipt. */
+function isReceiptError(message: string | ReceiptError): message is ReceiptError {
+ if (typeof message === 'string') {
+ return false;
+ }
+ return (message?.error ?? '') === CONST.IOU.RECEIPT_ERROR;
+}
+
+function DotIndicatorMessage({messages = {}, style, type, textStyles}: DotIndicatorMessageProps) {
+ const theme = useTheme();
+ const styles = useThemeStyles();
+
+ if (Object.keys(messages).length === 0) {
+ return null;
+ }
+
+ // Fetch the keys, sort them, and map through each key to get the corresponding message
+ const sortedMessages = Object.keys(messages)
+ .sort()
+ .map((key) => messages[key]);
+
+ // Removing duplicates using Set and transforming the result into an array
+ const uniqueMessages = [...new Set(sortedMessages)].map((message) => Localize.translateIfPhraseKey(message));
+
+ const isErrorMessage = type === 'error';
+
+ return (
+
+
+
+
+
+ {uniqueMessages.map((message, i) =>
+ isReceiptError(message) ? (
+ {
+ fileDownload(message.source, message.filename);
+ }}
+ >
+
+ {Localize.translateLocal('iou.error.receiptFailureMessage')}
+ {Localize.translateLocal('iou.error.saveFileMessage')}
+ {Localize.translateLocal('iou.error.loseFileMessage')}
+
+
+ ) : (
+
+ {message}
+
+ ),
+ )}
+
+
+ );
+}
+
+DotIndicatorMessage.displayName = 'DotIndicatorMessage';
+
+export default DotIndicatorMessage;
diff --git a/src/components/FocusTrapView/index.native.tsx b/src/components/FocusTrapView/index.native.tsx
deleted file mode 100644
index 1190cfda4156..000000000000
--- a/src/components/FocusTrapView/index.native.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({children}: FocusTrapViewProps) {
- return children;
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/index.tsx b/src/components/FocusTrapView/index.tsx
deleted file mode 100644
index 6b52512c2e63..000000000000
--- a/src/components/FocusTrapView/index.tsx
+++ /dev/null
@@ -1,42 +0,0 @@
-/*
- * The FocusTrap is only used on web and desktop
- */
-import FocusTrap from 'focus-trap-react';
-import React, {useRef} from 'react';
-import {View} from 'react-native';
-import viewRef from '@src/types/utils/viewRef';
-import FocusTrapViewProps from './types';
-
-function FocusTrapView({isEnabled = true, isActive = true, shouldEnableAutoFocus = false, ...props}: FocusTrapViewProps) {
- /**
- * Focus trap always needs a focusable element.
- * In case that we don't have any focusable elements in the modal,
- * the FocusTrap will use fallback View element using this ref.
- */
- const ref = useRef(null);
-
- return isEnabled ? (
- (shouldEnableAutoFocus && ref.current) ?? false,
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- fallbackFocus: () => ref.current!,
- clickOutsideDeactivates: true,
- }}
- >
-
-
- ) : (
- props.children
- );
-}
-
-FocusTrapView.displayName = 'FocusTrapView';
-
-export default FocusTrapView;
diff --git a/src/components/FocusTrapView/types.ts b/src/components/FocusTrapView/types.ts
deleted file mode 100644
index 500b4b4315d9..000000000000
--- a/src/components/FocusTrapView/types.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import {ViewProps} from 'react-native';
-import ChildrenProps from '@src/types/utils/ChildrenProps';
-
-type FocusTrapViewProps = ChildrenProps & {
- /**
- * Whether to enable the FocusTrap.
- * If the FocusTrap is disabled, we just pass the children through.
- */
- isEnabled?: boolean;
-
- /**
- * Whether to disable auto focus
- * It is used when the component inside the FocusTrap have their own auto focus logic
- */
- shouldEnableAutoFocus?: boolean;
-
- /** Whether the FocusTrap is active (listening for events) */
- isActive?: boolean;
-} & ViewProps;
-
-export default FocusTrapViewProps;
diff --git a/src/components/Form.js b/src/components/Form.js
index d5865dab44b8..ad5fcf611e9b 100644
--- a/src/components/Form.js
+++ b/src/components/Form.js
@@ -350,10 +350,18 @@ function Form(props) {
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user are focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (
+ relatedTargetId &&
+ _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)
+ ) {
+ return;
+ }
setTouchedInput(inputID);
if (props.shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
diff --git a/src/components/Form/FormProvider.js b/src/components/Form/FormProvider.js
index 92c76da5936d..776aaae688ed 100644
--- a/src/components/Form/FormProvider.js
+++ b/src/components/Form/FormProvider.js
@@ -47,6 +47,9 @@ const propTypes = {
errorFields: PropTypes.objectOf(PropTypes.objectOf(PropTypes.string)),
}),
+ /** Contains draft values for each input in the form */
+ draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])),
+
/** Should the button be enabled when offline */
enabledWhenOffline: PropTypes.bool,
@@ -77,6 +80,7 @@ const defaultProps = {
formState: {
isLoading: false,
},
+ draftValues: {},
enabledWhenOffline: false,
isSubmitActionDangerous: false,
scrollContextEnabled: false,
@@ -100,7 +104,7 @@ function getInitialValueByType(valueType) {
}
}
-function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, onSubmit, ...rest}) {
+function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnChange, children, formState, network, enabledWhenOffline, draftValues, onSubmit, ...rest}) {
const inputRefs = useRef({});
const touchedInputs = useRef({});
const [inputValues, setInputValues] = useState({});
@@ -208,7 +212,9 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
if (!_.isUndefined(propsToParse.value)) {
inputValues[inputID] = propsToParse.value;
- } else if (propsToParse.shouldUseDefaultValue) {
+ } else if (propsToParse.shouldSaveDraft && !_.isUndefined(draftValues[inputID]) && _.isUndefined(inputValues[inputID])) {
+ inputValues[inputID] = draftValues[inputID];
+ } else if (propsToParse.shouldUseDefaultValue && _.isUndefined(inputValues[inputID])) {
// We force the form to set the input value from the defaultValue props if there is a saved valid value
inputValues[inputID] = propsToParse.defaultValue;
} else if (_.isUndefined(inputValues[inputID])) {
@@ -263,10 +269,15 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
onBlur: (event) => {
// Only run validation when user proactively blurs the input.
if (Visibility.isVisible() && Visibility.hasFocus()) {
+ const relatedTargetId = lodashGet(event, 'nativeEvent.relatedTarget.id');
// We delay the validation in order to prevent Checkbox loss of focus when
// the user is focusing a TextInput and proceeds to toggle a CheckBox in
// web and mobile web platforms.
+
setTimeout(() => {
+ if (relatedTargetId && _.includes([CONST.OVERLAY.BOTTOM_BUTTON_NATIVE_ID, CONST.OVERLAY.TOP_BUTTON_NATIVE_ID, CONST.BACK_BUTTON_NATIVE_ID], relatedTargetId)) {
+ return;
+ }
setTouchedInput(inputID);
if (shouldValidateOnBlur) {
onValidate(inputValues, !hasServerError);
@@ -293,7 +304,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
});
if (propsToParse.shouldSaveDraft) {
- FormActions.setDraftValues(propsToParse.formID, {[inputKey]: value});
+ FormActions.setDraftValues(formID, {[inputKey]: value});
}
if (_.isFunction(propsToParse.onValueChange)) {
@@ -302,7 +313,7 @@ function FormProvider({validate, formID, shouldValidateOnBlur, shouldValidateOnC
},
};
},
- [errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
+ [draftValues, formID, errors, formState, hasServerError, inputValues, onValidate, setTouchedInput, shouldValidateOnBlur, shouldValidateOnChange],
);
const value = useMemo(() => ({registerInput}), [registerInput]);
@@ -333,5 +344,8 @@ export default compose(
formState: {
key: (props) => props.formID,
},
+ draftValues: {
+ key: (props) => `${props.formID}Draft`,
+ },
}),
)(FormProvider);
diff --git a/src/components/HeaderWithBackButton/index.js b/src/components/HeaderWithBackButton/index.js
index 1371e6a36b97..edb3b8d26831 100755
--- a/src/components/HeaderWithBackButton/index.js
+++ b/src/components/HeaderWithBackButton/index.js
@@ -79,6 +79,7 @@ function HeaderWithBackButton({
style={[styles.touchableButtonImage]}
role="button"
accessibilityLabel={translate('common.back')}
+ nativeID={CONST.BACK_BUTTON_NATIVE_ID}
>
this.setState({shouldShowAddPaymentMenu: false})}
anchorRef={this.anchorRef}
anchorPosition={{
diff --git a/src/components/LHNOptionsList/LHNOptionsList.js b/src/components/LHNOptionsList/LHNOptionsList.js
index 5e77947187e9..0d300c5e2179 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.js
+++ b/src/components/LHNOptionsList/LHNOptionsList.js
@@ -1,7 +1,8 @@
+import {FlashList} from '@shopify/flash-list';
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
-import {FlatList, View} from 'react-native';
+import {View} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import participantPropTypes from '@components/participantPropTypes';
@@ -11,6 +12,7 @@ import compose from '@libs/compose';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import reportActionPropTypes from '@pages/home/report/reportActionPropTypes';
import reportPropTypes from '@pages/reportPropTypes';
+import stylePropTypes from '@styles/stylePropTypes';
import useThemeStyles from '@styles/useThemeStyles';
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -19,12 +21,10 @@ import OptionRowLHNData from './OptionRowLHNData';
const propTypes = {
/** Wrapper style for the section list */
- // eslint-disable-next-line react/forbid-prop-types
- style: PropTypes.arrayOf(PropTypes.object),
+ style: stylePropTypes,
/** Extra styles for the section list container */
- // eslint-disable-next-line react/forbid-prop-types
- contentContainerStyles: PropTypes.arrayOf(PropTypes.object).isRequired,
+ contentContainerStyles: stylePropTypes.isRequired,
/** Sections for the section list */
data: PropTypes.arrayOf(PropTypes.string).isRequired,
@@ -80,7 +80,7 @@ const defaultProps = {
...withCurrentReportIDDefaultProps,
};
-const keyExtractor = (item) => item;
+const keyExtractor = (item) => `report_${item}`;
function LHNOptionsList({
style,
@@ -99,28 +99,6 @@ function LHNOptionsList({
currentReportID,
}) {
const styles = useThemeStyles();
- /**
- * This function is used to compute the layout of any given item in our list. Since we know that each item will have the exact same height, this is a performance optimization
- * so that the heights can be determined before the options are rendered. Otherwise, the heights are determined when each option is rendering and it causes a lot of overhead on large
- * lists.
- *
- * @param {Array} itemData - This is the same as the data we pass into the component
- * @param {Number} index the current item's index in the set of data
- *
- * @returns {Object}
- */
- const getItemLayout = useCallback(
- (itemData, index) => {
- const optionHeight = optionMode === CONST.OPTION_MODE.COMPACT ? variables.optionRowHeightCompact : variables.optionRowHeight;
- return {
- length: optionHeight,
- offset: index * optionHeight,
- index,
- };
- },
- [optionMode],
- );
-
/**
* Function which renders a row in the list
*
@@ -164,20 +142,17 @@ function LHNOptionsList({
return (
-
);
diff --git a/src/components/LHNOptionsList/OptionRowLHN.js b/src/components/LHNOptionsList/OptionRowLHN.js
index 8420f3db7a1e..4e01ee4d7830 100644
--- a/src/components/LHNOptionsList/OptionRowLHN.js
+++ b/src/components/LHNOptionsList/OptionRowLHN.js
@@ -20,7 +20,6 @@ import DateUtils from '@libs/DateUtils';
import DomUtils from '@libs/DomUtils';
import {getGroupChatName} from '@libs/GroupChatUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
-import Permissions from '@libs/Permissions';
import ReportActionComposeFocusManager from '@libs/ReportActionComposeFocusManager';
import * as ReportUtils from '@libs/ReportUtils';
import * as ContextMenuActions from '@pages/home/report/ContextMenu/ContextMenuActions';
@@ -36,9 +35,6 @@ const propTypes = {
// eslint-disable-next-line react/forbid-prop-types
hoverStyle: PropTypes.object,
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** The ID of the report that the option is for */
reportID: PropTypes.string.isRequired,
@@ -65,7 +61,6 @@ const defaultProps = {
style: null,
optionItem: null,
isFocused: false,
- betas: [],
};
function OptionRowLHN(props) {
@@ -157,7 +152,7 @@ function OptionRowLHN(props) {
const statusClearAfterDate = lodashGet(optionItem, 'status.clearAfter', '');
const formattedDate = DateUtils.getStatusUntilDate(statusClearAfterDate);
const statusContent = formattedDate ? `${statusText} (${formattedDate})` : statusText;
- const isStatusVisible = Permissions.canUseCustomStatus(props.betas) && !!emojiCode && ReportUtils.isOneOnOneChat(ReportUtils.getReport(optionItem.reportID));
+ const isStatusVisible = !!emojiCode && ReportUtils.isOneOnOneChat(ReportUtils.getReport(optionItem.reportID));
const isGroupChat =
optionItem.type === CONST.REPORT.TYPE.CHAT && _.isEmpty(optionItem.chatType) && !optionItem.isThread && lodashGet(optionItem, 'displayNamesWithTooltips.length', 0) > 2;
diff --git a/src/components/MessagesRow.js b/src/components/MessagesRow.js
index d9568834cbe3..e4d6240ba0fd 100644
--- a/src/components/MessagesRow.js
+++ b/src/components/MessagesRow.js
@@ -15,7 +15,9 @@ import Tooltip from './Tooltip';
const propTypes = {
/** The messages to display */
- messages: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))])),
+ messages: PropTypes.objectOf(
+ PropTypes.oneOfType([PropTypes.oneOfType([PropTypes.string, PropTypes.object]), PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.object]))]),
+ ),
/** The type of message, 'error' shows a red dot, 'success' shows a green dot */
type: PropTypes.oneOf(['error', 'success']).isRequired,
diff --git a/src/components/Modal/BaseModal.tsx b/src/components/Modal/BaseModal.tsx
index 54a178db1cdd..95a7f3adc279 100644
--- a/src/components/Modal/BaseModal.tsx
+++ b/src/components/Modal/BaseModal.tsx
@@ -56,6 +56,7 @@ function BaseModal(
*/
const hideModal = useCallback(
(callHideCallback = true) => {
+ Modal.willAlertModalBecomeVisible(false);
if (shouldSetModalVisibility) {
Modal.setModalVisibility(false);
}
@@ -77,8 +78,6 @@ function BaseModal(
Modal.willAlertModalBecomeVisible(true);
// To handle closing any modal already visible when this modal is mounted, i.e. PopoverReportActionContextMenu
removeOnCloseListener = Modal.setCloseModal(onClose);
- } else if (wasVisible && !isVisible) {
- Modal.willAlertModalBecomeVisible(false);
}
return () => {
@@ -96,7 +95,6 @@ function BaseModal(
return;
}
hideModal(true);
- Modal.willAlertModalBecomeVisible(false);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx
index 710ecd79b375..f760d3c0244e 100644
--- a/src/components/Modal/index.tsx
+++ b/src/components/Modal/index.tsx
@@ -1,16 +1,13 @@
import React, {useState} from 'react';
-import FocusTrapView from '@components/FocusTrapView';
import withWindowDimensions from '@components/withWindowDimensions';
import StatusBar from '@libs/StatusBar';
import * as StyleUtils from '@styles/StyleUtils';
import useTheme from '@styles/themes/useTheme';
-import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import BaseModal from './BaseModal';
import BaseModalProps from './types';
-function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, shouldEnableFocusTrap = false, ...rest}: BaseModalProps) {
- const styles = useThemeStyles();
+function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = () => {}, children, ...rest}: BaseModalProps) {
const theme = useTheme();
const [previousStatusBarColor, setPreviousStatusBarColor] = useState();
@@ -51,13 +48,7 @@ function Modal({fullscreen = true, onModalHide = () => {}, type, onModalShow = (
fullscreen={fullscreen}
type={type}
>
-
- {children}
-
+ {children}
);
}
diff --git a/src/components/Modal/modalPropTypes.js b/src/components/Modal/modalPropTypes.js
index 7465e28b28ad..84e610b694e4 100644
--- a/src/components/Modal/modalPropTypes.js
+++ b/src/components/Modal/modalPropTypes.js
@@ -66,9 +66,6 @@ const propTypes = {
* */
hideModalContentWhileAnimating: PropTypes.bool,
- /** Should the modal use custom focus trap logic */
- shouldEnableFocusTrap: PropTypes.bool,
-
...windowDimensionsPropTypes,
};
@@ -87,7 +84,6 @@ const defaultProps = {
statusBarTranslucent: true,
avoidKeyboard: false,
hideModalContentWhileAnimating: false,
- shouldEnableFocusTrap: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/Modal/types.ts b/src/components/Modal/types.ts
index ddb51a68ba1b..3fa60e6ac765 100644
--- a/src/components/Modal/types.ts
+++ b/src/components/Modal/types.ts
@@ -61,9 +61,6 @@ type BaseModalProps = WindowDimensionsProps &
* See: https://github.com/react-native-modal/react-native-modal/pull/116
* */
hideModalContentWhileAnimating?: boolean;
-
- /** Whether the modal should use focus trap */
- shouldEnableFocusTrap?: boolean;
};
export default BaseModalProps;
diff --git a/src/components/MoneyReportHeader.js b/src/components/MoneyReportHeader.js
index df41abea30a3..bf8bc7719316 100644
--- a/src/components/MoneyReportHeader.js
+++ b/src/components/MoneyReportHeader.js
@@ -119,7 +119,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
style={[styles.pv2]}
formattedAmount={formattedAmount}
/>
@@ -164,7 +163,6 @@ function MoneyReportHeader({session, personalDetails, policy, chatReport, nextSt
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, chatReport, moneyRequestReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
formattedAmount={formattedAmount}
/>
diff --git a/src/components/MoneyRequestConfirmationList.js b/src/components/MoneyRequestConfirmationList.js
index 6cf1b7e6cef1..efa9c5a49cec 100755
--- a/src/components/MoneyRequestConfirmationList.js
+++ b/src/components/MoneyRequestConfirmationList.js
@@ -509,7 +509,6 @@ function MoneyRequestConfirmationList(props) {
addDebitCardRoute={ROUTES.IOU_SEND_ADD_DEBIT_CARD}
currency={props.iouCurrencyCode}
policyID={props.policyID}
- shouldShowPaymentOptions
buttonSize={CONST.DROPDOWN_BUTTON_SIZE.LARGE}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
diff --git a/src/components/OptionRow.js b/src/components/OptionRow.js
index e1c554dc1d37..8afda6c375bb 100644
--- a/src/components/OptionRow.js
+++ b/src/components/OptionRow.js
@@ -190,7 +190,6 @@ function OptionRow(props) {
props.optionIsFocused ? styles.sidebarLinkActive : null,
props.shouldHaveOptionSeparator && styles.borderTop,
!props.onSelectRow && !props.isDisabled ? styles.cursorDefault : null,
- props.isSelected && props.highlightSelected && styles.optionRowSelected,
]}
accessibilityLabel={props.option.text}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
diff --git a/src/components/OptionsSelector/BaseOptionsSelector.js b/src/components/OptionsSelector/BaseOptionsSelector.js
index c2f3e2b47330..1702f66605f7 100755
--- a/src/components/OptionsSelector/BaseOptionsSelector.js
+++ b/src/components/OptionsSelector/BaseOptionsSelector.js
@@ -137,7 +137,7 @@ class BaseOptionsSelector extends Component {
this.setState(
{
allOptions: newOptions,
- focusedIndex: _.isNumber(this.props.initialFocusedIndex) ? this.props.initialFocusedIndex : newFocusedIndex,
+ focusedIndex: _.isNumber(this.props.focusedIndex) ? this.props.focusedIndex : newFocusedIndex,
},
() => {
// If we just toggled an option on a multi-selection page or cleared the search input, scroll to top
@@ -168,14 +168,14 @@ class BaseOptionsSelector extends Component {
* @returns {Number}
*/
getInitiallyFocusedIndex(allOptions) {
- if (_.isNumber(this.props.initialFocusedIndex)) {
- return this.props.initialFocusedIndex;
+ let defaultIndex;
+ if (this.props.shouldTextInputAppearBelowOptions) {
+ defaultIndex = allOptions.length;
+ } else if (this.props.focusedIndex >= 0) {
+ defaultIndex = this.props.focusedIndex;
+ } else {
+ defaultIndex = this.props.selectedOptions.length;
}
-
- if (this.props.selectedOptions.length > 0) {
- return this.props.selectedOptions.length;
- }
- const defaultIndex = this.props.shouldTextInputAppearBelowOptions ? allOptions.length : 0;
if (_.isUndefined(this.props.initiallyFocusedOptionKey)) {
return defaultIndex;
}
diff --git a/src/components/OptionsSelector/optionsSelectorPropTypes.js b/src/components/OptionsSelector/optionsSelectorPropTypes.js
index 94aab8fac5f6..ba4f5beb55cd 100644
--- a/src/components/OptionsSelector/optionsSelectorPropTypes.js
+++ b/src/components/OptionsSelector/optionsSelectorPropTypes.js
@@ -127,8 +127,8 @@ const propTypes = {
/** Whether to wrap large text up to 2 lines */
isRowMultilineSupported: PropTypes.bool,
- /** Initial focused index value */
- initialFocusedIndex: PropTypes.number,
+ /** Index for option to focus on */
+ focusedIndex: PropTypes.number,
/** Whether the text input should intercept swipes or not */
shouldTextInputInterceptSwipe: PropTypes.bool,
@@ -174,7 +174,7 @@ const defaultProps = {
onChangeText: () => {},
shouldUseStyleForChildren: true,
isRowMultilineSupported: false,
- initialFocusedIndex: undefined,
+ focusedIndex: undefined,
shouldTextInputInterceptSwipe: false,
shouldAllowScrollingChildren: false,
nestedScrollEnabled: true,
diff --git a/src/components/RadioButtonWithLabel.js b/src/components/RadioButtonWithLabel.tsx
similarity index 51%
rename from src/components/RadioButtonWithLabel.js
rename to src/components/RadioButtonWithLabel.tsx
index 178d5ebdd953..7d8df23bae49 100644
--- a/src/components/RadioButtonWithLabel.js
+++ b/src/components/RadioButtonWithLabel.tsx
@@ -1,85 +1,71 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import {View} from 'react-native';
-import _ from 'underscore';
+import React, {ComponentType} from 'react';
+import {StyleProp, View, ViewStyle} from 'react-native';
import useThemeStyles from '@styles/useThemeStyles';
import FormHelpMessage from './FormHelpMessage';
import * as Pressables from './Pressable';
import RadioButton from './RadioButton';
import Text from './Text';
-const propTypes = {
+type RadioButtonWithLabelProps = {
/** Whether the radioButton is checked */
- isChecked: PropTypes.bool.isRequired,
+ isChecked: boolean;
/** Called when the radioButton or label is pressed */
- onPress: PropTypes.func.isRequired,
+ onPress: () => void;
/** Container styles */
- style: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.object), PropTypes.object]),
+ style?: StyleProp;
/** Text that appears next to check box */
- label: PropTypes.string,
+ label?: string;
/** Component to display for label */
- LabelComponent: PropTypes.func,
+ LabelComponent?: ComponentType;
/** Should the input be styled for errors */
- hasError: PropTypes.bool,
+ hasError?: boolean;
/** Error text to display */
- errorText: PropTypes.string,
-};
-
-const defaultProps = {
- style: [],
- label: undefined,
- LabelComponent: undefined,
- hasError: false,
- errorText: '',
+ errorText?: string;
};
const PressableWithFeedback = Pressables.PressableWithFeedback;
-function RadioButtonWithLabel(props) {
+function RadioButtonWithLabel({LabelComponent, style, label = '', hasError = false, errorText = '', isChecked, onPress}: RadioButtonWithLabelProps) {
const styles = useThemeStyles();
- const LabelComponent = props.LabelComponent;
const defaultStyles = [styles.flexRow, styles.alignItemsCenter];
- const wrapperStyles = _.isArray(props.style) ? [...defaultStyles, ...props.style] : [...defaultStyles, props.style];
- if (!props.label && !LabelComponent) {
+ if (!label && !LabelComponent) {
throw new Error('Must provide at least label or LabelComponent prop');
}
return (
<>
-
+ props.onPress()}
+ onPress={onPress}
style={[styles.flexRow, styles.flexWrap, styles.flexShrink1, styles.alignItemsCenter]}
wrapperStyle={[styles.ml3, styles.pr2, styles.w100]}
// disable hover style when disabled
hoverDimmingValue={0.8}
pressDimmingValue={0.5}
>
- {Boolean(props.label) && {props.label}}
- {Boolean(LabelComponent) && }
+ {Boolean(label) && {label}}
+ {!!LabelComponent && }
-
+
>
);
}
-RadioButtonWithLabel.propTypes = propTypes;
-RadioButtonWithLabel.defaultProps = defaultProps;
RadioButtonWithLabel.displayName = 'RadioButtonWithLabel';
export default RadioButtonWithLabel;
diff --git a/src/components/ReportActionItem/MoneyRequestView.js b/src/components/ReportActionItem/MoneyRequestView.js
index 33ad99f32326..b10b8d87cabd 100644
--- a/src/components/ReportActionItem/MoneyRequestView.js
+++ b/src/components/ReportActionItem/MoneyRequestView.js
@@ -104,12 +104,13 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
formattedTransactionAmount = translate('common.tbd');
}
const formattedOriginalAmount = transactionOriginalAmount && transactionOriginalCurrency && CurrencyUtils.convertToDisplayString(transactionOriginalAmount, transactionOriginalCurrency);
- const isExpensifyCardTransaction = TransactionUtils.isExpensifyCardTransaction(transaction);
- const cardProgramName = isExpensifyCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
+ const isCardTransaction = TransactionUtils.isCardTransaction(transaction);
+ const cardProgramName = isCardTransaction ? CardUtils.getCardDescription(transactionCardID) : '';
// Flags for allowing or disallowing editing a money request
const isSettled = ReportUtils.isSettled(moneyRequestReport.reportID);
- const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction) && !isExpensifyCardTransaction;
+ const canEdit = ReportUtils.canEditMoneyRequest(parentReportAction);
+ const canEditAmount = canEdit && !isSettled && !isCardTransaction;
// A flag for verifying that the current report is a sub-report of a workspace chat
const isPolicyExpenseChat = useMemo(() => ReportUtils.isPolicyExpenseChat(ReportUtils.getRootParentReport(report)), [report]);
@@ -125,7 +126,7 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
let amountDescription = `${translate('iou.amount')}`;
- if (isExpensifyCardTransaction) {
+ if (isCardTransaction) {
if (formattedOriginalAmount) {
amountDescription += ` • ${translate('iou.original')} ${formattedOriginalAmount}`;
}
@@ -190,8 +191,8 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
titleIcon={Expensicons.Checkmark}
description={amountDescription}
titleStyle={styles.newKansasLarge}
- interactive={canEdit && !isSettled}
- shouldShowRightIcon={canEdit && !isSettled}
+ interactive={canEditAmount}
+ shouldShowRightIcon={canEditAmount}
onPress={() => Navigation.navigate(ROUTES.EDIT_REQUEST.getRoute(report.reportID, CONST.EDIT_REQUEST_FIELD.AMOUNT))}
brickRoadIndicator={hasErrors && transactionAmount === 0 ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : ''}
error={hasErrors && transactionAmount === 0 ? translate('common.error.enterAmount') : ''}
@@ -271,13 +272,12 @@ function MoneyRequestView({report, parentReport, policyCategories, shouldShowHor
/>
)}
- {isExpensifyCardTransaction && (
+ {isCardTransaction && (
)}
diff --git a/src/components/ReportActionItem/ReportPreview.js b/src/components/ReportActionItem/ReportPreview.js
index 9f291e1318f5..f04029182d45 100644
--- a/src/components/ReportActionItem/ReportPreview.js
+++ b/src/components/ReportActionItem/ReportPreview.js
@@ -254,7 +254,6 @@ function ReportPreview(props) {
onPress={(paymentType) => IOU.payMoneyRequest(paymentType, props.chatReport, props.iouReport)}
enablePaymentsRoute={ROUTES.ENABLE_PAYMENTS}
addBankAccountRoute={bankAccountRoute}
- shouldShowPaymentOptions
style={[styles.mt3]}
kycWallAnchorAlignment={{
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
diff --git a/src/components/RoomNameInput/index.js b/src/components/RoomNameInput/index.js
index e6695405ace8..14529d7b594a 100644
--- a/src/components/RoomNameInput/index.js
+++ b/src/components/RoomNameInput/index.js
@@ -57,7 +57,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
onSelectionChange={(event) => setSelection(event.nativeEvent.selection)}
errorText={errorText}
autoCapitalize="none"
- onBlur={() => isFocused && onBlur()}
+ onBlur={(event) => isFocused && onBlur(event)}
shouldDelayFocus={shouldDelayFocus}
autoFocus={isFocused && autoFocus}
maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH}
diff --git a/src/components/RoomNameInput/index.native.js b/src/components/RoomNameInput/index.native.js
index 828affe33d07..a2c09996ad34 100644
--- a/src/components/RoomNameInput/index.native.js
+++ b/src/components/RoomNameInput/index.native.js
@@ -41,7 +41,7 @@ function RoomNameInput({isFocused, autoFocus, disabled, errorText, forwardedRef,
errorText={errorText}
maxLength={CONST.REPORT.MAX_ROOM_NAME_LENGTH}
keyboardType={keyboardType} // this is a bit hacky solution to a RN issue https://github.com/facebook/react-native/issues/27449
- onBlur={() => isFocused && onBlur()}
+ onBlur={(event) => isFocused && onBlur(event)}
autoFocus={isFocused && autoFocus}
autoCapitalize="none"
shouldDelayFocus={shouldDelayFocus}
diff --git a/src/components/ScreenWrapper/index.js b/src/components/ScreenWrapper/index.js
index 01c28b3b8463..f9173c15da7d 100644
--- a/src/components/ScreenWrapper/index.js
+++ b/src/components/ScreenWrapper/index.js
@@ -1,11 +1,10 @@
-import {useIsFocused, useNavigation} from '@react-navigation/native';
+import {useNavigation} from '@react-navigation/native';
import lodashGet from 'lodash/get';
import React, {useEffect, useRef, useState} from 'react';
import {Keyboard, PanResponder, View} from 'react-native';
import {PickerAvoidingView} from 'react-native-picker-select';
import _ from 'underscore';
import CustomDevMenu from '@components/CustomDevMenu';
-import FocusTrapView from '@components/FocusTrapView';
import HeaderGap from '@components/HeaderGap';
import KeyboardAvoidingView from '@components/KeyboardAvoidingView';
import OfflineIndicator from '@components/OfflineIndicator';
@@ -40,8 +39,6 @@ const ScreenWrapper = React.forwardRef(
shouldDismissKeyboardBeforeClose,
onEntryTransitionEnd,
testID,
- shouldDisableFocusTrap,
- shouldEnableAutoFocus,
},
ref,
) => {
@@ -51,7 +48,6 @@ const ScreenWrapper = React.forwardRef(
const {isDevelopment} = useEnvironment();
const {isOffline} = useNetwork();
const navigation = useNavigation();
- const isFocused = useIsFocused();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const maxHeight = shouldEnableMaxHeight ? windowHeight : undefined;
const minHeight = shouldEnableMinHeight ? initialHeight : undefined;
@@ -150,27 +146,20 @@ const ScreenWrapper = React.forwardRef(
style={styles.flex1}
enabled={shouldEnablePickerAvoiding}
>
-
-
- {isDevelopment && }
- {isDevelopment && }
- {
- // If props.children is a function, call it to provide the insets to the children.
- _.isFunction(children)
- ? children({
- insets,
- safeAreaPaddingBottomStyle,
- didScreenTransitionEnd,
- })
- : children
- }
- {isSmallScreenWidth && shouldShowOfflineIndicator && }
-
+
+ {isDevelopment && }
+ {isDevelopment && }
+ {
+ // If props.children is a function, call it to provide the insets to the children.
+ _.isFunction(children)
+ ? children({
+ insets,
+ safeAreaPaddingBottomStyle,
+ didScreenTransitionEnd,
+ })
+ : children
+ }
+ {isSmallScreenWidth && shouldShowOfflineIndicator && }
diff --git a/src/components/ScreenWrapper/propTypes.js b/src/components/ScreenWrapper/propTypes.js
index 8984c860a15f..c98968bb112b 100644
--- a/src/components/ScreenWrapper/propTypes.js
+++ b/src/components/ScreenWrapper/propTypes.js
@@ -48,12 +48,6 @@ const propTypes = {
/** Styles for the offline indicator */
offlineIndicatorStyle: stylePropTypes,
-
- /** Whether to disable the focus trap */
- shouldDisableFocusTrap: PropTypes.bool,
-
- /** Whether to disable auto focus of the focus trap */
- shouldEnableAutoFocus: PropTypes.bool,
};
const defaultProps = {
@@ -69,8 +63,6 @@ const defaultProps = {
shouldShowOfflineIndicator: true,
offlineIndicatorStyle: [],
headerGapStyles: [],
- shouldDisableFocusTrap: false,
- shouldEnableAutoFocus: false,
};
export {propTypes, defaultProps};
diff --git a/src/components/SettlementButton.js b/src/components/SettlementButton.js
index d2030eac8d7d..c7342b0d36ac 100644
--- a/src/components/SettlementButton.js
+++ b/src/components/SettlementButton.js
@@ -5,7 +5,6 @@ import _ from 'underscore';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import compose from '@libs/compose';
-import Permissions from '@libs/Permissions';
import * as ReportUtils from '@libs/ReportUtils';
import iouReportPropTypes from '@pages/iouReportPropTypes';
import * as BankAccounts from '@userActions/BankAccounts';
@@ -34,15 +33,9 @@ const propTypes = {
/** The IOU/Expense report we are paying */
iouReport: iouReportPropTypes,
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** The route to redirect if user does not have a payment method setup */
enablePaymentsRoute: PropTypes.string.isRequired,
- /** Should we show the payment options? */
- shouldShowPaymentOptions: PropTypes.bool,
-
/** The last payment method used per policy */
nvp_lastPaymentMethod: PropTypes.objectOf(PropTypes.string),
@@ -92,12 +85,10 @@ const defaultProps = {
currency: CONST.CURRENCY.USD,
chatReportID: '',
- // The "betas" array, "iouReport" and "nvp_lastPaymentMethod" objects needs to be stable to prevent the "useMemo"
+ // The "iouReport" and "nvp_lastPaymentMethod" objects needs to be stable to prevent the "useMemo"
// hook from being recreated unnecessarily, hence the use of CONST.EMPTY_ARRAY and CONST.EMPTY_OBJECT
- betas: CONST.EMPTY_ARRAY,
iouReport: CONST.EMPTY_OBJECT,
nvp_lastPaymentMethod: CONST.EMPTY_OBJECT,
- shouldShowPaymentOptions: false,
style: [],
policyID: '',
formattedAmount: '',
@@ -117,7 +108,6 @@ function SettlementButton({
addBankAccountRoute,
kycWallAnchorAlignment,
paymentMethodDropdownAnchorAlignment,
- betas,
buttonSize,
chatReportID,
currency,
@@ -130,7 +120,6 @@ function SettlementButton({
onPress,
pressOnEnter,
policyID,
- shouldShowPaymentOptions,
style,
}) {
const {translate} = useLocalize();
@@ -160,32 +149,11 @@ function SettlementButton({
value: CONST.IOU.PAYMENT_TYPE.ELSEWHERE,
},
};
- const canUseWallet = !isExpenseReport && currency === CONST.CURRENCY.USD && Permissions.canUsePayWithExpensify(betas) && Permissions.canUseWallet(betas);
+ const canUseWallet = !isExpenseReport && currency === CONST.CURRENCY.USD;
// To achieve the one tap pay experience we need to choose the correct payment type as default,
// if user already paid for some request or expense, let's use the last payment method or use default.
- let paymentMethod = nvp_lastPaymentMethod[policyID] || '';
- if (!shouldShowPaymentOptions) {
- if (!paymentMethod) {
- // In case the user hasn't paid a request yet, let's default to VBBA payment type in case of expense reports
- if (isExpenseReport) {
- paymentMethod = CONST.IOU.PAYMENT_TYPE.VBBA;
- } else if (canUseWallet) {
- // If they have Wallet set up, use that payment method as default
- paymentMethod = CONST.IOU.PAYMENT_TYPE.EXPENSIFY;
- } else {
- paymentMethod = CONST.IOU.PAYMENT_TYPE.ELSEWHERE;
- }
- }
-
- // In case of the settlement button in the report preview component, we do not show payment options and the label for Wallet and ACH type is simply "Pay".
- return [
- {
- ...paymentMethods[paymentMethod],
- text: paymentMethod === CONST.IOU.PAYMENT_TYPE.ELSEWHERE ? translate('iou.payElsewhere') : translate('iou.pay'),
- },
- ];
- }
+ const paymentMethod = nvp_lastPaymentMethod[policyID] || '';
if (canUseWallet) {
buttonOptions.push(paymentMethods[CONST.IOU.PAYMENT_TYPE.EXPENSIFY]);
}
@@ -199,7 +167,7 @@ function SettlementButton({
return _.sortBy(buttonOptions, (method) => (method.value === paymentMethod ? 0 : 1));
}
return buttonOptions;
- }, [betas, currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, shouldShowPaymentOptions, translate]);
+ }, [currency, formattedAmount, iouReport, nvp_lastPaymentMethod, policyID, translate]);
const selectPaymentType = (event, iouPaymentType, triggerKYCFlow) => {
if (iouPaymentType === CONST.IOU.PAYMENT_TYPE.EXPENSIFY || iouPaymentType === CONST.IOU.PAYMENT_TYPE.VBBA) {
@@ -247,9 +215,6 @@ SettlementButton.displayName = 'SettlementButton';
export default compose(
withNavigation,
withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
nvp_lastPaymentMethod: {
key: ONYXKEYS.NVP_LAST_PAYMENT_METHOD,
},
diff --git a/src/components/TagPicker/index.js b/src/components/TagPicker/index.js
index d6d49e3fe288..f9071aa5267d 100644
--- a/src/components/TagPicker/index.js
+++ b/src/components/TagPicker/index.js
@@ -37,17 +37,6 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
];
}, [selectedTag]);
- const initialFocusedIndex = useMemo(() => {
- if (isTagsCountBelowThreshold && selectedOptions.length > 0) {
- return _.chain(policyTagList)
- .values()
- .findIndex((policyTag) => policyTag.name === selectedOptions[0].name, true)
- .value();
- }
-
- return 0;
- }, [policyTagList, selectedOptions, isTagsCountBelowThreshold]);
-
const enabledTags = useMemo(() => {
if (!shouldShowDisabledAndSelectedOption) {
return policyTagList;
@@ -64,6 +53,8 @@ function TagPicker({selectedTag, tag, policyTags, policyRecentlyUsedTags, onSubm
const headerMessage = OptionsListUtils.getHeaderMessageForNonUserList(lodashGet(sections, '[0].data.length', 0) > 0, searchValue);
+ const selectedOptionKey = lodashGet(_.filter(lodashGet(sections, '[0].data', []), (policyTag) => policyTag.searchText === selectedTag)[0], 'keyForList');
+
return (
diff --git a/src/components/UnreadActionIndicator.js b/src/components/UnreadActionIndicator.tsx
similarity index 53%
rename from src/components/UnreadActionIndicator.js
rename to src/components/UnreadActionIndicator.tsx
index 7555c93c2326..b34f962e57bd 100755
--- a/src/components/UnreadActionIndicator.js
+++ b/src/components/UnreadActionIndicator.tsx
@@ -1,26 +1,31 @@
import React from 'react';
import {View} from 'react-native';
+import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@styles/useThemeStyles';
import CONST from '@src/CONST';
import Text from './Text';
-import withLocalize, {withLocalizePropTypes} from './withLocalize';
-function UnreadActionIndicator(props) {
+type UnreadActionIndicatorProps = {
+ reportActionID: string;
+};
+
+function UnreadActionIndicator({reportActionID}: UnreadActionIndicatorProps) {
const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
return (
- {props.translate('common.new')}
+ {translate('common.new')}
);
}
-UnreadActionIndicator.propTypes = {...withLocalizePropTypes};
-
UnreadActionIndicator.displayName = 'UnreadActionIndicator';
-export default withLocalize(UnreadActionIndicator);
+
+export default UnreadActionIndicator;
diff --git a/src/hooks/useKeyboardShortcut.ts b/src/hooks/useKeyboardShortcut.ts
index e4a7a16f4cfc..a5921ba37e4a 100644
--- a/src/hooks/useKeyboardShortcut.ts
+++ b/src/hooks/useKeyboardShortcut.ts
@@ -1,4 +1,5 @@
import {useEffect} from 'react';
+import {GestureResponderEvent} from 'react-native';
import {ValueOf} from 'type-fest';
import KeyboardShortcut from '@libs/KeyboardShortcut';
import CONST from '@src/CONST';
@@ -23,7 +24,7 @@ type KeyboardShortcutConfig = {
* Register a keyboard shortcut handler.
* Recommendation: To ensure stability, wrap the `callback` function with the useCallback hook before using it with this hook.
*/
-export default function useKeyboardShortcut(shortcut: Shortcut, callback: () => void, config: KeyboardShortcutConfig | Record = {}) {
+export default function useKeyboardShortcut(shortcut: Shortcut, callback: (e?: GestureResponderEvent | KeyboardEvent) => void, config: KeyboardShortcutConfig | Record = {}) {
const {
captureOnInputs = true,
shouldBubble = false,
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 4c6ea25eb2c8..96e2e99824cd 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -215,6 +215,8 @@ export default {
more: 'More',
debitCard: 'Debit card',
bankAccount: 'Bank account',
+ personalBankAccount: 'Personal bank account',
+ businessBankAccount: 'Business bank account',
join: 'Join',
leave: 'Leave',
decline: 'Decline',
@@ -591,6 +593,9 @@ export default {
invalidSplit: 'Split amounts do not equal total amount',
other: 'Unexpected error, please try again later',
genericCreateFailureMessage: 'Unexpected error requesting money, please try again later',
+ receiptFailureMessage: "The receipt didn't upload. ",
+ saveFileMessage: 'Download the file ',
+ loseFileMessage: 'or dismiss this error and lose it',
genericDeleteFailureMessage: 'Unexpected error deleting the money request, please try again later',
genericEditFailureMessage: 'Unexpected error editing the money request, please try again later',
genericSmartscanFailureMessage: 'Transaction is missing fields',
@@ -903,6 +908,8 @@ export default {
activatePhysicalCard: 'Activate physical card',
error: {
thatDidntMatch: "That didn't match the last 4 digits on your card. Please try again.",
+ throttled:
+ "You've incorrectly entered the last 4 digits of your Expensify Card too many times. If you're sure the numbers are correct, please reach out to Concierge to resolve. Otherwise, try again later.",
},
},
getPhysicalCard: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 85eab5c3f14d..3f8f68977549 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -205,6 +205,8 @@ export default {
more: 'Más',
debitCard: 'Tarjeta de débito',
bankAccount: 'Cuenta bancaria',
+ personalBankAccount: 'Cuenta bancaria personal',
+ businessBankAccount: 'Cuenta bancaria comercial',
join: 'Unirse',
leave: 'Salir',
decline: 'Rechazar',
@@ -585,6 +587,9 @@ export default {
invalidSplit: 'La suma de las partes no equivale al monto total',
other: 'Error inesperado, por favor inténtalo más tarde',
genericCreateFailureMessage: 'Error inesperado solicitando dinero, Por favor, inténtalo más tarde',
+ receiptFailureMessage: 'El recibo no se subió. ',
+ saveFileMessage: 'Guarda el archivo ',
+ loseFileMessage: 'o descarta este error y piérdelo',
genericDeleteFailureMessage: 'Error inesperado eliminando la solicitud de dinero. Por favor, inténtalo más tarde',
genericEditFailureMessage: 'Error inesperado al guardar la solicitud de dinero. Por favor, inténtalo más tarde',
genericSmartscanFailureMessage: 'La transacción tiene campos vacíos',
@@ -899,6 +904,8 @@ export default {
activatePhysicalCard: 'Activar tarjeta física',
error: {
thatDidntMatch: 'Los 4 últimos dígitos de tu tarjeta no coinciden. Por favor, inténtalo de nuevo.',
+ throttled:
+ 'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.',
},
},
// TODO: add translation
diff --git a/src/libs/E2E/apiMocks/beginSignin.ts b/src/libs/E2E/apiMocks/beginSignin.ts
index 298846250a12..c5002f1f3dd8 100644
--- a/src/libs/E2E/apiMocks/beginSignin.ts
+++ b/src/libs/E2E/apiMocks/beginSignin.ts
@@ -17,11 +17,6 @@ const beginSignin = ({email}: SigninParams): Response => ({
validated: true,
},
},
- {
- onyxMethod: 'set',
- key: 'betas',
- value: ['passwordless'],
- },
],
jsonCode: 200,
requestID: '783e54ef4b38cff5-SJC',
diff --git a/src/libs/E2E/apiMocks/openApp.ts b/src/libs/E2E/apiMocks/openApp.ts
index 13fc9f1f6784..a0583d8439c0 100644
--- a/src/libs/E2E/apiMocks/openApp.ts
+++ b/src/libs/E2E/apiMocks/openApp.ts
@@ -1457,82 +1457,7 @@ const openApp = (): Response => ({
{
onyxMethod: 'set',
key: 'betas',
- value: [
- 'all',
- 'pdfMetaStore',
- 'reportActionContextMenu',
- 'submitPolicy',
- 'attendees',
- 'autoExport',
- 'autoExportIntacct',
- 'autoExportQbo',
- 'autoExportXero',
- 'autoJoinPolicy',
- 'automatedTaxExemption',
- 'billPay',
- 'categoryDefaultTax',
- 'collectableDepositAccounts',
- 'conciergeTravel',
- 'connectedCards',
- 'discrepancy',
- 'domainContactBilling',
- 'domainTwoFactorAuth',
- 'duplicateDetection',
- 'emailSuppressionBeta',
- 'expensesV2',
- 'expensifyCard',
- 'expensifyCardIntacctReconciliation',
- 'expensifyCardNetSuiteReconciliation',
- 'expensifyCardQBOReconciliation',
- 'expensifyCardRapidIncreaseFraud',
- 'expensifyCardXeroReconciliation',
- 'expensifyOrg',
- 'fixViolationPushNotification',
- 'freePlan',
- 'freePlanFullLaunch',
- 'freePlanSoftLaunch',
- 'gusto',
- 'inboxCache',
- 'inboxHiddenTasks',
- 'indirectIntegrationSetup',
- 'IOU',
- 'joinPolicy',
- 'loadPolicyAsync',
- 'mapReceipt',
- 'mergeAPI',
- 'mobileRealtimeReportComments',
- 'mobileSecureReceipts',
- 'monthlySettlement',
- 'namesAndAvatars',
- 'nativeChat',
- 'newPricing',
- 'newsletterThree',
- 'nextSteps',
- 'openFaceHamburger',
- 'pdfMetaStore',
- 'perDiem',
- 'perDiemInternational',
- 'pricingCopyChanges',
- 'qboInvoices',
- 'quickbooksDesktopV2',
- 'realtimeReportComments',
- 's2wAnnouncement',
- 'scheduledAutoReporting',
- 'secureReceipts',
- 'secureReceiptsReports',
- 'selfServiceHardLaunch',
- 'sendMoney',
- 'smartScanUserDisputes',
- 'smsSignUp',
- 'stripeConnect',
- 'submitPolicy',
- 'summaryEmail',
- 'swipeToWin',
- 'taxForMileage',
- 'twoFactorAuth',
- 'venmoIntegration',
- 'zenefitsIntegration',
- ],
+ value: ['all'],
},
{
onyxMethod: 'merge',
diff --git a/src/libs/E2E/apiMocks/signinUser.ts b/src/libs/E2E/apiMocks/signinUser.ts
index d94d2fda9016..a7d841196c48 100644
--- a/src/libs/E2E/apiMocks/signinUser.ts
+++ b/src/libs/E2E/apiMocks/signinUser.ts
@@ -36,82 +36,7 @@ const signinUser = ({email}: SigninParams): Response => ({
{
onyxMethod: 'set',
key: 'betas',
- value: [
- 'all',
- 'pdfMetaStore',
- 'reportActionContextMenu',
- 'submitPolicy',
- 'attendees',
- 'autoExport',
- 'autoExportIntacct',
- 'autoExportQbo',
- 'autoExportXero',
- 'autoJoinPolicy',
- 'automatedTaxExemption',
- 'billPay',
- 'categoryDefaultTax',
- 'collectableDepositAccounts',
- 'conciergeTravel',
- 'connectedCards',
- 'discrepancy',
- 'domainContactBilling',
- 'domainTwoFactorAuth',
- 'duplicateDetection',
- 'emailSuppressionBeta',
- 'expensesV2',
- 'expensifyCard',
- 'expensifyCardIntacctReconciliation',
- 'expensifyCardNetSuiteReconciliation',
- 'expensifyCardQBOReconciliation',
- 'expensifyCardRapidIncreaseFraud',
- 'expensifyCardXeroReconciliation',
- 'expensifyOrg',
- 'fixViolationPushNotification',
- 'freePlan',
- 'freePlanFullLaunch',
- 'freePlanSoftLaunch',
- 'gusto',
- 'inboxCache',
- 'inboxHiddenTasks',
- 'indirectIntegrationSetup',
- 'IOU',
- 'joinPolicy',
- 'loadPolicyAsync',
- 'mapReceipt',
- 'mergeAPI',
- 'mobileRealtimeReportComments',
- 'mobileSecureReceipts',
- 'monthlySettlement',
- 'namesAndAvatars',
- 'nativeChat',
- 'newPricing',
- 'newsletterThree',
- 'nextSteps',
- 'openFaceHamburger',
- 'pdfMetaStore',
- 'perDiem',
- 'perDiemInternational',
- 'pricingCopyChanges',
- 'qboInvoices',
- 'quickbooksDesktopV2',
- 'realtimeReportComments',
- 's2wAnnouncement',
- 'scheduledAutoReporting',
- 'secureReceipts',
- 'secureReceiptsReports',
- 'selfServiceHardLaunch',
- 'sendMoney',
- 'smartScanUserDisputes',
- 'smsSignUp',
- 'stripeConnect',
- 'submitPolicy',
- 'summaryEmail',
- 'swipeToWin',
- 'taxForMileage',
- 'twoFactorAuth',
- 'venmoIntegration',
- 'zenefitsIntegration',
- ],
+ value: ['all'],
},
{
onyxMethod: 'merge',
diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts
index 5bc8ea1d3508..46bdd510f5c4 100644
--- a/src/libs/ErrorUtils.ts
+++ b/src/libs/ErrorUtils.ts
@@ -42,6 +42,14 @@ function getMicroSecondOnyxError(error: string): Record {
return {[DateUtils.getMicroseconds()]: error};
}
+/**
+ * Method used to get an error object with microsecond as the key and an object as the value.
+ * @param error - error key or message to be saved
+ */
+function getMicroSecondOnyxErrorObject(error: Record): Record> {
+ return {[DateUtils.getMicroseconds()]: error};
+}
+
type OnyxDataWithErrors = {
errors?: Errors;
};
@@ -111,4 +119,4 @@ function addErrorMessage(errors: ErrorsList, inpu
}
}
-export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
+export {getAuthenticateErrorMessage, getMicroSecondOnyxError, getMicroSecondOnyxErrorObject, getLatestErrorMessage, getLatestErrorField, getEarliestErrorField, addErrorMessage};
diff --git a/src/libs/HttpUtils.js b/src/libs/HttpUtils.ts
similarity index 65%
rename from src/libs/HttpUtils.js
rename to src/libs/HttpUtils.ts
index 2df7421ea91c..859c8624833c 100644
--- a/src/libs/HttpUtils.js
+++ b/src/libs/HttpUtils.ts
@@ -1,13 +1,16 @@
import Onyx from 'react-native-onyx';
-import _ from 'underscore';
+import {ValueOf} from 'type-fest';
import alert from '@components/Alert';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import {RequestType} from '@src/types/onyx/Request';
+import type Response from '@src/types/onyx/Response';
import * as ApiUtils from './ApiUtils';
import HttpsError from './Errors/HttpsError';
let shouldFailAllRequests = false;
let shouldForceOffline = false;
+
Onyx.connect({
key: ONYXKEYS.NETWORK,
callback: (network) => {
@@ -25,14 +28,8 @@ let cancellationController = new AbortController();
/**
* Send an HTTP request, and attempt to resolve the json response.
* If there is a network error, we'll set the application offline.
- *
- * @param {String} url
- * @param {String} [method]
- * @param {Object} [body]
- * @param {Boolean} [canCancel]
- * @returns {Promise}
*/
-function processHTTPRequest(url, method = 'get', body = null, canCancel = true) {
+function processHTTPRequest(url: string, method: RequestType = 'get', body: FormData | null = null, canCancel = true): Promise {
return fetch(url, {
// We hook requests to the same Controller signal, so we can cancel them all at once
signal: canCancel ? cancellationController.signal : undefined,
@@ -49,40 +46,41 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (!response.ok) {
// Expensify site is down or there was an internal server error, or something temporary like a Bad Gateway, or unknown error occurred
- const serviceInterruptedStatuses = [
+ const serviceInterruptedStatuses: Array> = [
CONST.HTTP_STATUS.INTERNAL_SERVER_ERROR,
CONST.HTTP_STATUS.BAD_GATEWAY,
CONST.HTTP_STATUS.GATEWAY_TIMEOUT,
CONST.HTTP_STATUS.UNKNOWN_ERROR,
];
- if (_.contains(serviceInterruptedStatuses, response.status)) {
+ if (serviceInterruptedStatuses.indexOf(response.status as ValueOf) > -1) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: response.status,
+ status: response.status.toString(),
title: 'Issue connecting to Expensify site',
});
- } else if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
+ }
+ if (response.status === CONST.HTTP_STATUS.TOO_MANY_REQUESTS) {
throw new HttpsError({
message: CONST.ERROR.THROTTLED,
- status: response.status,
+ status: response.status.toString(),
title: 'API request throttled',
});
}
throw new HttpsError({
message: response.statusText,
- status: response.status,
+ status: response.status.toString(),
});
}
- return response.json();
+ return response.json() as Promise;
})
.then((response) => {
// Some retried requests will result in a "Unique Constraints Violation" error from the server, which just means the record already exists
if (response.jsonCode === CONST.JSON_CODE.BAD_REQUEST && response.message === CONST.ERROR_TITLE.DUPLICATE_RECORD) {
throw new HttpsError({
message: CONST.ERROR.DUPLICATE_RECORD,
- status: CONST.JSON_CODE.BAD_REQUEST,
+ status: CONST.JSON_CODE.BAD_REQUEST.toString(),
title: CONST.ERROR_TITLE.DUPLICATE_RECORD,
});
}
@@ -91,43 +89,42 @@ function processHTTPRequest(url, method = 'get', body = null, canCancel = true)
if (response.jsonCode === CONST.JSON_CODE.EXP_ERROR && response.title === CONST.ERROR_TITLE.SOCKET && response.type === CONST.ERROR_TYPE.SOCKET) {
throw new HttpsError({
message: CONST.ERROR.EXPENSIFY_SERVICE_INTERRUPTED,
- status: CONST.JSON_CODE.EXP_ERROR,
+ status: CONST.JSON_CODE.EXP_ERROR.toString(),
title: CONST.ERROR_TITLE.SOCKET,
});
}
if (response.jsonCode === CONST.JSON_CODE.MANY_WRITES_ERROR) {
- const {phpCommandName, authWriteCommands} = response.data;
- // eslint-disable-next-line max-len
- const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
- ', ',
- )}. Check the APIWriteCommands class in Web-Expensify`;
- alert('Too many auth writes', message);
+ if (response.data) {
+ const {phpCommandName, authWriteCommands} = response.data;
+ // eslint-disable-next-line max-len
+ const message = `The API call (${phpCommandName}) did more Auth write requests than allowed. Count ${authWriteCommands.length}, commands: ${authWriteCommands.join(
+ ', ',
+ )}. Check the APIWriteCommands class in Web-Expensify`;
+ alert('Too many auth writes', message);
+ }
}
- return response;
+ return response as Promise;
});
}
/**
* Makes XHR request
- * @param {String} command the name of the API command
- * @param {Object} data parameters for the API command
- * @param {String} type HTTP request type (get/post)
- * @param {Boolean} shouldUseSecure should we use the secure server
- * @returns {Promise}
+ * @param command the name of the API command
+ * @param data parameters for the API command
+ * @param type HTTP request type (get/post)
+ * @param shouldUseSecure should we use the secure server
*/
-function xhr(command, data, type = CONST.NETWORK.METHOD.POST, shouldUseSecure = false) {
+function xhr(command: string, data: Record, type: RequestType = CONST.NETWORK.METHOD.POST, shouldUseSecure = false): Promise {
const formData = new FormData();
- _.each(data, (val, key) => {
- // Do not send undefined request parameters to our API. They will be processed as strings of 'undefined'.
- if (_.isUndefined(val)) {
+ Object.keys(data).forEach((key) => {
+ if (typeof data[key] === 'undefined') {
return;
}
-
- formData.append(key, val);
+ formData.append(key, data[key] as string | Blob);
});
const url = ApiUtils.getCommandURL({shouldUseSecure, command});
- return processHTTPRequest(url, type, formData, data.canCancel);
+ return processHTTPRequest(url, type, formData, Boolean(data.canCancel));
}
function cancelPendingRequests() {
diff --git a/src/libs/Localize/index.ts b/src/libs/Localize/index.ts
index 6910bc7e9bdb..488ff0d9b98a 100644
--- a/src/libs/Localize/index.ts
+++ b/src/libs/Localize/index.ts
@@ -96,10 +96,12 @@ function translateLocal(phrase: TKey, ...variable
return translate(BaseLocaleListener.getPreferredLocale(), phrase, ...variables);
}
+type MaybePhraseKey = string | [string, Record & {isTranslated?: true}] | [];
+
/**
* Return translated string for given error.
*/
-function translateIfPhraseKey(message: string | [string, Record & {isTranslated?: true}] | []): string {
+function translateIfPhraseKey(message: MaybePhraseKey): string {
if (!message || (Array.isArray(message) && message.length === 0)) {
return '';
}
@@ -138,4 +140,4 @@ function getDevicePreferredLocale(): string {
}
export {translate, translateLocal, translateIfPhraseKey, arrayToString, getDevicePreferredLocale};
-export type {PhraseParameters, Phrase};
+export type {PhraseParameters, Phrase, MaybePhraseKey};
diff --git a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
index 7a4cbf7db3c5..44d996282617 100644
--- a/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
+++ b/src/libs/Navigation/AppNavigator/Navigators/Overlay.js
@@ -29,6 +29,7 @@ function Overlay(props) {
onPress={props.onPress}
accessibilityLabel={translate('common.close')}
role={CONST.ACCESSIBILITY_ROLE.BUTTON}
+ nativeID={CONST.OVERLAY.TOP_BUTTON_NATIVE_ID}
/>
diff --git a/src/libs/Navigation/Navigation.js b/src/libs/Navigation/Navigation.js
index 2629d36999bf..bfc0f509373e 100644
--- a/src/libs/Navigation/Navigation.js
+++ b/src/libs/Navigation/Navigation.js
@@ -98,6 +98,40 @@ function getDistanceFromPathInRootNavigator(path) {
return -1;
}
+/**
+ * Returns the current active route
+ * @returns {String}
+ */
+function getActiveRoute() {
+ const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
+ const currentRouteHasName = lodashGet(currentRoute, 'name', false);
+ if (!currentRouteHasName) {
+ return '';
+ }
+
+ const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
+
+ if (routeFromState) {
+ return routeFromState;
+ }
+
+ return '';
+}
+
+/**
+ * Check whether the passed route is currently Active or not.
+ *
+ * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
+ * is undefined in the first navigation.
+ *
+ * @param {String} routePath Path to check
+ * @return {Boolean} is active
+ */
+function isActiveRoute(routePath) {
+ // We remove First forward slash from the URL before matching
+ return getActiveRoute().substring(1) === routePath;
+}
+
/**
* Main navigation method for redirecting to a route.
* @param {String} route
@@ -111,7 +145,7 @@ function navigate(route = ROUTES.HOME, type) {
pendingRoute = route;
return;
}
- linkTo(navigationRef.current, route, type);
+ linkTo(navigationRef.current, route, type, isActiveRoute(route));
}
/**
@@ -221,26 +255,6 @@ function dismissModal(targetReportID) {
}
}
-/**
- * Returns the current active route
- * @returns {String}
- */
-function getActiveRoute() {
- const currentRoute = navigationRef.current && navigationRef.current.getCurrentRoute();
- const currentRouteHasName = lodashGet(currentRoute, 'name', false);
- if (!currentRouteHasName) {
- return '';
- }
-
- const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config);
-
- if (routeFromState) {
- return routeFromState;
- }
-
- return '';
-}
-
/**
* Returns the current active route without the URL params
* @returns {String}
@@ -265,20 +279,6 @@ function getRouteNameFromStateEvent(event) {
}
}
-/**
- * Check whether the passed route is currently Active or not.
- *
- * Building path with getPathFromState since navigationRef.current.getCurrentRoute().path
- * is undefined in the first navigation.
- *
- * @param {String} routePath Path to check
- * @return {Boolean} is active
- */
-function isActiveRoute(routePath) {
- // We remove First forward slash from the URL before matching
- return getActiveRoute().substring(1) === routePath;
-}
-
/**
* Navigate to the route that we originally intended to go to
* but the NavigationContainer was not ready when navigate() was called
diff --git a/src/libs/Navigation/linkTo.js b/src/libs/Navigation/linkTo.js
index 55bd4b31dbdf..ca87a0d7b79c 100644
--- a/src/libs/Navigation/linkTo.js
+++ b/src/libs/Navigation/linkTo.js
@@ -41,7 +41,7 @@ function getMinimalAction(action, state) {
return currentAction;
}
-export default function linkTo(navigation, path, type) {
+export default function linkTo(navigation, path, type, isActiveRoute) {
if (navigation === undefined) {
throw new Error("Couldn't find a navigation object. Is your component inside a screen in a navigator?");
}
@@ -86,7 +86,7 @@ export default function linkTo(navigation, path, type) {
// There are situations where a route already exists on the current navigation stack
// But we want to push the same route instead of going back in the stack
// Which would break the user navigation history
- if (type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
+ if (!isActiveRoute && type === CONST.NAVIGATION.ACTION_TYPE.PUSH) {
minimalAction.type = CONST.NAVIGATION.ACTION_TYPE.PUSH;
}
// There are situations when the user is trying to access a route which he has no access to
diff --git a/src/libs/Network/SequentialQueue.ts b/src/libs/Network/SequentialQueue.ts
index 5da032baaf45..d4aee4a221e5 100644
--- a/src/libs/Network/SequentialQueue.ts
+++ b/src/libs/Network/SequentialQueue.ts
@@ -160,7 +160,7 @@ NetworkStore.onReconnection(flush);
function push(request: OnyxRequest) {
// Add request to Persisted Requests so that it can be retried if it fails
- PersistedRequests.save(request);
+ PersistedRequests.save([request]);
// If we are offline we don't need to trigger the queue to empty as it will happen when we come back online
if (NetworkStore.isOffline()) {
diff --git a/src/libs/Network/enhanceParameters.ts b/src/libs/Network/enhanceParameters.ts
index 3fadeea7447c..6ff54f94bc88 100644
--- a/src/libs/Network/enhanceParameters.ts
+++ b/src/libs/Network/enhanceParameters.ts
@@ -37,8 +37,5 @@ export default function enhanceParameters(command: string, parameters: Record {
const connectionID = Onyx.connect({
- key: ONYXKEYS.ONYX_UPDATES.LAST_UPDATE_ID,
- callback: (lastUpdateID) => {
+ key: ONYXKEYS.ONYX_UPDATES_LAST_UPDATE_ID_APPLIED_TO_CLIENT,
+ callback: (lastUpdateIDAppliedToClient) => {
Onyx.disconnect(connectionID);
- resolve(lastUpdateID);
+ resolve(lastUpdateIDAppliedToClient);
},
});
});
@@ -26,15 +28,19 @@ export default function backgroundRefresh() {
return;
}
- getLastOnyxUpdateID().then((lastUpdateID) => {
- /**
- * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
- * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
- * so we must manually run confirmReadyToOpenApp here instead.
- *
- * See more here: https://reactnative.dev/docs/headless-js-android
- */
- App.confirmReadyToOpenApp();
- App.reconnectApp(lastUpdateID);
- });
+ getLastOnyxUpdateID()
+ .then((lastUpdateIDAppliedToClient) => {
+ /**
+ * ReconnectApp waits on the isReadyToOpenApp promise to resolve and this normally only resolves when the LHN is rendered.
+ * However on Android, this callback is run in the background using a Headless JS task which does not render the React UI,
+ * so we must manually run confirmReadyToOpenApp here instead.
+ *
+ * See more here: https://reactnative.dev/docs/headless-js-android
+ */
+ App.confirmReadyToOpenApp();
+ App.reconnectApp(lastUpdateIDAppliedToClient);
+ })
+ .catch((error) => {
+ Log.alert(`${CONST.ERROR.ENSURE_BUGBOT} [PushNotification] backgroundRefresh failed. This should never happen.`, {error});
+ });
}
diff --git a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
index 04fd34bf6075..ede873f79c6e 100644
--- a/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
+++ b/src/libs/Notification/PushNotification/subscribeToReportCommentPushNotifications.js
@@ -23,18 +23,20 @@ export default function subscribeToReportCommentPushNotifications() {
}
Log.info('[PushNotification] onSelected() - called', false, {reportID, reportActionID});
- Navigation.isNavigationReady().then(() => {
- try {
- // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back
- if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) {
- Navigation.goBack(ROUTES.HOME);
- }
+ Navigation.isNavigationReady()
+ .then(Navigation.waitForProtectedRoutes)
+ .then(() => {
+ try {
+ // If a chat is visible other than the one we are trying to navigate to, then we need to navigate back
+ if (Navigation.getActiveRoute().slice(1, 2) === ROUTES.REPORT && !Navigation.isActiveRoute(`r/${reportID}`)) {
+ Navigation.goBack(ROUTES.HOME);
+ }
- Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID});
- Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
- } catch (error) {
- Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message});
- }
- });
+ Log.info('[PushNotification] onSelected() - Navigation is ready. Navigating...', false, {reportID, reportActionID});
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(reportID));
+ } catch (error) {
+ Log.alert('[PushNotification] onSelected() - failed', {reportID, reportActionID, error: error.message});
+ }
+ });
});
}
diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts
index b37db2584394..5bd70fee4d83 100644
--- a/src/libs/PaymentUtils.ts
+++ b/src/libs/PaymentUtils.ts
@@ -26,7 +26,7 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi
function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData']): string {
if (account) {
- if (accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT && 'accountNumber' in account) {
+ if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) {
return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`;
}
if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD && 'cardNumber' in account) {
diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts
index 0c8843b87415..c3e01735fb07 100644
--- a/src/libs/Permissions.ts
+++ b/src/libs/Permissions.ts
@@ -9,18 +9,10 @@ function canUseChronos(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.CHRONOS_IN_CASH) || canUseAllBetas(betas);
}
-function canUsePayWithExpensify(betas: Beta[]): boolean {
- return betas?.includes(CONST.BETAS.PAY_WITH_EXPENSIFY) || canUseAllBetas(betas);
-}
-
function canUseDefaultRooms(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.DEFAULT_ROOMS) || canUseAllBetas(betas);
}
-function canUseWallet(betas: Beta[]): boolean {
- return betas?.includes(CONST.BETAS.BETA_EXPENSIFY_WALLET) || canUseAllBetas(betas);
-}
-
function canUseCommentLinking(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.BETA_COMMENT_LINKING) || canUseAllBetas(betas);
}
@@ -34,14 +26,6 @@ function canUsePolicyRooms(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.POLICY_ROOMS) || canUseAllBetas(betas);
}
-function canUseTasks(betas: Beta[]): boolean {
- return betas?.includes(CONST.BETAS.TASKS) || canUseAllBetas(betas);
-}
-
-function canUseCustomStatus(betas: Beta[]): boolean {
- return betas?.includes(CONST.BETAS.CUSTOM_STATUS) || canUseAllBetas(betas);
-}
-
function canUseViolations(betas: Beta[]): boolean {
return betas?.includes(CONST.BETAS.VIOLATIONS) || canUseAllBetas(betas);
}
@@ -55,13 +39,9 @@ function canUseLinkPreviews(): boolean {
export default {
canUseChronos,
- canUsePayWithExpensify,
canUseDefaultRooms,
- canUseWallet,
canUseCommentLinking,
canUsePolicyRooms,
- canUseTasks,
- canUseCustomStatus,
canUseLinkPreviews,
canUseViolations,
};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index a5edd6fe549f..333d621167b7 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -69,7 +69,7 @@ function isDeletedParentAction(reportAction: OnyxEntry): boolean {
}
function isReversedTransaction(reportAction: OnyxEntry) {
- return (reportAction?.message?.[0].isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
+ return (reportAction?.message?.[0]?.isReversedTransaction ?? false) && (reportAction?.childVisibleActionCount ?? 0) > 0;
}
function isPendingRemove(reportAction: OnyxEntry): boolean {
@@ -415,9 +415,12 @@ function getLastVisibleMessage(reportID: string, actionsToMerge: ReportActions =
};
}
- const messageText = message?.text ?? '';
+ let messageText = message?.text ?? '';
+ if (messageText) {
+ messageText = String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim();
+ }
return {
- lastMessageText: String(messageText).replace(CONST.REGEX.AFTER_FIRST_LINE_BREAK, '').substring(0, CONST.REPORT.LAST_MESSAGE_TEXT_MAX_LENGTH).trim(),
+ lastMessageText: messageText,
};
}
@@ -628,6 +631,26 @@ function isNotifiableReportAction(reportAction: OnyxEntry): boolea
return actions.includes(reportAction.actionName);
}
+/**
+ * Helper method to determine if the provided accountID has made a request on the specified report.
+ *
+ * @param reportID
+ * @param currentAccountID
+ * @returns
+ */
+function hasRequestFromCurrentAccount(reportID: string, currentAccountID: number): boolean {
+ if (!reportID) {
+ return false;
+ }
+
+ const reportActions = Object.values(getAllReportActions(reportID));
+ if (reportActions.length === 0) {
+ return false;
+ }
+
+ return reportActions.some((action) => action.actionName === CONST.REPORT.ACTIONS.TYPE.IOU && action.actorAccountID === currentAccountID);
+}
+
export {
extractLinksFromMessageHtml,
getAllReportActions,
@@ -668,6 +691,7 @@ export {
isReimbursementQueuedAction,
shouldReportActionBeVisible,
shouldReportActionBeVisibleAsLastAction,
+ hasRequestFromCurrentAccount,
getFirstVisibleReportActionID,
isChannelLogMemberAction,
};
diff --git a/src/libs/ReportUtils.js b/src/libs/ReportUtils.js
index b8eca8c28a34..2e91a93af7e1 100644
--- a/src/libs/ReportUtils.js
+++ b/src/libs/ReportUtils.js
@@ -839,8 +839,15 @@ function isOneOnOneChat(report) {
* @returns {Object}
*/
function getReport(reportID) {
- // Deleted reports are set to null and lodashGet will still return null in that case, so we need to add an extra check
- return lodashGet(allReports, `${ONYXKEYS.COLLECTION.REPORT}${reportID}`, {}) || {};
+ /**
+ * Using typical string concatenation here due to performance issues
+ * with template literals.
+ */
+ if (!allReports) {
+ return {};
+ }
+
+ return allReports[ONYXKEYS.COLLECTION.REPORT + reportID] || {};
}
/**
@@ -1561,14 +1568,25 @@ function getMoneyRequestSpendBreakdown(report, allReportsDict = null) {
* @returns {String}
*/
function getPolicyExpenseChatName(report, policy = undefined) {
- const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || lodashGet(allPersonalDetails, [report.ownerAccountID, 'login']) || report.reportName;
+ const ownerAccountID = report.ownerAccountID;
+ const personalDetails = allPersonalDetails[ownerAccountID];
+ const login = personalDetails ? personalDetails.login : null;
+ const reportOwnerDisplayName = getDisplayNameForParticipant(report.ownerAccountID) || login || report.reportName;
// If the policy expense chat is owned by this user, use the name of the policy as the report name.
if (report.isOwnPolicyExpenseChat) {
return getPolicyName(report, false, policy);
}
- const policyExpenseChatRole = lodashGet(allPolicies, [`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`, 'role']) || 'user';
+ let policyExpenseChatRole = 'user';
+ /**
+ * Using typical string concatenation here due to performance issues
+ * with template literals.
+ */
+ const policyItem = allPolicies[ONYXKEYS.COLLECTION.POLICY + report.policyID];
+ if (policyItem) {
+ policyExpenseChatRole = policyItem.role || 'user';
+ }
// If this user is not admin and this policy expense chat has been archived because of account merging, this must be an old workspace chat
// of the account which was merged into the current user's account. Use the name of the policy as the name of the report.
@@ -1820,9 +1838,10 @@ function getTransactionReportName(reportAction) {
* @param {Object} [reportAction={}] This can be either a report preview action or the IOU action
* @param {Boolean} [shouldConsiderReceiptBeingScanned=false]
* @param {Boolean} isPreviewMessageForParentChatReport
+ * @param {Object} [policy]
* @returns {String}
*/
-function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false) {
+function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceiptBeingScanned = false, isPreviewMessageForParentChatReport = false, policy = undefined) {
const reportActionMessage = lodashGet(reportAction, 'message[0].html', '');
if (_.isEmpty(report) || !report.reportID) {
@@ -1846,7 +1865,7 @@ function getReportPreviewMessage(report, reportAction = {}, shouldConsiderReceip
}
const totalAmount = getMoneyRequestReimbursableTotal(report);
- const payerName = isExpenseReport(report) ? getPolicyName(report) : getDisplayNameForParticipant(report.managerID, true);
+ const payerName = isExpenseReport(report) ? getPolicyName(report, false, policy) : getDisplayNameForParticipant(report.managerID, true);
const formattedAmount = CurrencyUtils.convertToDisplayString(totalAmount, report.currency);
if (isReportApproved(report) && getPolicyType(report, allPolicies) === CONST.POLICY.TYPE.CORPORATE) {
@@ -2255,6 +2274,19 @@ function navigateToDetailsPage(report) {
Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID));
}
+/**
+ * Go back to the details page of a given report
+ *
+ * @param {Object} report
+ */
+function goBackToDetailsPage(report) {
+ if (isOneOnOneChat(report)) {
+ Navigation.goBack(ROUTES.PROFILE.getRoute(report.participantAccountIDs[0]));
+ return;
+ }
+ Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(report.reportID));
+}
+
/**
* Generate a random reportID up to 53 bits aka 9,007,199,254,740,991 (Number.MAX_SAFE_INTEGER).
* There were approximately 98,000,000 reports with sequential IDs generated before we started using this approach, those make up roughly one billionth of the space for these numbers,
@@ -2669,6 +2701,7 @@ function buildOptimisticIOUReportAction(
whisperedToAccountIDs: _.contains([CONST.IOU.RECEIPT_STATE.SCANREADY, CONST.IOU.RECEIPT_STATE.SCANNING], receipt.state) ? [currentUserAccountID] : [],
};
}
+
/**
* Builds an optimistic APPROVED report action with a randomly generated reportActionID.
*
@@ -2707,6 +2740,56 @@ function buildOptimisticApprovedReportAction(amount, currency, expenseReportID)
};
}
+/**
+ * Builds an optimistic MOVED report action with a randomly generated reportActionID.
+ * This action is used when we move reports across workspaces.
+ *
+ * @param {String} fromPolicyID
+ * @param {String} toPolicyID
+ * @param {Number} newParentReportID
+ * @param {Number} movedReportID
+ *
+ * @returns {Object}
+ */
+function buildOptimisticMovedReportAction(fromPolicyID, toPolicyID, newParentReportID, movedReportID) {
+ const originalMessage = {
+ fromPolicyID,
+ toPolicyID,
+ newParentReportID,
+ movedReportID,
+ };
+
+ const policyName = getPolicyName(allReports[`${ONYXKEYS.COLLECTION.REPORT}${newParentReportID}`]);
+ const movedActionMessage = [
+ {
+ html: `moved the report to the ${policyName} workspace`,
+ text: `moved the report to the ${policyName} workspace`,
+ type: CONST.REPORT.MESSAGE.TYPE.COMMENT,
+ },
+ ];
+
+ return {
+ actionName: CONST.REPORT.ACTIONS.TYPE.MOVED,
+ actorAccountID: currentUserAccountID,
+ automatic: false,
+ avatar: lodashGet(currentUserPersonalDetails, 'avatar', UserUtils.getDefaultAvatarURL(currentUserAccountID)),
+ isAttachment: false,
+ originalMessage,
+ message: movedActionMessage,
+ person: [
+ {
+ style: 'strong',
+ text: lodashGet(currentUserPersonalDetails, 'displayName', currentUserEmail),
+ type: 'TEXT',
+ },
+ ],
+ reportActionID: NumberUtils.rand64(),
+ shouldShow: true,
+ created: DateUtils.getDBTime(),
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ };
+}
+
/**
* Builds an optimistic SUBMITTED report action with a randomly generated reportActionID.
*
@@ -4351,6 +4434,7 @@ export {
buildOptimisticEditedTaskReportAction,
buildOptimisticIOUReport,
buildOptimisticApprovedReportAction,
+ buildOptimisticMovedReportAction,
buildOptimisticSubmittedReportAction,
buildOptimisticExpenseReport,
buildOptimisticIOUReportAction,
@@ -4424,6 +4508,7 @@ export {
hasSingleParticipant,
getReportRecipientAccountIDs,
isOneOnOneChat,
+ goBackToDetailsPage,
getTransactionReportName,
getTransactionDetails,
getTaskAssigneeChatOnyxData,
diff --git a/src/libs/Request.ts b/src/libs/Request.ts
index 335731763ec9..18fadca467ad 100644
--- a/src/libs/Request.ts
+++ b/src/libs/Request.ts
@@ -16,7 +16,7 @@ function makeXHR(request: Request): Promise {
return new Promise((resolve) => resolve());
}
- return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure) as Promise;
+ return HttpUtils.xhr(request.command, finalParameters, request.type, request.shouldUseSecure);
});
}
diff --git a/src/libs/SidebarUtils.ts b/src/libs/SidebarUtils.ts
index 58c4a124335d..763a0000ba35 100644
--- a/src/libs/SidebarUtils.ts
+++ b/src/libs/SidebarUtils.ts
@@ -158,18 +158,6 @@ function getOrderedReportIDs(
}
}
- // There are a few properties that need to be calculated for the report which are used when sorting reports.
- reportsToDisplay.forEach((report) => {
- // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
- // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
- // the reportDisplayName property to the report object directly.
- // eslint-disable-next-line no-param-reassign
- report.displayName = ReportUtils.getReportName(report);
-
- // eslint-disable-next-line no-param-reassign
- report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
- });
-
// The LHN is split into four distinct groups, and each group is sorted a little differently. The groups will ALWAYS be in this order:
// 1. Pinned/GBR - Always sorted by reportDisplayName
// 2. Drafts - Always sorted by reportDisplayName
@@ -183,7 +171,18 @@ function getOrderedReportIDs(
const draftReports: Report[] = [];
const nonArchivedReports: Report[] = [];
const archivedReports: Report[] = [];
+
+ // There are a few properties that need to be calculated for the report which are used when sorting reports.
reportsToDisplay.forEach((report) => {
+ // Normally, the spread operator would be used here to clone the report and prevent the need to reassign the params.
+ // However, this code needs to be very performant to handle thousands of reports, so in the interest of speed, we're just going to disable this lint rule and add
+ // the reportDisplayName property to the report object directly.
+ // eslint-disable-next-line no-param-reassign
+ report.displayName = ReportUtils.getReportName(report);
+
+ // eslint-disable-next-line no-param-reassign
+ report.iouReportAmount = ReportUtils.getMoneyRequestReimbursableTotal(report, allReports);
+
const isPinned = report.isPinned ?? false;
if (isPinned || ReportUtils.requiresAttentionFromCurrentUser(report)) {
pinnedAndGBRReports.push(report);
diff --git a/src/libs/TransactionUtils.ts b/src/libs/TransactionUtils.ts
index 00ce8c55dbd7..baf4ba6fb2f8 100644
--- a/src/libs/TransactionUtils.ts
+++ b/src/libs/TransactionUtils.ts
@@ -330,6 +330,13 @@ function isExpensifyCardTransaction(transaction: Transaction): boolean {
return isExpensifyCard(transaction.cardID);
}
+/**
+ * Determine whether a transaction is made with a card.
+ */
+function isCardTransaction(transaction: Transaction): boolean {
+ return (transaction?.cardID ?? 0) > 0;
+}
+
/**
* Check if the transaction status is set to Pending.
*/
@@ -475,6 +482,7 @@ export {
getValidWaypoints,
isDistanceRequest,
isExpensifyCardTransaction,
+ isCardTransaction,
isPending,
isPosted,
getWaypoints,
diff --git a/src/libs/UnreadIndicatorUpdater/index.js b/src/libs/UnreadIndicatorUpdater/index.js
index 9af74f8313c3..bfa0cd911177 100644
--- a/src/libs/UnreadIndicatorUpdater/index.js
+++ b/src/libs/UnreadIndicatorUpdater/index.js
@@ -1,3 +1,4 @@
+import {InteractionManager} from 'react-native';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import * as ReportUtils from '@libs/ReportUtils';
@@ -5,11 +6,33 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import updateUnread from './updateUnread/index';
+let previousUnreadCount = 0;
+
Onyx.connect({
key: ONYXKEYS.COLLECTION.REPORT,
waitForCollectionCallback: true,
callback: (reportsFromOnyx) => {
- const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
- updateUnread(_.size(unreadReports));
+ if (!reportsFromOnyx) {
+ return;
+ }
+
+ /**
+ * We need to wait until after interactions have finished to update the unread count because otherwise
+ * the unread count will be updated while the interactions/animations are in progress and we don't want
+ * to put more work on the main thread.
+ *
+ * For eg. On web we are manipulating DOM and it makes it a better candidate to wait until after interactions
+ * have finished.
+ *
+ * More info: https://reactnative.dev/docs/interactionmanager
+ */
+ InteractionManager.runAfterInteractions(() => {
+ const unreadReports = _.filter(reportsFromOnyx, (report) => ReportUtils.isUnread(report) && report.notificationPreference !== CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN);
+ const unreadReportsCount = _.size(unreadReports);
+ if (previousUnreadCount !== unreadReportsCount) {
+ previousUnreadCount = unreadReportsCount;
+ updateUnread(unreadReportsCount);
+ }
+ });
},
});
diff --git a/src/libs/__mocks__/Permissions.ts b/src/libs/__mocks__/Permissions.ts
index e95d13f52803..4f47c0b756b7 100644
--- a/src/libs/__mocks__/Permissions.ts
+++ b/src/libs/__mocks__/Permissions.ts
@@ -12,5 +12,4 @@ export default {
...jest.requireActual('../Permissions'),
canUseDefaultRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.DEFAULT_ROOMS),
canUsePolicyRooms: (betas: Beta[]) => betas.includes(CONST.BETAS.POLICY_ROOMS),
- canUseCustomStatus: (betas: Beta[]) => betas.includes(CONST.BETAS.CUSTOM_STATUS),
};
diff --git a/src/libs/actions/IOU.js b/src/libs/actions/IOU.js
index 939a11dad511..8d44e3898062 100644
--- a/src/libs/actions/IOU.js
+++ b/src/libs/actions/IOU.js
@@ -141,6 +141,18 @@ function resetMoneyRequestInfo(id = '') {
});
}
+/**
+ * Helper function to get the receipt error for money requests, or the generic error if there's no receipt
+ *
+ * @param {Object} receipt
+ * @returns {Object}
+ */
+function getReceiptError(receipt) {
+ return _.isEmpty(receipt)
+ ? ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage')
+ : ErrorUtils.getMicroSecondOnyxErrorObject({error: CONST.IOU.RECEIPT_ERROR, source: receipt.source, filename: receipt.filename});
+}
+
function buildOnyxDataForMoneyRequest(
chatReport,
iouReport,
@@ -344,7 +356,7 @@ function buildOnyxDataForMoneyRequest(
...(isNewChatReport
? {
[chatCreatedAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
[reportPreviewAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError(null),
@@ -353,7 +365,7 @@ function buildOnyxDataForMoneyRequest(
: {
[reportPreviewAction.reportActionID]: {
created: reportPreviewAction.created,
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
}),
},
@@ -365,7 +377,7 @@ function buildOnyxDataForMoneyRequest(
...(isNewIOUReport
? {
[iouCreatedAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
[iouAction.reportActionID]: {
errors: ErrorUtils.getMicroSecondOnyxError(null),
@@ -373,7 +385,7 @@ function buildOnyxDataForMoneyRequest(
}
: {
[iouAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(transaction.receipt),
},
}),
},
@@ -1433,7 +1445,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${splitChatReport.reportID}`,
value: {
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(receipt),
},
},
});
@@ -1456,7 +1468,7 @@ function startSplitBill(participants, currentUserLogin, currentUserAccountID, co
errors: ErrorUtils.getMicroSecondOnyxError('report.genericCreateReportFailureMessage'),
},
[splitIOUReportAction.reportActionID]: {
- errors: ErrorUtils.getMicroSecondOnyxError('iou.error.genericCreateFailureMessage'),
+ errors: getReceiptError(receipt),
},
},
},
diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts
index bcc5d8142470..02f0b49fe3d2 100644
--- a/src/libs/actions/PaymentMethods.ts
+++ b/src/libs/actions/PaymentMethods.ts
@@ -81,7 +81,7 @@ function getMakeDefaultPaymentOnyxData(
key: ONYXKEYS.USER_WALLET,
value: {
walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
// Only clear the error if this is optimistic data. If this is failure data, we do not want to clear the error that came from the server.
errors: null,
},
@@ -91,7 +91,7 @@ function getMakeDefaultPaymentOnyxData(
key: ONYXKEYS.USER_WALLET,
value: {
walletLinkedAccountID: bankAccountID || fundID,
- walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
+ walletLinkedAccountType: bankAccountID ? CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT : CONST.PAYMENT_METHODS.DEBIT_CARD,
},
},
];
@@ -99,7 +99,7 @@ function getMakeDefaultPaymentOnyxData(
if (previousPaymentMethod?.methodID) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ key: previousPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[previousPaymentMethod.methodID]: {
isDefault: !isOptimisticData,
@@ -111,7 +111,7 @@ function getMakeDefaultPaymentOnyxData(
if (currentPaymentMethod?.methodID) {
onyxData.push({
onyxMethod: Onyx.METHOD.MERGE,
- key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
+ key: currentPaymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST,
value: {
[currentPaymentMethod.methodID]: {
isDefault: isOptimisticData,
@@ -223,7 +223,8 @@ function clearDebitCardFormErrorAndSubmit() {
*
*/
function transferWalletBalance(paymentMethod: PaymentMethod) {
- const paymentMethodIDKey = paymentMethod.accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
+ const paymentMethodIDKey =
+ paymentMethod.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? CONST.PAYMENT_METHOD_ID_KEYS.BANK_ACCOUNT : CONST.PAYMENT_METHOD_ID_KEYS.DEBIT_CARD;
type TransferWalletBalanceParameters = Partial, number | undefined>>;
diff --git a/src/libs/actions/PersistedRequests.ts b/src/libs/actions/PersistedRequests.ts
index c788d69de70e..c35de9ee94c4 100644
--- a/src/libs/actions/PersistedRequests.ts
+++ b/src/libs/actions/PersistedRequests.ts
@@ -17,17 +17,15 @@ function clear() {
return Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, []);
}
-function save(requestToPersist: Request) {
- // Check for a request w/ matching idempotencyKey in the queue
- const existingRequestIndex = persistedRequests.findIndex((request) => request.data?.idempotencyKey && request.data?.idempotencyKey === requestToPersist.data?.idempotencyKey);
- if (existingRequestIndex > -1) {
- // Merge the new request into the existing one, keeping its place in the queue
- persistedRequests.splice(existingRequestIndex, 1, requestToPersist);
+function save(requestsToPersist: Request[]) {
+ let requests: Request[] = [];
+ if (persistedRequests.length) {
+ requests = persistedRequests.concat(requestsToPersist);
} else {
- // If not, push the new request to the end of the queue
- persistedRequests.push(requestToPersist);
+ requests = requestsToPersist;
}
- Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, persistedRequests);
+ persistedRequests = requests;
+ Onyx.set(ONYXKEYS.PERSISTED_REQUESTS, requests);
}
function remove(requestToRemove: Request) {
diff --git a/src/libs/actions/Policy.js b/src/libs/actions/Policy.js
index 4df510d44db7..ebc1cdf9a2e1 100644
--- a/src/libs/actions/Policy.js
+++ b/src/libs/actions/Policy.js
@@ -7,12 +7,15 @@ import lodashUnion from 'lodash/union';
import Onyx from 'react-native-onyx';
import _ from 'underscore';
import * as API from '@libs/API';
+import DateUtils from '@libs/DateUtils';
import * as ErrorUtils from '@libs/ErrorUtils';
import Log from '@libs/Log';
import * as NumberUtils from '@libs/NumberUtils';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
+import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportUtils from '@libs/ReportUtils';
+import * as TransactionUtils from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
@@ -415,9 +418,10 @@ function removeMembers(accountIDs, policyID) {
*
* @param {String} policyID
* @param {Object} invitedEmailsToAccountIDs
+ * @param {Boolean} hasOutstandingChildRequest
* @returns {Object} - object with onyxSuccessData, onyxOptimisticData, and optimisticReportIDs (map login to reportID)
*/
-function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
+function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs, hasOutstandingChildRequest = false) {
const workspaceMembersChats = {
onyxSuccessData: [],
onyxOptimisticData: [],
@@ -463,6 +467,7 @@ function createPolicyExpenseChats(policyID, invitedEmailsToAccountIDs) {
createChat: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
},
isOptimisticReport: true,
+ hasOutstandingChildRequest,
},
});
workspaceMembersChats.onyxOptimisticData.push({
@@ -1458,6 +1463,403 @@ function buildOptimisticPolicyRecentlyUsedCategories(policyID, category) {
return lodashUnion([category], policyRecentlyUsedCategories);
}
+/**
+ * This flow is used for bottom up flow converting IOU report to an expense report. When user takes this action,
+ * we create a Collect type workspace when the person taking the action becomes an owner and an admin, while we
+ * add a new member to the workspace as an employee and convert the IOU report passed as a param into an expense report.
+ *
+ * @param {Object} iouReport
+ * @returns {String} policyID of the workspace we have created
+ */
+function createWorkspaceFromIOUPayment(iouReport) {
+ // This flow only works for IOU reports
+ if (!ReportUtils.isIOUReport(iouReport)) {
+ return;
+ }
+
+ // Generate new variables for the policy
+ const policyID = generatePolicyID();
+ const workspaceName = generateDefaultWorkspaceName(sessionEmail);
+ const employeeAccountID = iouReport.ownerAccountID;
+ const employeeEmail = iouReport.ownerEmail;
+ const {customUnits, customUnitID, customUnitRateID} = buildOptimisticCustomUnits();
+ const oldPersonalPolicyID = iouReport.policyID;
+ const iouReportID = iouReport.reportID;
+
+ const {
+ announceChatReportID,
+ announceChatData,
+ announceReportActionData,
+ announceCreatedReportActionID,
+ adminsChatReportID,
+ adminsChatData,
+ adminsReportActionData,
+ adminsCreatedReportActionID,
+ expenseChatReportID: workspaceChatReportID,
+ expenseChatData: workspaceChatData,
+ expenseReportActionData: workspaceChatReportActionData,
+ expenseCreatedReportActionID: workspaceChatCreatedReportActionID,
+ } = ReportUtils.buildOptimisticWorkspaceChats(policyID, workspaceName);
+
+ // Create the workspace chat for the employee whose IOU is being paid
+ const employeeWorkspaceChat = createPolicyExpenseChats(policyID, {[employeeEmail]: employeeAccountID}, true);
+ const newWorkspace = {
+ id: policyID,
+
+ // We are creating a collect policy in this case
+ type: CONST.POLICY.TYPE.TEAM,
+ name: workspaceName,
+ role: CONST.POLICY.ROLE.ADMIN,
+ owner: sessionEmail,
+ isPolicyExpenseChatEnabled: true,
+
+ // Setting the currency to USD as we can only add the VBBA for this policy currency right now
+ outputCurrency: CONST.CURRENCY.USD,
+ pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ customUnits,
+ };
+
+ const optimisticData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: newWorkspace,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: {
+ [sessionAccountID]: {
+ role: CONST.POLICY.ROLE.ADMIN,
+ errors: {},
+ },
+ [employeeAccountID]: {
+ role: CONST.POLICY.ROLE.USER,
+ errors: {},
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...announceChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: announceReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...adminsChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: adminsReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD,
+ },
+ ...workspaceChatData,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: workspaceChatReportActionData,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS_DRAFTS}${policyID}`,
+ value: null,
+ },
+ ...employeeWorkspaceChat.onyxOptimisticData,
+ ];
+
+ const successData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`,
+ value: {pendingAction: null},
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: {
+ [_.keys(announceChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: {
+ [_.keys(adminsChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: {
+ pendingFields: {
+ addWorkspaceRoom: null,
+ },
+ pendingAction: null,
+ },
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: {
+ [_.keys(workspaceChatData)[0]]: {
+ pendingAction: null,
+ },
+ },
+ },
+ ...employeeWorkspaceChat.onyxSuccessData,
+ ];
+
+ const failureData = [
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.POLICY_MEMBERS}${policyID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${announceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${announceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${adminsChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${adminsChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${workspaceChatReportID}`,
+ value: null,
+ },
+ {
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${workspaceChatReportID}`,
+ value: null,
+ },
+ ];
+
+ // Compose the memberData object which is used to add the employee to the workspace and
+ // optimistically create the workspace chat for them.
+ const memberData = {
+ accountID: Number(employeeAccountID),
+ email: employeeEmail,
+ workspaceChatReportID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportID,
+ workspaceChatCreatedReportActionID: employeeWorkspaceChat.reportCreationData[employeeEmail].reportActionID,
+ };
+
+ const oldChatReportID = iouReport.chatReportID;
+
+ // Next we need to convert the IOU report to Expense report.
+ // We need to change:
+ // - report type
+ // - change the sign of the report total
+ // - update its policyID and policyName
+ // - update the chatReportID to point to the new workspace chat
+ const expenseReport = {
+ ...iouReport,
+ chatReportID: memberData.workspaceChatReportID,
+ policyID,
+ policyName: workspaceName,
+ type: CONST.REPORT.TYPE.EXPENSE,
+ total: -iouReport.total,
+ };
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
+ value: expenseReport,
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${iouReportID}`,
+ value: iouReport,
+ });
+
+ // The expense report transactions need to have the amount reversed to negative values
+ const reportTransactions = TransactionUtils.getAllReportTransactions(iouReportID);
+
+ // For performance reasons, we are going to compose a merge collection data for transactions
+ const transactionsOptimisticData = {};
+ const transactionFailureData = {};
+ _.each(reportTransactions, (transaction) => {
+ transactionsOptimisticData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = {
+ ...transaction,
+ amount: -transaction.amount,
+ modifiedAmount: transaction.modifiedAmount ? -transaction.modifiedAmount : 0,
+ };
+
+ transactionFailureData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = transaction;
+ });
+
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}`,
+ value: transactionsOptimisticData,
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE_COLLECTION,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}`,
+ value: transactionFailureData,
+ });
+
+ // We need to move the report preview action from the DM to the workspace chat.
+ const reportPreview = ReportActionsUtils.getParentReportAction(iouReport);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[reportPreview.reportActionID]: null},
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[reportPreview.reportActionID]: reportPreview},
+ });
+
+ // To optimistically remove the GBR from the DM we need to update the hasOutstandingChildRequest param to false
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`,
+ value: {
+ hasOutstandingChildRequest: false,
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT}${oldChatReportID}`,
+ value: {
+ hasOutstandingChildRequest: true,
+ },
+ });
+
+ // Update the created timestamp of the report preview action to be after the workspace chat created timestamp.
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`,
+ value: {
+ [reportPreview.reportActionID]: {
+ ...reportPreview,
+ message: ReportUtils.getReportPreviewMessage(expenseReport, {}, false, false, newWorkspace),
+ created: DateUtils.getDBTime(),
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${memberData.workspaceChatReportID}`,
+ value: {[reportPreview.reportActionID]: null},
+ });
+
+ // Create the MOVED report action and add it to the DM chat which indicates to the user where the report has been moved
+ const movedReportAction = ReportUtils.buildOptimisticMovedReportAction(oldPersonalPolicyID, policyID, memberData.workspaceChatReportID, iouReportID);
+ optimisticData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[movedReportAction.reportActionID]: movedReportAction},
+ });
+ successData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {
+ [movedReportAction.reportActionID]: {
+ ...movedReportAction,
+ pendingAction: null,
+ },
+ },
+ });
+ failureData.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${oldChatReportID}`,
+ value: {[movedReportAction.reportActionID]: null},
+ });
+
+ API.write(
+ 'CreateWorkspaceFromIOUPayment',
+ {
+ policyID,
+ announceChatReportID,
+ adminsChatReportID,
+ expenseChatReportID: workspaceChatReportID,
+ ownerEmail: '',
+ makeMeAdmin: false,
+ policyName: workspaceName,
+ type: CONST.POLICY.TYPE.TEAM,
+ announceCreatedReportActionID,
+ adminsCreatedReportActionID,
+ expenseCreatedReportActionID: workspaceChatCreatedReportActionID,
+ customUnitID,
+ customUnitRateID,
+ iouReportID,
+ memberData: JSON.stringify(memberData),
+ reportActionID: movedReportAction.reportActionID,
+ },
+ {optimisticData, successData, failureData},
+ );
+
+ return policyID;
+}
+
export {
removeMembers,
addMembersToWorkspace,
@@ -1484,6 +1886,7 @@ export {
openWorkspaceMembersPage,
openWorkspaceInvitePage,
removeWorkspace,
+ createWorkspaceFromIOUPayment,
setWorkspaceInviteMembersDraft,
clearErrors,
dismissAddedWithPrimaryLoginMessages,
diff --git a/src/libs/actions/Report.js b/src/libs/actions/Report.js
index ac45a1e3f3be..a03488429405 100644
--- a/src/libs/actions/Report.js
+++ b/src/libs/actions/Report.js
@@ -470,9 +470,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (!reportID) {
return;
}
-
- const commandName = 'OpenReport';
-
const optimisticReportData = [
{
onyxMethod: Onyx.METHOD.MERGE,
@@ -538,7 +535,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
emailList: participantLoginList ? participantLoginList.join(',') : '',
accountIDList: participantAccountIDList ? participantAccountIDList.join(',') : '',
parentReportActionID,
- idempotencyKey: `${commandName}_${reportID}`,
};
if (isFromDeepLink) {
@@ -616,7 +612,6 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
// Add the createdReportActionID parameter to the API call
params.createdReportActionID = optimisticCreatedAction.reportActionID;
- params.idempotencyKey = `${params.idempotencyKey}_NewReport_${optimisticCreatedAction.reportActionID}`;
// If we are creating a thread, ensure the report action has childReportID property added
if (newReportObject.parentReportID && parentReportActionID) {
@@ -637,12 +632,12 @@ function openReport(reportID, participantLoginList = [], newReportObject = {}, p
if (isFromDeepLink) {
// eslint-disable-next-line rulesdir/no-api-side-effects-method
- API.makeRequestWithSideEffects(commandName, params, onyxData).finally(() => {
+ API.makeRequestWithSideEffects('OpenReport', params, onyxData).finally(() => {
Onyx.set(ONYXKEYS.IS_CHECKING_PUBLIC_ROOM, false);
});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.write(commandName, params, onyxData);
+ API.write('OpenReport', params, onyxData);
}
}
@@ -1375,11 +1370,12 @@ function saveReportActionDraftNumberOfLines(reportID, reportActionID, numberOfLi
* @param {boolean} navigate
* @param {String} parentReportID
* @param {String} parentReportActionID
+ * @param {Object} report
*/
-function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0) {
+function updateNotificationPreference(reportID, previousValue, newValue, navigate, parentReportID = 0, parentReportActionID = 0, report = {}) {
if (previousValue === newValue) {
- if (navigate) {
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ if (navigate && report.reportID) {
+ ReportUtils.goBackToDetailsPage(report);
}
return;
}
@@ -1411,7 +1407,7 @@ function updateNotificationPreference(reportID, previousValue, newValue, navigat
}
API.write('UpdateReportNotificationPreference', {reportID, notificationPreference: newValue}, {optimisticData, failureData});
if (navigate) {
- Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(reportID));
+ ReportUtils.goBackToDetailsPage(report);
}
}
diff --git a/src/libs/actions/Task.js b/src/libs/actions/Task.js
index ef998d6dac8d..c91f6d1a2eec 100644
--- a/src/libs/actions/Task.js
+++ b/src/libs/actions/Task.js
@@ -450,6 +450,9 @@ function editTaskAssigneeAndNavigate(report, ownerAccountID, assigneeEmail, assi
pendingFields: {
...(assigneeAccountID && {managerID: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}),
},
+ notificationPreference: [assigneeAccountID, ownerAccountID].includes(currentUserAccountID)
+ ? CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS
+ : CONST.REPORT.NOTIFICATION_PREFERENCE.HIDDEN,
};
const optimisticData = [
diff --git a/src/libs/actions/User.js b/src/libs/actions/User.js
index 3c91dc4624cd..ad6fd7be10dd 100644
--- a/src/libs/actions/User.js
+++ b/src/libs/actions/User.js
@@ -77,7 +77,6 @@ function closeAccount(message) {
* Resends a validation link to a given login
*
* @param {String} login
- * @param {Boolean} isPasswordless - temporary param to trigger passwordless flow in backend
*/
function resendValidateCode(login) {
Session.resendValidateCode(login);
diff --git a/src/libs/fileDownload/FileUtils.ts b/src/libs/fileDownload/FileUtils.ts
index 5bac47fb63ec..618571ddf400 100644
--- a/src/libs/fileDownload/FileUtils.ts
+++ b/src/libs/fileDownload/FileUtils.ts
@@ -165,7 +165,7 @@ const readFileAsync: ReadFileAsync = (path, fileName, onSuccess, onFailure = ()
}
res.blob()
.then((blob) => {
- const file = new File([blob], cleanFileName(fileName));
+ const file = new File([blob], cleanFileName(fileName), {type: blob.type});
file.source = path;
// For some reason, the File object on iOS does not have a uri property
// so images aren't uploaded correctly to the backend
diff --git a/src/pages/ProfilePage.js b/src/pages/ProfilePage.js
index 17ea63ca1003..ffe8271629f4 100755
--- a/src/pages/ProfilePage.js
+++ b/src/pages/ProfilePage.js
@@ -25,7 +25,6 @@ import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import * as ValidationUtils from '@libs/ValidationUtils';
@@ -133,7 +132,7 @@ function ProfilePage(props) {
const statusEmojiCode = lodashGet(details, 'status.emojiCode', '');
const statusText = lodashGet(details, 'status.text', '');
- const hasStatus = !!statusEmojiCode && Permissions.canUseCustomStatus(props.betas);
+ const hasStatus = !!statusEmojiCode;
const statusContent = `${statusEmojiCode} ${statusText}`;
const navigateBackTo = lodashGet(props.route, 'params.backTo', ROUTES.HOME);
@@ -148,10 +147,7 @@ function ProfilePage(props) {
}, [accountID, hasMinimumDetails]);
return (
-
+ Navigation.goBack(navigateBackTo)}
@@ -295,9 +291,6 @@ export default compose(
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
session: {
key: ONYXKEYS.SESSION,
},
diff --git a/src/pages/ReferralDetailsPage.js b/src/pages/ReferralDetailsPage.js
index 282e85fe0237..36fc74836d58 100644
--- a/src/pages/ReferralDetailsPage.js
+++ b/src/pages/ReferralDetailsPage.js
@@ -17,6 +17,7 @@ import Navigation from '@libs/Navigation/Navigation';
import styles from '@styles/styles';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
const propTypes = {
/** Navigation route context info provided by react navigation */
@@ -52,6 +53,17 @@ function ReferralDetailsPage({route, account}) {
return `${CONST.REFERRAL_PROGRAM.LINK}/?thanks=${encodeURIComponent(email)}`;
}
+ function getFallbackRoute() {
+ const fallbackRoutes = {
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.MONEY_REQUEST]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.REQUEST),
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.SEND_MONEY]: ROUTES.MONEY_REQUEST_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SEND),
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.START_CHAT]: ROUTES.NEW_CHAT,
+ [CONST.REFERRAL_PROGRAM.CONTENT_TYPES.REFER_FRIEND]: ROUTES.SEARCH,
+ };
+
+ return fallbackRoutes[contentType];
+ }
+
return (
Navigation.goBack()}
+ onBackButtonPress={() => Navigation.goBack(getFallbackRoute())}
/>
Navigation.goBack()}
+ onPress={() => Navigation.goBack(getFallbackRoute())}
pressOnEnter
enterKeyEventListenerPriority={1}
/>
diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js
index b46d3aa0aa28..a953dca378fd 100644
--- a/src/pages/ReimbursementAccount/BankAccountManualStep.js
+++ b/src/pages/ReimbursementAccount/BankAccountManualStep.js
@@ -3,7 +3,8 @@ import React, {useCallback} from 'react';
import {Image} from 'react-native';
import _ from 'underscore';
import CheckboxWithLabel from '@components/CheckboxWithLabel';
-import Form from '@components/Form';
+import FormProvider from '@components/Form/FormProvider';
+import InputWrapper from '@components/Form/InputWrapper';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import Text from '@components/Text';
@@ -86,7 +87,7 @@ function BankAccountManualStep(props) {
guidesCallTaskID={CONST.GUIDES_CALL_TASK_IDS.WORKSPACE_BANK_ACCOUNT}
onBackButtonPress={props.onBackButtonPress}
/>
-
-
-
-
-
+
);
}
diff --git a/src/pages/ReportDetailsPage.js b/src/pages/ReportDetailsPage.js
index abfe625f1508..d39af722a1df 100644
--- a/src/pages/ReportDetailsPage.js
+++ b/src/pages/ReportDetailsPage.js
@@ -14,7 +14,6 @@ import participantPropTypes from '@components/participantPropTypes';
import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback';
import RoomHeaderAvatars from '@components/RoomHeaderAvatars';
import ScreenWrapper from '@components/ScreenWrapper';
-import Text from '@components/Text';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
@@ -154,19 +153,17 @@ function ReportDetailsPage(props) {
const icons = useMemo(() => ReportUtils.getIcons(props.report, props.personalDetails, props.policies), [props.report, props.personalDetails, props.policies]);
const chatRoomSubtitleText = chatRoomSubtitle ? (
-
- {chatRoomSubtitle}
-
+ textStyles={[styles.sidebarLinkText, styles.textLabelSupporting, styles.pre, styles.mt1]}
+ shouldUseFullTitle
+ />
) : null;
return (
-
+
{isPolicyAdmin ? (
.map((accountID, index) => {
const userPersonalDetail = lodashGet(personalDetails, accountID, {displayName: personalDetails.displayName || translate('common.hidden'), avatar: ''});
const userLogin = LocalePhoneNumber.formatPhoneNumber(userPersonalDetail.login || '') || translate('common.hidden');
+ const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(userPersonalDetail, 'displayName');
return {
alternateText: userLogin,
- displayName: userPersonalDetail.displayName,
+ displayName,
accountID: userPersonalDetail.accountID,
icons: [
{
@@ -74,9 +76,9 @@ const getAllParticipants = (report, personalDetails, translate) =>
],
keyForList: `${index}-${userLogin}`,
login: userLogin,
- text: userPersonalDetail.displayName,
+ text: displayName,
tooltipText: userLogin,
- participantsList: [{accountID, displayName: userPersonalDetail.displayName}],
+ participantsList: [{accountID, displayName}],
};
})
.sortBy((participant) => participant.displayName.toLowerCase())
diff --git a/src/pages/home/ReportScreen.js b/src/pages/home/ReportScreen.js
index 312f64ea13f3..33646e7129cd 100644
--- a/src/pages/home/ReportScreen.js
+++ b/src/pages/home/ReportScreen.js
@@ -378,7 +378,6 @@ function ReportScreen({
style={screenWrapperStyle}
shouldEnableKeyboardAvoidingView={isTopMostReportId}
testID={ReportScreen.displayName}
- shouldDisableFocusTrap
>
{
- if (!Permissions.canUseTasks(betas) || !ReportUtils.canCreateTaskInReport(report)) {
+ if (!ReportUtils.canCreateTaskInReport(report)) {
return [];
}
@@ -163,7 +155,7 @@ function AttachmentPickerWithMenuItems({
onSelected: () => Task.clearOutTaskInfoAndNavigate(reportID),
},
];
- }, [betas, report, reportID, translate]);
+ }, [report, reportID, translate]);
const onPopoverMenuClose = () => {
setMenuVisibility(false);
@@ -287,8 +279,4 @@ AttachmentPickerWithMenuItems.propTypes = propTypes;
AttachmentPickerWithMenuItems.defaultProps = defaultProps;
AttachmentPickerWithMenuItems.displayName = 'AttachmentPickerWithMenuItems';
-export default withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
-})(AttachmentPickerWithMenuItems);
+export default AttachmentPickerWithMenuItems;
diff --git a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
index 07e4cb9ae839..663db82a6067 100644
--- a/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
+++ b/src/pages/home/report/ReportActionCompose/ComposerWithSuggestions/ComposerWithSuggestions.js
@@ -230,8 +230,14 @@ function ComposerWithSuggestions({
}
}
const newCommentConverted = convertToLTRForComposer(newComment);
+ const isNewCommentEmpty = !!newCommentConverted.match(/^(\s)*$/);
+ const isPrevCommentEmpty = !!commentRef.current.match(/^(\s)*$/);
+
+ /** Only update isCommentEmpty state if it's different from previous one */
+ if (isNewCommentEmpty !== isPrevCommentEmpty) {
+ setIsCommentEmpty(isNewCommentEmpty);
+ }
emojisPresentBefore.current = emojis;
- setIsCommentEmpty(!!newCommentConverted.match(/^(\s)*$/));
setValue(newCommentConverted);
if (commentValue !== newComment) {
const position = Math.max(selection.end + (newComment.length - commentRef.current.length), cursorPosition || 0);
@@ -500,7 +506,7 @@ function ComposerWithSuggestions({
InputFocus.inputFocusChange(false);
return;
}
- focus();
+ focus(true);
}, [focus, prevIsFocused, editFocused, prevIsModalVisible, isFocused, modal.isVisible, isNextModalWillOpenRef]);
useEffect(() => {
// Scrolls the composer to the bottom and sets the selection to the end, so that longer drafts are easier to edit
diff --git a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
index 88f0d0a68c67..6e8d8d12afaf 100644
--- a/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
+++ b/src/pages/home/report/ReportActionCompose/SuggestionEmoji.js
@@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
-import React, {useCallback, useImperativeHandle, useRef, useState} from 'react';
+import React, {useCallback, useEffect, useImperativeHandle, useRef, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import EmojiSuggestions from '@components/EmojiSuggestions';
@@ -59,6 +59,7 @@ function SuggestionEmoji({
forwardedRef,
resetKeyboardInput,
measureParentContainer,
+ isComposerFocused,
}) {
const [suggestionValues, setSuggestionValues] = useState(defaultSuggestionsValues);
@@ -180,6 +181,13 @@ function SuggestionEmoji({
[value, preferredLocale, setHighlightedEmojiIndex, resetSuggestions],
);
+ useEffect(() => {
+ if (!isComposerFocused) {
+ return;
+ }
+ calculateEmojiSuggestion(selection.end);
+ }, [selection, calculateEmojiSuggestion, isComposerFocused]);
+
const onSelectionChange = useCallback(
(e) => {
/**
diff --git a/src/pages/home/report/ReportActionItem.js b/src/pages/home/report/ReportActionItem.js
index 9f803f72cbbb..17d49bd0f486 100644
--- a/src/pages/home/report/ReportActionItem.js
+++ b/src/pages/home/report/ReportActionItem.js
@@ -428,7 +428,12 @@ function ReportActionItem(props) {
isHidden={isHidden}
style={[
_.contains(
- [..._.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG), CONST.REPORT.ACTIONS.TYPE.IOU, CONST.REPORT.ACTIONS.TYPE.APPROVED],
+ [
+ ..._.values(CONST.REPORT.ACTIONS.TYPE.POLICYCHANGELOG),
+ CONST.REPORT.ACTIONS.TYPE.IOU,
+ CONST.REPORT.ACTIONS.TYPE.APPROVED,
+ CONST.REPORT.ACTIONS.TYPE.MOVED,
+ ],
props.action.actionName,
)
? styles.colorMuted
diff --git a/src/pages/home/report/ReportActionItemSingle.js b/src/pages/home/report/ReportActionItemSingle.js
index 955e024bd7a8..e69531e2cc53 100644
--- a/src/pages/home/report/ReportActionItemSingle.js
+++ b/src/pages/home/report/ReportActionItemSingle.js
@@ -2,7 +2,6 @@ import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React, {useCallback, useMemo} from 'react';
import {View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
import _ from 'underscore';
import Avatar from '@components/Avatar';
import MultipleAvatars from '@components/MultipleAvatars';
@@ -14,11 +13,9 @@ import Text from '@components/Text';
import Tooltip from '@components/Tooltip';
import UserDetailsTooltip from '@components/UserDetailsTooltip';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
-import compose from '@libs/compose';
import ControlSelection from '@libs/ControlSelection';
import DateUtils from '@libs/DateUtils';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
import reportPropTypes from '@pages/reportPropTypes';
@@ -26,7 +23,6 @@ import styles from '@styles/styles';
import * as StyleUtils from '@styles/StyleUtils';
import themeColors from '@styles/themes/default';
import CONST from '@src/CONST';
-import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import ReportActionItemDate from './ReportActionItemDate';
import ReportActionItemFragment from './ReportActionItemFragment';
@@ -205,7 +201,7 @@ function ReportActionItemSingle(props) {
);
};
- const hasEmojiStatus = !displayAllActors && status && status.emojiCode && Permissions.canUseCustomStatus(props.betas);
+ const hasEmojiStatus = !displayAllActors && status && status.emojiCode;
const formattedDate = DateUtils.getStatusUntilDate(lodashGet(status, 'clearAfter'));
const statusText = lodashGet(status, 'text', '');
const statusTooltipText = formattedDate ? `${statusText} (${formattedDate})` : statusText;
@@ -267,11 +263,4 @@ ReportActionItemSingle.propTypes = propTypes;
ReportActionItemSingle.defaultProps = defaultProps;
ReportActionItemSingle.displayName = 'ReportActionItemSingle';
-export default compose(
- withLocalize,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- }),
-)(ReportActionItemSingle);
+export default withLocalize(ReportActionItemSingle);
diff --git a/src/pages/home/report/ReportAttachments.js b/src/pages/home/report/ReportAttachments.js
index 173a4b5637be..c580da7887a2 100644
--- a/src/pages/home/report/ReportAttachments.js
+++ b/src/pages/home/report/ReportAttachments.js
@@ -2,6 +2,7 @@ import PropTypes from 'prop-types';
import React, {useCallback} from 'react';
import _ from 'underscore';
import AttachmentModal from '@components/AttachmentModal';
+import ComposerFocusManager from '@libs/ComposerFocusManager';
import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import ROUTES from '@src/ROUTES';
@@ -38,7 +39,11 @@ function ReportAttachments(props) {
defaultOpen
report={report}
source={source}
- onModalHide={() => Navigation.dismissModal()}
+ onModalHide={() => {
+ Navigation.dismissModal();
+ // This enables Composer refocus when the attachments modal is closed by the browser navigation
+ ComposerFocusManager.setReadyToFocus();
+ }}
onCarouselAttachmentChange={onCarouselAttachmentChange}
/>
);
diff --git a/src/pages/home/sidebar/SidebarLinks.js b/src/pages/home/sidebar/SidebarLinks.js
index 5e69be266342..2aba742f157f 100644
--- a/src/pages/home/sidebar/SidebarLinks.js
+++ b/src/pages/home/sidebar/SidebarLinks.js
@@ -1,7 +1,7 @@
/* eslint-disable rulesdir/onyx-props-must-have-default */
import PropTypes from 'prop-types';
import React, {useCallback, useEffect, useRef} from 'react';
-import {InteractionManager, View} from 'react-native';
+import {InteractionManager, StyleSheet, View} from 'react-native';
import _ from 'underscore';
import LogoComponent from '@assets/images/expensify-wordmark.svg';
import Header from '@components/Header';
@@ -177,16 +177,21 @@ function SidebarLinks({onLinkClick, insets, optionListItems, isLoading, priority
-
-
- {isLoading && }
+
+
+ {isLoading && optionListItems.length === 0 && (
+
+
+
+ )}
+
);
}
diff --git a/src/pages/home/sidebar/SidebarLinksData.js b/src/pages/home/sidebar/SidebarLinksData.js
index dc7d00566bc0..088eb5c0092a 100644
--- a/src/pages/home/sidebar/SidebarLinksData.js
+++ b/src/pages/home/sidebar/SidebarLinksData.js
@@ -199,23 +199,28 @@ export default compose(
chatReports: {
key: ONYXKEYS.COLLECTION.REPORT,
selector: chatReportSelector,
+ initialValue: {},
},
isLoadingReportData: {
key: ONYXKEYS.IS_LOADING_REPORT_DATA,
},
priorityMode: {
key: ONYXKEYS.NVP_PRIORITY_MODE,
+ initialValue: CONST.PRIORITY_MODE.DEFAULT,
},
betas: {
key: ONYXKEYS.BETAS,
+ initialValue: [],
},
allReportActions: {
key: ONYXKEYS.COLLECTION.REPORT_ACTIONS,
selector: reportActionsSelector,
+ initialValue: {},
},
policies: {
key: ONYXKEYS.COLLECTION.POLICY,
selector: policySelector,
+ initialValue: {},
},
}),
)(SidebarLinksData);
diff --git a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
index efb5e839f618..5b7a126a4655 100644
--- a/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
+++ b/src/pages/home/sidebar/SidebarScreen/BaseSidebarScreen.js
@@ -30,7 +30,6 @@ function BaseSidebarScreen(props) {
shouldEnableKeyboardAvoidingView={false}
style={[styles.sidebar, Browser.isMobile() ? styles.userSelectNone : {}]}
testID={BaseSidebarScreen.displayName}
- shouldDisableFocusTrap
>
{({insets}) => (
<>
diff --git a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
index 739f7e3e0295..a1ba7042f158 100644
--- a/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
+++ b/src/pages/home/sidebar/SidebarScreen/FloatingActionButtonAndPopover.js
@@ -13,7 +13,6 @@ import withWindowDimensions from '@components/withWindowDimensions';
import usePrevious from '@hooks/usePrevious';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import useThemeStyles from '@styles/useThemeStyles';
import * as App from '@userActions/App';
import * as IOU from '@userActions/IOU';
@@ -54,9 +53,6 @@ const propTypes = {
name: PropTypes.string,
}),
- /* Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** Indicated whether the report data is loading */
isLoading: PropTypes.bool,
@@ -74,7 +70,6 @@ const defaultProps = {
onHideCreateMenu: () => {},
onShowCreateMenu: () => {},
allPolicies: {},
- betas: [],
isLoading: false,
innerRef: null,
demoInfo: {},
@@ -207,15 +202,13 @@ function FloatingActionButtonAndPopover(props) {
text: props.translate('iou.sendMoney'),
onSelected: () => interceptAnonymousUser(() => IOU.startMoneyRequest(CONST.IOU.TYPE.SEND)),
},
- ...(Permissions.canUseTasks(props.betas)
- ? [
- {
- icon: Expensicons.Task,
- text: props.translate('newTaskPage.assignTask'),
- onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()),
- },
- ]
- : []),
+ ...[
+ {
+ icon: Expensicons.Task,
+ text: props.translate('newTaskPage.assignTask'),
+ onSelected: () => interceptAnonymousUser(() => Task.clearOutTaskInfoAndNavigate()),
+ },
+ ],
{
icon: Expensicons.Heart,
text: props.translate('sidebarScreen.saveTheWorld'),
@@ -278,9 +271,6 @@ export default compose(
key: ONYXKEYS.COLLECTION.POLICY,
selector: policySelector,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
isLoading: {
key: ONYXKEYS.IS_LOADING_APP,
},
diff --git a/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
index 8e41e1c6af6a..8e680a20d419 100644
--- a/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
+++ b/src/pages/home/sidebar/SignInOrAvatarWithOptionalStatus.js
@@ -2,13 +2,9 @@
import lodashGet from 'lodash/get';
import PropTypes from 'prop-types';
import React from 'react';
-import {withOnyx} from 'react-native-onyx';
import withCurrentUserPersonalDetails from '@components/withCurrentUserPersonalDetails';
-import compose from '@libs/compose';
-import Permissions from '@libs/Permissions';
import personalDetailsPropType from '@pages/personalDetailsPropType';
import * as Session from '@userActions/Session';
-import ONYXKEYS from '@src/ONYXKEYS';
import AvatarWithOptionalStatus from './AvatarWithOptionalStatus';
import PressableAvatarWithIndicator from './PressableAvatarWithIndicator';
import SignInButton from './SignInButton';
@@ -19,22 +15,17 @@ const propTypes = {
/** Whether the create menu is open or not */
isCreateMenuOpen: PropTypes.bool,
-
- /** Beta features list */
- betas: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
- betas: [],
isCreateMenuOpen: false,
currentUserPersonalDetails: {
status: {emojiCode: ''},
},
};
-function SignInOrAvatarWithOptionalStatus({currentUserPersonalDetails, isCreateMenuOpen, betas}) {
- const statusEmojiCode = lodashGet(currentUserPersonalDetails, 'status.emojiCode', '');
- const emojiStatus = Permissions.canUseCustomStatus(betas) ? statusEmojiCode : '';
+function SignInOrAvatarWithOptionalStatus({currentUserPersonalDetails, isCreateMenuOpen}) {
+ const emojiStatus = lodashGet(currentUserPersonalDetails, 'status.emojiCode', '');
if (Session.isAnonymousUser()) {
return ;
@@ -53,11 +44,4 @@ function SignInOrAvatarWithOptionalStatus({currentUserPersonalDetails, isCreateM
SignInOrAvatarWithOptionalStatus.propTypes = propTypes;
SignInOrAvatarWithOptionalStatus.defaultProps = defaultProps;
SignInOrAvatarWithOptionalStatus.displayName = 'SignInOrAvatarWithOptionalStatus';
-export default compose(
- withCurrentUserPersonalDetails,
- withOnyx({
- betas: {
- key: ONYXKEYS.BETAS,
- },
- }),
-)(SignInOrAvatarWithOptionalStatus);
+export default withCurrentUserPersonalDetails(SignInOrAvatarWithOptionalStatus);
diff --git a/src/pages/iou/SplitBillDetailsPage.js b/src/pages/iou/SplitBillDetailsPage.js
index 2ebe96d60ed8..d1fe21d8cf4e 100644
--- a/src/pages/iou/SplitBillDetailsPage.js
+++ b/src/pages/iou/SplitBillDetailsPage.js
@@ -109,10 +109,7 @@ function SplitBillDetailsPage(props) {
);
return (
-
+
diff --git a/src/pages/settings/InitialSettingsPage.js b/src/pages/settings/InitialSettingsPage.js
index d88105b31360..1bd57bcab32b 100755
--- a/src/pages/settings/InitialSettingsPage.js
+++ b/src/pages/settings/InitialSettingsPage.js
@@ -25,7 +25,6 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation';
import compose from '@libs/compose';
import * as CurrencyUtils from '@libs/CurrencyUtils';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import * as UserUtils from '@libs/UserUtils';
@@ -89,9 +88,6 @@ const propTypes = {
/** Bank account attached to free plan */
reimbursementAccount: ReimbursementAccountProps.reimbursementAccountPropTypes,
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
-
/** Information about the user accepting the terms for payments */
walletTerms: walletTermsPropTypes,
@@ -120,7 +116,6 @@ const defaultProps = {
currentBalance: 0,
},
reimbursementAccount: {},
- betas: [],
walletTerms: {},
bankAccountList: {},
fundList: null,
@@ -288,8 +283,7 @@ function InitialSettingsPage(props) {
* @param {Boolean} isPaymentItem whether the item being rendered is the payments menu item
* @returns {Number} the user wallet balance
*/
- const getWalletBalance = (isPaymentItem) =>
- isPaymentItem && Permissions.canUseWallet(props.betas) ? CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance) : undefined;
+ const getWalletBalance = (isPaymentItem) => isPaymentItem && CurrencyUtils.convertToDisplayString(props.userWallet.currentBalance);
return (
<>
@@ -324,7 +318,7 @@ function InitialSettingsPage(props) {
})}
>
);
- }, [getDefaultMenuItems, props.betas, props.userWallet.currentBalance, translate, isExecuting, singleExecution]);
+ }, [getDefaultMenuItems, props.userWallet.currentBalance, translate, isExecuting, singleExecution]);
const headerContent = (
@@ -426,9 +420,6 @@ export default compose(
userWallet: {
key: ONYXKEYS.USER_WALLET,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
bankAccountList: {
key: ONYXKEYS.BANK_ACCOUNT_LIST,
},
diff --git a/src/pages/settings/Profile/ProfilePage.js b/src/pages/settings/Profile/ProfilePage.js
index 1e4485b4c36b..fc6c38f96335 100755
--- a/src/pages/settings/Profile/ProfilePage.js
+++ b/src/pages/settings/Profile/ProfilePage.js
@@ -15,7 +15,6 @@ import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import withWindowDimensions, {windowDimensionsPropTypes} from '@components/withWindowDimensions';
import compose from '@libs/compose';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import * as UserUtils from '@libs/UserUtils';
import userPropTypes from '@pages/settings/userPropTypes';
import useThemeStyles from '@styles/useThemeStyles';
@@ -83,15 +82,13 @@ function ProfilePage(props) {
pageRoute: ROUTES.SETTINGS_CONTACT_METHODS.route,
brickRoadIndicator: contactMethodBrickRoadIndicator,
},
- ...(Permissions.canUseCustomStatus(props.betas)
- ? [
- {
- description: props.translate('statusPage.status'),
- title: emojiCode ? `${emojiCode} ${lodashGet(props, 'currentUserPersonalDetails.status.text', '')}` : '',
- pageRoute: ROUTES.SETTINGS_STATUS,
- },
- ]
- : []),
+ ...[
+ {
+ description: props.translate('statusPage.status'),
+ title: emojiCode ? `${emojiCode} ${lodashGet(props, 'currentUserPersonalDetails.status.text', '')}` : '',
+ pageRoute: ROUTES.SETTINGS_STATUS,
+ },
+ ],
{
description: props.translate('pronounsPage.pronouns'),
title: getPronouns(),
@@ -184,8 +181,5 @@ export default compose(
user: {
key: ONYXKEYS.USER,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
}),
)(ProfilePage);
diff --git a/src/pages/settings/Report/NotificationPreferencePage.js b/src/pages/settings/Report/NotificationPreferencePage.js
index 75e9e0d5c5e8..c6044bd81efe 100644
--- a/src/pages/settings/Report/NotificationPreferencePage.js
+++ b/src/pages/settings/Report/NotificationPreferencePage.js
@@ -6,13 +6,11 @@ import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import withLocalize, {withLocalizePropTypes} from '@components/withLocalize';
import compose from '@libs/compose';
-import Navigation from '@libs/Navigation/Navigation';
import * as ReportUtils from '@libs/ReportUtils';
import withReportOrNotFound from '@pages/home/report/withReportOrNotFound';
import reportPropTypes from '@pages/reportPropTypes';
import * as Report from '@userActions/Report';
import CONST from '@src/CONST';
-import ROUTES from '@src/ROUTES';
const propTypes = {
...withLocalizePropTypes,
@@ -41,11 +39,13 @@ function NotificationPreferencePage(props) {
Navigation.goBack(ROUTES.REPORT_SETTINGS.getRoute(props.report.reportID))}
+ onBackButtonPress={() => ReportUtils.goBackToDetailsPage(props.report)}
/>
Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true)}
+ onSelectRow={(option) =>
+ Report.updateNotificationPreference(props.report.reportID, props.report.notificationPreference, option.value, true, undefined, undefined, props.report)
+ }
initiallyFocusedOptionKey={_.find(notificationPreferenceOptions, (locale) => locale.isSelected).keyForList}
/>
diff --git a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
index 9a9e42f75576..31a33efa3996 100644
--- a/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
+++ b/src/pages/settings/Security/TwoFactorAuth/TwoFactorAuthSteps.js
@@ -1,4 +1,4 @@
-import React, {useCallback, useEffect, useMemo} from 'react';
+import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {withOnyx} from 'react-native-onyx';
import useAnimatedStepContext from '@components/AnimatedStep/useAnimatedStepContext';
import * as TwoFactorAuthActions from '@userActions/TwoFactorAuthActions';
@@ -13,20 +13,30 @@ import TwoFactorAuthContext from './TwoFactorAuthContext';
import {defaultAccount, TwoFactorAuthPropTypes} from './TwoFactorAuthPropTypes';
function TwoFactorAuthSteps({account = defaultAccount}) {
- const currentStep = useMemo(() => {
- if (account.twoFactorAuthStep) {
- return account.twoFactorAuthStep;
- }
- return account.requiresTwoFactorAuth ? CONST.TWO_FACTOR_AUTH_STEPS.ENABLED : CONST.TWO_FACTOR_AUTH_STEPS.CODES;
- }, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]);
+ const [currentStep, setCurrentStep] = useState(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
const {setAnimationDirection} = useAnimatedStepContext();
useEffect(() => () => TwoFactorAuthActions.clearTwoFactorAuthData(), []);
+
+ useEffect(() => {
+ if (account.twoFactorAuthStep) {
+ setCurrentStep(account.twoFactorAuthStep);
+ return;
+ }
+
+ if (account.requiresTwoFactorAuth) {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.ENABLED);
+ } else {
+ setCurrentStep(CONST.TWO_FACTOR_AUTH_STEPS.CODES);
+ }
+ }, [account.requiresTwoFactorAuth, account.twoFactorAuthStep]);
+
const handleSetStep = useCallback(
(step, animationDirection = CONST.ANIMATION_DIRECTION.IN) => {
setAnimationDirection(animationDirection);
TwoFactorAuthActions.setTwoFactorAuthStep(step);
+ setCurrentStep(step);
},
[setAnimationDirection],
);
diff --git a/src/pages/settings/Wallet/AddDebitCardPage.js b/src/pages/settings/Wallet/AddDebitCardPage.js
index 45893dfb5d65..ed4a545ff208 100644
--- a/src/pages/settings/Wallet/AddDebitCardPage.js
+++ b/src/pages/settings/Wallet/AddDebitCardPage.js
@@ -14,9 +14,7 @@ import TextLink from '@components/TextLink';
import useLocalize from '@hooks/useLocalize';
import usePrevious from '@hooks/usePrevious';
import Navigation from '@libs/Navigation/Navigation';
-import Permissions from '@libs/Permissions';
import * as ValidationUtils from '@libs/ValidationUtils';
-import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import useThemeStyles from '@styles/useThemeStyles';
import * as PaymentMethods from '@userActions/PaymentMethods';
import CONST from '@src/CONST';
@@ -28,16 +26,12 @@ const propTypes = {
formData: PropTypes.shape({
setupComplete: PropTypes.bool,
}),
-
- /** List of betas available to current user */
- betas: PropTypes.arrayOf(PropTypes.string),
};
const defaultProps = {
formData: {
setupComplete: false,
},
- betas: [],
};
function DebitCardPage(props) {
@@ -104,10 +98,6 @@ function DebitCardPage(props) {
return errors;
};
- if (!Permissions.canUseWallet(props.betas)) {
- return ;
- }
-
return (
nameOnCardRef.current && nameOnCardRef.current.focus()}
@@ -212,7 +202,4 @@ export default withOnyx({
formData: {
key: ONYXKEYS.FORMS.ADD_DEBIT_CARD_FORM,
},
- betas: {
- key: ONYXKEYS.BETAS,
- },
})(DebitCardPage);
diff --git a/src/pages/settings/Wallet/ChooseTransferAccountPage.js b/src/pages/settings/Wallet/ChooseTransferAccountPage.js
index 15c172d33626..2ffbf12f52c2 100644
--- a/src/pages/settings/Wallet/ChooseTransferAccountPage.js
+++ b/src/pages/settings/Wallet/ChooseTransferAccountPage.js
@@ -37,7 +37,7 @@ function ChooseTransferAccountPage(props) {
* @param {Object} account of the selected account data
*/
const selectAccountAndNavigateBack = (event, accountType, account) => {
- PaymentMethods.saveWalletTransferAccountTypeAndID(accountType, accountType === CONST.PAYMENT_METHODS.BANK_ACCOUNT ? account.bankAccountID : account.fundID);
+ PaymentMethods.saveWalletTransferAccountTypeAndID(accountType, accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT ? account.bankAccountID : account.fundID);
Navigation.goBack(ROUTES.SETTINGS_WALLET_TRANSFER_BALANCE);
};
@@ -70,7 +70,7 @@ function ChooseTransferAccountPage(props) {